mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-08-21 09:26:49 +02:00
Compare commits
9 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
9b8929e697 | ||
![]() |
5b8ff86029 | ||
![]() |
e2e5930985 | ||
![]() |
2ceac9a87d | ||
![]() |
bca9bf9b11 | ||
![]() |
768f1346a3 | ||
![]() |
f9496e2fe0 | ||
![]() |
62c40d1b7b | ||
![]() |
e076747f85 |
34
CHANGELOG.md
34
CHANGELOG.md
@@ -1,5 +1,39 @@
|
||||
# Changelog
|
||||
|
||||
## v1.20.0 (22/08/2023)
|
||||
|
||||
### What's Changed
|
||||
* Add option to share bookmarks publicly by @sissbruecker in https://github.com/sissbruecker/linkding/pull/503
|
||||
* Various improvements to favicons by @sissbruecker in https://github.com/sissbruecker/linkding/pull/504
|
||||
* Add support for PRIVATE flag in import and export by @sissbruecker in https://github.com/sissbruecker/linkding/pull/505
|
||||
* Avoid page reload when triggering actions in bookmark list by @sissbruecker in https://github.com/sissbruecker/linkding/pull/506
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.19.1...v1.20.0
|
||||
|
||||
---
|
||||
|
||||
## v1.19.1 (29/07/2023)
|
||||
|
||||
### What's Changed
|
||||
* Add Postman Collection to Community section of README by @gingerbeardman in https://github.com/sissbruecker/linkding/pull/476
|
||||
* Added Dev Container support by @acbgbca in https://github.com/sissbruecker/linkding/pull/474
|
||||
* Added Apple web-app meta tag #358 by @acbgbca in https://github.com/sissbruecker/linkding/pull/359
|
||||
* Bump requests from 2.28.1 to 2.31.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/478
|
||||
* Allow passing title and description to new bookmark form by @acbgbca in https://github.com/sissbruecker/linkding/pull/479
|
||||
* Enable WAL to avoid locked database lock errors by @sissbruecker in https://github.com/sissbruecker/linkding/pull/480
|
||||
* Fix website loader content encoding detection by @sissbruecker in https://github.com/sissbruecker/linkding/pull/482
|
||||
* Bump certifi from 2022.12.7 to 2023.7.22 by @dependabot in https://github.com/sissbruecker/linkding/pull/497
|
||||
* Bump django from 4.1.9 to 4.1.10 by @dependabot in https://github.com/sissbruecker/linkding/pull/494
|
||||
|
||||
### New Contributors
|
||||
* @gingerbeardman made their first contribution in https://github.com/sissbruecker/linkding/pull/476
|
||||
* @acbgbca made their first contribution in https://github.com/sissbruecker/linkding/pull/474
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.19.0...v1.19.1
|
||||
|
||||
---
|
||||
|
||||
## v1.19.0 (20/05/2023)
|
||||
|
||||
### What's Changed
|
||||
|
@@ -1,5 +1,6 @@
|
||||
from bookmarks import queries
|
||||
from bookmarks.models import Toast
|
||||
from bookmarks import utils
|
||||
|
||||
|
||||
def toasts(request):
|
||||
@@ -23,3 +24,9 @@ def public_shares(request):
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def app_version(request):
|
||||
return {
|
||||
'app_version': utils.app_version
|
||||
}
|
||||
|
232
bookmarks/e2e/e2e_test_bookmark_page_bulk_edit.py
Normal file
232
bookmarks/e2e/e2e_test_bookmark_page_bulk_edit.py
Normal file
@@ -0,0 +1,232 @@
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
from bookmarks.models import Bookmark
|
||||
|
||||
|
||||
class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
def setup_test_data(self):
|
||||
self.setup_numbered_bookmarks(50)
|
||||
self.setup_numbered_bookmarks(50, archived=True)
|
||||
self.setup_numbered_bookmarks(50, prefix='foo')
|
||||
self.setup_numbered_bookmarks(50, archived=True, prefix='foo')
|
||||
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='Bookmark').count())
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='Archived Bookmark').count())
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='foo').count())
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='foo').count())
|
||||
|
||||
def test_active_bookmarks_bulk_select_across(self):
|
||||
self.setup_test_data()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse('bookmarks:index'), p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
|
||||
self.select_bulk_action('Delete')
|
||||
self.locate_bulk_edit_bar().get_by_text('Execute').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
|
||||
|
||||
self.assertEqual(0, Bookmark.objects.filter(is_archived=False, title__startswith='Bookmark').count())
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='Archived Bookmark').count())
|
||||
self.assertEqual(0, Bookmark.objects.filter(is_archived=False, title__startswith='foo').count())
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='foo').count())
|
||||
|
||||
def test_archived_bookmarks_bulk_select_across(self):
|
||||
self.setup_test_data()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse('bookmarks:archived'), p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
|
||||
self.select_bulk_action('Delete')
|
||||
self.locate_bulk_edit_bar().get_by_text('Execute').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
|
||||
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='Bookmark').count())
|
||||
self.assertEqual(0, Bookmark.objects.filter(is_archived=True, title__startswith='Archived Bookmark').count())
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='foo').count())
|
||||
self.assertEqual(0, Bookmark.objects.filter(is_archived=True, title__startswith='foo').count())
|
||||
|
||||
def test_active_bookmarks_bulk_select_across_respects_query(self):
|
||||
self.setup_test_data()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse('bookmarks:index') + '?q=foo', p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
|
||||
self.select_bulk_action('Delete')
|
||||
self.locate_bulk_edit_bar().get_by_text('Execute').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
|
||||
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='Bookmark').count())
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='Archived Bookmark').count())
|
||||
self.assertEqual(0, Bookmark.objects.filter(is_archived=False, title__startswith='foo').count())
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='foo').count())
|
||||
|
||||
def test_archived_bookmarks_bulk_select_across_respects_query(self):
|
||||
self.setup_test_data()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse('bookmarks:archived') + '?q=foo', p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
|
||||
self.select_bulk_action('Delete')
|
||||
self.locate_bulk_edit_bar().get_by_text('Execute').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
|
||||
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='Bookmark').count())
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='Archived Bookmark').count())
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='foo').count())
|
||||
self.assertEqual(0, Bookmark.objects.filter(is_archived=True, title__startswith='foo').count())
|
||||
|
||||
def test_select_all_toggles_all_checkboxes(self):
|
||||
self.setup_numbered_bookmarks(5)
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse('bookmarks:index')
|
||||
page = self.open(url, p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
|
||||
checkboxes = page.locator('label[ld-bulk-edit-checkbox] input')
|
||||
self.assertEqual(6, checkboxes.count())
|
||||
for i in range(checkboxes.count()):
|
||||
expect(checkboxes.nth(i)).not_to_be_checked()
|
||||
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
|
||||
for i in range(checkboxes.count()):
|
||||
expect(checkboxes.nth(i)).to_be_checked()
|
||||
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
|
||||
for i in range(checkboxes.count()):
|
||||
expect(checkboxes.nth(i)).not_to_be_checked()
|
||||
|
||||
def test_select_all_shows_select_across(self):
|
||||
self.setup_numbered_bookmarks(5)
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse('bookmarks:index')
|
||||
self.open(url, p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
|
||||
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
|
||||
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
expect(self.locate_bulk_edit_select_across()).to_be_visible()
|
||||
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
|
||||
|
||||
def test_select_across_is_unchecked_when_toggling_all(self):
|
||||
self.setup_numbered_bookmarks(5)
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse('bookmarks:index')
|
||||
self.open(url, p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
|
||||
# Show select across, check it
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
expect(self.locate_bulk_edit_select_across()).to_be_checked()
|
||||
|
||||
# Hide select across by toggling select all
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
|
||||
|
||||
# Show select across again, verify it is unchecked
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
expect(self.locate_bulk_edit_select_across()).not_to_be_checked()
|
||||
|
||||
def test_select_across_is_unchecked_when_toggling_bookmark(self):
|
||||
self.setup_numbered_bookmarks(5)
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse('bookmarks:index')
|
||||
self.open(url, p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
|
||||
# Show select across, check it
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
expect(self.locate_bulk_edit_select_across()).to_be_checked()
|
||||
|
||||
# Hide select across by toggling a single bookmark
|
||||
self.locate_bookmark('Bookmark 1').locator('label[ld-bulk-edit-checkbox]').click()
|
||||
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
|
||||
|
||||
# Show select across again, verify it is unchecked
|
||||
self.locate_bookmark('Bookmark 1').locator('label[ld-bulk-edit-checkbox]').click()
|
||||
expect(self.locate_bulk_edit_select_across()).not_to_be_checked()
|
||||
|
||||
def test_execute_resets_all_checkboxes(self):
|
||||
self.setup_numbered_bookmarks(100)
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse('bookmarks:index')
|
||||
page = self.open(url, p)
|
||||
|
||||
# Select all bookmarks, enable select across
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
|
||||
# Get reference for bookmark list
|
||||
bookmark_list = page.locator('ul[ld-bookmark-list]')
|
||||
|
||||
# Execute bulk action
|
||||
self.select_bulk_action('Mark as unread')
|
||||
self.locate_bulk_edit_bar().get_by_text('Execute').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
|
||||
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
|
||||
# Verify bulk edit checkboxes are reset
|
||||
checkboxes = page.locator('label[ld-bulk-edit-checkbox] input')
|
||||
self.assertEqual(31, checkboxes.count())
|
||||
for i in range(checkboxes.count()):
|
||||
expect(checkboxes.nth(i)).not_to_be_checked()
|
||||
|
||||
# Toggle select all and verify select across is reset
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
expect(self.locate_bulk_edit_select_across()).not_to_be_checked()
|
||||
|
||||
def test_update_select_across_bookmark_count(self):
|
||||
self.setup_numbered_bookmarks(100)
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse('bookmarks:index')
|
||||
self.open(url, p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
|
||||
expect(self.locate_bulk_edit_bar().get_by_text('All pages (100 bookmarks)')).to_be_visible()
|
||||
|
||||
self.select_bulk_action('Delete')
|
||||
self.locate_bulk_edit_bar().get_by_text('Execute').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
|
||||
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
|
||||
expect(self.locate_bulk_edit_bar().get_by_text('All pages (70 bookmarks)')).to_be_visible()
|
@@ -119,10 +119,27 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse('bookmarks:index'), p)
|
||||
|
||||
expect(self.locate_bookmark('Bookmark 2').get_by_text('Bookmark 2')).to_have_class('text-italic')
|
||||
self.locate_bookmark('Bookmark 2').get_by_text('Mark as read').click()
|
||||
expect(self.locate_bookmark('Bookmark 2')).to_have_class('unread')
|
||||
self.locate_bookmark('Bookmark 2').get_by_text('Unread').click()
|
||||
self.locate_bookmark('Bookmark 2').get_by_text('Yes').click()
|
||||
|
||||
expect(self.locate_bookmark('Bookmark 2').get_by_text('Bookmark 2')).not_to_have_class('text-italic')
|
||||
expect(self.locate_bookmark('Bookmark 2')).not_to_have_class('unread')
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_active_bookmarks_partial_update_on_unshare(self):
|
||||
self.setup_fixture()
|
||||
bookmark2 = self.get_numbered_bookmark('Bookmark 2')
|
||||
bookmark2.shared = True
|
||||
bookmark2.save()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse('bookmarks:index'), p)
|
||||
|
||||
expect(self.locate_bookmark('Bookmark 2')).to_have_class('shared')
|
||||
self.locate_bookmark('Bookmark 2').get_by_text('Shared').click()
|
||||
self.locate_bookmark('Bookmark 2').get_by_text('Yes').click()
|
||||
|
||||
expect(self.locate_bookmark('Bookmark 2')).not_to_have_class('shared')
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_active_bookmarks_partial_update_on_bulk_archive(self):
|
||||
@@ -133,7 +150,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bookmark('Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Archive').click()
|
||||
self.select_bulk_action('Archive')
|
||||
self.locate_bulk_edit_bar().get_by_text('Execute').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
|
||||
|
||||
self.assertVisibleBookmarks(['Bookmark 1', 'Bookmark 3'])
|
||||
@@ -148,7 +166,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bookmark('Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Delete').click()
|
||||
self.select_bulk_action('Delete')
|
||||
self.locate_bulk_edit_bar().get_by_text('Execute').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
|
||||
|
||||
self.assertVisibleBookmarks(['Bookmark 1', 'Bookmark 3'])
|
||||
@@ -180,7 +199,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
self.assertVisibleTags(['Archived Tag 1', 'Archived Tag 3'])
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_archived_bookmarks_partial_update_on_bulk_archive(self):
|
||||
def test_archived_bookmarks_partial_update_on_bulk_unarchive(self):
|
||||
self.setup_fixture()
|
||||
|
||||
with sync_playwright() as p:
|
||||
@@ -188,7 +207,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bookmark('Archived Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Archive').click()
|
||||
self.select_bulk_action('Unarchive')
|
||||
self.locate_bulk_edit_bar().get_by_text('Execute').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
|
||||
|
||||
self.assertVisibleBookmarks(['Archived Bookmark 1', 'Archived Bookmark 3'])
|
||||
@@ -203,7 +223,8 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bookmark('Archived Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Delete').click()
|
||||
self.select_bulk_action('Delete')
|
||||
self.locate_bulk_edit_bar().get_by_text('Execute').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
|
||||
|
||||
self.assertVisibleBookmarks(['Archived Bookmark 1', 'Archived Bookmark 3'])
|
||||
|
@@ -41,5 +41,14 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
|
||||
def locate_bulk_edit_bar(self):
|
||||
return self.page.locator('.bulk-edit-bar')
|
||||
|
||||
def locate_bulk_edit_select_all(self):
|
||||
return self.locate_bulk_edit_bar().locator('label[ld-bulk-edit-checkbox][all]')
|
||||
|
||||
def locate_bulk_edit_select_across(self):
|
||||
return self.locate_bulk_edit_bar().locator('label.select-across')
|
||||
|
||||
def locate_bulk_edit_toggle(self):
|
||||
return self.page.get_by_title('Bulk edit')
|
||||
|
||||
def select_bulk_action(self, value: str):
|
||||
return self.locate_bulk_edit_bar().locator('select[name="bulk_action"]').select_option(value)
|
||||
|
@@ -36,8 +36,18 @@ class BookmarkPage {
|
||||
swap(this.bookmarkList, bookmarkListHtml);
|
||||
swap(this.tagCloud, tagCloudHtml);
|
||||
|
||||
// Dispatch list updated event
|
||||
const listElement = this.bookmarkList.querySelector(
|
||||
"ul[data-bookmarks-total]",
|
||||
);
|
||||
const bookmarksTotal =
|
||||
(listElement && listElement.dataset.bookmarksTotal) || 0;
|
||||
|
||||
this.bookmarkList.dispatchEvent(
|
||||
new CustomEvent("bookmark-list-updated", { bubbles: true }),
|
||||
new CustomEvent("bookmark-list-updated", {
|
||||
bubbles: true,
|
||||
detail: { bookmarksTotal },
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@@ -4,6 +4,9 @@ class BulkEdit {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
this.active = false;
|
||||
this.actionSelect = element.querySelector("select[name='bulk_action']");
|
||||
this.tagAutoComplete = element.querySelector(".tag-autocomplete");
|
||||
this.selectAcross = element.querySelector("label.select-across");
|
||||
|
||||
element.addEventListener(
|
||||
"bulk-edit-toggle-active",
|
||||
@@ -21,6 +24,11 @@ class BulkEdit {
|
||||
"bookmark-list-updated",
|
||||
this.onListUpdated.bind(this),
|
||||
);
|
||||
|
||||
this.actionSelect.addEventListener(
|
||||
"change",
|
||||
this.onActionSelected.bind(this),
|
||||
);
|
||||
}
|
||||
|
||||
get allCheckbox() {
|
||||
@@ -48,23 +56,56 @@ class BulkEdit {
|
||||
}
|
||||
|
||||
onToggleBookmark() {
|
||||
this.allCheckbox.checked = this.bookmarkCheckboxes.every((checkbox) => {
|
||||
const allChecked = this.bookmarkCheckboxes.every((checkbox) => {
|
||||
return checkbox.checked;
|
||||
});
|
||||
this.allCheckbox.checked = allChecked;
|
||||
this.updateSelectAcross(allChecked);
|
||||
}
|
||||
|
||||
onToggleAll() {
|
||||
const checked = this.allCheckbox.checked;
|
||||
const allChecked = this.allCheckbox.checked;
|
||||
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||
checkbox.checked = checked;
|
||||
checkbox.checked = allChecked;
|
||||
});
|
||||
this.updateSelectAcross(allChecked);
|
||||
}
|
||||
|
||||
onListUpdated() {
|
||||
onActionSelected() {
|
||||
const action = this.actionSelect.value;
|
||||
|
||||
if (action === "bulk_tag" || action === "bulk_untag") {
|
||||
this.tagAutoComplete.classList.remove("d-none");
|
||||
} else {
|
||||
this.tagAutoComplete.classList.add("d-none");
|
||||
}
|
||||
}
|
||||
|
||||
onListUpdated(event) {
|
||||
// Reset checkbox states
|
||||
this.reset();
|
||||
|
||||
// Update total number of bookmarks
|
||||
const total = event.detail.bookmarksTotal;
|
||||
const totalSpan = this.selectAcross.querySelector("span.total");
|
||||
totalSpan.textContent = total;
|
||||
}
|
||||
|
||||
updateSelectAcross(allChecked) {
|
||||
if (allChecked) {
|
||||
this.selectAcross.classList.remove("d-none");
|
||||
} else {
|
||||
this.selectAcross.classList.add("d-none");
|
||||
this.selectAcross.querySelector("input").checked = false;
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.allCheckbox.checked = false;
|
||||
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||
checkbox.checked = false;
|
||||
});
|
||||
this.updateSelectAcross(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -16,9 +16,31 @@ class ConfirmButtonBehavior {
|
||||
onClick(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const container = document.createElement("span");
|
||||
container.className = "confirmation";
|
||||
|
||||
const icon = this.button.getAttribute("confirm-icon");
|
||||
if (icon) {
|
||||
const iconElement = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"svg",
|
||||
);
|
||||
iconElement.style.width = "16px";
|
||||
iconElement.style.height = "16px";
|
||||
iconElement.innerHTML = `<use xlink:href="#${icon}"></use>`;
|
||||
container.append(iconElement);
|
||||
}
|
||||
|
||||
const question = this.button.getAttribute("confirm-question");
|
||||
if (question) {
|
||||
const questionElement = document.createElement("span");
|
||||
questionElement.innerText = question;
|
||||
container.append(question);
|
||||
}
|
||||
|
||||
const cancelButton = document.createElement(this.button.nodeName);
|
||||
cancelButton.type = "button";
|
||||
cancelButton.innerText = "Cancel";
|
||||
cancelButton.innerText = question ? "No" : "Cancel";
|
||||
cancelButton.className = "btn btn-link btn-sm mr-1";
|
||||
cancelButton.addEventListener("click", this.reset.bind(this));
|
||||
|
||||
@@ -26,12 +48,10 @@ class ConfirmButtonBehavior {
|
||||
confirmButton.type = this.button.dataset.type;
|
||||
confirmButton.name = this.button.dataset.name;
|
||||
confirmButton.value = this.button.dataset.value;
|
||||
confirmButton.innerText = "Confirm";
|
||||
confirmButton.innerText = question ? "Yes" : "Confirm";
|
||||
confirmButton.className = "btn btn-link btn-sm";
|
||||
confirmButton.addEventListener("click", this.reset.bind(this));
|
||||
|
||||
const container = document.createElement("span");
|
||||
container.className = "confirmation";
|
||||
container.append(cancelButton, confirmButton);
|
||||
this.container = container;
|
||||
|
||||
|
@@ -14,12 +14,13 @@ class TagAutocomplete {
|
||||
id: element.id,
|
||||
name: element.name,
|
||||
value: element.value,
|
||||
placeholder: element.getAttribute("placeholder") || "",
|
||||
apiClient: apiClient,
|
||||
variant: element.getAttribute("variant"),
|
||||
},
|
||||
});
|
||||
|
||||
element.replaceWith(wrapper);
|
||||
element.replaceWith(wrapper.firstElementChild);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -11,6 +11,7 @@
|
||||
export let mode = '';
|
||||
export let apiClient;
|
||||
export let filters;
|
||||
export let linkTarget = '_blank';
|
||||
|
||||
let isFocus = false;
|
||||
let isOpen = false;
|
||||
@@ -164,7 +165,7 @@
|
||||
close()
|
||||
}
|
||||
if (suggestion.type === 'bookmark') {
|
||||
window.open(suggestion.bookmark.url, '_blank')
|
||||
window.open(suggestion.bookmark.url, linkTarget)
|
||||
close()
|
||||
}
|
||||
if (suggestion.type === 'tag') {
|
||||
@@ -209,11 +210,7 @@
|
||||
{#each suggestions.tags as suggestion}
|
||||
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
|
||||
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
|
||||
<div class="tile tile-centered">
|
||||
<div class="tile-content">
|
||||
{suggestion.label}
|
||||
</div>
|
||||
</div>
|
||||
{suggestion.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
@@ -224,11 +221,7 @@
|
||||
{#each suggestions.search as suggestion}
|
||||
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
|
||||
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
|
||||
<div class="tile tile-centered">
|
||||
<div class="tile-content">
|
||||
{suggestion.label}
|
||||
</div>
|
||||
</div>
|
||||
{suggestion.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
@@ -239,11 +232,7 @@
|
||||
{#each suggestions.bookmarks as suggestion}
|
||||
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
|
||||
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
|
||||
<div class="tile tile-centered">
|
||||
<div class="tile-content">
|
||||
{suggestion.label}
|
||||
</div>
|
||||
</div>
|
||||
{suggestion.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
|
@@ -4,6 +4,7 @@
|
||||
export let id;
|
||||
export let name;
|
||||
export let value;
|
||||
export let placeholder;
|
||||
export let apiClient;
|
||||
export let variant = 'default';
|
||||
|
||||
@@ -118,7 +119,7 @@
|
||||
<!-- autocomplete input container -->
|
||||
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
|
||||
<!-- autocomplete real input box -->
|
||||
<input id="{id}" name="{name}" value="{value ||''}" placeholder=" "
|
||||
<input id="{id}" name="{name}" value="{value ||''}" placeholder="{placeholder || ' '}"
|
||||
class="form-input" type="text" autocomplete="off" autocapitalize="off"
|
||||
on:input={handleInput} on:keydown={handleKeyDown}
|
||||
on:focus={handleFocus} on:blur={handleBlur}>
|
||||
@@ -131,11 +132,7 @@
|
||||
{#each suggestions as tag,i}
|
||||
<li class="menu-item" class:selected={selectedIndex === i}>
|
||||
<a href="#" on:mousedown|preventDefault={() => complete(tag)}>
|
||||
<div class="tile tile-centered">
|
||||
<div class="tile-content">
|
||||
{tag.name}
|
||||
</div>
|
||||
</div>
|
||||
{tag.name}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
@@ -156,6 +153,7 @@
|
||||
.form-autocomplete.small .form-autocomplete-input {
|
||||
height: 1.4rem;
|
||||
min-height: 1.4rem;
|
||||
padding: 0.05rem 0.3rem;
|
||||
}
|
||||
|
||||
.form-autocomplete.small .form-autocomplete-input input {
|
||||
|
@@ -119,6 +119,34 @@ def untag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_us
|
||||
Bookmark.objects.bulk_update(bookmarks, ['date_modified'])
|
||||
|
||||
|
||||
def mark_bookmarks_as_read(bookmark_ids: [Union[int, str]], current_user: User):
|
||||
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
|
||||
|
||||
bookmarks.update(unread=False, date_modified=timezone.now())
|
||||
|
||||
|
||||
def mark_bookmarks_as_unread(bookmark_ids: [Union[int, str]], current_user: User):
|
||||
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
|
||||
|
||||
bookmarks.update(unread=True, date_modified=timezone.now())
|
||||
|
||||
|
||||
def share_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
|
||||
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
|
||||
|
||||
bookmarks.update(shared=True, date_modified=timezone.now())
|
||||
|
||||
|
||||
def unshare_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
|
||||
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
|
||||
|
||||
bookmarks.update(shared=False, date_modified=timezone.now())
|
||||
|
||||
|
||||
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
|
||||
to_bookmark.title = from_bookmark.title
|
||||
to_bookmark.description = from_bookmark.description
|
||||
|
@@ -1,6 +0,0 @@
|
||||
.auth-page {
|
||||
> .columns {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
@@ -1,14 +1,29 @@
|
||||
/* Main layout */
|
||||
body {
|
||||
margin: 20px 10px;
|
||||
|
||||
@media (min-width: $size-sm) {
|
||||
// High horizontal padding accounts for checkboxes that show up in bulk edit mode
|
||||
margin: 20px 24px;
|
||||
// Horizontal padding accounts for checkboxes that show up in bulk edit mode
|
||||
margin: 20px 32px;
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 40px;
|
||||
margin-bottom: $unit-10;
|
||||
|
||||
.logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0 $unit-3;
|
||||
font-size: $font-size-lg;
|
||||
}
|
||||
}
|
||||
|
||||
header .toasts {
|
||||
@@ -23,97 +38,101 @@ header .toasts {
|
||||
}
|
||||
}
|
||||
|
||||
.navbar {
|
||||
/* Shared components */
|
||||
|
||||
.navbar-brand {
|
||||
// Content area component
|
||||
section.content-area {
|
||||
h2 {
|
||||
font-size: $font-size-lg;
|
||||
}
|
||||
|
||||
.content-area-header {
|
||||
border-bottom: solid 1px $border-color;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: $unit-2;
|
||||
margin-bottom: $unit-4;
|
||||
|
||||
.logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
h2 {
|
||||
line-height: 1.8rem;
|
||||
margin-right: auto;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-transform: uppercase;
|
||||
display: inline-block;
|
||||
margin: 0 0 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-toggle {
|
||||
}
|
||||
}
|
||||
|
||||
/* Overrides */
|
||||
// Confirm button component
|
||||
span.confirmation {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: $unit-1;
|
||||
color: $error-color !important;
|
||||
|
||||
// Reduce heading sizes
|
||||
h1 {
|
||||
font-size: inherit;
|
||||
svg {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.btn.btn-link {
|
||||
color: $error-color !important;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: .85rem;
|
||||
/* Additional utilities */
|
||||
|
||||
.truncate {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.text-gray-dark {
|
||||
color: $gray-color-dark;
|
||||
}
|
||||
|
||||
// Fix up visited styles
|
||||
a:visited {
|
||||
color: $link-color;
|
||||
}
|
||||
a:visited:hover {
|
||||
color: $link-color-dark;
|
||||
}
|
||||
.btn-link:visited:not(.btn-primary) {
|
||||
color: $link-color;
|
||||
}
|
||||
.btn-link:visited:not(.btn-primary):hover {
|
||||
color: $link-color-dark;
|
||||
.align-baseline {
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
code {
|
||||
color: $gray-color-dark;
|
||||
background-color: $code-bg-color;
|
||||
box-shadow: 1px 1px 0 $code-shadow-color;
|
||||
.align-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// Increase spacing between columns
|
||||
.container > .columns > .column:not(:first-child) {
|
||||
padding-left: 2rem;
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
// Remove left padding from first pagination link
|
||||
.pagination .page-item:first-child a {
|
||||
padding-left: 0;
|
||||
.mb-4 {
|
||||
margin-bottom: $unit-4;
|
||||
}
|
||||
|
||||
// Override border color for tab block
|
||||
.tab-block {
|
||||
border-bottom: solid 1px $border-color;
|
||||
.mx-auto {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
// Form auto-complete menu
|
||||
.form-autocomplete .menu {
|
||||
.menu-item.selected > a, .menu-item > a:hover {
|
||||
background: $secondary-color;
|
||||
color: $primary-color;
|
||||
.ml-auto {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn.btn-wide {
|
||||
padding-left: $unit-6;
|
||||
padding-right: $unit-6;
|
||||
}
|
||||
|
||||
.btn.btn-sm.btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: $unit-h;
|
||||
|
||||
svg {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.group-item, .group-item:hover {
|
||||
color: $gray-color;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
font-size: 0.6rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
// Increase input font size on small viewports to prevent zooming on focus the input
|
||||
// on mobile devices. 430px relates to the "normalized" iPhone 14 Pro Max
|
||||
// viewport size
|
||||
@media screen and (max-width: 430px) {
|
||||
.form-input {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
50
bookmarks/styles/bookmark-form.scss
Normal file
50
bookmarks/styles/bookmark-form.scss
Normal file
@@ -0,0 +1,50 @@
|
||||
.bookmarks-form {
|
||||
|
||||
.btn.form-icon {
|
||||
padding: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
visibility: hidden;
|
||||
color: $gray-color;
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
color: $gray-color-dark;
|
||||
}
|
||||
|
||||
> svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.has-icon-right > input, .has-icon-right > textarea {
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
.has-icon-right > input:placeholder-shown ~ .btn.form-icon,
|
||||
.has-icon-right > textarea:placeholder-shown ~ .btn.form-icon {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.form-icon.loading {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.form-input-hint.bookmark-exists {
|
||||
display: none;
|
||||
color: $warning-color;
|
||||
|
||||
a {
|
||||
color: $warning-color;
|
||||
text-decoration: underline;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
details.notes textarea {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
@@ -1,3 +1,7 @@
|
||||
.bookmarks-page.grid {
|
||||
grid-gap: $unit-10;
|
||||
}
|
||||
|
||||
/* Bookmark search box */
|
||||
.bookmarks-page .search {
|
||||
$searchbox-width: 180px;
|
||||
@@ -62,9 +66,14 @@ li[ld-bookmark-item] {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&.unread .title a {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.title img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: $unit-h;
|
||||
vertical-align: text-top;
|
||||
}
|
||||
|
||||
@@ -80,11 +89,18 @@ li[ld-bookmark-item] {
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
.actions, .extra-actions {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
column-gap: $unit-2;
|
||||
}
|
||||
|
||||
@media (max-width: $size-sm) {
|
||||
.extra-actions {
|
||||
width: 100%;
|
||||
margin-top: $unit-1;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -108,24 +124,17 @@ li[ld-bookmark-item] {
|
||||
.separator {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.toggle-notes {
|
||||
align-self: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bookmark-pagination {
|
||||
margin-top: 1rem;
|
||||
margin-top: $unit-4;
|
||||
}
|
||||
|
||||
.tag-cloud {
|
||||
|
||||
.selected-tags {
|
||||
margin-bottom: 0.8rem;
|
||||
margin-bottom: $unit-4;
|
||||
|
||||
a, a:visited:hover {
|
||||
color: $error-color;
|
||||
@@ -139,7 +148,7 @@ li[ld-bookmark-item] {
|
||||
}
|
||||
|
||||
.group {
|
||||
margin-bottom: 0.4rem;
|
||||
margin-bottom: $unit-2;
|
||||
}
|
||||
|
||||
.highlight-char {
|
||||
@@ -149,63 +158,12 @@ li[ld-bookmark-item] {
|
||||
}
|
||||
}
|
||||
|
||||
.bookmarks-form {
|
||||
|
||||
.btn.form-icon {
|
||||
padding: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
visibility: hidden;
|
||||
color: $gray-color;
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
color: $gray-color-dark;
|
||||
}
|
||||
|
||||
> svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.has-icon-right > input, .has-icon-right > textarea {
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
.has-icon-right > input:placeholder-shown ~ .btn.form-icon,
|
||||
.has-icon-right > textarea:placeholder-shown ~ .btn.form-icon {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.form-icon.loading {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.form-input-hint.bookmark-exists {
|
||||
display: none;
|
||||
color: $warning-color;
|
||||
|
||||
a {
|
||||
color: $warning-color;
|
||||
text-decoration: underline;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
details.notes textarea {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmark notes */
|
||||
ul.bookmark-list {
|
||||
.notes {
|
||||
display: none;
|
||||
max-height: 300px;
|
||||
margin: 4px 0;
|
||||
margin: $unit-1 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -218,11 +176,11 @@ ul.bookmark-list {
|
||||
/* Bookmark notes markdown styles */
|
||||
ul.bookmark-list .notes-content {
|
||||
& {
|
||||
padding: 0.4rem 0.6rem;
|
||||
padding: $unit-2 $unit-3;
|
||||
}
|
||||
|
||||
p, ul, ol, pre, blockquote {
|
||||
margin: 0 0 0.4rem 0;
|
||||
margin: 0 0 $unit-2 0;
|
||||
}
|
||||
|
||||
> *:first-child {
|
||||
@@ -234,17 +192,17 @@ ul.bookmark-list .notes-content {
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin-left: 0.8rem;
|
||||
margin-left: $unit-4;
|
||||
}
|
||||
|
||||
ul li, ol li {
|
||||
margin-top: 0.2rem;
|
||||
margin-top: $unit-1;
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: 0.2rem 0.4rem;
|
||||
padding: $unit-1 $unit-2;
|
||||
background-color: $code-bg-color;
|
||||
border-radius: 0.2rem;
|
||||
border-radius: $unit-1;
|
||||
}
|
||||
|
||||
pre code {
|
||||
@@ -268,9 +226,9 @@ $bulk-edit-transition-duration: 400ms;
|
||||
|
||||
[ld-bulk-edit] {
|
||||
.bulk-edit-bar {
|
||||
margin-top: -17px;
|
||||
margin-bottom: 16px;
|
||||
margin-top: -1px;
|
||||
margin-left: -$bulk-edit-bar-offset;
|
||||
margin-bottom: $unit-4;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height $bulk-edit-transition-duration;
|
||||
@@ -309,7 +267,7 @@ $bulk-edit-transition-duration: 400ms;
|
||||
transition: all $bulk-edit-transition-duration;
|
||||
|
||||
.form-icon {
|
||||
top: 0.2rem;
|
||||
top: $unit-1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,9 +280,9 @@ $bulk-edit-transition-duration: 400ms;
|
||||
.bulk-edit-actions {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
padding: 4px 0;
|
||||
padding: $unit-1 0;
|
||||
border-top: solid 1px $border-color;
|
||||
gap: 8px;
|
||||
gap: $unit-2;
|
||||
|
||||
button {
|
||||
padding: 0 !important;
|
||||
@@ -334,10 +292,15 @@ $bulk-edit-transition-duration: 400ms;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
> input, .form-autocomplete {
|
||||
> input, .form-autocomplete, select {
|
||||
width: auto;
|
||||
max-width: 200px;
|
||||
max-width: 140px;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.select-across {
|
||||
margin: 0 0 0 auto;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,32 +0,0 @@
|
||||
/* Dark theme overrides */
|
||||
|
||||
/* Buttons */
|
||||
.btn.btn-primary {
|
||||
background: $dt-primary-button-color;
|
||||
border-color: darken($dt-primary-button-color, 5%);
|
||||
|
||||
&:hover, &:active, &:focus {
|
||||
background: darken($dt-primary-button-color, 5%);
|
||||
border-color: darken($dt-primary-button-color, 10%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus ring*/
|
||||
a:focus, .btn:focus {
|
||||
box-shadow: 0 0 0 .1rem rgba($primary-color, .5);
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.has-error .form-input, .form-input.is-error, .has-error .form-select, .form-select.is-error {
|
||||
background: darken($error-color, 40%);
|
||||
}
|
||||
|
||||
.form-checkbox input:checked + .form-icon, .form-radio input:checked + .form-icon, .form-switch input:checked + .form-icon {
|
||||
background: $dt-primary-button-color;
|
||||
border-color: $dt-primary-button-color;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination .page-item.active a {
|
||||
background: $dt-primary-button-color;
|
||||
}
|
108
bookmarks/styles/responsive.scss
Normal file
108
bookmarks/styles/responsive.scss
Normal file
@@ -0,0 +1,108 @@
|
||||
.container {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: 100%;
|
||||
max-width: $size-lg;
|
||||
}
|
||||
|
||||
.show-sm,
|
||||
.show-md {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.width-25 {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
.width-50 {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.width-75 {
|
||||
width: 75%;
|
||||
}
|
||||
|
||||
.width-100 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.grid {
|
||||
--grid-columns: 3;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--grid-columns), 1fr);
|
||||
grid-gap: $unit-4;
|
||||
}
|
||||
|
||||
.grid > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.col-1 {
|
||||
grid-column: unquote("span min(1, var(--grid-columns))");
|
||||
}
|
||||
|
||||
.col-2 {
|
||||
grid-column: unquote("span min(2, var(--grid-columns))");
|
||||
}
|
||||
|
||||
.col-3 {
|
||||
grid-column: unquote("span min(3, var(--grid-columns))");
|
||||
}
|
||||
|
||||
@media (max-width: $size-md) {
|
||||
.hide-md {
|
||||
display: none !important;
|
||||
}
|
||||
.show-md {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.width-md-25 {
|
||||
width: 25%;
|
||||
}
|
||||
.width-md-50 {
|
||||
width: 50%;
|
||||
}
|
||||
.width-md-75 {
|
||||
width: 75%;
|
||||
}
|
||||
.width-md-100 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.columns-md-1 {
|
||||
--grid-columns: 1;
|
||||
}
|
||||
.columns-md-2 {
|
||||
--grid-columns: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $size-sm) {
|
||||
.hide-sm {
|
||||
display: none !important;
|
||||
}
|
||||
.show-sm {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.width-sm-25 {
|
||||
width: 25%;
|
||||
}
|
||||
.width-sm-50 {
|
||||
width: 50%;
|
||||
}
|
||||
.width-sm-75 {
|
||||
width: 75%;
|
||||
}
|
||||
.width-sm-100 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.columns-sm-1 {
|
||||
--grid-columns: 1;
|
||||
}
|
||||
.columns-sm-2 {
|
||||
--grid-columns: 2;
|
||||
}
|
||||
}
|
@@ -1,10 +1,9 @@
|
||||
.settings-page {
|
||||
section.content-area {
|
||||
margin-bottom: 2rem;
|
||||
margin-bottom: $unit-12;
|
||||
|
||||
h2 {
|
||||
font-size: 1.0rem;
|
||||
margin-bottom: 0.8rem;
|
||||
margin-bottom: $unit-4;
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -1,28 +0,0 @@
|
||||
// Content area component
|
||||
section.content-area {
|
||||
|
||||
.content-area-header {
|
||||
border-bottom: solid 1px $border-color;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
line-height: 1.8rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm button component
|
||||
span.confirmation {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
span.confirmation .btn.btn-link {
|
||||
color: $error-color !important;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
110
bookmarks/styles/spectre.scss
Normal file
110
bookmarks/styles/spectre.scss
Normal file
@@ -0,0 +1,110 @@
|
||||
// Customized Spectre CSS imports, removing modules that are not used
|
||||
// See node_modules/spectre.css/src/spectre.scss for the original version
|
||||
|
||||
// Variables and mixins
|
||||
@import "../../node_modules/spectre.css/src/variables";
|
||||
@import "../../node_modules/spectre.css/src/mixins";
|
||||
|
||||
/*! Spectre.css v#{$version} | MIT License | github.com/picturepan2/spectre */
|
||||
// Reset and dependencies
|
||||
@import "../../node_modules/spectre.css/src/normalize";
|
||||
@import "../../node_modules/spectre.css/src/base";
|
||||
|
||||
// Elements
|
||||
@import "../../node_modules/spectre.css/src/typography";
|
||||
@import "../../node_modules/spectre.css/src/asian";
|
||||
@import "../../node_modules/spectre.css/src/tables";
|
||||
@import "../../node_modules/spectre.css/src/buttons";
|
||||
@import "../../node_modules/spectre.css/src/forms";
|
||||
@import "../../node_modules/spectre.css/src/labels";
|
||||
@import "../../node_modules/spectre.css/src/codes";
|
||||
@import "../../node_modules/spectre.css/src/media";
|
||||
|
||||
// Components
|
||||
@import "../../node_modules/spectre.css/src/dropdowns";
|
||||
@import "../../node_modules/spectre.css/src/empty";
|
||||
@import "../../node_modules/spectre.css/src/menus";
|
||||
@import "../../node_modules/spectre.css/src/pagination";
|
||||
@import "../../node_modules/spectre.css/src/tabs";
|
||||
@import "../../node_modules/spectre.css/src/toasts";
|
||||
@import "../../node_modules/spectre.css/src/tooltips";
|
||||
|
||||
// Utility classes
|
||||
@import "../../node_modules/spectre.css/src/animations";
|
||||
@import "../../node_modules/spectre.css/src/utilities";
|
||||
|
||||
// Auto-complete component
|
||||
@import "../../node_modules/spectre.css/src/autocomplete";
|
||||
|
||||
|
||||
/* Spectre overrides / fixes */
|
||||
|
||||
// Fix up visited styles
|
||||
a:visited {
|
||||
color: $link-color;
|
||||
}
|
||||
|
||||
a:visited:hover {
|
||||
color: $link-color-dark;
|
||||
}
|
||||
|
||||
.btn-link:visited:not(.btn-primary) {
|
||||
color: $link-color;
|
||||
}
|
||||
|
||||
.btn-link:visited:not(.btn-primary):hover {
|
||||
color: $link-color-dark;
|
||||
}
|
||||
|
||||
// Disable transitions on buttons, which can otherwise flicker while loading CSS file
|
||||
// something to do with .btn applying a transition for background, and then .btn-link setting a different background
|
||||
.btn {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
// Make code work with light and dark theme
|
||||
code {
|
||||
color: $gray-color-dark;
|
||||
background-color: $code-bg-color;
|
||||
box-shadow: 1px 1px 0 $code-shadow-color;
|
||||
}
|
||||
|
||||
// Remove left padding from first pagination link
|
||||
.pagination .page-item:first-child a {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
// Override border color for tab block
|
||||
.tab-block {
|
||||
border-bottom: solid 1px $border-color;
|
||||
}
|
||||
|
||||
// Fix padding for first menu item
|
||||
ul.menu li:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
// Form auto-complete menu
|
||||
.form-autocomplete .menu {
|
||||
.menu-item.selected > a, .menu-item > a:hover {
|
||||
background: $secondary-color;
|
||||
color: $primary-color;
|
||||
}
|
||||
|
||||
.group-item, .group-item:hover {
|
||||
color: $gray-color;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
font-size: 0.6rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
// Increase input font size on small viewports to prevent zooming on focus the input
|
||||
// on mobile devices. 430px relates to the "normalized" iPhone 14 Pro Max
|
||||
// viewport size
|
||||
@media screen and (max-width: 430px) {
|
||||
.form-input {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
@@ -2,16 +2,49 @@
|
||||
@import "variables-dark";
|
||||
|
||||
// Import Spectre CSS lib
|
||||
@import "../../node_modules/spectre.css/src/spectre";
|
||||
@import "../../node_modules/spectre.css/src/autocomplete";
|
||||
@import "spectre";
|
||||
|
||||
// Import style modules
|
||||
@import "base";
|
||||
@import "util";
|
||||
@import "shared";
|
||||
@import "bookmarks";
|
||||
@import "responsive";
|
||||
@import "bookmark-page";
|
||||
@import "bookmark-form";
|
||||
@import "settings";
|
||||
@import "auth";
|
||||
|
||||
// Dark theme overrides
|
||||
@import "dark";
|
||||
/* Dark theme overrides */
|
||||
|
||||
// Buttons
|
||||
.btn.btn-primary {
|
||||
background: $dt-primary-button-color;
|
||||
border-color: darken($dt-primary-button-color, 5%);
|
||||
|
||||
&:hover, &:active, &:focus {
|
||||
background: darken($dt-primary-button-color, 5%);
|
||||
border-color: darken($dt-primary-button-color, 10%);
|
||||
}
|
||||
}
|
||||
|
||||
// Focus ring
|
||||
a:focus, .btn:focus {
|
||||
box-shadow: 0 0 0 .1rem rgba($primary-color, .5);
|
||||
}
|
||||
|
||||
// Forms
|
||||
.form-input:not(:placeholder-shown):invalid,
|
||||
.form-input:not(:placeholder-shown):invalid:focus,
|
||||
.has-error .form-input,
|
||||
.form-input.is-error,
|
||||
.has-error .form-select,
|
||||
.form-select.is-error {
|
||||
background: darken($error-color, 40%);
|
||||
}
|
||||
|
||||
.form-checkbox input:checked + .form-icon, .form-radio input:checked + .form-icon, .form-switch input:checked + .form-icon {
|
||||
background: $dt-primary-button-color;
|
||||
border-color: $dt-primary-button-color;
|
||||
}
|
||||
|
||||
// Pagination
|
||||
.pagination .page-item.active a {
|
||||
background: $dt-primary-button-color;
|
||||
}
|
||||
|
@@ -2,13 +2,11 @@
|
||||
@import "variables-light";
|
||||
|
||||
// Import Spectre CSS lib
|
||||
@import "../../node_modules/spectre.css/src/spectre";
|
||||
@import "../../node_modules/spectre.css/src/autocomplete";
|
||||
@import "spectre";
|
||||
|
||||
// Import style modules
|
||||
@import "base";
|
||||
@import "util";
|
||||
@import "shared";
|
||||
@import "bookmarks";
|
||||
@import "responsive";
|
||||
@import "bookmark-page";
|
||||
@import "bookmark-form";
|
||||
@import "settings";
|
||||
@import "auth";
|
||||
|
@@ -1,21 +0,0 @@
|
||||
.spacer {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.truncate {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.text-gray-dark {
|
||||
color: $gray-color-dark;
|
||||
}
|
||||
|
||||
.align-baseline {
|
||||
align-items: baseline;
|
||||
}
|
@@ -4,25 +4,26 @@
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bookmarks-page columns"
|
||||
<div class="bookmarks-page grid columns-md-1"
|
||||
ld-bulk-edit
|
||||
ld-bookmark-page
|
||||
bookmarks-url="{% url 'bookmarks:partials.bookmark_list.archived' %}"
|
||||
tags-url="{% url 'bookmarks:partials.tag_cloud.archived' %}">
|
||||
|
||||
{# Bookmark list #}
|
||||
<section class="content-area column col-8 col-md-12">
|
||||
<div class="content-area-header">
|
||||
<section class="content-area col-2">
|
||||
<div class="content-area-header mb-0">
|
||||
<h2>Archived bookmarks</h2>
|
||||
<div class="spacer"></div>
|
||||
{% bookmark_search bookmark_list.filters tag_cloud.tags mode='archived' %}
|
||||
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||
<div class="d-flex">
|
||||
{% bookmark_search bookmark_list.filters tag_cloud.tags mode='archived' %}
|
||||
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="bookmark-actions" action="{% url 'bookmarks:action' %}?return_url={{ bookmark_list.return_url }}"
|
||||
<form class="bookmark-actions" action="{% url 'bookmarks:archived.action' %}?q={{ bookmark_list.filters.query }}&return_url={{ bookmark_list.return_url }}"
|
||||
method="post">
|
||||
{% csrf_token %}
|
||||
{% include 'bookmarks/bulk_edit/bar.html' with mode='archive' %}
|
||||
{% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_archive' %}
|
||||
|
||||
<div class="bookmark-list-container">
|
||||
{% include 'bookmarks/bookmark_list.html' %}
|
||||
@@ -31,7 +32,7 @@
|
||||
</section>
|
||||
|
||||
{# Tag cloud #}
|
||||
<section class="content-area column col-4 hide-md">
|
||||
<section class="content-area col-1 hide-md">
|
||||
<div class="content-area-header">
|
||||
<h2>Tags</h2>
|
||||
</div>
|
||||
@@ -41,5 +42,5 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="{% static "bundle.js" %}"></script>
|
||||
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
|
||||
{% endblock %}
|
||||
|
@@ -5,125 +5,117 @@
|
||||
{% if bookmark_list.is_empty %}
|
||||
{% include 'bookmarks/empty_bookmarks.html' %}
|
||||
{% else %}
|
||||
<ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}">
|
||||
{% for bookmark in bookmark_list.bookmarks_page %}
|
||||
<li ld-bookmark-item>
|
||||
<ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}"
|
||||
data-bookmarks-total="{{ bookmark_list.bookmarks_total }}">
|
||||
{% for bookmark_item in bookmark_list.items %}
|
||||
<li ld-bookmark-item{% if bookmark_item.css_classes %} class="{{ bookmark_item.css_classes }}"{% endif %}>
|
||||
<label ld-bulk-edit-checkbox class="form-checkbox">
|
||||
<input type="checkbox" name="bookmark_id" value="{{ bookmark.id }}">
|
||||
<input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}">
|
||||
<i class="form-icon"></i>
|
||||
</label>
|
||||
<div class="title">
|
||||
<a href="{{ bookmark.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
|
||||
class="{% if bookmark.unread %}text-italic{% endif %}">
|
||||
{% if bookmark.favicon_file and bookmark_list.show_favicons %}
|
||||
<img src="{% static bookmark.favicon_file %}" alt="">
|
||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener">
|
||||
{% if bookmark_item.favicon_file and bookmark_list.show_favicons %}
|
||||
<img src="{% static bookmark_item.favicon_file %}" alt="">
|
||||
{% endif %}
|
||||
{{ bookmark.resolved_title }}
|
||||
{{ bookmark_item.title }}
|
||||
</a>
|
||||
</div>
|
||||
{% if bookmark_list.show_url %}
|
||||
<div class="url-path truncate">
|
||||
<a href="{{ bookmark.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
|
||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
|
||||
class="url-display text-sm">
|
||||
{{ bookmark.url }}
|
||||
{{ bookmark_item.url }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="description truncate">
|
||||
{% if bookmark.tag_names %}
|
||||
{% if bookmark_item.tag_names %}
|
||||
<span>
|
||||
{% for tag_name in bookmark.tag_names %}
|
||||
{% for tag_name in bookmark_item.tag_names %}
|
||||
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if bookmark.tag_names and bookmark.resolved_description %} | {% endif %}
|
||||
{% if bookmark.resolved_description %}
|
||||
<span>{{ bookmark.resolved_description }}</span>
|
||||
{% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %}
|
||||
{% if bookmark_item.description %}
|
||||
<span>{{ bookmark_item.description }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if bookmark.notes %}
|
||||
{% if bookmark_item.notes %}
|
||||
<div class="notes bg-gray text-gray-dark">
|
||||
<div class="notes-content">
|
||||
{% markdown bookmark.notes %}
|
||||
{% markdown bookmark_item.notes %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="actions text-gray text-sm">
|
||||
{% if bookmark_list.date_display == 'relative' %}
|
||||
<span>
|
||||
{% if bookmark.web_archive_snapshot_url %}
|
||||
<a href="{{ bookmark.web_archive_snapshot_url }}"
|
||||
title="Show snapshot on the Internet Archive Wayback Machine"
|
||||
target="{{ bookmark_list.link_target }}"
|
||||
rel="noopener">
|
||||
{% endif %}
|
||||
<span>{{ bookmark.date_added|humanize_relative_date }}</span>
|
||||
{% if bookmark.web_archive_snapshot_url %}
|
||||
∞
|
||||
</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% if bookmark_item.display_date %}
|
||||
{% if bookmark_item.web_archive_snapshot_url %}
|
||||
<a href="{{ bookmark_item.web_archive_snapshot_url }}"
|
||||
title="Show snapshot on the Internet Archive Wayback Machine"
|
||||
target="{{ bookmark_list.link_target }}"
|
||||
rel="noopener">
|
||||
{{ bookmark_item.display_date }} ∞
|
||||
</a>
|
||||
{% else %}
|
||||
<span>{{ bookmark_item.display_date }}</span>
|
||||
{% endif %}
|
||||
<span class="separator">|</span>
|
||||
{% endif %}
|
||||
{% if bookmark_list.date_display == 'absolute' %}
|
||||
<span>
|
||||
{% if bookmark.web_archive_snapshot_url %}
|
||||
<a href="{{ bookmark.web_archive_snapshot_url }}"
|
||||
title="Show snapshot on the Internet Archive Wayback Machine"
|
||||
target="{{ bookmark_list.link_target }}"
|
||||
rel="noopener">
|
||||
{% endif %}
|
||||
<span>{{ bookmark.date_added|humanize_absolute_date }}</span>
|
||||
{% if bookmark.web_archive_snapshot_url %}
|
||||
∞
|
||||
</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="separator">|</span>
|
||||
{% endif %}
|
||||
{% if bookmark.owner == request.user %}
|
||||
{% if bookmark_item.is_editable %}
|
||||
{# Bookmark owner actions #}
|
||||
<a href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ bookmark_list.return_url }}">Edit</a>
|
||||
{% if bookmark.is_archived %}
|
||||
<button type="submit" name="unarchive" value="{{ bookmark.id }}"
|
||||
<a href="{% url 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url }}">Edit</a>
|
||||
{% if bookmark_item.is_archived %}
|
||||
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Unarchive
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" name="archive" value="{{ bookmark.id }}"
|
||||
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Archive
|
||||
</button>
|
||||
{% endif %}
|
||||
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark.id }}"
|
||||
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Remove
|
||||
</button>
|
||||
{% if bookmark.unread %}
|
||||
<span class="separator">|</span>
|
||||
<button type="submit" name="mark_as_read" value="{{ bookmark.id }}"
|
||||
class="btn btn-link btn-sm">Mark as read
|
||||
</button>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{# Shared bookmark actions #}
|
||||
<span>Shared by
|
||||
<a href="?{% replace_query_param user=bookmark.owner.username %}">{{ bookmark.owner.username }}</a>
|
||||
<a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if bookmark.notes and not bookmark_list.show_notes %}
|
||||
<span class="separator">|</span>
|
||||
<button class="btn btn-link btn-sm toggle-notes" title="Toggle notes">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-notes" width="16"
|
||||
height="16"
|
||||
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="M5 3m0 2a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z"></path>
|
||||
<path d="M9 7l6 0"></path>
|
||||
<path d="M9 11l6 0"></path>
|
||||
<path d="M9 15l4 0"></path>
|
||||
</svg>
|
||||
<span>Notes</span>
|
||||
</button>
|
||||
{% if bookmark_item.has_extra_actions %}
|
||||
<div class="extra-actions">
|
||||
<span class="separator hide-sm">|</span>
|
||||
{% if bookmark_item.show_mark_as_read %}
|
||||
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button confirm-icon="ld-icon-read" confirm-question="Mark as read?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-unread"></use>
|
||||
</svg>
|
||||
Unread
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_unshare %}
|
||||
<button type="submit" name="unshare" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button confirm-icon="ld-icon-unshare" confirm-question="Unshare?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-share"></use>
|
||||
</svg>
|
||||
Shared
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_notes_button %}
|
||||
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-note"></use>
|
||||
</svg>
|
||||
Notes
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
|
@@ -3,32 +3,37 @@
|
||||
<div class="bulk-edit-bar">
|
||||
<div class="bulk-edit-actions bg-gray">
|
||||
<label ld-bulk-edit-checkbox all class="form-checkbox">
|
||||
<input type="checkbox" style="display: none">
|
||||
<input type="checkbox">
|
||||
<i class="form-icon"></i>
|
||||
</label>
|
||||
{% if mode == 'archive' %}
|
||||
<button ld-confirm-button type="submit" name="bulk_unarchive" class="btn btn-link btn-sm"
|
||||
title="Unarchive selected bookmarks">Unarchive
|
||||
</button>
|
||||
{% else %}
|
||||
<button ld-confirm-button type="submit" name="bulk_archive" class="btn btn-link btn-sm"
|
||||
title="Archive selected bookmarks">Archive
|
||||
</button>
|
||||
{% endif %}
|
||||
<span class="text-sm text-gray-dark">•</span>
|
||||
<button ld-confirm-button type="submit" name="bulk_delete" class="btn btn-link btn-sm"
|
||||
title="Delete selected bookmarks">Delete
|
||||
</button>
|
||||
<span class="text-sm text-gray-dark">•</span>
|
||||
<span class="text-sm text-gray-dark"><label for="bulk-edit-tags-input">Tags:</label></span>
|
||||
<input ld-tag-autocomplete variant="small"
|
||||
name="bulk_tag_string" class="form-input input-sm" placeholder=" ">
|
||||
<button type="submit" name="bulk_tag" class="btn btn-link btn-sm"
|
||||
title="Add tags to selected bookmarks">Add
|
||||
</button>
|
||||
<button type="submit" name="bulk_untag" class="btn btn-link btn-sm"
|
||||
title="Remove tags from selected bookmarks">Remove
|
||||
</button>
|
||||
<select name="bulk_action" class="form-select select-sm">
|
||||
{% if not 'bulk_archive' in disable_actions %}
|
||||
<option value="bulk_archive">Archive</option>
|
||||
{% endif %}
|
||||
{% if not 'bulk_unarchive' in disable_actions %}
|
||||
<option value="bulk_unarchive">Unarchive</option>
|
||||
{% endif %}
|
||||
<option value="bulk_delete">Delete</option>
|
||||
<option value="bulk_tag">Add tags</option>
|
||||
<option value="bulk_untag">Remove tags</option>
|
||||
<option value="bulk_read">Mark as read</option>
|
||||
<option value="bulk_unread">Mark as unread</option>
|
||||
{% if request.user_profile.enable_sharing %}
|
||||
<option value="bulk_share">Share</option>
|
||||
<option value="bulk_unshare">Unshare</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
<div class="tag-autocomplete d-none">
|
||||
<input ld-tag-autocomplete variant="small"
|
||||
name="bulk_tag_string" class="form-input input-sm" placeholder="Tag names...">
|
||||
</div>
|
||||
<button ld-confirm-button type="submit" name="bulk_execute" class="btn btn-link btn-sm">Execute</button>
|
||||
|
||||
<label class="form-checkbox select-across d-none">
|
||||
<input type="checkbox" name="bulk_select_across">
|
||||
<i class="form-icon"></i>
|
||||
All pages (<span class="total">{{ bookmark_list.bookmarks_total }}</span> bookmarks)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endhtmlmin %}
|
||||
|
@@ -2,15 +2,13 @@
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block content %}
|
||||
<div class="columns">
|
||||
<section class="content-area column col-12">
|
||||
<div class="content-area-header">
|
||||
<h2>Edit bookmark</h2>
|
||||
</div>
|
||||
<form action="{% url 'bookmarks:edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post"
|
||||
class="col-6 col-md-12" novalidate>
|
||||
{% bookmark_form form return_url bookmark_id %}
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<section class="content-area">
|
||||
<div class="content-area-header">
|
||||
<h2>Edit bookmark</h2>
|
||||
</div>
|
||||
<form action="{% url 'bookmarks:edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post"
|
||||
class="width-50 width-md-100" novalidate>
|
||||
{% bookmark_form form return_url bookmark_id %}
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
@@ -116,8 +116,7 @@
|
||||
<a href="{{ cancel_url }}" class="btn">Nevermind</a>
|
||||
</div>
|
||||
|
||||
{# Replace tag input with auto-complete component #}
|
||||
<script src="{% static "bundle.js" %}"></script>
|
||||
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
|
||||
<script type="application/javascript">
|
||||
/**
|
||||
* - Pre-fill title and description placeholders with metadata from website as soon as URL changes
|
||||
|
@@ -4,25 +4,26 @@
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bookmarks-page columns"
|
||||
<div class="bookmarks-page grid columns-md-1"
|
||||
ld-bulk-edit
|
||||
ld-bookmark-page
|
||||
bookmarks-url="{% url 'bookmarks:partials.bookmark_list.active' %}"
|
||||
tags-url="{% url 'bookmarks:partials.tag_cloud.active' %}">
|
||||
|
||||
{# Bookmark list #}
|
||||
<section class="content-area column col-8 col-md-12">
|
||||
<div class="content-area-header">
|
||||
<section class="content-area col-2">
|
||||
<div class="content-area-header mb-0">
|
||||
<h2>Bookmarks</h2>
|
||||
<div class="spacer"></div>
|
||||
{% bookmark_search bookmark_list.filters tag_cloud.tags %}
|
||||
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||
<div class="d-flex">
|
||||
{% bookmark_search bookmark_list.filters tag_cloud.tags %}
|
||||
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="bookmark-actions" action="{% url 'bookmarks:action' %}?return_url={{ bookmark_list.return_url }}"
|
||||
method="post">
|
||||
<form class="bookmark-actions" action="{% url 'bookmarks:index.action' %}?q={{ bookmark_list.filters.query }}&return_url={{ bookmark_list.return_url }}"
|
||||
method="post" autocomplete="off">
|
||||
{% csrf_token %}
|
||||
{% include 'bookmarks/bulk_edit/bar.html' with mode='default' %}
|
||||
{% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_unarchive' %}
|
||||
|
||||
<div class="bookmark-list-container">
|
||||
{% include 'bookmarks/bookmark_list.html' %}
|
||||
@@ -31,8 +32,8 @@
|
||||
</section>
|
||||
|
||||
{# Tag cloud #}
|
||||
<section class="content-area column col-4 hide-md">
|
||||
<div class="content-area-header">
|
||||
<section class="content-area col-1 hide-md">
|
||||
<div class="content-area-header mb-4">
|
||||
<h2>Tags</h2>
|
||||
</div>
|
||||
<div class="tag-cloud-container">
|
||||
@@ -41,5 +42,5 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="{% static "bundle.js" %}"></script>
|
||||
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
|
||||
{% endblock %}
|
||||
|
@@ -18,21 +18,81 @@
|
||||
{# Include SASS styles, files are resolved from bookmarks/styles #}
|
||||
{# Include specific theme variant based on user profile setting #}
|
||||
{% if request.user_profile.theme == 'light' %}
|
||||
<link href="{% sass_src 'theme-light.scss' %}" rel="stylesheet" type="text/css"/>
|
||||
<link href="{% sass_src 'theme-light.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
||||
{% elif request.user_profile.theme == 'dark' %}
|
||||
<link href="{% sass_src 'theme-dark.scss' %}" rel="stylesheet" type="text/css"/>
|
||||
<link href="{% sass_src 'theme-dark.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
||||
{% else %}
|
||||
{# Use auto theme as fallback #}
|
||||
<link href="{% sass_src 'theme-dark.scss' %}" rel="stylesheet" type="text/css"
|
||||
<link href="{% sass_src 'theme-dark.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
|
||||
media="(prefers-color-scheme: dark)"/>
|
||||
<link href="{% sass_src 'theme-light.scss' %}" rel="stylesheet" type="text/css"
|
||||
<link href="{% sass_src 'theme-light.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
|
||||
media="(prefers-color-scheme: light)"/>
|
||||
{% endif %}
|
||||
</head>
|
||||
<body ld-global-shortcuts>
|
||||
<header>
|
||||
|
||||
<div class="d-none">
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="ld-icon-unread" 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="M3 19a9 9 0 0 1 9 0a9 9 0 0 1 9 0"></path>
|
||||
<path d="M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0"></path>
|
||||
<path d="M3 6l0 13"></path>
|
||||
<path d="M12 6l0 13"></path>
|
||||
<path d="M21 6l0 13"></path>
|
||||
</symbol>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="ld-icon-read" 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="M3 19a9 9 0 0 1 9 0a9 9 0 0 1 5.899 -1.096"></path>
|
||||
<path d="M3 6a9 9 0 0 1 2.114 -.884m3.8 -.21c1.07 .17 2.116 .534 3.086 1.094a9 9 0 0 1 9 0"></path>
|
||||
<path d="M3 6v13"></path>
|
||||
<path d="M12 6v2m0 4v7"></path>
|
||||
<path d="M21 6v11"></path>
|
||||
<path d="M3 3l18 18"></path>
|
||||
</symbol>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="ld-icon-share" 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="M6 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
|
||||
<path d="M18 6m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
|
||||
<path d="M18 18m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
|
||||
<path d="M8.7 10.7l6.6 -3.4"></path>
|
||||
<path d="M8.7 13.3l6.6 3.4"></path>
|
||||
</symbol>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="ld-icon-unshare" 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="M6 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
|
||||
<path d="M18 6m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
|
||||
<path d="M15.861 15.896a3 3 0 0 0 4.265 4.22m.578 -3.417a3.012 3.012 0 0 0 -1.507 -1.45"></path>
|
||||
<path d="M8.7 10.7l1.336 -.688m2.624 -1.352l2.64 -1.36"></path>
|
||||
<path d="M8.7 13.3l6.6 3.4"></path>
|
||||
<path d="M3 3l18 18"></path>
|
||||
</symbol>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="ld-icon-note" 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="M5 3m0 2a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z"></path>
|
||||
<path d="M9 7l6 0"></path>
|
||||
<path d="M9 11l6 0"></path>
|
||||
<path d="M9 15l4 0"></path>
|
||||
</symbol>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<header class="container">
|
||||
{% if has_toasts %}
|
||||
<div class="toasts container grid-lg">
|
||||
<div class="toasts">
|
||||
<form action="{% url 'bookmarks:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post">
|
||||
{% csrf_token %}
|
||||
{% for toast in toast_messages %}
|
||||
@@ -44,27 +104,21 @@
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="navbar container grid-lg">
|
||||
<section class="navbar-section">
|
||||
<a href="{% url 'bookmarks:index' %}" class="navbar-brand text-bold">
|
||||
<img class="logo" src="{% static 'logo.png' %}" alt="Application logo">
|
||||
<h1>linkding</h1>
|
||||
</a>
|
||||
</section>
|
||||
<div class="d-flex justify-between">
|
||||
<a href="{% url 'bookmarks:index' %}" class="d-flex align-center">
|
||||
<img class="logo" src="{% static 'logo.png' %}" alt="Application logo">
|
||||
<h1>LINKDING</h1>
|
||||
</a>
|
||||
{% if request.user.is_authenticated %}
|
||||
{# Only show nav items menu when logged in #}
|
||||
<section class="navbar-section">
|
||||
{% include 'bookmarks/nav_menu.html' %}
|
||||
</section>
|
||||
{% include 'bookmarks/nav_menu.html' %}
|
||||
{% elif has_public_shares %}
|
||||
{# Otherwise show link to shared bookmarks if there are publicly shared bookmarks #}
|
||||
<section class="navbar-section">
|
||||
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared bookmarks</a>
|
||||
</section>
|
||||
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared bookmarks</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
<div class="content container grid-lg">
|
||||
<div class="content container">
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
@@ -2,14 +2,12 @@
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block content %}
|
||||
<div class="columns">
|
||||
<section class="content-area column col-12">
|
||||
<div class="content-area-header">
|
||||
<h2>New bookmark</h2>
|
||||
</div>
|
||||
<form action="{% url 'bookmarks:new' %}" method="post" class="col-6 col-md-12" novalidate>
|
||||
{% bookmark_form form return_url auto_close=auto_close %}
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<section class="content-area">
|
||||
<div class="content-area-header">
|
||||
<h2>New bookmark</h2>
|
||||
</div>
|
||||
<form action="{% url 'bookmarks:new' %}" method="post" class="width-50 width-md-100" novalidate>
|
||||
{% bookmark_form form return_url auto_close=auto_close %}
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
@@ -34,6 +34,7 @@
|
||||
value: '{{ filters.query }}',
|
||||
tags: uniqueTags,
|
||||
mode: '{{ mode }}',
|
||||
linkTarget: '{{ request.user_profile.bookmark_link_target }}',
|
||||
apiClient,
|
||||
filters,
|
||||
}
|
||||
|
@@ -4,20 +4,19 @@
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bookmarks-page columns"
|
||||
<div class="bookmarks-page grid columns-md-1"
|
||||
ld-bookmark-page
|
||||
bookmarks-url="{% url 'bookmarks:partials.bookmark_list.shared' %}"
|
||||
tags-url="{% url 'bookmarks:partials.tag_cloud.shared' %}">
|
||||
|
||||
{# Bookmark list #}
|
||||
<section class="content-area column col-8 col-md-12">
|
||||
<section class="content-area col-2">
|
||||
<div class="content-area-header">
|
||||
<h2>Shared bookmarks</h2>
|
||||
<div class="spacer"></div>
|
||||
{% bookmark_search bookmark_list.filters tag_cloud.tags mode='shared' %}
|
||||
</div>
|
||||
|
||||
<form class="bookmark-actions" action="{% url 'bookmarks:action' %}?return_url={{ bookmark_list.return_url }}"
|
||||
<form class="bookmark-actions" action="{% url 'bookmarks:shared.action' %}?return_url={{ bookmark_list.return_url }}"
|
||||
method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
@@ -28,7 +27,7 @@
|
||||
</section>
|
||||
|
||||
{# Filters #}
|
||||
<section class="content-area column col-4 hide-md">
|
||||
<section class="content-area col-1 hide-md">
|
||||
<div class="content-area-header">
|
||||
<h2>User</h2>
|
||||
</div>
|
||||
@@ -45,5 +44,5 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="{% static "bundle.js" %}"></script>
|
||||
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
|
||||
{% endblock %}
|
||||
|
@@ -4,13 +4,5 @@
|
||||
{% block title %}Registration complete{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="auth-page">
|
||||
<div class="columns">
|
||||
<section class="content-area column col-12">
|
||||
<p>Registration complete. You can now use the application.</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>Registration complete. You can now use the application.</p>
|
||||
{% endblock %}
|
||||
|
@@ -4,41 +4,35 @@
|
||||
{% block title %}Registration{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="auth-page">
|
||||
<div class="columns">
|
||||
<section class="content-area column col-5 col-md-12">
|
||||
<div class="content-area-header">
|
||||
<h2>Register</h2>
|
||||
</div>
|
||||
<form method="post" action="{% url 'django_registration_register' %}">
|
||||
{% csrf_token %}
|
||||
<div class="form-group {% if form.errors.username %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.username.id_for_label }}">Username</label>
|
||||
{{ form.username|add_class:'form-input' }}
|
||||
<div class="form-input-hint">{{ form.errors.username }}</div>
|
||||
</div>
|
||||
<div class="form-group {% if form.errors.email %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.email.id_for_label }}">Email</label>
|
||||
{{ form.email|add_class:'form-input' }}
|
||||
<div class="form-input-hint">{{ form.errors.email }}</div>
|
||||
</div>
|
||||
<div class="form-group {% if form.errors.password1 %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.password1.id_for_label }}">Password</label>
|
||||
{{ form.password1|add_class:'form-input' }}
|
||||
<div class="form-input-hint">{{ form.errors.password1 }}</div>
|
||||
</div>
|
||||
<div class="form-group {% if form.errors.password2 %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.password2.id_for_label }}">Confirm Password</label>
|
||||
{{ form.password2|add_class:'form-input' }}
|
||||
<div class="form-input-hint">{{ form.errors.password2 }}</div>
|
||||
</div>
|
||||
<br/>
|
||||
<input type="submit" value="Register" class="btn btn-primary col-md-12">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<section class="content-area mx-auto width-50 width-md-100">
|
||||
<div class="content-area-header">
|
||||
<h2>Register</h2>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{% url 'django_registration_register' %}" novalidate>
|
||||
{% csrf_token %}
|
||||
<div class="form-group {% if form.errors.username %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.username.id_for_label }}">Username</label>
|
||||
{{ form.username|add_class:'form-input'|attr:"placeholder: " }}
|
||||
<div class="form-input-hint">{{ form.errors.username }}</div>
|
||||
</div>
|
||||
<div class="form-group {% if form.errors.email %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.email.id_for_label }}">Email</label>
|
||||
{{ form.email|add_class:'form-input'|attr:"placeholder: " }}
|
||||
<div class="form-input-hint">{{ form.errors.email }}</div>
|
||||
</div>
|
||||
<div class="form-group {% if form.errors.password1 %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.password1.id_for_label }}">Password</label>
|
||||
{{ form.password1|add_class:'form-input'|attr:"placeholder: " }}
|
||||
<div class="form-input-hint">{{ form.errors.password1 }}</div>
|
||||
</div>
|
||||
<div class="form-group {% if form.errors.password2 %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.password2.id_for_label }}">Confirm Password</label>
|
||||
{{ form.password2|add_class:'form-input'|attr:"placeholder: " }}
|
||||
<div class="form-input-hint">{{ form.errors.password2 }}</div>
|
||||
</div>
|
||||
<br/>
|
||||
<input type="submit" value="Register" class="btn btn-primary btn-wide">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
@@ -4,46 +4,35 @@
|
||||
{% block title %}Login{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="auth-page">
|
||||
<div class="columns">
|
||||
<section class="content-area column col-5 col-md-12">
|
||||
<div class="content-area-header">
|
||||
<h2>Login</h2>
|
||||
</div>
|
||||
<form method="post" action="{% url 'login' %}">
|
||||
{% csrf_token %}
|
||||
{% if form.errors %}
|
||||
<div class="form-group has-error">
|
||||
<p class="form-input-hint">Your username and password didn't match. Please try again.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="{{ form.username.id_for_label }}">Username</label>
|
||||
{{ form.username|add_class:'form-input'|attr:"placeholder: " }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="{{ form.password.id_for_label }}">Password</label>
|
||||
{{ form.password|add_class:'form-input'|attr:"placeholder: " }}
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
<div class="columns">
|
||||
<div class="column col-3">
|
||||
<input type="submit" value="Login" class="btn btn-primary">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
</div>
|
||||
{% if allow_registration %}
|
||||
<div class="column col-auto col-ml-auto">
|
||||
<a href="{% url 'django_registration_register' %}" class="btn btn-link">Register</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<section class="content-area mx-auto width-50 width-md-100">
|
||||
<div class="content-area-header">
|
||||
<h2>Login</h2>
|
||||
</div>
|
||||
<form method="post" action="{% url 'login' %}">
|
||||
{% csrf_token %}
|
||||
{% if form.errors %}
|
||||
<div class="form-group has-error">
|
||||
<p class="form-input-hint">Your username and password didn't match. Please try again.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="{{ form.username.id_for_label }}">Username</label>
|
||||
{{ form.username|add_class:'form-input'|attr:"placeholder: " }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="{{ form.password.id_for_label }}">Password</label>
|
||||
{{ form.password|add_class:'form-input'|attr:"placeholder: " }}
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
<div class="d-flex justify-between">
|
||||
<input type="submit" value="Login" class="btn btn-primary btn-wide">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
{% if allow_registration %}
|
||||
<a href="{% url 'django_registration_register' %}" class="btn btn-link">Register</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
@@ -4,18 +4,12 @@
|
||||
{% block title %}Password changed{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="auth-page">
|
||||
<div class="columns">
|
||||
<section class="content-area column col-5 col-md-12">
|
||||
<div class="content-area-header">
|
||||
<h2>Password Changed</h2>
|
||||
</div>
|
||||
<p class="text-success">
|
||||
Your password was changed successfully.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
<section class="content-area mx-auto width-50 width-md-100">
|
||||
<div class="content-area-header">
|
||||
<h2>Password Changed</h2>
|
||||
</div>
|
||||
|
||||
<p class="text-success">
|
||||
Your password was changed successfully.
|
||||
</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
@@ -4,52 +4,42 @@
|
||||
{% block title %}Change Password{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="auth-page">
|
||||
<div class="columns">
|
||||
<section class="content-area column col-5 col-md-12">
|
||||
<div class="content-area-header">
|
||||
<h2>Change Password</h2>
|
||||
</div>
|
||||
<form method="post" action="{% url 'change_password' %}">
|
||||
{% csrf_token %}
|
||||
<div class="form-group {% if form.old_password.errors %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.old_password.id_for_label }}">Old password</label>
|
||||
{{ form.old_password|add_class:'form-input'|attr:"placeholder: " }}
|
||||
{% if form.old_password.errors %}
|
||||
<div class="form-input-hint">
|
||||
{{ form.old_password.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-group {% if form.new_password1.errors %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.new_password1.id_for_label }}">New password</label>
|
||||
{{ form.new_password1|add_class:'form-input'|attr:"placeholder: " }}
|
||||
{% if form.new_password1.errors %}
|
||||
<div class="form-input-hint">
|
||||
{{ form.new_password1.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-group {% if form.new_password2.errors %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.new_password2.id_for_label }}">Confirm new password</label>
|
||||
{{ form.new_password2|add_class:'form-input'|attr:"placeholder: " }}
|
||||
{% if form.new_password2.errors %}
|
||||
<div class="form-input-hint">
|
||||
{{ form.new_password2.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
<div class="columns">
|
||||
<div class="column col-3">
|
||||
<input type="submit" value="Change Password" class="btn btn-primary">
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<section class="content-area mx-auto width-50 width-md-100">
|
||||
<div class="content-area-header">
|
||||
<h2>Change Password</h2>
|
||||
</div>
|
||||
<form method="post" action="{% url 'change_password' %}">
|
||||
{% csrf_token %}
|
||||
<div class="form-group {% if form.old_password.errors %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.old_password.id_for_label }}">Old password</label>
|
||||
{{ form.old_password|add_class:'form-input'|attr:"placeholder: " }}
|
||||
{% if form.old_password.errors %}
|
||||
<div class="form-input-hint">
|
||||
{{ form.old_password.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-group {% if form.new_password1.errors %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.new_password1.id_for_label }}">New password</label>
|
||||
{{ form.new_password1|add_class:'form-input'|attr:"placeholder: " }}
|
||||
{% if form.new_password1.errors %}
|
||||
<div class="form-input-hint">
|
||||
{{ form.new_password1.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-group {% if form.new_password2.errors %}has-error{% endif %}">
|
||||
<label class="form-label" for="{{ form.new_password2.id_for_label }}">Confirm new password</label>
|
||||
{{ form.new_password2|add_class:'form-input'|attr:"placeholder: " }}
|
||||
{% if form.new_password2.errors %}
|
||||
<div class="form-input-hint">
|
||||
{{ form.new_password2.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
<input type="submit" value="Change Password" class="btn btn-primary btn-wide">
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
@@ -16,14 +16,14 @@
|
||||
{% csrf_token %}
|
||||
<div class="form-group">
|
||||
<label for="{{ form.theme.id_for_label }}" class="form-label">Theme</label>
|
||||
{{ form.theme|add_class:"form-select col-2 col-sm-12" }}
|
||||
{{ form.theme|add_class:"form-select width-25 width-sm-100" }}
|
||||
<div class="form-input-hint">
|
||||
Whether to use a light or dark theme, or automatically adjust the theme based on your system's settings.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.bookmark_date_display.id_for_label }}" class="form-label">Bookmark date format</label>
|
||||
{{ form.bookmark_date_display|add_class:"form-select col-2 col-sm-12" }}
|
||||
{{ form.bookmark_date_display|add_class:"form-select width-25 width-sm-100" }}
|
||||
<div class="form-input-hint">
|
||||
Whether to show bookmark dates as relative (how long ago), or as absolute dates. Alternatively the date can
|
||||
be hidden.
|
||||
@@ -50,14 +50,14 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.bookmark_link_target.id_for_label }}" class="form-label">Open bookmarks in</label>
|
||||
{{ form.bookmark_link_target|add_class:"form-select col-2 col-sm-12" }}
|
||||
{{ form.bookmark_link_target|add_class:"form-select width-25 width-sm-100" }}
|
||||
<div class="form-input-hint">
|
||||
Whether to open bookmarks a new page or in the same page.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.tag_search.id_for_label }}" class="form-label">Tag search</label>
|
||||
{{ form.tag_search|add_class:"form-select col-2 col-sm-12" }}
|
||||
{{ form.tag_search|add_class:"form-select width-25 width-sm-100" }}
|
||||
<div class="form-input-hint">
|
||||
In strict mode, tags must be prefixed with a hash character (#).
|
||||
In lax mode, tags can also be searched without the hash character.
|
||||
@@ -92,7 +92,7 @@
|
||||
<div class="form-group">
|
||||
<label for="{{ form.web_archive_integration.id_for_label }}" class="form-label">Internet Archive
|
||||
integration</label>
|
||||
{{ form.web_archive_integration|add_class:"form-select col-2 col-sm-12" }}
|
||||
{{ form.web_archive_integration|add_class:"form-select width-25 width-sm-100" }}
|
||||
<div class="form-input-hint">
|
||||
Enabling this feature will automatically create snapshots of bookmarked websites on the <a
|
||||
href="https://web.archive.org/" target="_blank" rel="noopener">Internet Archive Wayback
|
||||
@@ -155,9 +155,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="input-group col-8 col-md-12">
|
||||
<div class="input-group width-75 width-md-100">
|
||||
<input class="form-input" type="file" name="import_file">
|
||||
<input type="submit" class="input-group-btn col-2 btn btn-primary" value="Upload">
|
||||
<input type="submit" class="input-group-btn btn btn-primary" value="Upload">
|
||||
</div>
|
||||
{% if import_success_message %}
|
||||
<div class="has-success">
|
||||
|
@@ -33,7 +33,7 @@
|
||||
<p>The following token can be used to authenticate 3rd-party applications against the REST API:</p>
|
||||
<div class="form-group">
|
||||
<div class="columns">
|
||||
<div class="column col-6 col-md-12">
|
||||
<div class="column width-50 width-md-100">
|
||||
<input class="form-input" value="{{ api_token }}" readonly>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -23,6 +23,7 @@ def bookmark_search(context, filters: BookmarkFilters, tags: [Tag], mode: str =
|
||||
tag_names = [tag.name for tag in tags]
|
||||
tags_string = build_tag_string(tag_names, ' ')
|
||||
return {
|
||||
'request': context['request'],
|
||||
'filters': filters,
|
||||
'tags_string': tags_string,
|
||||
'mode': mode,
|
||||
|
@@ -100,11 +100,12 @@ class BookmarkFactoryMixin:
|
||||
|
||||
for i in range(1, count + 1):
|
||||
title = f'{prefix} {i}{suffix}'
|
||||
url = f'https://example.com/{prefix}/{i}'
|
||||
tags = []
|
||||
if with_tags:
|
||||
tag_name = f'{tag_prefix} {i}{suffix}'
|
||||
tags = [self.setup_tag(name=tag_name)]
|
||||
self.setup_bookmark(title=title, is_archived=archived, shared=shared, tags=tags, user=user)
|
||||
self.setup_bookmark(url=url, title=title, is_archived=archived, shared=shared, tags=tags, user=user)
|
||||
|
||||
def get_numbered_bookmark(self, title: str):
|
||||
return Bookmark.objects.get(title=title)
|
||||
@@ -251,3 +252,8 @@ def disable_logging(f):
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def collapse_whitespace(text: str):
|
||||
text = text.replace('\n', '').replace('\r', '')
|
||||
return ' '.join(text.split())
|
||||
|
@@ -22,7 +22,7 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_archive_should_archive_bookmark(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'archive': [bookmark.id],
|
||||
})
|
||||
|
||||
@@ -34,7 +34,7 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark = self.setup_bookmark(user=other_user)
|
||||
|
||||
response = self.client.post(reverse('bookmarks:action'), {
|
||||
response = self.client.post(reverse('bookmarks:index.action'), {
|
||||
'archive': [bookmark.id],
|
||||
})
|
||||
|
||||
@@ -46,7 +46,7 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_unarchive_should_unarchive_bookmark(self):
|
||||
bookmark = self.setup_bookmark(is_archived=True)
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'unarchive': [bookmark.id],
|
||||
})
|
||||
bookmark.refresh_from_db()
|
||||
@@ -57,7 +57,7 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark = self.setup_bookmark(is_archived=True, user=other_user)
|
||||
|
||||
response = self.client.post(reverse('bookmarks:action'), {
|
||||
response = self.client.post(reverse('bookmarks:index.action'), {
|
||||
'unarchive': [bookmark.id],
|
||||
})
|
||||
bookmark.refresh_from_db()
|
||||
@@ -68,7 +68,7 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_delete_should_delete_bookmark(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'remove': [bookmark.id],
|
||||
})
|
||||
|
||||
@@ -78,7 +78,7 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark = self.setup_bookmark(user=other_user)
|
||||
|
||||
response = self.client.post(reverse('bookmarks:action'), {
|
||||
response = self.client.post(reverse('bookmarks:index.action'), {
|
||||
'remove': [bookmark.id],
|
||||
})
|
||||
self.assertEqual(response.status_code, 404)
|
||||
@@ -87,20 +87,45 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_mark_as_read(self):
|
||||
bookmark = self.setup_bookmark(unread=True)
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'mark_as_read': [bookmark.id],
|
||||
})
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertFalse(bookmark.unread)
|
||||
|
||||
def test_unshare_should_unshare_bookmark(self):
|
||||
bookmark = self.setup_bookmark(shared=True)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'unshare': [bookmark.id],
|
||||
})
|
||||
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertFalse(bookmark.shared)
|
||||
|
||||
def test_can_only_unshare_own_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||
|
||||
response = self.client.post(reverse('bookmarks:index.action'), {
|
||||
'unshare': [bookmark.id],
|
||||
})
|
||||
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertTrue(bookmark.shared)
|
||||
|
||||
def test_bulk_archive(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_archive': [''],
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_archive'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
@@ -114,8 +139,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark(user=other_user)
|
||||
bookmark3 = self.setup_bookmark(user=other_user)
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_archive': [''],
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_archive'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
@@ -128,8 +154,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark(is_archived=True)
|
||||
bookmark3 = self.setup_bookmark(is_archived=True)
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_unarchive': [''],
|
||||
self.client.post(reverse('bookmarks:archived.action'), {
|
||||
'bulk_action': ['bulk_unarchive'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
@@ -143,8 +170,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark(is_archived=True, user=other_user)
|
||||
bookmark3 = self.setup_bookmark(is_archived=True, user=other_user)
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_unarchive': [''],
|
||||
self.client.post(reverse('bookmarks:archived.action'), {
|
||||
'bulk_action': ['bulk_unarchive'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
@@ -157,8 +185,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_delete': [''],
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_delete'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
@@ -172,8 +201,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark(user=other_user)
|
||||
bookmark3 = self.setup_bookmark(user=other_user)
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_delete': [''],
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_delete'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
@@ -188,8 +218,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_tag': [''],
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_tag'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
@@ -210,8 +241,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_tag': [''],
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_tag'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
@@ -231,8 +263,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_untag': [''],
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_untag'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
@@ -253,8 +286,9 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark(tags=[tag1, tag2], user=other_user)
|
||||
bookmark3 = self.setup_bookmark(tags=[tag1, tag2], user=other_user)
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_untag': [''],
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_untag'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
@@ -267,18 +301,240 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
|
||||
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
|
||||
|
||||
def test_bulk_mark_as_read(self):
|
||||
bookmark1 = self.setup_bookmark(unread=True)
|
||||
bookmark2 = self.setup_bookmark(unread=True)
|
||||
bookmark3 = self.setup_bookmark(unread=True)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_read'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).unread)
|
||||
|
||||
def test_can_only_bulk_mark_as_read_own_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark1 = self.setup_bookmark(unread=True, user=other_user)
|
||||
bookmark2 = self.setup_bookmark(unread=True, user=other_user)
|
||||
bookmark3 = self.setup_bookmark(unread=True, user=other_user)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_read'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).unread)
|
||||
|
||||
def test_bulk_mark_as_unread(self):
|
||||
bookmark1 = self.setup_bookmark(unread=False)
|
||||
bookmark2 = self.setup_bookmark(unread=False)
|
||||
bookmark3 = self.setup_bookmark(unread=False)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_unread'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).unread)
|
||||
|
||||
def test_can_only_bulk_mark_as_unread_own_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark1 = self.setup_bookmark(unread=False, user=other_user)
|
||||
bookmark2 = self.setup_bookmark(unread=False, user=other_user)
|
||||
bookmark3 = self.setup_bookmark(unread=False, user=other_user)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_unread'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).unread)
|
||||
|
||||
def test_bulk_share(self):
|
||||
bookmark1 = self.setup_bookmark(shared=False)
|
||||
bookmark2 = self.setup_bookmark(shared=False)
|
||||
bookmark3 = self.setup_bookmark(shared=False)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_share'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).shared)
|
||||
|
||||
def test_can_only_bulk_share_own_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark1 = self.setup_bookmark(shared=False, user=other_user)
|
||||
bookmark2 = self.setup_bookmark(shared=False, user=other_user)
|
||||
bookmark3 = self.setup_bookmark(shared=False, user=other_user)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_share'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)
|
||||
|
||||
def test_bulk_unshare(self):
|
||||
bookmark1 = self.setup_bookmark(shared=True)
|
||||
bookmark2 = self.setup_bookmark(shared=True)
|
||||
bookmark3 = self.setup_bookmark(shared=True)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_unshare'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)
|
||||
|
||||
def test_can_only_bulk_unshare_own_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark1 = self.setup_bookmark(shared=True, user=other_user)
|
||||
bookmark2 = self.setup_bookmark(shared=True, user=other_user)
|
||||
bookmark3 = self.setup_bookmark(shared=True, user=other_user)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_unshare'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).shared)
|
||||
|
||||
def test_bulk_select_across(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_archive'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_select_across': ['on'],
|
||||
})
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived)
|
||||
|
||||
def test_bulk_select_across_respects_query(self):
|
||||
self.setup_numbered_bookmarks(3, prefix='foo')
|
||||
self.setup_numbered_bookmarks(3, prefix='bar')
|
||||
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith='foo').count())
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action') + '?q=foo', {
|
||||
'bulk_action': ['bulk_delete'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_select_across': ['on'],
|
||||
})
|
||||
|
||||
self.assertEqual(0, Bookmark.objects.filter(title__startswith='foo').count())
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith='bar').count())
|
||||
|
||||
def test_bulk_select_across_ignores_page(self):
|
||||
self.setup_numbered_bookmarks(100)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action') + '?page=2', {
|
||||
'bulk_action': ['bulk_delete'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_select_across': ['on'],
|
||||
})
|
||||
|
||||
self.assertEqual(0, Bookmark.objects.count())
|
||||
|
||||
def setup_bulk_edit_scope_test_data(self):
|
||||
# create a number of bookmarks with different states / visibility
|
||||
self.setup_numbered_bookmarks(3, with_tags=True)
|
||||
self.setup_numbered_bookmarks(3, with_tags=True, archived=True)
|
||||
self.setup_numbered_bookmarks(3,
|
||||
shared=True,
|
||||
prefix="Joe's Bookmark",
|
||||
user=self.setup_user(enable_sharing=True))
|
||||
|
||||
def test_index_action_bulk_select_across_only_affects_active_bookmarks(self):
|
||||
self.setup_bulk_edit_scope_test_data()
|
||||
|
||||
self.assertIsNotNone(Bookmark.objects.filter(title='Bookmark 1').first())
|
||||
self.assertIsNotNone(Bookmark.objects.filter(title='Bookmark 2').first())
|
||||
self.assertIsNotNone(Bookmark.objects.filter(title='Bookmark 3').first())
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_delete'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_select_across': ['on'],
|
||||
})
|
||||
|
||||
self.assertEqual(6, Bookmark.objects.count())
|
||||
self.assertIsNone(Bookmark.objects.filter(title='Bookmark 1').first())
|
||||
self.assertIsNone(Bookmark.objects.filter(title='Bookmark 2').first())
|
||||
self.assertIsNone(Bookmark.objects.filter(title='Bookmark 3').first())
|
||||
|
||||
def test_archived_action_bulk_select_across_only_affects_archived_bookmarks(self):
|
||||
self.setup_bulk_edit_scope_test_data()
|
||||
|
||||
self.assertIsNotNone(Bookmark.objects.filter(title='Archived Bookmark 1').first())
|
||||
self.assertIsNotNone(Bookmark.objects.filter(title='Archived Bookmark 2').first())
|
||||
self.assertIsNotNone(Bookmark.objects.filter(title='Archived Bookmark 3').first())
|
||||
|
||||
self.client.post(reverse('bookmarks:archived.action'), {
|
||||
'bulk_action': ['bulk_delete'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_select_across': ['on'],
|
||||
})
|
||||
|
||||
self.assertEqual(6, Bookmark.objects.count())
|
||||
self.assertIsNone(Bookmark.objects.filter(title='Archived Bookmark 1').first())
|
||||
self.assertIsNone(Bookmark.objects.filter(title='Archived Bookmark 2').first())
|
||||
self.assertIsNone(Bookmark.objects.filter(title='Archived Bookmark 3').first())
|
||||
|
||||
def test_shared_action_bulk_select_across_not_supported(self):
|
||||
self.setup_bulk_edit_scope_test_data()
|
||||
|
||||
response = self.client.post(reverse('bookmarks:shared.action'), {
|
||||
'bulk_action': ['bulk_delete'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_select_across': ['on'],
|
||||
})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_handles_empty_bookmark_id(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
response = self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_archive': [''],
|
||||
response = self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_archive'],
|
||||
'bulk_execute': [''],
|
||||
})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
response = self.client.post(reverse('bookmarks:action'), {
|
||||
'bulk_archive': [''],
|
||||
response = self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_archive'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [],
|
||||
})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
@@ -290,7 +546,7 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
self.client.post(reverse('bookmarks:action'), {
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
@@ -301,9 +557,10 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
url = reverse('bookmarks:action') + '?return_url=' + reverse('bookmarks:settings.index')
|
||||
url = reverse('bookmarks:index.action') + '?return_url=' + reverse('bookmarks:settings.index')
|
||||
response = self.client.post(url, {
|
||||
'bulk_archive': [''],
|
||||
'bulk_action': ['bulk_archive'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
|
||||
@@ -315,9 +572,10 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
def post_with(return_url, follow=None):
|
||||
url = reverse('bookmarks:action') + f'?return_url={return_url}'
|
||||
url = reverse('bookmarks:index.action') + f'?return_url={return_url}'
|
||||
return self.client.post(url, {
|
||||
'bulk_archive': [''],
|
||||
'bulk_action': ['bulk_archive'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
}, follow=follow)
|
||||
|
||||
|
@@ -5,7 +5,7 @@ from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import Bookmark, Tag, UserProfile
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin, collapse_whitespace
|
||||
|
||||
|
||||
class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
@@ -20,7 +20,7 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
for bookmark in bookmarks:
|
||||
self.assertInHTML(
|
||||
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener" class="">{bookmark.resolved_title}</a>',
|
||||
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
|
||||
html
|
||||
)
|
||||
|
||||
@@ -29,7 +29,7 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
for bookmark in bookmarks:
|
||||
self.assertInHTML(
|
||||
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener" class="">{bookmark.resolved_title}</a>',
|
||||
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
|
||||
html,
|
||||
count=0
|
||||
)
|
||||
@@ -68,8 +68,10 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
]
|
||||
|
||||
response = self.client.get(reverse('bookmarks:archived'))
|
||||
html = collapse_whitespace(response.content.decode())
|
||||
|
||||
self.assertContains(response, '<ul class="bookmark-list">') # Should render list
|
||||
# Should render list
|
||||
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
|
||||
@@ -86,8 +88,10 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
]
|
||||
|
||||
response = self.client.get(reverse('bookmarks:archived') + '?q=searchvalue')
|
||||
html = collapse_whitespace(response.content.decode())
|
||||
|
||||
self.assertContains(response, '<ul class="bookmark-list">') # Should render list
|
||||
# Should render list
|
||||
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
|
||||
@@ -214,3 +218,41 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
response = self.client.get(reverse('bookmarks:archived'))
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks, '_self')
|
||||
|
||||
def test_allowed_bulk_actions(self):
|
||||
url = reverse('bookmarks:archived')
|
||||
response = self.client.get(url)
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(f'''
|
||||
<select name="bulk_action" class="form-select select-sm">
|
||||
<option value="bulk_unarchive">Unarchive</option>
|
||||
<option value="bulk_delete">Delete</option>
|
||||
<option value="bulk_tag">Add tags</option>
|
||||
<option value="bulk_untag">Remove tags</option>
|
||||
<option value="bulk_read">Mark as read</option>
|
||||
<option value="bulk_unread">Mark as unread</option>
|
||||
</select>
|
||||
''', html)
|
||||
|
||||
def test_allowed_bulk_actions_with_sharing_enabled(self):
|
||||
user_profile = self.user.profile
|
||||
user_profile.enable_sharing = True
|
||||
user_profile.save()
|
||||
|
||||
url = reverse('bookmarks:archived')
|
||||
response = self.client.get(url)
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(f'''
|
||||
<select name="bulk_action" class="form-select select-sm">
|
||||
<option value="bulk_unarchive">Unarchive</option>
|
||||
<option value="bulk_delete">Delete</option>
|
||||
<option value="bulk_tag">Add tags</option>
|
||||
<option value="bulk_untag">Remove tags</option>
|
||||
<option value="bulk_read">Mark as read</option>
|
||||
<option value="bulk_unread">Mark as unread</option>
|
||||
<option value="bulk_share">Share</option>
|
||||
<option value="bulk_unshare">Unshare</option>
|
||||
</select>
|
||||
''', html)
|
||||
|
@@ -6,7 +6,7 @@ from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import Bookmark, Tag, UserProfile
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin, collapse_whitespace
|
||||
|
||||
|
||||
class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
@@ -21,7 +21,7 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
|
||||
for bookmark in bookmarks:
|
||||
self.assertInHTML(
|
||||
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener" class="">{bookmark.resolved_title}</a>',
|
||||
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
|
||||
html
|
||||
)
|
||||
|
||||
@@ -30,7 +30,7 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
|
||||
for bookmark in bookmarks:
|
||||
self.assertInHTML(
|
||||
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener" class="">{bookmark.resolved_title}</a>',
|
||||
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
|
||||
html,
|
||||
count=0
|
||||
)
|
||||
@@ -69,8 +69,10 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
]
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index'))
|
||||
html = collapse_whitespace(response.content.decode())
|
||||
|
||||
self.assertContains(response, '<ul class="bookmark-list">') # Should render list
|
||||
# Should render list
|
||||
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
|
||||
@@ -87,8 +89,10 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
]
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index') + '?q=searchvalue')
|
||||
html = collapse_whitespace(response.content.decode())
|
||||
|
||||
self.assertContains(response, '<ul class="bookmark-list">') # Should render list
|
||||
# Should render list
|
||||
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
|
||||
@@ -240,3 +244,41 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
self.assertInHTML(f'''
|
||||
<a href="{edit_url}?return_url={return_url}">Edit</a>
|
||||
''', html)
|
||||
|
||||
def test_allowed_bulk_actions(self):
|
||||
url = reverse('bookmarks:index')
|
||||
response = self.client.get(url)
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(f'''
|
||||
<select name="bulk_action" class="form-select select-sm">
|
||||
<option value="bulk_archive">Archive</option>
|
||||
<option value="bulk_delete">Delete</option>
|
||||
<option value="bulk_tag">Add tags</option>
|
||||
<option value="bulk_untag">Remove tags</option>
|
||||
<option value="bulk_read">Mark as read</option>
|
||||
<option value="bulk_unread">Mark as unread</option>
|
||||
</select>
|
||||
''', html)
|
||||
|
||||
def test_allowed_bulk_actions_with_sharing_enabled(self):
|
||||
user_profile = self.user.profile
|
||||
user_profile.enable_sharing = True
|
||||
user_profile.save()
|
||||
|
||||
url = reverse('bookmarks:index')
|
||||
response = self.client.get(url)
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(f'''
|
||||
<select name="bulk_action" class="form-select select-sm">
|
||||
<option value="bulk_archive">Archive</option>
|
||||
<option value="bulk_delete">Delete</option>
|
||||
<option value="bulk_tag">Add tags</option>
|
||||
<option value="bulk_untag">Remove tags</option>
|
||||
<option value="bulk_read">Mark as read</option>
|
||||
<option value="bulk_unread">Mark as unread</option>
|
||||
<option value="bulk_share">Share</option>
|
||||
<option value="bulk_unshare">Unshare</option>
|
||||
</select>
|
||||
''', html)
|
||||
|
@@ -5,7 +5,7 @@ from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import Bookmark, Tag, UserProfile
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, collapse_whitespace
|
||||
|
||||
|
||||
class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
@@ -16,13 +16,13 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def assertBookmarkCount(self, html: str, bookmark: Bookmark, count: int, link_target: str = '_blank'):
|
||||
self.assertInHTML(
|
||||
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener" class="">{bookmark.resolved_title}</a>',
|
||||
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
|
||||
html, count=count
|
||||
)
|
||||
|
||||
def assertVisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
|
||||
html = response.content.decode()
|
||||
self.assertContains(response, '<li ld-bookmark-item>', count=len(bookmarks))
|
||||
self.assertContains(response, '<li ld-bookmark-item class="shared">', count=len(bookmarks))
|
||||
|
||||
for bookmark in bookmarks:
|
||||
self.assertBookmarkCount(html, bookmark, 1, link_target)
|
||||
@@ -84,8 +84,10 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
]
|
||||
|
||||
response = self.client.get(reverse('bookmarks:shared'))
|
||||
html = collapse_whitespace(response.content.decode())
|
||||
|
||||
self.assertContains(response, '<ul class="bookmark-list">') # Should render list
|
||||
# Should render list
|
||||
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
|
||||
@@ -124,8 +126,10 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
]
|
||||
|
||||
response = self.client.get(reverse('bookmarks:shared') + '?q=searchvalue')
|
||||
html = collapse_whitespace(response.content.decode())
|
||||
|
||||
self.assertContains(response, '<ul class="bookmark-list">') # Should render list
|
||||
# Should render list
|
||||
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
|
||||
@@ -145,8 +149,10 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
]
|
||||
|
||||
response = self.client.get(reverse('bookmarks:shared'))
|
||||
html = collapse_whitespace(response.content.decode())
|
||||
|
||||
self.assertContains(response, '<ul class="bookmark-list">') # Should render list
|
||||
# Should render list
|
||||
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="3">', html)
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
|
||||
|
@@ -28,7 +28,7 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
|
||||
context = CaptureQueriesContext(self.get_connection())
|
||||
with context:
|
||||
response = self.client.get(reverse('bookmarks:shared'))
|
||||
self.assertContains(response, '<li ld-bookmark-item>', num_initial_bookmarks)
|
||||
self.assertContains(response, '<li ld-bookmark-item class="shared">', num_initial_bookmarks)
|
||||
|
||||
number_of_queries = context.final_queries
|
||||
|
||||
@@ -41,4 +41,4 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
|
||||
# assert num queries doesn't increase
|
||||
with self.assertNumQueries(number_of_queries):
|
||||
response = self.client.get(reverse('bookmarks:shared'))
|
||||
self.assertContains(response, '<li ld-bookmark-item>', num_initial_bookmarks + num_additional_bookmarks)
|
||||
self.assertContains(response, '<li ld-bookmark-item class="shared">', num_initial_bookmarks + num_additional_bookmarks)
|
||||
|
@@ -10,21 +10,19 @@ from django.utils import timezone, formats
|
||||
|
||||
from bookmarks.middlewares import UserProfileMiddleware
|
||||
from bookmarks.models import Bookmark, UserProfile, User
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, collapse_whitespace
|
||||
from bookmarks.views.partials import contexts
|
||||
|
||||
|
||||
class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def assertBookmarksLink(self, html: str, bookmark: Bookmark, link_target: str = '_blank'):
|
||||
unread = bookmark.unread
|
||||
favicon_img = f'<img src="/static/{bookmark.favicon_file}" alt="">' if bookmark.favicon_file else ''
|
||||
self.assertInHTML(
|
||||
f'''
|
||||
<a href="{bookmark.url}"
|
||||
target="{link_target}"
|
||||
rel="noopener"
|
||||
class="{'text-italic' if unread else ''}">
|
||||
rel="noopener">
|
||||
{favicon_img}
|
||||
{bookmark.resolved_title}
|
||||
</a>
|
||||
@@ -34,21 +32,16 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def assertDateLabel(self, html: str, label_content: str):
|
||||
self.assertInHTML(f'''
|
||||
<span>
|
||||
<span>{label_content}</span>
|
||||
</span>
|
||||
<span>{label_content}</span>
|
||||
<span class="separator">|</span>
|
||||
''', html)
|
||||
|
||||
def assertWebArchiveLink(self, html: str, label_content: str, url: str, link_target: str = '_blank'):
|
||||
self.assertInHTML(f'''
|
||||
<span>
|
||||
<a href="{url}"
|
||||
title="Show snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
|
||||
<span>{label_content}</span>
|
||||
∞
|
||||
</a>
|
||||
</span>
|
||||
<a href="{url}"
|
||||
title="Show snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
|
||||
{label_content} ∞
|
||||
</a>
|
||||
<span class="separator">|</span>
|
||||
''', html)
|
||||
|
||||
@@ -126,18 +119,36 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def assertNotesToggle(self, html: str, count=1):
|
||||
self.assertInHTML(f'''
|
||||
<button class="btn btn-link btn-sm toggle-notes" title="Toggle notes">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-notes" width="16" height="16"
|
||||
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="M5 3m0 2a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z"></path>
|
||||
<path d="M9 7l6 0"></path>
|
||||
<path d="M9 11l6 0"></path>
|
||||
<path d="M9 15l4 0"></path>
|
||||
</svg>
|
||||
<span>Notes</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-note"></use>
|
||||
</svg>
|
||||
Notes
|
||||
</button>
|
||||
''', html, count=count)
|
||||
|
||||
def assertUnshareButton(self, html: str, bookmark: Bookmark, count=1):
|
||||
self.assertInHTML(f'''
|
||||
<button type="submit" name="unshare" value="{bookmark.id}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button confirm-icon="ld-icon-unshare" confirm-question="Unshare?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-share"></use>
|
||||
</svg>
|
||||
Shared
|
||||
</button>
|
||||
''', html, count=count)
|
||||
|
||||
def assertMarkAsReadButton(self, html: str, bookmark: Bookmark, count=1):
|
||||
self.assertInHTML(f'''
|
||||
<button type="submit" name="mark_as_read" value="{bookmark.id}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button confirm-icon="ld-icon-read" confirm-question="Mark as read?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-unread"></use>
|
||||
</svg>
|
||||
Unread
|
||||
</button>
|
||||
''', html, count=count)
|
||||
|
||||
def render_template(self,
|
||||
@@ -236,11 +247,31 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_self')
|
||||
|
||||
def test_should_respect_unread_flag(self):
|
||||
bookmark = self.setup_bookmark(unread=True)
|
||||
def test_should_reflect_unread_state_as_css_class(self):
|
||||
self.setup_bookmark(unread=True)
|
||||
html = self.render_template()
|
||||
|
||||
self.assertBookmarksLink(html, bookmark)
|
||||
self.assertIn('<li ld-bookmark-item class="unread">', html)
|
||||
|
||||
def test_should_reflect_shared_state_as_css_class(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.enable_sharing = True
|
||||
profile.save()
|
||||
|
||||
self.setup_bookmark(shared=True)
|
||||
html = self.render_template()
|
||||
|
||||
self.assertIn('<li ld-bookmark-item class="shared">', html)
|
||||
|
||||
def test_should_reflect_both_unread_and_shared_state_as_css_class(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.enable_sharing = True
|
||||
profile.save()
|
||||
|
||||
self.setup_bookmark(unread=True, shared=True)
|
||||
html = self.render_template()
|
||||
|
||||
self.assertIn('<li ld-bookmark-item class="unread shared">', html)
|
||||
|
||||
def test_show_bookmark_actions_for_owned_bookmarks(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
@@ -333,6 +364,66 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
self.assertBookmarkURLHidden(html, bookmark)
|
||||
|
||||
def test_show_mark_as_read_when_unread(self):
|
||||
bookmark = self.setup_bookmark(unread=True)
|
||||
html = self.render_template()
|
||||
|
||||
self.assertMarkAsReadButton(html, bookmark)
|
||||
|
||||
def test_hide_mark_as_read_when_read(self):
|
||||
bookmark = self.setup_bookmark(unread=False)
|
||||
html = self.render_template()
|
||||
|
||||
self.assertMarkAsReadButton(html, bookmark, count=0)
|
||||
|
||||
def test_hide_mark_as_read_for_non_owned_bookmarks(self):
|
||||
other_user = self.setup_user(enable_sharing=True)
|
||||
|
||||
bookmark = self.setup_bookmark(user=other_user, shared=True, unread=True)
|
||||
html = self.render_template(context_type=contexts.SharedBookmarkListContext)
|
||||
|
||||
self.assertBookmarksLink(html, bookmark)
|
||||
self.assertMarkAsReadButton(html, bookmark, count=0)
|
||||
|
||||
def test_show_unshare_button_when_shared(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.enable_sharing = True
|
||||
profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark(shared=True)
|
||||
html = self.render_template()
|
||||
|
||||
self.assertUnshareButton(html, bookmark)
|
||||
|
||||
def test_hide_unshare_button_when_not_shared(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.enable_sharing = True
|
||||
profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark(shared=False)
|
||||
html = self.render_template()
|
||||
|
||||
self.assertUnshareButton(html, bookmark, count=0)
|
||||
|
||||
def test_hide_unshare_button_when_sharing_is_disabled(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.enable_sharing = False
|
||||
profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark(shared=True)
|
||||
html = self.render_template()
|
||||
|
||||
self.assertUnshareButton(html, bookmark, count=0)
|
||||
|
||||
def test_hide_unshare_for_non_owned_bookmarks(self):
|
||||
other_user = self.setup_user(enable_sharing=True)
|
||||
|
||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||
html = self.render_template(context_type=contexts.SharedBookmarkListContext)
|
||||
|
||||
self.assertBookmarksLink(html, bookmark)
|
||||
self.assertUnshareButton(html, bookmark, count=0)
|
||||
|
||||
def test_without_notes(self):
|
||||
self.setup_bookmark()
|
||||
html = self.render_template()
|
||||
@@ -363,9 +454,9 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def test_notes_are_hidden_initially_by_default(self):
|
||||
self.setup_bookmark(notes='Test note')
|
||||
html = self.render_template()
|
||||
html = collapse_whitespace(self.render_template())
|
||||
|
||||
self.assertIn('<ul class="bookmark-list">', html)
|
||||
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="1">', html)
|
||||
|
||||
def test_notes_are_hidden_initially_with_permanent_notes_disabled(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
@@ -373,9 +464,9 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
profile.save()
|
||||
|
||||
self.setup_bookmark(notes='Test note')
|
||||
html = self.render_template()
|
||||
html = collapse_whitespace(self.render_template())
|
||||
|
||||
self.assertIn('<ul class="bookmark-list">', html)
|
||||
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="1">', html)
|
||||
|
||||
def test_notes_are_visible_initially_with_permanent_notes_enabled(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
@@ -383,9 +474,9 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
profile.save()
|
||||
|
||||
self.setup_bookmark(notes='Test note')
|
||||
html = self.render_template()
|
||||
html = collapse_whitespace(self.render_template())
|
||||
|
||||
self.assertIn('<ul class="bookmark-list show-notes">', html)
|
||||
self.assertIn('<ul class="bookmark-list show-notes" data-bookmarks-total="1">', html)
|
||||
|
||||
def test_toggle_notes_is_visible_by_default(self):
|
||||
self.setup_bookmark(notes='Test note')
|
||||
@@ -425,6 +516,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
bookmark.notes = '**Example:** `print("Hello world!")`'
|
||||
bookmark.favicon_file = 'https_example_com.png'
|
||||
bookmark.shared = True
|
||||
bookmark.unread = True
|
||||
bookmark.save()
|
||||
|
||||
html = self.render_template(context_type=contexts.SharedBookmarkListContext, user=AnonymousUser())
|
||||
@@ -432,6 +524,8 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
|
||||
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_blank')
|
||||
self.assertNoBookmarkActions(html, bookmark)
|
||||
self.assertShareInfo(html, bookmark)
|
||||
self.assertMarkAsReadButton(html, bookmark, count=0)
|
||||
self.assertUnshareButton(html, bookmark, count=0)
|
||||
note_html = '<p><strong>Example:</strong> <code>print("Hello world!")</code></p>'
|
||||
self.assertNotes(html, note_html, 1)
|
||||
self.assertFaviconVisible(html, bookmark)
|
||||
|
@@ -8,7 +8,8 @@ from bookmarks.models import Bookmark, Tag
|
||||
from bookmarks.services import tasks
|
||||
from bookmarks.services import website_loader
|
||||
from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \
|
||||
unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks
|
||||
unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks, mark_bookmarks_as_read, \
|
||||
mark_bookmarks_as_unread, share_bookmarks, unshare_bookmarks
|
||||
from bookmarks.services.website_loader import WebsiteMetadata
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
@@ -452,3 +453,183 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertCountEqual(bookmark1.tags.all(), [])
|
||||
self.assertCountEqual(bookmark2.tags.all(), [])
|
||||
self.assertCountEqual(bookmark3.tags.all(), [])
|
||||
|
||||
def test_mark_bookmarks_as_read(self):
|
||||
bookmark1 = self.setup_bookmark(unread=True)
|
||||
bookmark2 = self.setup_bookmark(unread=True)
|
||||
bookmark3 = self.setup_bookmark(unread=True)
|
||||
|
||||
mark_bookmarks_as_read([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).unread)
|
||||
|
||||
def test_mark_bookmarks_as_read_should_only_update_specified_bookmarks(self):
|
||||
bookmark1 = self.setup_bookmark(unread=True)
|
||||
bookmark2 = self.setup_bookmark(unread=True)
|
||||
bookmark3 = self.setup_bookmark(unread=True)
|
||||
|
||||
mark_bookmarks_as_read([bookmark1.id, bookmark3.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).unread)
|
||||
|
||||
def test_mark_bookmarks_as_read_should_only_update_user_owned_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark1 = self.setup_bookmark(unread=True)
|
||||
bookmark2 = self.setup_bookmark(unread=True)
|
||||
inaccessible_bookmark = self.setup_bookmark(unread=True, user=other_user)
|
||||
|
||||
mark_bookmarks_as_read([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)
|
||||
self.assertTrue(Bookmark.objects.get(id=inaccessible_bookmark.id).unread)
|
||||
|
||||
def test_mark_bookmarks_as_read_should_accept_mix_of_int_and_string_ids(self):
|
||||
bookmark1 = self.setup_bookmark(unread=True)
|
||||
bookmark2 = self.setup_bookmark(unread=True)
|
||||
bookmark3 = self.setup_bookmark(unread=True)
|
||||
|
||||
mark_bookmarks_as_read([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], self.get_or_create_test_user())
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).unread)
|
||||
|
||||
def test_mark_bookmarks_as_unread(self):
|
||||
bookmark1 = self.setup_bookmark(unread=False)
|
||||
bookmark2 = self.setup_bookmark(unread=False)
|
||||
bookmark3 = self.setup_bookmark(unread=False)
|
||||
|
||||
mark_bookmarks_as_unread([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).unread)
|
||||
|
||||
def test_mark_bookmarks_as_unread_should_only_update_specified_bookmarks(self):
|
||||
bookmark1 = self.setup_bookmark(unread=False)
|
||||
bookmark2 = self.setup_bookmark(unread=False)
|
||||
bookmark3 = self.setup_bookmark(unread=False)
|
||||
|
||||
mark_bookmarks_as_unread([bookmark1.id, bookmark3.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).unread)
|
||||
|
||||
def test_mark_bookmarks_as_unread_should_only_update_user_owned_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark1 = self.setup_bookmark(unread=False)
|
||||
bookmark2 = self.setup_bookmark(unread=False)
|
||||
inaccessible_bookmark = self.setup_bookmark(unread=False, user=other_user)
|
||||
|
||||
mark_bookmarks_as_unread([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)
|
||||
self.assertFalse(Bookmark.objects.get(id=inaccessible_bookmark.id).unread)
|
||||
|
||||
def test_mark_bookmarks_as_unread_should_accept_mix_of_int_and_string_ids(self):
|
||||
bookmark1 = self.setup_bookmark(unread=False)
|
||||
bookmark2 = self.setup_bookmark(unread=False)
|
||||
bookmark3 = self.setup_bookmark(unread=False)
|
||||
|
||||
mark_bookmarks_as_unread([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], self.get_or_create_test_user())
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).unread)
|
||||
|
||||
def test_share_bookmarks(self):
|
||||
bookmark1 = self.setup_bookmark(shared=False)
|
||||
bookmark2 = self.setup_bookmark(shared=False)
|
||||
bookmark3 = self.setup_bookmark(shared=False)
|
||||
|
||||
share_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).shared)
|
||||
|
||||
def test_share_bookmarks_should_only_update_specified_bookmarks(self):
|
||||
bookmark1 = self.setup_bookmark(shared=False)
|
||||
bookmark2 = self.setup_bookmark(shared=False)
|
||||
bookmark3 = self.setup_bookmark(shared=False)
|
||||
|
||||
share_bookmarks([bookmark1.id, bookmark3.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).shared)
|
||||
|
||||
def test_share_bookmarks_should_only_update_user_owned_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark1 = self.setup_bookmark(shared=False)
|
||||
bookmark2 = self.setup_bookmark(shared=False)
|
||||
inaccessible_bookmark = self.setup_bookmark(shared=False, user=other_user)
|
||||
|
||||
share_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)
|
||||
self.assertFalse(Bookmark.objects.get(id=inaccessible_bookmark.id).shared)
|
||||
|
||||
def test_share_bookmarks_should_accept_mix_of_int_and_string_ids(self):
|
||||
bookmark1 = self.setup_bookmark(shared=False)
|
||||
bookmark2 = self.setup_bookmark(shared=False)
|
||||
bookmark3 = self.setup_bookmark(shared=False)
|
||||
|
||||
share_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], self.get_or_create_test_user())
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).shared)
|
||||
|
||||
def test_unshare_bookmarks(self):
|
||||
bookmark1 = self.setup_bookmark(shared=True)
|
||||
bookmark2 = self.setup_bookmark(shared=True)
|
||||
bookmark3 = self.setup_bookmark(shared=True)
|
||||
|
||||
unshare_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)
|
||||
|
||||
def test_unshare_bookmarks_should_only_update_specified_bookmarks(self):
|
||||
bookmark1 = self.setup_bookmark(shared=True)
|
||||
bookmark2 = self.setup_bookmark(shared=True)
|
||||
bookmark3 = self.setup_bookmark(shared=True)
|
||||
|
||||
unshare_bookmarks([bookmark1.id, bookmark3.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)
|
||||
|
||||
def test_unshare_bookmarks_should_only_update_user_owned_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
bookmark1 = self.setup_bookmark(shared=True)
|
||||
bookmark2 = self.setup_bookmark(shared=True)
|
||||
inaccessible_bookmark = self.setup_bookmark(shared=True, user=other_user)
|
||||
|
||||
unshare_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user())
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
|
||||
self.assertTrue(Bookmark.objects.get(id=inaccessible_bookmark.id).shared)
|
||||
|
||||
def test_unshare_bookmarks_should_accept_mix_of_int_and_string_ids(self):
|
||||
bookmark1 = self.setup_bookmark(shared=True)
|
||||
bookmark2 = self.setup_bookmark(shared=True)
|
||||
bookmark3 = self.setup_bookmark(shared=True)
|
||||
|
||||
unshare_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], self.get_or_create_test_user())
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)
|
||||
|
@@ -30,7 +30,7 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
response = self.client.get(reverse('bookmarks:index'))
|
||||
|
||||
# Should render toasts container
|
||||
self.assertContains(response, '<div class="toasts container grid-lg">')
|
||||
self.assertContains(response, '<div class="toasts">')
|
||||
# Should render two toasts
|
||||
self.assertContains(response, '<div class="toast">', count=2)
|
||||
|
||||
|
@@ -13,12 +13,14 @@ urlpatterns = [
|
||||
re_path(r'^$', RedirectView.as_view(pattern_name='bookmarks:index', permanent=False)),
|
||||
# Bookmarks
|
||||
path('bookmarks', views.bookmarks.index, name='index'),
|
||||
path('bookmarks/action', views.bookmarks.index_action, name='index.action'),
|
||||
path('bookmarks/archived', views.bookmarks.archived, name='archived'),
|
||||
path('bookmarks/archived/action', views.bookmarks.archived_action, name='archived.action'),
|
||||
path('bookmarks/shared', views.bookmarks.shared, name='shared'),
|
||||
path('bookmarks/shared/action', views.bookmarks.shared_action, name='shared.action'),
|
||||
path('bookmarks/new', views.bookmarks.new, name='new'),
|
||||
path('bookmarks/close', views.bookmarks.close, name='close'),
|
||||
path('bookmarks/<int:bookmark_id>/edit', views.bookmarks.edit, name='edit'),
|
||||
path('bookmarks/action', views.bookmarks.action, name='action'),
|
||||
# Partials
|
||||
path('bookmarks/partials/bookmark-list/active', partials.active_bookmark_list,
|
||||
name='partials.bookmark_list.active'),
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
@@ -6,6 +7,13 @@ from dateutil.relativedelta import relativedelta
|
||||
from django.template.defaultfilters import pluralize
|
||||
from django.utils import timezone, formats
|
||||
|
||||
try:
|
||||
with open("version.txt", "r") as f:
|
||||
app_version = f.read().strip("\n")
|
||||
except Exception as exc:
|
||||
logging.exception(exc)
|
||||
app_version = ''
|
||||
|
||||
|
||||
def unique(elements, key):
|
||||
return list({key(element): element for element in elements}.values())
|
||||
|
@@ -1,12 +1,14 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponseRedirect, Http404
|
||||
from django.db.models import QuerySet
|
||||
from django.http import HttpResponseRedirect, Http404, HttpResponseBadRequest
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks import queries
|
||||
from bookmarks.models import Bookmark, BookmarkForm, BookmarkFilters, build_tag_string
|
||||
from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \
|
||||
unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks
|
||||
unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks, mark_bookmarks_as_read, \
|
||||
mark_bookmarks_as_unread, share_bookmarks, unshare_bookmarks
|
||||
from bookmarks.utils import get_safe_return_url
|
||||
from bookmarks.views.partials import contexts
|
||||
|
||||
@@ -145,6 +147,16 @@ def unarchive(request, bookmark_id: int):
|
||||
unarchive_bookmark(bookmark)
|
||||
|
||||
|
||||
def unshare(request, bookmark_id: int):
|
||||
try:
|
||||
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
|
||||
except Bookmark.DoesNotExist:
|
||||
raise Http404('Bookmark does not exist')
|
||||
|
||||
bookmark.shared = False
|
||||
bookmark.save()
|
||||
|
||||
|
||||
def mark_as_read(request, bookmark_id: int):
|
||||
try:
|
||||
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
|
||||
@@ -156,8 +168,26 @@ def mark_as_read(request, bookmark_id: int):
|
||||
|
||||
|
||||
@login_required
|
||||
def action(request):
|
||||
# Determine action
|
||||
def index_action(request):
|
||||
filters = BookmarkFilters(request)
|
||||
query = queries.query_bookmarks(request.user, request.user_profile, filters.query)
|
||||
return action(request, query)
|
||||
|
||||
|
||||
@login_required
|
||||
def archived_action(request):
|
||||
filters = BookmarkFilters(request)
|
||||
query = queries.query_archived_bookmarks(request.user, request.user_profile, filters.query)
|
||||
return action(request, query)
|
||||
|
||||
|
||||
@login_required
|
||||
def shared_action(request):
|
||||
return action(request)
|
||||
|
||||
|
||||
def action(request, query: QuerySet[Bookmark] = None):
|
||||
# Single bookmark actions
|
||||
if 'archive' in request.POST:
|
||||
archive(request, request.POST['archive'])
|
||||
if 'unarchive' in request.POST:
|
||||
@@ -166,23 +196,44 @@ def action(request):
|
||||
remove(request, request.POST['remove'])
|
||||
if 'mark_as_read' in request.POST:
|
||||
mark_as_read(request, request.POST['mark_as_read'])
|
||||
if 'bulk_archive' in request.POST:
|
||||
bookmark_ids = request.POST.getlist('bookmark_id')
|
||||
archive_bookmarks(bookmark_ids, request.user)
|
||||
if 'bulk_unarchive' in request.POST:
|
||||
bookmark_ids = request.POST.getlist('bookmark_id')
|
||||
unarchive_bookmarks(bookmark_ids, request.user)
|
||||
if 'bulk_delete' in request.POST:
|
||||
bookmark_ids = request.POST.getlist('bookmark_id')
|
||||
delete_bookmarks(bookmark_ids, request.user)
|
||||
if 'bulk_tag' in request.POST:
|
||||
bookmark_ids = request.POST.getlist('bookmark_id')
|
||||
tag_string = convert_tag_string(request.POST['bulk_tag_string'])
|
||||
tag_bookmarks(bookmark_ids, tag_string, request.user)
|
||||
if 'bulk_untag' in request.POST:
|
||||
bookmark_ids = request.POST.getlist('bookmark_id')
|
||||
tag_string = convert_tag_string(request.POST['bulk_tag_string'])
|
||||
untag_bookmarks(bookmark_ids, tag_string, request.user)
|
||||
if 'unshare' in request.POST:
|
||||
unshare(request, request.POST['unshare'])
|
||||
|
||||
# Bulk actions
|
||||
if 'bulk_execute' in request.POST:
|
||||
if query is None:
|
||||
return HttpResponseBadRequest('View does not support bulk actions')
|
||||
|
||||
bulk_action = request.POST['bulk_action']
|
||||
|
||||
# Determine set of bookmarks
|
||||
if request.POST.get('bulk_select_across') == 'on':
|
||||
# Query full list of bookmarks across all pages
|
||||
bookmark_ids = query.only('id').values_list('id', flat=True)
|
||||
else:
|
||||
# Use only selected bookmarks
|
||||
bookmark_ids = request.POST.getlist('bookmark_id')
|
||||
|
||||
if 'bulk_archive' == bulk_action:
|
||||
archive_bookmarks(bookmark_ids, request.user)
|
||||
if 'bulk_unarchive' == bulk_action:
|
||||
unarchive_bookmarks(bookmark_ids, request.user)
|
||||
if 'bulk_delete' == bulk_action:
|
||||
delete_bookmarks(bookmark_ids, request.user)
|
||||
if 'bulk_tag' == bulk_action:
|
||||
tag_string = convert_tag_string(request.POST['bulk_tag_string'])
|
||||
tag_bookmarks(bookmark_ids, tag_string, request.user)
|
||||
if 'bulk_untag' == bulk_action:
|
||||
tag_string = convert_tag_string(request.POST['bulk_tag_string'])
|
||||
untag_bookmarks(bookmark_ids, tag_string, request.user)
|
||||
if 'bulk_read' == bulk_action:
|
||||
mark_bookmarks_as_read(bookmark_ids, request.user)
|
||||
if 'bulk_unread' == bulk_action:
|
||||
mark_bookmarks_as_unread(bookmark_ids, request.user)
|
||||
if 'bulk_share' == bulk_action:
|
||||
share_bookmarks(bookmark_ids, request.user)
|
||||
if 'bulk_unshare' == bulk_action:
|
||||
unshare_bookmarks(bookmark_ids, request.user)
|
||||
|
||||
return_url = get_safe_return_url(request.GET.get('return_url'), reverse('bookmarks:index'))
|
||||
return HttpResponseRedirect(return_url)
|
||||
|
@@ -7,17 +7,58 @@ from django.db import models
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks import queries
|
||||
from bookmarks.models import BookmarkFilters, User, UserProfile, Tag
|
||||
from bookmarks.utils import unique
|
||||
from bookmarks.models import Bookmark, BookmarkFilters, User, UserProfile, Tag
|
||||
from bookmarks import utils
|
||||
|
||||
DEFAULT_PAGE_SIZE = 30
|
||||
|
||||
|
||||
class BookmarkItem:
|
||||
def __init__(self, bookmark: Bookmark, user: User, profile: UserProfile) -> None:
|
||||
self.bookmark = bookmark
|
||||
|
||||
is_editable = bookmark.owner == user
|
||||
self.is_editable = is_editable
|
||||
|
||||
self.id = bookmark.id
|
||||
self.url = bookmark.url
|
||||
self.title = bookmark.resolved_title
|
||||
self.description = bookmark.resolved_description
|
||||
self.notes = bookmark.notes
|
||||
self.tag_names = bookmark.tag_names
|
||||
self.web_archive_snapshot_url = bookmark.web_archive_snapshot_url
|
||||
self.favicon_file = bookmark.favicon_file
|
||||
self.is_archived = bookmark.is_archived
|
||||
self.unread = bookmark.unread
|
||||
self.owner = bookmark.owner
|
||||
|
||||
css_classes = []
|
||||
if bookmark.unread:
|
||||
css_classes.append('unread')
|
||||
if bookmark.shared:
|
||||
css_classes.append('shared')
|
||||
|
||||
self.css_classes = ' '.join(css_classes)
|
||||
|
||||
if profile.bookmark_date_display == UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE:
|
||||
self.display_date = utils.humanize_relative_date(bookmark.date_added)
|
||||
elif profile.bookmark_date_display == UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE:
|
||||
self.display_date = utils.humanize_absolute_date(bookmark.date_added)
|
||||
|
||||
self.show_notes_button = bookmark.notes and not profile.permanent_notes
|
||||
self.show_mark_as_read = is_editable and bookmark.unread
|
||||
self.show_unshare = is_editable and bookmark.shared and profile.enable_sharing
|
||||
|
||||
self.has_extra_actions = self.show_notes_button or self.show_mark_as_read or self.show_unshare
|
||||
|
||||
|
||||
class BookmarkListContext:
|
||||
def __init__(self, request: WSGIRequest) -> None:
|
||||
self.request = request
|
||||
self.filters = BookmarkFilters(self.request)
|
||||
|
||||
user = request.user
|
||||
user_profile = request.user_profile
|
||||
query_set = self.get_bookmark_query_set()
|
||||
page_number = request.GET.get('page')
|
||||
paginator = Paginator(query_set, DEFAULT_PAGE_SIZE)
|
||||
@@ -25,14 +66,17 @@ class BookmarkListContext:
|
||||
# Prefetch related objects, this avoids n+1 queries when accessing fields in templates
|
||||
models.prefetch_related_objects(bookmarks_page.object_list, 'owner', 'tags')
|
||||
|
||||
self.items = [BookmarkItem(bookmark, user, user_profile) for bookmark in bookmarks_page]
|
||||
|
||||
self.is_empty = paginator.count == 0
|
||||
self.bookmarks_page = bookmarks_page
|
||||
self.bookmarks_total = paginator.count
|
||||
self.return_url = self.generate_return_url(page_number)
|
||||
self.link_target = request.user_profile.bookmark_link_target
|
||||
self.date_display = request.user_profile.bookmark_date_display
|
||||
self.show_url = request.user_profile.display_url
|
||||
self.show_favicons = request.user_profile.enable_favicons
|
||||
self.show_notes = request.user_profile.permanent_notes
|
||||
self.link_target = user_profile.bookmark_link_target
|
||||
self.date_display = user_profile.bookmark_date_display
|
||||
self.show_url = user_profile.display_url
|
||||
self.show_favicons = user_profile.enable_favicons
|
||||
self.show_notes = user_profile.permanent_notes
|
||||
|
||||
def generate_return_url(self, page: int):
|
||||
base_url = self.get_base_url()
|
||||
@@ -120,8 +164,8 @@ class TagCloudContext:
|
||||
query_set = self.get_tag_query_set()
|
||||
tags = list(query_set)
|
||||
selected_tags = self.get_selected_tags(tags)
|
||||
unique_tags = unique(tags, key=lambda x: str.lower(x.name))
|
||||
unique_selected_tags = unique(selected_tags, key=lambda x: str.lower(x.name))
|
||||
unique_tags = utils.unique(tags, key=lambda x: str.lower(x.name))
|
||||
unique_selected_tags = utils.unique(selected_tags, key=lambda x: str.lower(x.name))
|
||||
has_selected_tags = len(unique_selected_tags) > 0
|
||||
unselected_tags = set(unique_tags).symmetric_difference(unique_selected_tags)
|
||||
groups = TagGroup.create_tag_groups(unselected_tags)
|
||||
|
@@ -16,16 +16,10 @@ from bookmarks.models import UserProfileForm, FeedToken
|
||||
from bookmarks.queries import query_bookmarks
|
||||
from bookmarks.services import exporter, tasks
|
||||
from bookmarks.services import importer
|
||||
from bookmarks.utils import app_version
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
with open("version.txt", "r") as f:
|
||||
app_version = f.read().strip("\n")
|
||||
except Exception as exc:
|
||||
logging.exception(exc)
|
||||
pass
|
||||
|
||||
|
||||
@login_required
|
||||
def general(request):
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "linkding",
|
||||
"version": "1.20.0",
|
||||
"version": "1.21.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
@@ -24,6 +24,6 @@ sqlparse==0.4.4
|
||||
supervisor==4.2.4
|
||||
typing-extensions==3.10.0.0
|
||||
urllib3==1.26.11
|
||||
uWSGI==2.0.20
|
||||
uWSGI==2.0.22
|
||||
waybackpy==3.0.6
|
||||
webencodings==0.5.1
|
||||
|
@@ -73,6 +73,7 @@ TEMPLATES = [
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'bookmarks.context_processors.toasts',
|
||||
'bookmarks.context_processors.public_shares',
|
||||
'bookmarks.context_processors.app_version',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
@@ -1 +1 @@
|
||||
1.20.0
|
||||
1.21.0
|
||||
|
Reference in New Issue
Block a user