mirror of
https://github.com/sissbruecker/linkding.git
synced 2026-02-28 06:53:12 +08:00
Convert behaviors to web components
This commit is contained in:
@@ -1,37 +0,0 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class BookmarkItem extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
// Toggle notes
|
||||
this.onToggleNotes = this.onToggleNotes.bind(this);
|
||||
this.notesToggle = element.querySelector(".toggle-notes");
|
||||
if (this.notesToggle) {
|
||||
this.notesToggle.addEventListener("click", this.onToggleNotes);
|
||||
}
|
||||
|
||||
// Add tooltip to title if it is truncated
|
||||
const titleAnchor = element.querySelector(".title > a");
|
||||
const titleSpan = titleAnchor.querySelector("span");
|
||||
requestAnimationFrame(() => {
|
||||
if (titleSpan.offsetWidth > titleAnchor.offsetWidth) {
|
||||
titleAnchor.dataset.tooltip = titleSpan.textContent;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.notesToggle) {
|
||||
this.notesToggle.removeEventListener("click", this.onToggleNotes);
|
||||
}
|
||||
}
|
||||
|
||||
onToggleNotes(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.element.classList.toggle("show-notes");
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-bookmark-item", BookmarkItem);
|
||||
@@ -1,125 +0,0 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class BulkEdit extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.active = element.classList.contains("active");
|
||||
|
||||
this.init = this.init.bind(this);
|
||||
this.onToggleActive = this.onToggleActive.bind(this);
|
||||
this.onToggleAll = this.onToggleAll.bind(this);
|
||||
this.onToggleBookmark = this.onToggleBookmark.bind(this);
|
||||
this.onActionSelected = this.onActionSelected.bind(this);
|
||||
|
||||
this.init();
|
||||
// Reset when bookmarks are updated
|
||||
document.addEventListener("bookmark-list-updated", this.init);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.removeListeners();
|
||||
document.removeEventListener("bookmark-list-updated", this.init);
|
||||
}
|
||||
|
||||
init() {
|
||||
// Update elements
|
||||
this.activeToggle = this.element.querySelector(".bulk-edit-active-toggle");
|
||||
this.actionSelect = this.element.querySelector(
|
||||
"select[name='bulk_action']",
|
||||
);
|
||||
this.tagAutoComplete = this.element.querySelector(".tag-autocomplete");
|
||||
this.selectAcross = this.element.querySelector("label.select-across");
|
||||
this.allCheckbox = this.element.querySelector(
|
||||
".bulk-edit-checkbox.all input",
|
||||
);
|
||||
this.bookmarkCheckboxes = Array.from(
|
||||
this.element.querySelectorAll(".bulk-edit-checkbox:not(.all) input"),
|
||||
);
|
||||
|
||||
// Add listeners, ensure there are no dupes by possibly removing existing listeners
|
||||
this.removeListeners();
|
||||
this.addListeners();
|
||||
|
||||
// Reset checkbox states
|
||||
this.reset();
|
||||
|
||||
// Update total number of bookmarks
|
||||
const totalHolder = this.element.querySelector("[data-bookmarks-total]");
|
||||
const total = totalHolder?.dataset.bookmarksTotal || 0;
|
||||
const totalSpan = this.selectAcross.querySelector("span.total");
|
||||
totalSpan.textContent = total;
|
||||
}
|
||||
|
||||
addListeners() {
|
||||
this.activeToggle.addEventListener("click", this.onToggleActive);
|
||||
this.actionSelect.addEventListener("change", this.onActionSelected);
|
||||
this.allCheckbox.addEventListener("change", this.onToggleAll);
|
||||
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||
checkbox.addEventListener("change", this.onToggleBookmark);
|
||||
});
|
||||
}
|
||||
|
||||
removeListeners() {
|
||||
this.activeToggle.removeEventListener("click", this.onToggleActive);
|
||||
this.actionSelect.removeEventListener("change", this.onActionSelected);
|
||||
this.allCheckbox.removeEventListener("change", this.onToggleAll);
|
||||
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||
checkbox.removeEventListener("change", this.onToggleBookmark);
|
||||
});
|
||||
}
|
||||
|
||||
onToggleActive() {
|
||||
this.active = !this.active;
|
||||
if (this.active) {
|
||||
this.element.classList.add("active");
|
||||
} else {
|
||||
this.element.classList.remove("active");
|
||||
}
|
||||
}
|
||||
|
||||
onToggleBookmark() {
|
||||
const allChecked = this.bookmarkCheckboxes.every((checkbox) => {
|
||||
return checkbox.checked;
|
||||
});
|
||||
this.allCheckbox.checked = allChecked;
|
||||
this.updateSelectAcross(allChecked);
|
||||
}
|
||||
|
||||
onToggleAll() {
|
||||
const allChecked = this.allCheckbox.checked;
|
||||
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||
checkbox.checked = allChecked;
|
||||
});
|
||||
this.updateSelectAcross(allChecked);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-bulk-edit", BulkEdit);
|
||||
@@ -1,42 +0,0 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class ClearButtonBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.field = document.getElementById(element.dataset.for);
|
||||
if (!this.field) {
|
||||
console.error(`Field with ID ${element.dataset.for} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.update = this.update.bind(this);
|
||||
this.clear = this.clear.bind(this);
|
||||
|
||||
this.element.addEventListener("click", this.clear);
|
||||
this.field.addEventListener("input", this.update);
|
||||
this.field.addEventListener("value-changed", this.update);
|
||||
this.update();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (!this.field) {
|
||||
return;
|
||||
}
|
||||
this.element.removeEventListener("click", this.clear);
|
||||
this.field.removeEventListener("input", this.update);
|
||||
this.field.removeEventListener("value-changed", this.update);
|
||||
}
|
||||
|
||||
update() {
|
||||
this.element.style.display = this.field.value ? "inline-flex" : "none";
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.field.value = "";
|
||||
this.field.focus();
|
||||
this.update();
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-clear-button", ClearButtonBehavior);
|
||||
@@ -1,113 +0,0 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
import { FocusTrapController, isKeyboardActive } from "./focus-utils";
|
||||
import { PositionController } from "./position-controller";
|
||||
|
||||
let confirmId = 0;
|
||||
|
||||
function nextConfirmId() {
|
||||
return `confirm-${confirmId++}`;
|
||||
}
|
||||
|
||||
class ConfirmButtonBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.element.addEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.opened) {
|
||||
this.close();
|
||||
}
|
||||
this.element.removeEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (this.opened) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
const dropdown = document.createElement("div");
|
||||
dropdown.className = "dropdown confirm-dropdown active";
|
||||
|
||||
const confirmId = nextConfirmId();
|
||||
const questionId = `${confirmId}-question`;
|
||||
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "menu with-arrow";
|
||||
menu.role = "alertdialog";
|
||||
menu.setAttribute("aria-modal", "true");
|
||||
menu.setAttribute("aria-labelledby", questionId);
|
||||
menu.addEventListener("keydown", this.onMenuKeyDown.bind(this));
|
||||
|
||||
const question = document.createElement("span");
|
||||
question.id = questionId;
|
||||
question.textContent =
|
||||
this.element.getAttribute("ld-confirm-question") || "Are you sure?";
|
||||
question.style.fontWeight = "bold";
|
||||
|
||||
const cancelButton = document.createElement("button");
|
||||
cancelButton.textContent = "Cancel";
|
||||
cancelButton.type = "button";
|
||||
cancelButton.className = "btn";
|
||||
cancelButton.tabIndex = 0;
|
||||
cancelButton.addEventListener("click", () => this.close());
|
||||
|
||||
const confirmButton = document.createElement("button");
|
||||
confirmButton.textContent = "Confirm";
|
||||
confirmButton.type = "submit";
|
||||
confirmButton.name = this.element.name;
|
||||
confirmButton.value = this.element.value;
|
||||
confirmButton.className = "btn btn-error";
|
||||
confirmButton.addEventListener("click", () => this.confirm());
|
||||
|
||||
const arrow = document.createElement("div");
|
||||
arrow.className = "menu-arrow";
|
||||
|
||||
menu.append(question, cancelButton, confirmButton, arrow);
|
||||
dropdown.append(menu);
|
||||
document.body.append(dropdown);
|
||||
|
||||
this.positionController = new PositionController({
|
||||
anchor: this.element,
|
||||
overlay: menu,
|
||||
arrow: arrow,
|
||||
offset: 12,
|
||||
});
|
||||
this.positionController.enable();
|
||||
this.focusTrap = new FocusTrapController(menu);
|
||||
this.dropdown = dropdown;
|
||||
this.opened = true;
|
||||
}
|
||||
|
||||
onMenuKeyDown(event) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
confirm() {
|
||||
this.element.closest("form").requestSubmit(this.element);
|
||||
this.close();
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this.opened) return;
|
||||
this.positionController.disable();
|
||||
this.focusTrap.destroy();
|
||||
this.dropdown.remove();
|
||||
this.element.focus({ focusVisible: isKeyboardActive() });
|
||||
this.opened = false;
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-confirm-button", ConfirmButtonBehavior);
|
||||
@@ -1,17 +0,0 @@
|
||||
import { registerBehavior } from "./index";
|
||||
import { isKeyboardActive, setAfterPageLoadFocusTarget } from "./focus-utils";
|
||||
import { ModalBehavior } from "./modal";
|
||||
|
||||
class DetailsModalBehavior extends ModalBehavior {
|
||||
doClose() {
|
||||
super.doClose();
|
||||
|
||||
// Try restore focus to view details to view details link of respective bookmark
|
||||
const bookmarkId = this.element.dataset.bookmarkId;
|
||||
setAfterPageLoadFocusTarget(
|
||||
`ul.bookmark-list li[data-bookmark-id='${bookmarkId}'] a.view-action`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-details-modal", DetailsModalBehavior);
|
||||
@@ -1,73 +0,0 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class DropdownBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
this.opened = false;
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onOutsideClick = this.onOutsideClick.bind(this);
|
||||
this.onEscape = this.onEscape.bind(this);
|
||||
this.onFocusOut = this.onFocusOut.bind(this);
|
||||
|
||||
// Prevent opening the dropdown automatically on focus, so that it only
|
||||
// opens on click then JS is enabled
|
||||
this.element.style.setProperty("--dropdown-focus-display", "none");
|
||||
this.element.addEventListener("keydown", this.onEscape);
|
||||
this.element.addEventListener("focusout", this.onFocusOut);
|
||||
|
||||
this.toggle = element.querySelector(".dropdown-toggle");
|
||||
this.toggle.setAttribute("aria-expanded", "false");
|
||||
this.toggle.addEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.close();
|
||||
this.toggle.removeEventListener("click", this.onClick);
|
||||
this.element.removeEventListener("keydown", this.onEscape);
|
||||
this.element.removeEventListener("focusout", this.onFocusOut);
|
||||
}
|
||||
|
||||
open() {
|
||||
this.opened = true;
|
||||
this.element.classList.add("active");
|
||||
this.toggle.setAttribute("aria-expanded", "true");
|
||||
document.addEventListener("click", this.onOutsideClick);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.opened = false;
|
||||
this.element.classList.remove("active");
|
||||
this.toggle.setAttribute("aria-expanded", "false");
|
||||
document.removeEventListener("click", this.onOutsideClick);
|
||||
}
|
||||
|
||||
onClick() {
|
||||
if (this.opened) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
|
||||
onOutsideClick(event) {
|
||||
if (!this.element.contains(event.target)) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
onEscape(event) {
|
||||
if (event.key === "Escape" && this.opened) {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
this.toggle.focus();
|
||||
}
|
||||
}
|
||||
|
||||
onFocusOut(event) {
|
||||
if (!this.element.contains(event.relatedTarget)) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-dropdown", DropdownBehavior);
|
||||
@@ -1,97 +0,0 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
import { ModalBehavior } from "./modal";
|
||||
import { isKeyboardActive } from "./focus-utils";
|
||||
|
||||
class FilterDrawerTriggerBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.onClick = this.onClick.bind(this);
|
||||
|
||||
element.addEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.element.removeEventListener("click", this.onClick);
|
||||
}
|
||||
|
||||
onClick() {
|
||||
const modal = document.createElement("div");
|
||||
modal.classList.add("modal", "drawer", "filter-drawer");
|
||||
modal.setAttribute("ld-filter-drawer", "");
|
||||
modal.innerHTML = `
|
||||
<div class="modal-overlay"></div>
|
||||
<div class="modal-container" role="dialog" aria-modal="true">
|
||||
<div class="modal-header">
|
||||
<h2>Filters</h2>
|
||||
<button class="btn btn-noborder close" aria-label="Close dialog">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
|
||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M18 6l-12 12"></path>
|
||||
<path d="M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="content"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.querySelector(".modals").appendChild(modal);
|
||||
}
|
||||
}
|
||||
|
||||
class FilterDrawerBehavior extends ModalBehavior {
|
||||
init() {
|
||||
// Teleport content before creating focus trap, otherwise it will not detect
|
||||
// focusable content elements
|
||||
this.teleport();
|
||||
super.init();
|
||||
// Add active class to start slide-in animation
|
||||
this.element.classList.add("active");
|
||||
}
|
||||
|
||||
destroy() {
|
||||
super.destroy();
|
||||
// Always close on destroy to restore drawer content to original location
|
||||
// before turbo caches DOM
|
||||
this.doClose();
|
||||
}
|
||||
|
||||
mapHeading(container, from, to) {
|
||||
const headings = container.querySelectorAll(from);
|
||||
headings.forEach((heading) => {
|
||||
const newHeading = document.createElement(to);
|
||||
newHeading.textContent = heading.textContent;
|
||||
heading.replaceWith(newHeading);
|
||||
});
|
||||
}
|
||||
|
||||
teleport() {
|
||||
const content = this.element.querySelector(".content");
|
||||
const sidePanel = document.querySelector(".side-panel");
|
||||
content.append(...sidePanel.children);
|
||||
this.mapHeading(content, "h2", "h3");
|
||||
}
|
||||
|
||||
teleportBack() {
|
||||
const sidePanel = document.querySelector(".side-panel");
|
||||
const content = this.element.querySelector(".content");
|
||||
sidePanel.append(...content.children);
|
||||
this.mapHeading(sidePanel, "h3", "h2");
|
||||
}
|
||||
|
||||
doClose() {
|
||||
super.doClose();
|
||||
this.teleportBack();
|
||||
|
||||
// Try restore focus to drawer trigger
|
||||
const restoreFocusElement =
|
||||
document.querySelector("[ld-filter-drawer-trigger]") || document.body;
|
||||
restoreFocusElement.focus({ focusVisible: isKeyboardActive() });
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-filter-drawer-trigger", FilterDrawerTriggerBehavior);
|
||||
registerBehavior("ld-filter-drawer", FilterDrawerBehavior);
|
||||
@@ -1,109 +0,0 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class FormSubmit extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
this.element.addEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.element.removeEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
// Check for Ctrl/Cmd + Enter combination
|
||||
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.element.requestSubmit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AutoSubmitBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.submit = this.submit.bind(this);
|
||||
element.addEventListener("change", this.submit);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.element.removeEventListener("change", this.submit);
|
||||
}
|
||||
|
||||
submit() {
|
||||
this.element.closest("form").requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
// Resets form controls to their initial values before Turbo caches the DOM.
|
||||
// Useful for filter forms where navigating back would otherwise still show
|
||||
// values from after the form submission, which means the filters would be out
|
||||
// of sync with the URL.
|
||||
class FormResetBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.controls = this.element.querySelectorAll("input, select");
|
||||
this.controls.forEach((control) => {
|
||||
if (control.type === "checkbox" || control.type === "radio") {
|
||||
control.__initialValue = control.checked;
|
||||
} else {
|
||||
control.__initialValue = control.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.controls.forEach((control) => {
|
||||
if (control.type === "checkbox" || control.type === "radio") {
|
||||
control.checked = control.__initialValue;
|
||||
} else {
|
||||
control.value = control.__initialValue;
|
||||
}
|
||||
delete control.__initialValue;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class UploadButton extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
this.fileInput = element.nextElementSibling;
|
||||
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onChange = this.onChange.bind(this);
|
||||
|
||||
element.addEventListener("click", this.onClick);
|
||||
this.fileInput.addEventListener("change", this.onChange);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.element.removeEventListener("click", this.onClick);
|
||||
this.fileInput.removeEventListener("change", this.onChange);
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
event.preventDefault();
|
||||
this.fileInput.click();
|
||||
}
|
||||
|
||||
onChange() {
|
||||
// Check if the file input has a file selected
|
||||
if (!this.fileInput.files.length) {
|
||||
return;
|
||||
}
|
||||
const form = this.fileInput.closest("form");
|
||||
form.requestSubmit(this.element);
|
||||
// remove selected file so it doesn't get submitted again
|
||||
this.fileInput.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-form-submit", FormSubmit);
|
||||
registerBehavior("ld-auto-submit", AutoSubmitBehavior);
|
||||
registerBehavior("ld-form-reset", FormResetBehavior);
|
||||
registerBehavior("ld-upload-button", UploadButton);
|
||||
@@ -1,80 +0,0 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
|
||||
class GlobalShortcuts extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
document.addEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
document.removeEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
// Skip if event occurred within an input element
|
||||
const targetNodeName = event.target.nodeName;
|
||||
const isInputTarget =
|
||||
targetNodeName === "INPUT" ||
|
||||
targetNodeName === "SELECT" ||
|
||||
targetNodeName === "TEXTAREA";
|
||||
|
||||
if (isInputTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle shortcuts for navigating bookmarks with arrow keys
|
||||
const isArrowUp = event.key === "ArrowUp";
|
||||
const isArrowDown = event.key === "ArrowDown";
|
||||
if (isArrowUp || isArrowDown) {
|
||||
event.preventDefault();
|
||||
|
||||
// Detect current bookmark list item
|
||||
const path = event.composedPath();
|
||||
const currentItem = path.find(
|
||||
(item) => item.hasAttribute && item.hasAttribute("ld-bookmark-item"),
|
||||
);
|
||||
|
||||
// Find next item
|
||||
let nextItem;
|
||||
if (currentItem) {
|
||||
nextItem = isArrowUp
|
||||
? currentItem.previousElementSibling
|
||||
: currentItem.nextElementSibling;
|
||||
} else {
|
||||
// Select first item
|
||||
nextItem = document.querySelector("[ld-bookmark-item]");
|
||||
}
|
||||
// Focus first link
|
||||
if (nextItem) {
|
||||
nextItem.querySelector("a").focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle shortcut for toggling all notes
|
||||
if (event.key === "e") {
|
||||
const list = document.querySelector(".bookmark-list");
|
||||
if (list) {
|
||||
list.classList.toggle("show-notes");
|
||||
}
|
||||
}
|
||||
|
||||
// Handle shortcut for focusing search input
|
||||
if (event.key === "s") {
|
||||
const searchInput = document.querySelector('input[type="search"]');
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle shortcut for adding new bookmark
|
||||
if (event.key === "n") {
|
||||
window.location.assign("/bookmarks/new");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-global-shortcuts", GlobalShortcuts);
|
||||
@@ -1,119 +0,0 @@
|
||||
const behaviorRegistry = {};
|
||||
const debug = false;
|
||||
|
||||
const mutationObserver = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.removedNodes.forEach((node) => {
|
||||
if (node instanceof HTMLElement && !node.isConnected) {
|
||||
destroyBehaviors(node);
|
||||
}
|
||||
});
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node instanceof HTMLElement && node.isConnected) {
|
||||
applyBehaviors(node);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Update behaviors on Turbo events
|
||||
// - turbo:load: initial page load, only listen once, afterward can rely on turbo:render
|
||||
// - turbo:render: after page navigation, including back/forward, and failed form submissions
|
||||
// - turbo:before-cache: before page navigation, reset DOM before caching
|
||||
document.addEventListener(
|
||||
"turbo:load",
|
||||
() => {
|
||||
mutationObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
applyBehaviors(document.body);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
document.addEventListener("turbo:render", () => {
|
||||
mutationObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
applyBehaviors(document.body);
|
||||
});
|
||||
|
||||
document.addEventListener("turbo:before-cache", () => {
|
||||
destroyBehaviors(document.body);
|
||||
});
|
||||
|
||||
export class Behavior {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
}
|
||||
|
||||
destroy() {}
|
||||
}
|
||||
|
||||
export function registerBehavior(name, behavior) {
|
||||
behaviorRegistry[name] = behavior;
|
||||
}
|
||||
|
||||
export function applyBehaviors(container, behaviorNames = null) {
|
||||
if (!behaviorNames) {
|
||||
behaviorNames = Object.keys(behaviorRegistry);
|
||||
}
|
||||
|
||||
behaviorNames.forEach((behaviorName) => {
|
||||
const behavior = behaviorRegistry[behaviorName];
|
||||
const elements = Array.from(
|
||||
container.querySelectorAll(`[${behaviorName}]`),
|
||||
);
|
||||
|
||||
// Include the container element if it has the behavior
|
||||
if (container.hasAttribute && container.hasAttribute(behaviorName)) {
|
||||
elements.push(container);
|
||||
}
|
||||
|
||||
elements.forEach((element) => {
|
||||
element.__behaviors = element.__behaviors || [];
|
||||
const hasBehavior = element.__behaviors.some(
|
||||
(b) => b instanceof behavior,
|
||||
);
|
||||
|
||||
if (hasBehavior) {
|
||||
return;
|
||||
}
|
||||
|
||||
const behaviorInstance = new behavior(element);
|
||||
element.__behaviors.push(behaviorInstance);
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[Behavior] ${behaviorInstance.constructor.name} initialized`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function destroyBehaviors(element) {
|
||||
const behaviorNames = Object.keys(behaviorRegistry);
|
||||
|
||||
behaviorNames.forEach((behaviorName) => {
|
||||
const elements = Array.from(element.querySelectorAll(`[${behaviorName}]`));
|
||||
elements.push(element);
|
||||
|
||||
elements.forEach((element) => {
|
||||
if (!element.__behaviors) {
|
||||
return;
|
||||
}
|
||||
|
||||
element.__behaviors.forEach((behavior) => {
|
||||
behavior.destroy();
|
||||
if (debug) {
|
||||
console.log(`[Behavior] ${behavior.constructor.name} destroyed`);
|
||||
}
|
||||
});
|
||||
delete element.__behaviors;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
import "../components/SearchAutocomplete.js";
|
||||
|
||||
class SearchAutocomplete extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
const input = element.querySelector("input");
|
||||
if (!input) {
|
||||
console.warn("SearchAutocomplete: input element not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const autocomplete = document.createElement("ld-search-autocomplete");
|
||||
autocomplete.name = "q";
|
||||
autocomplete.placeholder = input.getAttribute("placeholder") || "";
|
||||
autocomplete.value = input.value;
|
||||
autocomplete.linkTarget = input.dataset.linkTarget || "_blank";
|
||||
autocomplete.mode = input.dataset.mode || "";
|
||||
autocomplete.search = {
|
||||
user: input.dataset.user,
|
||||
shared: input.dataset.shared,
|
||||
unread: input.dataset.unread,
|
||||
};
|
||||
|
||||
this.input = input;
|
||||
this.autocomplete = autocomplete;
|
||||
input.replaceWith(this.autocomplete);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.autocomplete.replaceWith(this.input);
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-search-autocomplete", SearchAutocomplete);
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
import "../components/TagAutocomplete.js";
|
||||
|
||||
class TagAutocomplete extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
const input = element.querySelector("input");
|
||||
if (!input) {
|
||||
console.warn("TagAutocomplete: input element not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const autocomplete = document.createElement("ld-tag-autocomplete");
|
||||
autocomplete.id = input.id;
|
||||
autocomplete.name = input.name;
|
||||
autocomplete.value = input.value;
|
||||
autocomplete.placeholder = input.getAttribute("placeholder") || "";
|
||||
autocomplete.ariaDescribedBy = input.getAttribute("aria-describedby") || "";
|
||||
autocomplete.variant = input.getAttribute("variant") || "default";
|
||||
|
||||
this.input = input;
|
||||
this.autocomplete = autocomplete;
|
||||
input.replaceWith(this.autocomplete);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.autocomplete.replaceWith(this.input);
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-tag-autocomplete", TagAutocomplete);
|
||||
142
bookmarks/frontend/components/bookmark-page.js
Normal file
142
bookmarks/frontend/components/bookmark-page.js
Normal file
@@ -0,0 +1,142 @@
|
||||
class BookmarkPage extends HTMLElement {
|
||||
connectedCallback() {
|
||||
this.update = this.update.bind(this);
|
||||
this.onToggleNotes = this.onToggleNotes.bind(this);
|
||||
this.onToggleBulkEdit = this.onToggleBulkEdit.bind(this);
|
||||
this.onBulkActionChange = this.onBulkActionChange.bind(this);
|
||||
this.onToggleAll = this.onToggleAll.bind(this);
|
||||
this.onToggleBookmark = this.onToggleBookmark.bind(this);
|
||||
|
||||
this.oldItems = [];
|
||||
this.update();
|
||||
document.addEventListener("bookmark-list-updated", this.update);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
document.removeEventListener("bookmark-list-updated", this.update);
|
||||
}
|
||||
|
||||
update() {
|
||||
requestAnimationFrame(() => {
|
||||
const items = this.querySelectorAll("ul.bookmark-list > li");
|
||||
this.updateTooltips(items);
|
||||
this.updateNotesToggles(items, this.oldItems);
|
||||
this.updateBulkEdit(items, this.oldItems);
|
||||
this.oldItems = items;
|
||||
});
|
||||
}
|
||||
|
||||
updateTooltips(items) {
|
||||
// Add tooltip to title if it is truncated
|
||||
items.forEach((item) => {
|
||||
const titleAnchor = item.querySelector(".title > a");
|
||||
const titleSpan = titleAnchor.querySelector("span");
|
||||
if (titleSpan.offsetWidth > titleAnchor.offsetWidth) {
|
||||
titleAnchor.dataset.tooltip = titleSpan.textContent;
|
||||
} else {
|
||||
delete titleAnchor.dataset.tooltip;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateNotesToggles(items, oldItems) {
|
||||
oldItems.forEach((oldItem) => {
|
||||
const oldToggle = oldItem.querySelector(".toggle-notes");
|
||||
if (oldToggle) {
|
||||
oldToggle.removeEventListener("click", this.onToggleNotes);
|
||||
}
|
||||
});
|
||||
|
||||
items.forEach((item) => {
|
||||
const notesToggle = item.querySelector(".toggle-notes");
|
||||
if (notesToggle) {
|
||||
notesToggle.addEventListener("click", this.onToggleNotes);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onToggleNotes(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.target.closest("li").classList.toggle("show-notes");
|
||||
}
|
||||
|
||||
updateBulkEdit() {
|
||||
if (this.hasAttribute("no-bulk-edit")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove existing listeners
|
||||
this.activeToggle?.removeEventListener("click", this.onToggleBulkEdit);
|
||||
this.actionSelect?.removeEventListener("change", this.onBulkActionChange);
|
||||
this.allCheckbox?.removeEventListener("change", this.onToggleAll);
|
||||
this.bookmarkCheckboxes?.forEach((checkbox) => {
|
||||
checkbox.removeEventListener("change", this.onToggleBookmark);
|
||||
});
|
||||
|
||||
// Re-query elements
|
||||
this.activeToggle = this.querySelector(".bulk-edit-active-toggle");
|
||||
this.actionSelect = this.querySelector("select[name='bulk_action']");
|
||||
this.allCheckbox = this.querySelector(".bulk-edit-checkbox.all input");
|
||||
this.bookmarkCheckboxes = Array.from(
|
||||
this.querySelectorAll(".bulk-edit-checkbox:not(.all) input"),
|
||||
);
|
||||
this.selectAcross = this.querySelector("label.select-across");
|
||||
|
||||
// Add listeners
|
||||
this.activeToggle.addEventListener("click", this.onToggleBulkEdit);
|
||||
this.actionSelect.addEventListener("change", this.onBulkActionChange);
|
||||
this.allCheckbox.addEventListener("change", this.onToggleAll);
|
||||
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||
checkbox.addEventListener("change", this.onToggleBookmark);
|
||||
});
|
||||
|
||||
// Reset checkbox states
|
||||
this.allCheckbox.checked = false;
|
||||
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||
checkbox.checked = false;
|
||||
});
|
||||
this.updateSelectAcross(false);
|
||||
|
||||
// Update total number of bookmarks
|
||||
const totalHolder = this.querySelector("[data-bookmarks-total]");
|
||||
const total = totalHolder?.dataset.bookmarksTotal || 0;
|
||||
const totalSpan = this.selectAcross.querySelector("span.total");
|
||||
totalSpan.textContent = total;
|
||||
}
|
||||
|
||||
onToggleBulkEdit() {
|
||||
this.classList.toggle("active");
|
||||
}
|
||||
|
||||
onBulkActionChange() {
|
||||
this.dataset.bulkAction = this.actionSelect.value;
|
||||
}
|
||||
|
||||
onToggleAll() {
|
||||
const allChecked = this.allCheckbox.checked;
|
||||
this.bookmarkCheckboxes.forEach((checkbox) => {
|
||||
checkbox.checked = allChecked;
|
||||
});
|
||||
this.updateSelectAcross(allChecked);
|
||||
}
|
||||
|
||||
onToggleBookmark() {
|
||||
const allChecked = this.bookmarkCheckboxes.every((checkbox) => {
|
||||
return checkbox.checked;
|
||||
});
|
||||
this.allCheckbox.checked = allChecked;
|
||||
this.updateSelectAcross(allChecked);
|
||||
}
|
||||
|
||||
updateSelectAcross(allChecked) {
|
||||
if (allChecked) {
|
||||
this.selectAcross.classList.remove("d-none");
|
||||
} else {
|
||||
this.selectAcross.classList.add("d-none");
|
||||
this.selectAcross.querySelector("input").checked = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("ld-bookmark-page", BookmarkPage);
|
||||
39
bookmarks/frontend/components/clear-button.js
Normal file
39
bookmarks/frontend/components/clear-button.js
Normal file
@@ -0,0 +1,39 @@
|
||||
class ClearButton extends HTMLElement {
|
||||
connectedCallback() {
|
||||
requestAnimationFrame(() => {
|
||||
this.field = document.getElementById(this.dataset.for);
|
||||
if (!this.field) {
|
||||
console.error(`Field with ID ${this.dataset.for} not found`);
|
||||
return;
|
||||
}
|
||||
this.update = this.update.bind(this);
|
||||
this.clear = this.clear.bind(this);
|
||||
|
||||
this.addEventListener("click", this.clear);
|
||||
this.field.addEventListener("input", this.update);
|
||||
this.field.addEventListener("value-changed", this.update);
|
||||
this.update();
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (!this.field) {
|
||||
return;
|
||||
}
|
||||
this.removeEventListener("click", this.clear);
|
||||
this.field.removeEventListener("input", this.update);
|
||||
this.field.removeEventListener("value-changed", this.update);
|
||||
}
|
||||
|
||||
update() {
|
||||
this.style.display = this.field.value ? "inline" : "none";
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.field.value = "";
|
||||
this.field.focus();
|
||||
this.update();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("ld-clear-button", ClearButton);
|
||||
103
bookmarks/frontend/components/confirm-dropdown.js
Normal file
103
bookmarks/frontend/components/confirm-dropdown.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { FocusTrapController, isKeyboardActive } from "../utils/focus.js";
|
||||
import { PositionController } from "../utils/position-controller.js";
|
||||
|
||||
let confirmId = 0;
|
||||
|
||||
function nextConfirmId() {
|
||||
return `confirm-${confirmId++}`;
|
||||
}
|
||||
|
||||
function removeAll() {
|
||||
document
|
||||
.querySelectorAll("ld-confirm-dropdown")
|
||||
.forEach((dropdown) => dropdown.close());
|
||||
}
|
||||
|
||||
// Create a confirm dropdown whenever a button with the data-confirm attribute is clicked
|
||||
document.addEventListener("click", (event) => {
|
||||
// Check if the clicked element is a button with data-confirm
|
||||
const button = event.target.closest("button[data-confirm]");
|
||||
if (!button) return;
|
||||
|
||||
// Remove any existing confirm dropdowns
|
||||
removeAll();
|
||||
|
||||
// Show confirmation dropdown
|
||||
event.preventDefault();
|
||||
|
||||
const dropdown = document.createElement("ld-confirm-dropdown");
|
||||
dropdown.button = button;
|
||||
document.body.appendChild(dropdown);
|
||||
});
|
||||
|
||||
// Remove all confirm dropdowns when:
|
||||
// - Turbo caches the page
|
||||
// - The escape key is pressed
|
||||
document.addEventListener("turbo:before-cache", removeAll);
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
removeAll();
|
||||
}
|
||||
});
|
||||
|
||||
class ConfirmDropdown extends LitElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.confirmId = nextConfirmId();
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
firstUpdated(props) {
|
||||
super.firstUpdated(props);
|
||||
this.classList.add("dropdown", "confirm-dropdown", "active");
|
||||
|
||||
const menu = this.querySelector(".menu");
|
||||
this.positionController = new PositionController({
|
||||
anchor: this.button,
|
||||
overlay: menu,
|
||||
arrow: this.querySelector(".menu-arrow"),
|
||||
offset: 12,
|
||||
});
|
||||
this.positionController.enable();
|
||||
this.focusTrap = new FocusTrapController(menu);
|
||||
}
|
||||
|
||||
render() {
|
||||
const questionText = this.button.dataset.confirmQuestion || "Are you sure?";
|
||||
return html`
|
||||
<div
|
||||
class="menu with-arrow"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby=${this.confirmId}
|
||||
>
|
||||
<span id=${this.confirmId} style="font-weight: bold;">
|
||||
${questionText}
|
||||
</span>
|
||||
<button type="button" class="btn" @click=${this.close}>Cancel</button>
|
||||
<button type="submit" class="btn btn-error" @click=${this.confirm}>
|
||||
Confirm
|
||||
</button>
|
||||
<div class="menu-arrow"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
confirm() {
|
||||
this.button.closest("form").requestSubmit(this.button);
|
||||
this.close();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.positionController.disable();
|
||||
this.focusTrap.destroy();
|
||||
this.remove();
|
||||
this.button.focus({ focusVisible: isKeyboardActive() });
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("ld-confirm-dropdown", ConfirmDropdown);
|
||||
16
bookmarks/frontend/components/details-modal.js
Normal file
16
bookmarks/frontend/components/details-modal.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { setAfterPageLoadFocusTarget } from "../utils/focus.js";
|
||||
import { Modal } from "./modal.js";
|
||||
|
||||
class DetailsModal extends Modal {
|
||||
doClose() {
|
||||
super.doClose();
|
||||
|
||||
// Try restore focus to view details to view details link of respective bookmark
|
||||
const bookmarkId = this.dataset.bookmarkId;
|
||||
setAfterPageLoadFocusTarget(
|
||||
`ul.bookmark-list li[data-bookmark-id='${bookmarkId}'] a.view-action`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("ld-details-modal", DetailsModal);
|
||||
76
bookmarks/frontend/components/dropdown.js
Normal file
76
bookmarks/frontend/components/dropdown.js
Normal file
@@ -0,0 +1,76 @@
|
||||
class Dropdown extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.opened = false;
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onOutsideClick = this.onOutsideClick.bind(this);
|
||||
this.onEscape = this.onEscape.bind(this);
|
||||
this.onFocusOut = this.onFocusOut.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
// Defer setup to next frame when children are available in the DOM
|
||||
requestAnimationFrame(() => {
|
||||
// Prevent opening the dropdown automatically on focus, so that it only
|
||||
// opens on click when JS is enabled
|
||||
this.style.setProperty("--dropdown-focus-display", "none");
|
||||
this.addEventListener("keydown", this.onEscape);
|
||||
this.addEventListener("focusout", this.onFocusOut);
|
||||
|
||||
this.toggle = this.querySelector(".dropdown-toggle");
|
||||
this.toggle.setAttribute("aria-expanded", "false");
|
||||
this.toggle.addEventListener("click", this.onClick);
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.close();
|
||||
this.toggle?.removeEventListener("click", this.onClick);
|
||||
this.removeEventListener("keydown", this.onEscape);
|
||||
this.removeEventListener("focusout", this.onFocusOut);
|
||||
}
|
||||
|
||||
open() {
|
||||
this.opened = true;
|
||||
this.classList.add("active");
|
||||
this.toggle.setAttribute("aria-expanded", "true");
|
||||
document.addEventListener("click", this.onOutsideClick);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.opened = false;
|
||||
this.classList.remove("active");
|
||||
this.toggle?.setAttribute("aria-expanded", "false");
|
||||
document.removeEventListener("click", this.onOutsideClick);
|
||||
}
|
||||
|
||||
onClick() {
|
||||
if (this.opened) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
|
||||
onOutsideClick(event) {
|
||||
if (!this.contains(event.target)) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
onEscape(event) {
|
||||
if (event.key === "Escape" && this.opened) {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
this.toggle.focus();
|
||||
}
|
||||
}
|
||||
|
||||
onFocusOut(event) {
|
||||
if (!this.contains(event.relatedTarget)) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("ld-dropdown", Dropdown);
|
||||
109
bookmarks/frontend/components/filter-drawer.js
Normal file
109
bookmarks/frontend/components/filter-drawer.js
Normal file
@@ -0,0 +1,109 @@
|
||||
import { html, render } from "lit";
|
||||
import { Modal } from "./modal.js";
|
||||
import { isKeyboardActive } from "../utils/focus.js";
|
||||
|
||||
class FilterDrawerTrigger extends HTMLElement {
|
||||
connectedCallback() {
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.addEventListener("click", this.onClick.bind(this));
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.removeEventListener("click", this.onClick.bind(this));
|
||||
}
|
||||
|
||||
onClick() {
|
||||
const modal = document.createElement("ld-filter-drawer");
|
||||
document.body.querySelector(".modals").appendChild(modal);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("ld-filter-drawer-trigger", FilterDrawerTrigger);
|
||||
|
||||
class FilterDrawer extends Modal {
|
||||
connectedCallback() {
|
||||
this.classList.add("modal", "drawer", "filter-drawer");
|
||||
|
||||
// Render modal structure
|
||||
render(
|
||||
html`
|
||||
<div class="modal-overlay"></div>
|
||||
<div class="modal-container" role="dialog" aria-modal="true">
|
||||
<div class="modal-header">
|
||||
<h2>Filters</h2>
|
||||
<button class="btn btn-noborder close" aria-label="Close dialog">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M18 6l-12 12"></path>
|
||||
<path d="M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="content"></div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
this,
|
||||
);
|
||||
// Teleport filter content
|
||||
this.teleport();
|
||||
// Force close on turbo cache to restore content
|
||||
this.doClose = this.doClose.bind(this);
|
||||
document.addEventListener("turbo:before-cache", this.doClose);
|
||||
// Add active class to start slide-in animation
|
||||
requestAnimationFrame(() => this.classList.add("active"));
|
||||
// Call super after rendering to ensure elements are available
|
||||
super.connectedCallback();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.teleportBack();
|
||||
document.addEventListener("turbo:before-cache", this.doClose);
|
||||
}
|
||||
|
||||
mapHeading(container, from, to) {
|
||||
const headings = container.querySelectorAll(from);
|
||||
headings.forEach((heading) => {
|
||||
const newHeading = document.createElement(to);
|
||||
newHeading.textContent = heading.textContent;
|
||||
heading.replaceWith(newHeading);
|
||||
});
|
||||
}
|
||||
|
||||
teleport() {
|
||||
const content = this.querySelector(".content");
|
||||
const sidePanel = document.querySelector(".side-panel");
|
||||
content.append(...sidePanel.children);
|
||||
this.mapHeading(content, "h2", "h3");
|
||||
}
|
||||
|
||||
teleportBack() {
|
||||
const sidePanel = document.querySelector(".side-panel");
|
||||
const content = this.querySelector(".content");
|
||||
sidePanel.append(...content.children);
|
||||
this.mapHeading(sidePanel, "h3", "h2");
|
||||
}
|
||||
|
||||
doClose() {
|
||||
super.doClose();
|
||||
|
||||
// Try restore focus to drawer trigger
|
||||
const restoreFocusElement =
|
||||
document.querySelector("ld-filter-drawer-trigger") || document.body;
|
||||
restoreFocusElement.focus({ focusVisible: isKeyboardActive() });
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("ld-filter-drawer", FilterDrawer);
|
||||
73
bookmarks/frontend/components/form.js
Normal file
73
bookmarks/frontend/components/form.js
Normal file
@@ -0,0 +1,73 @@
|
||||
class Form extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
this.onChange = this.onChange.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.addEventListener("keydown", this.onKeyDown);
|
||||
this.addEventListener("change", this.onChange);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (this.hasAttribute("data-form-reset")) {
|
||||
// Resets form controls to their initial values before Turbo caches the DOM.
|
||||
// Useful for filter forms where navigating back would otherwise still show
|
||||
// values from after the form submission, which means the filters would be out
|
||||
// of sync with the URL.
|
||||
this.initFormReset();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.removeEventListener("keydown", this.onKeyDown);
|
||||
this.removeEventListener("change", this.onChange);
|
||||
if (this.hasAttribute("data-form-reset")) {
|
||||
this.resetForm();
|
||||
}
|
||||
}
|
||||
|
||||
onChange(event) {
|
||||
if (event.target.hasAttribute("data-submit-on-change")) {
|
||||
this.querySelector("form")?.requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
// Check for Ctrl/Cmd + Enter combination
|
||||
if (
|
||||
this.hasAttribute("data-submit-on-ctrl-enter") &&
|
||||
event.key === "Enter" &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.querySelector("form")?.requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
initFormReset() {
|
||||
this.controls = this.querySelectorAll("input, select");
|
||||
this.controls.forEach((control) => {
|
||||
if (control.type === "checkbox" || control.type === "radio") {
|
||||
control.__initialValue = control.checked;
|
||||
} else {
|
||||
control.__initialValue = control.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
resetForm() {
|
||||
this.controls.forEach((control) => {
|
||||
if (control.type === "checkbox" || control.type === "radio") {
|
||||
control.checked = control.__initialValue;
|
||||
} else {
|
||||
control.value = control.__initialValue;
|
||||
}
|
||||
delete control.__initialValue;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("ld-form", Form);
|
||||
@@ -1,24 +1,25 @@
|
||||
import { Behavior, registerBehavior } from "./index";
|
||||
import { FocusTrapController } from "./focus-utils";
|
||||
import { FocusTrapController } from "../utils/focus.js";
|
||||
|
||||
export class ModalBehavior extends Behavior {
|
||||
constructor(element) {
|
||||
super(element);
|
||||
export class Modal extends HTMLElement {
|
||||
connectedCallback() {
|
||||
requestAnimationFrame(() => {
|
||||
this.onClose = this.onClose.bind(this);
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
this.overlay = this.querySelector(".modal-overlay");
|
||||
this.closeButton = this.querySelector(".modal-header .close");
|
||||
|
||||
this.onClose = this.onClose.bind(this);
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
this.overlay.addEventListener("click", this.onClose);
|
||||
this.closeButton.addEventListener("click", this.onClose);
|
||||
document.addEventListener("keydown", this.onKeyDown);
|
||||
|
||||
this.overlay = element.querySelector(".modal-overlay");
|
||||
this.closeButton = element.querySelector(".modal-header .close");
|
||||
|
||||
this.overlay.addEventListener("click", this.onClose);
|
||||
this.closeButton.addEventListener("click", this.onClose);
|
||||
document.addEventListener("keydown", this.onKeyDown);
|
||||
|
||||
this.init();
|
||||
this.setupScrollLock();
|
||||
this.focusTrap = new FocusTrapController(
|
||||
this.querySelector(".modal-container"),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
disconnectedCallback() {
|
||||
this.overlay.removeEventListener("click", this.onClose);
|
||||
this.closeButton.removeEventListener("click", this.onClose);
|
||||
document.removeEventListener("keydown", this.onKeyDown);
|
||||
@@ -27,13 +28,6 @@ export class ModalBehavior extends Behavior {
|
||||
this.focusTrap.destroy();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupScrollLock();
|
||||
this.focusTrap = new FocusTrapController(
|
||||
this.element.querySelector(".modal-container"),
|
||||
);
|
||||
}
|
||||
|
||||
setupScrollLock() {
|
||||
document.body.classList.add("scroll-lock");
|
||||
}
|
||||
@@ -61,8 +55,8 @@ export class ModalBehavior extends Behavior {
|
||||
|
||||
onClose(event) {
|
||||
event.preventDefault();
|
||||
this.element.classList.add("closing");
|
||||
this.element.addEventListener(
|
||||
this.classList.add("closing");
|
||||
this.addEventListener(
|
||||
"animationend",
|
||||
(event) => {
|
||||
if (event.animationName === "fade-out") {
|
||||
@@ -74,17 +68,16 @@ export class ModalBehavior extends Behavior {
|
||||
}
|
||||
|
||||
doClose() {
|
||||
this.element.remove();
|
||||
this.removeScrollLock();
|
||||
this.element.dispatchEvent(new CustomEvent("modal:close"));
|
||||
this.remove();
|
||||
this.dispatchEvent(new CustomEvent("modal:close"));
|
||||
|
||||
// Navigate to close URL
|
||||
const closeUrl = this.element.dataset.closeUrl;
|
||||
const frame = this.element.dataset.turboFrame;
|
||||
const closeUrl = this.dataset.closeUrl;
|
||||
const frame = this.dataset.turboFrame;
|
||||
if (closeUrl) {
|
||||
Turbo.visit(closeUrl, { action: "replace", frame: frame });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerBehavior("ld-modal", ModalBehavior);
|
||||
customElements.define("ld-modal", Modal);
|
||||
@@ -1,23 +1,26 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { PositionController } from "../behaviors/position-controller";
|
||||
import { SearchHistory } from "./SearchHistory.js";
|
||||
import { html } from "lit";
|
||||
import { api } from "../api.js";
|
||||
import { cache } from "../cache.js";
|
||||
import { TurboLitElement } from "../utils/element.js";
|
||||
import {
|
||||
clampText,
|
||||
debounce,
|
||||
getCurrentWord,
|
||||
getCurrentWordBounds,
|
||||
} from "../util.js";
|
||||
} from "../utils/input.js";
|
||||
import { PositionController } from "../utils/position-controller.js";
|
||||
import { SearchHistory } from "../utils/search-history.js";
|
||||
import { cache } from "../utils/tag-cache.js";
|
||||
|
||||
export class SearchAutocomplete extends LitElement {
|
||||
export class SearchAutocomplete extends TurboLitElement {
|
||||
static properties = {
|
||||
name: { type: String },
|
||||
placeholder: { type: String },
|
||||
value: { type: String },
|
||||
inputName: { type: String, attribute: "input-name" },
|
||||
inputPlaceholder: { type: String, attribute: "input-placeholder" },
|
||||
inputValue: { type: String, attribute: "input-value" },
|
||||
mode: { type: String },
|
||||
search: { type: Object },
|
||||
linkTarget: { type: String },
|
||||
user: { type: String },
|
||||
shared: { type: String },
|
||||
unread: { type: String },
|
||||
target: { type: String },
|
||||
isFocus: { state: true },
|
||||
isOpen: { state: true },
|
||||
suggestions: { state: true },
|
||||
@@ -26,12 +29,11 @@ export class SearchAutocomplete extends LitElement {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.name = "";
|
||||
this.placeholder = "";
|
||||
this.value = "";
|
||||
this.inputName = "";
|
||||
this.inputPlaceholder = "";
|
||||
this.inputValue = "";
|
||||
this.mode = "";
|
||||
this.search = {};
|
||||
this.linkTarget = "_blank";
|
||||
this.target = "_blank";
|
||||
this.isFocus = false;
|
||||
this.isOpen = false;
|
||||
this.suggestions = {
|
||||
@@ -47,10 +49,6 @@ export class SearchAutocomplete extends LitElement {
|
||||
this.debouncedLoadSuggestions = debounce(() => this.loadSuggestions());
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.style.setProperty("--menu-max-height", "400px");
|
||||
this.input = this.querySelector("input");
|
||||
@@ -81,7 +79,7 @@ export class SearchAutocomplete extends LitElement {
|
||||
}
|
||||
|
||||
handleInput(e) {
|
||||
this.value = e.target.value;
|
||||
this.inputValue = e.target.value;
|
||||
this.debouncedLoadSuggestions();
|
||||
}
|
||||
|
||||
@@ -162,7 +160,7 @@ export class SearchAutocomplete extends LitElement {
|
||||
|
||||
// Recent search suggestions
|
||||
const recentSearches = this.searchHistory
|
||||
.getRecentSearches(this.value, 5)
|
||||
.getRecentSearches(this.inputValue, 5)
|
||||
.map((value) => ({
|
||||
type: "search",
|
||||
index: nextIndex(),
|
||||
@@ -173,11 +171,13 @@ export class SearchAutocomplete extends LitElement {
|
||||
// Bookmark suggestions
|
||||
let bookmarks = [];
|
||||
|
||||
if (this.value && this.value.length >= 3) {
|
||||
if (this.inputValue && this.inputValue.length >= 3) {
|
||||
const path = this.mode ? `/${this.mode}` : "";
|
||||
const suggestionSearch = {
|
||||
...this.search,
|
||||
q: this.value,
|
||||
user: this.user,
|
||||
shared: this.shared,
|
||||
unread: this.unread,
|
||||
q: this.inputValue,
|
||||
};
|
||||
const fetchedBookmarks = await api.listBookmarks(suggestionSearch, {
|
||||
limit: 5,
|
||||
@@ -219,11 +219,11 @@ export class SearchAutocomplete extends LitElement {
|
||||
|
||||
completeSuggestion(suggestion) {
|
||||
if (suggestion.type === "search") {
|
||||
this.value = suggestion.value;
|
||||
this.inputValue = suggestion.value;
|
||||
this.close();
|
||||
}
|
||||
if (suggestion.type === "bookmark") {
|
||||
window.open(suggestion.bookmark.url, this.linkTarget);
|
||||
window.open(suggestion.bookmark.url, this.target);
|
||||
this.close();
|
||||
}
|
||||
if (suggestion.type === "tag") {
|
||||
@@ -293,10 +293,10 @@ export class SearchAutocomplete extends LitElement {
|
||||
<input
|
||||
type="search"
|
||||
class="form-input"
|
||||
name="${this.name}"
|
||||
placeholder="${this.placeholder}"
|
||||
name="${this.inputName}"
|
||||
placeholder="${this.inputPlaceholder}"
|
||||
autocomplete="off"
|
||||
.value="${this.value}"
|
||||
.value="${this.inputValue}"
|
||||
@input=${this.handleInput}
|
||||
@keydown=${this.handleKeyDown}
|
||||
@focus=${this.handleFocus}
|
||||
@@ -1,15 +1,16 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { PositionController } from "../behaviors/position-controller.js";
|
||||
import { cache } from "../cache.js";
|
||||
import { getCurrentWord, getCurrentWordBounds } from "../util.js";
|
||||
import { html, nothing } from "lit";
|
||||
import { TurboLitElement } from "../utils/element.js";
|
||||
import { getCurrentWord, getCurrentWordBounds } from "../utils/input.js";
|
||||
import { PositionController } from "../utils/position-controller.js";
|
||||
import { cache } from "../utils/tag-cache.js";
|
||||
|
||||
export class TagAutocomplete extends LitElement {
|
||||
export class TagAutocomplete extends TurboLitElement {
|
||||
static properties = {
|
||||
id: { type: String },
|
||||
name: { type: String },
|
||||
value: { type: String },
|
||||
placeholder: { type: String },
|
||||
ariaDescribedBy: { type: String, attribute: "aria-described-by" },
|
||||
inputId: { type: String, attribute: "input-id" },
|
||||
inputName: { type: String, attribute: "input-name" },
|
||||
inputValue: { type: String, attribute: "input-value" },
|
||||
inputPlaceholder: { type: String, attribute: "input-placeholder" },
|
||||
inputAriaDescribedBy: { type: String, attribute: "input-aria-describedby" },
|
||||
variant: { type: String },
|
||||
isFocus: { state: true },
|
||||
isOpen: { state: true },
|
||||
@@ -19,11 +20,11 @@ export class TagAutocomplete extends LitElement {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.id = "";
|
||||
this.name = "";
|
||||
this.value = "";
|
||||
this.placeholder = "";
|
||||
this.ariaDescribedBy = "";
|
||||
this.inputId = "";
|
||||
this.inputName = "";
|
||||
this.inputValue = "";
|
||||
this.inputPlaceholder = "";
|
||||
this.inputAriaDescribedBy = "";
|
||||
this.variant = "default";
|
||||
this.isFocus = false;
|
||||
this.isOpen = false;
|
||||
@@ -33,10 +34,6 @@ export class TagAutocomplete extends LitElement {
|
||||
this.suggestionList = null;
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.input = this.querySelector("input");
|
||||
this.suggestionList = this.querySelector(".menu");
|
||||
@@ -159,15 +156,15 @@ export class TagAutocomplete extends LitElement {
|
||||
>
|
||||
<!-- autocomplete real input box -->
|
||||
<input
|
||||
id="${this.id}"
|
||||
name="${this.name}"
|
||||
.value="${this.value || ""}"
|
||||
placeholder="${this.placeholder || " "}"
|
||||
id="${this.inputId || nothing}"
|
||||
name="${this.inputName || nothing}"
|
||||
.value="${this.inputValue || ""}"
|
||||
placeholder="${this.inputPlaceholder || " "}"
|
||||
class="form-input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
aria-describedby="${this.ariaDescribedBy}"
|
||||
aria-describedby="${this.inputAriaDescribedBy || nothing}"
|
||||
@input=${this.handleInput}
|
||||
@keydown=${this.handleKeyDown}
|
||||
@focus=${this.handleFocus}
|
||||
36
bookmarks/frontend/components/upload-button.js
Normal file
36
bookmarks/frontend/components/upload-button.js
Normal file
@@ -0,0 +1,36 @@
|
||||
class UploadButton extends HTMLElement {
|
||||
connectedCallback() {
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onChange = this.onChange.bind(this);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.button = this.querySelector('button[type="submit"]');
|
||||
this.button.addEventListener("click", this.onClick);
|
||||
|
||||
this.fileInput = this.querySelector('input[type="file"]');
|
||||
this.fileInput.addEventListener("change", this.onChange);
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.button.removeEventListener("click", this.onClick);
|
||||
this.fileInput.removeEventListener("change", this.onChange);
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
event.preventDefault();
|
||||
this.fileInput.click();
|
||||
}
|
||||
|
||||
onChange() {
|
||||
// Check if the file input has a file selected
|
||||
if (!this.fileInput.files.length) {
|
||||
return;
|
||||
}
|
||||
this.closest("form").requestSubmit(this.button);
|
||||
// remove selected file so it doesn't get submitted again
|
||||
this.fileInput.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("ld-upload-button", UploadButton);
|
||||
@@ -1,15 +1,13 @@
|
||||
import "@hotwired/turbo";
|
||||
import "./behaviors/bookmark-page";
|
||||
import "./behaviors/bulk-edit";
|
||||
import "./behaviors/clear-button";
|
||||
import "./behaviors/confirm-button";
|
||||
import "./behaviors/details-modal";
|
||||
import "./behaviors/dropdown";
|
||||
import "./behaviors/filter-drawer";
|
||||
import "./behaviors/form";
|
||||
import "./behaviors/global-shortcuts";
|
||||
import "./behaviors/search-autocomplete";
|
||||
import "./behaviors/tag-autocomplete";
|
||||
|
||||
export { api } from "./api";
|
||||
export { cache } from "./cache";
|
||||
import "./components/bookmark-page.js";
|
||||
import "./components/clear-button.js";
|
||||
import "./components/confirm-dropdown.js";
|
||||
import "./components/details-modal.js";
|
||||
import "./components/dropdown.js";
|
||||
import "./components/filter-drawer.js";
|
||||
import "./components/form.js";
|
||||
import "./components/modal.js";
|
||||
import "./components/search-autocomplete.js";
|
||||
import "./components/tag-autocomplete.js";
|
||||
import "./components/upload-button.js";
|
||||
import "./shortcuts.js";
|
||||
|
||||
62
bookmarks/frontend/shortcuts.js
Normal file
62
bookmarks/frontend/shortcuts.js
Normal file
@@ -0,0 +1,62 @@
|
||||
document.addEventListener("keydown", (event) => {
|
||||
// Skip if event occurred within an input element
|
||||
const targetNodeName = event.target.nodeName;
|
||||
const isInputTarget =
|
||||
targetNodeName === "INPUT" ||
|
||||
targetNodeName === "SELECT" ||
|
||||
targetNodeName === "TEXTAREA";
|
||||
|
||||
if (isInputTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle shortcuts for navigating bookmarks with arrow keys
|
||||
const isArrowUp = event.key === "ArrowUp";
|
||||
const isArrowDown = event.key === "ArrowDown";
|
||||
if (isArrowUp || isArrowDown) {
|
||||
event.preventDefault();
|
||||
|
||||
// Detect current bookmark list item
|
||||
const items = document.querySelectorAll("ul.bookmark-list > li");
|
||||
const path = event.composedPath();
|
||||
const currentItem = path.find((item) => items.includes(item));
|
||||
|
||||
// Find next item
|
||||
let nextItem;
|
||||
if (currentItem) {
|
||||
nextItem = isArrowUp
|
||||
? currentItem.previousElementSibling
|
||||
: currentItem.nextElementSibling;
|
||||
} else {
|
||||
// Select first item
|
||||
nextItem = items[0];
|
||||
}
|
||||
// Focus first link
|
||||
if (nextItem) {
|
||||
nextItem.querySelector("a").focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle shortcut for toggling all notes
|
||||
if (event.key === "e") {
|
||||
const list = document.querySelector(".bookmark-list");
|
||||
if (list) {
|
||||
list.classList.toggle("show-notes");
|
||||
}
|
||||
}
|
||||
|
||||
// Handle shortcut for focusing search input
|
||||
if (event.key === "s") {
|
||||
const searchInput = document.querySelector('input[type="search"]');
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle shortcut for adding new bookmark
|
||||
if (event.key === "n") {
|
||||
window.location.assign("/bookmarks/new");
|
||||
}
|
||||
});
|
||||
44
bookmarks/frontend/utils/element.js
Normal file
44
bookmarks/frontend/utils/element.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { LitElement } from "lit";
|
||||
|
||||
let isTopFrameVisit = false;
|
||||
|
||||
document.addEventListener("turbo:visit", (event) => {
|
||||
const url = event.detail.url;
|
||||
isTopFrameVisit =
|
||||
document.querySelector(`turbo-frame[src="${url}"][target="_top"]`) !== null;
|
||||
});
|
||||
|
||||
document.addEventListener("turbo:render", () => {
|
||||
isTopFrameVisit = false;
|
||||
});
|
||||
|
||||
export class TurboLitElement extends LitElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.__prepareForCache = this.__prepareForCache.bind(this);
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this; // Render to light DOM
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
document.addEventListener("turbo:before-cache", this.__prepareForCache);
|
||||
super.connectedCallback();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
document.removeEventListener("turbo:before-cache", this.__prepareForCache);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
__prepareForCache() {
|
||||
// Remove rendered contents before caching, otherwise restoring the DOM from
|
||||
// cache will result in duplicated contents. Turbo also fires before-cache
|
||||
// when rendering a frame that does target the top frame, in which case we
|
||||
// want to keep the contents.
|
||||
if (!isTopFrameVisit) {
|
||||
this.innerHTML = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,6 @@ export function debounce(callback, delay = 250) {
|
||||
};
|
||||
}
|
||||
|
||||
export function preventDefault(fn) {
|
||||
return function (event) {
|
||||
event.preventDefault();
|
||||
fn.call(this, event);
|
||||
};
|
||||
}
|
||||
|
||||
export function clampText(text, maxChars = 30) {
|
||||
if (!text || text.length <= 30) return text;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { api } from "./api.js";
|
||||
import { api } from "../api.js";
|
||||
|
||||
class Cache {
|
||||
class TagCache {
|
||||
constructor(api) {
|
||||
this.api = api;
|
||||
|
||||
@@ -32,4 +32,4 @@ class Cache {
|
||||
}
|
||||
}
|
||||
|
||||
export const cache = new Cache(api);
|
||||
export const cache = new TagCache(api);
|
||||
@@ -22,7 +22,7 @@
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
& .form-group .clear-button,
|
||||
& .form-group ld-clear-button,
|
||||
& .form-group #refresh-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
grid-gap: var(--unit-9);
|
||||
}
|
||||
|
||||
[ld-filter-drawer-trigger] {
|
||||
ld-filter-drawer-trigger {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
[ld-filter-drawer-trigger] {
|
||||
ld-filter-drawer-trigger {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
[ld-filter-drawer-trigger] {
|
||||
ld-filter-drawer-trigger {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
@@ -60,32 +60,6 @@
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* Regular input */
|
||||
|
||||
& input[type="search"] {
|
||||
height: var(--control-size);
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/* Enhanced auto-complete input */
|
||||
/* This needs a bit more wrangling to make the CSS component align with the attached button */
|
||||
|
||||
& .form-autocomplete {
|
||||
height: var(--control-size);
|
||||
|
||||
& .form-autocomplete-input {
|
||||
width: 100%;
|
||||
height: var(--control-size);
|
||||
|
||||
& input[type="search"] {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Group search options button with search button */
|
||||
height: var(--control-size);
|
||||
border-radius: var(--border-radius);
|
||||
@@ -151,15 +125,6 @@
|
||||
}
|
||||
|
||||
/* Bookmark list */
|
||||
ul.bookmark-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
/* Increase line-height for better separation within / between items */
|
||||
line-height: 1.1rem;
|
||||
}
|
||||
|
||||
@keyframes appear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
@@ -172,177 +137,182 @@ ul.bookmark-list {
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmarks */
|
||||
li[ld-bookmark-item] {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: var(--unit-2);
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--unit-3);
|
||||
ul.bookmark-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
& .content {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
/* Increase line-height for better separation within / between items */
|
||||
line-height: 1.1rem;
|
||||
|
||||
& .preview-image {
|
||||
flex: 0 0 auto;
|
||||
width: 100px;
|
||||
height: 60px;
|
||||
margin-top: var(--unit-h);
|
||||
border-radius: var(--border-radius);
|
||||
border: solid 1px var(--border-color);
|
||||
object-fit: cover;
|
||||
> li {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: var(--unit-2);
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--unit-3);
|
||||
|
||||
&.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--body-color-contrast);
|
||||
& .content {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
& .img {
|
||||
width: var(--unit-12);
|
||||
height: var(--unit-12);
|
||||
background-color: var(--tertiary-text-color);
|
||||
-webkit-mask: url(preview-placeholder.svg) no-repeat center;
|
||||
mask: url(preview-placeholder.svg) no-repeat center;
|
||||
& .preview-image {
|
||||
flex: 0 0 auto;
|
||||
width: 100px;
|
||||
height: 60px;
|
||||
margin-top: var(--unit-h);
|
||||
border-radius: var(--border-radius);
|
||||
border: solid 1px var(--border-color);
|
||||
object-fit: cover;
|
||||
|
||||
&.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--body-color-contrast);
|
||||
|
||||
& .img {
|
||||
width: var(--unit-12);
|
||||
height: var(--unit-12);
|
||||
background-color: var(--tertiary-text-color);
|
||||
-webkit-mask: url(preview-placeholder.svg) no-repeat center;
|
||||
mask: url(preview-placeholder.svg) no-repeat center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .form-checkbox.bulk-edit-checkbox {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& .title {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
& .title img {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
& .title img + a {
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
& .title a {
|
||||
color: var(--bookmark-title-color);
|
||||
font-weight: var(--bookmark-title-weight);
|
||||
display: block;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
& .title a[data-tooltip]:hover::after,
|
||||
& .title a[data-tooltip]:focus::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: max-content;
|
||||
max-width: 90%;
|
||||
height: fit-content;
|
||||
background-color: #292f62;
|
||||
color: #fff;
|
||||
padding: var(--unit-1);
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid #424a8c;
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
white-space: normal;
|
||||
pointer-events: none;
|
||||
animation: 0.3s ease 0s appear;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
& .title a[data-tooltip]::after {
|
||||
display: none;
|
||||
& .title {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
&.unread .title a {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
& .url-path,
|
||||
& .url-display {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--secondary-link-color);
|
||||
}
|
||||
|
||||
& .description {
|
||||
color: var(--bookmark-description-color);
|
||||
font-weight: var(--bookmark-description-weight);
|
||||
}
|
||||
|
||||
& .description.separate {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: var(--ld-bookmark-description-max-lines, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
& .tags {
|
||||
& a,
|
||||
& a:visited:hover {
|
||||
color: var(--alternative-color);
|
||||
& .title img {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
& .actions,
|
||||
& .extra-actions {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
column-gap: var(--unit-2);
|
||||
}
|
||||
& .title img + a {
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
& .extra-actions {
|
||||
& .title a {
|
||||
color: var(--bookmark-title-color);
|
||||
font-weight: var(--bookmark-title-weight);
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: var(--unit-1);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
& .actions {
|
||||
color: var(--bookmark-actions-color);
|
||||
font-size: var(--font-size-sm);
|
||||
& .title a[data-tooltip]:hover::after,
|
||||
& .title a[data-tooltip]:focus::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: max-content;
|
||||
max-width: 90%;
|
||||
height: fit-content;
|
||||
background-color: #292f62;
|
||||
color: #fff;
|
||||
padding: var(--unit-1);
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid #424a8c;
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
white-space: normal;
|
||||
pointer-events: none;
|
||||
animation: 0.3s ease 0s appear;
|
||||
}
|
||||
|
||||
& a,
|
||||
& button.btn-link {
|
||||
@media (pointer: coarse) {
|
||||
& .title a[data-tooltip]::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.unread .title a {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
& .url-path,
|
||||
& .url-display {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--secondary-link-color);
|
||||
}
|
||||
|
||||
& .description {
|
||||
color: var(--bookmark-description-color);
|
||||
font-weight: var(--bookmark-description-weight);
|
||||
}
|
||||
|
||||
& .description.separate {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: var(--ld-bookmark-description-max-lines, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
& .tags {
|
||||
& a,
|
||||
& a:visited:hover {
|
||||
color: var(--alternative-color);
|
||||
}
|
||||
}
|
||||
|
||||
& .actions,
|
||||
& .extra-actions {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
column-gap: var(--unit-2);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
& .extra-actions {
|
||||
width: 100%;
|
||||
margin-top: var(--unit-1);
|
||||
}
|
||||
}
|
||||
|
||||
& .actions {
|
||||
color: var(--bookmark-actions-color);
|
||||
--btn-icon-color: var(--bookmark-actions-color);
|
||||
font-weight: var(--bookmark-actions-weight);
|
||||
padding: 0;
|
||||
height: auto;
|
||||
vertical-align: unset;
|
||||
border: none;
|
||||
box-sizing: border-box;
|
||||
transition: none;
|
||||
text-decoration: none;
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
color: var(--bookmark-actions-hover-color);
|
||||
--btn-icon-color: var(--bookmark-actions-hover-color);
|
||||
& a,
|
||||
& button.btn-link {
|
||||
color: var(--bookmark-actions-color);
|
||||
--btn-icon-color: var(--bookmark-actions-color);
|
||||
font-weight: var(--bookmark-actions-weight);
|
||||
padding: 0;
|
||||
height: auto;
|
||||
vertical-align: unset;
|
||||
border: none;
|
||||
box-sizing: border-box;
|
||||
transition: none;
|
||||
text-decoration: none;
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
color: var(--bookmark-actions-hover-color);
|
||||
--btn-icon-color: var(--bookmark-actions-hover-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmark pagination */
|
||||
.bookmark-pagination {
|
||||
margin-top: var(--unit-4);
|
||||
|
||||
@@ -456,7 +426,7 @@ ul.bookmark-list {
|
||||
--bulk-edit-transition-duration: 400ms;
|
||||
}
|
||||
|
||||
[ld-bulk-edit] {
|
||||
ld-bookmark-page {
|
||||
& .bulk-edit-bar {
|
||||
margin-top: -1px;
|
||||
margin-left: calc(-1 * var(--bulk-edit-bar-offset));
|
||||
@@ -504,7 +474,7 @@ ul.bookmark-list {
|
||||
|
||||
/* Bookmark checkboxes */
|
||||
|
||||
& li[ld-bookmark-item] .form-checkbox.bulk-edit-checkbox {
|
||||
& .form-checkbox.bulk-edit-checkbox:not(.all) {
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: var(--bulk-edit-toggle-width);
|
||||
@@ -525,7 +495,7 @@ ul.bookmark-list {
|
||||
}
|
||||
}
|
||||
|
||||
&.active li[ld-bookmark-item] .form-checkbox.bulk-edit-checkbox {
|
||||
&.active .form-checkbox.bulk-edit-checkbox:not(.all) {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -560,4 +530,15 @@ ul.bookmark-list {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
}
|
||||
|
||||
& ld-tag-autocomplete {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&[data-bulk-action="bulk_tag"],
|
||||
&[data-bulk-action="bulk_untag"] {
|
||||
& ld-tag-autocomplete {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
flex: 1 0 auto;
|
||||
line-height: var(--unit-4);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: 100% !important;
|
||||
margin: 0;
|
||||
border: none;
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block content %}
|
||||
<div ld-bulk-edit
|
||||
class="bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}">
|
||||
<ld-bookmark-page
|
||||
class="bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}">
|
||||
|
||||
{# Bookmark list #}
|
||||
<main class="main col-2" aria-labelledby="main-heading">
|
||||
@@ -14,7 +14,9 @@
|
||||
<div class="header-controls">
|
||||
{% bookmark_search bookmark_list.search mode='archived' %}
|
||||
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||
<button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
|
||||
<ld-filter-drawer-trigger>
|
||||
<button class="btn ml-2">Filters</button>
|
||||
</ld-filter-drawer-trigger>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,7 +37,7 @@
|
||||
{% include 'bookmarks/bundle_section.html' %}
|
||||
{% include 'bookmarks/tag_section.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</ld-bookmark-page>
|
||||
{% endblock %}
|
||||
|
||||
{% block overlays %}
|
||||
|
||||
@@ -11,14 +11,16 @@
|
||||
style="--ld-bookmark-description-max-lines:{{ bookmark_list.description_max_lines }};"
|
||||
data-bookmarks-total="{{ bookmark_list.bookmarks_total }}">
|
||||
{% for bookmark_item in bookmark_list.items %}
|
||||
<li ld-bookmark-item data-bookmark-id="{{ bookmark_item.id }}" role="listitem"
|
||||
<li data-bookmark-id="{{ bookmark_item.id }}" role="listitem"
|
||||
{% if bookmark_item.css_classes %}class="{{ bookmark_item.css_classes }}"{% endif %}>
|
||||
<div class="content">
|
||||
<div class="title">
|
||||
<label class="form-checkbox bulk-edit-checkbox">
|
||||
<input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}">
|
||||
<i class="form-icon"></i>
|
||||
</label>
|
||||
{% if not bookmark_list.is_preview %}
|
||||
<label class="form-checkbox bulk-edit-checkbox">
|
||||
<input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}">
|
||||
<i class="form-icon"></i>
|
||||
</label>
|
||||
{% endif %}
|
||||
{% if bookmark_item.favicon_file and bookmark_list.show_favicons %}
|
||||
<img class="favicon" src="{% static bookmark_item.favicon_file %}" alt="">
|
||||
{% endif %}
|
||||
@@ -85,7 +87,7 @@
|
||||
{# View link is visible for both owned and shared bookmarks #}
|
||||
{% if bookmark_list.show_view_action %}
|
||||
<a href="{{ bookmark_item.details_url }}" class="view-action"
|
||||
data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
|
||||
data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
|
||||
{% endif %}
|
||||
{% if bookmark_item.is_editable %}
|
||||
{# Bookmark owner actions #}
|
||||
@@ -104,7 +106,7 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if bookmark_list.show_remove_action %}
|
||||
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
|
||||
<button data-confirm type="submit" name="remove" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Remove
|
||||
</button>
|
||||
{% endif %}
|
||||
@@ -120,7 +122,7 @@
|
||||
{% 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 ld-confirm-question="Mark as read?">
|
||||
data-confirm data-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>
|
||||
@@ -130,7 +132,7 @@
|
||||
{% 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 ld-confirm-question="Unshare?">
|
||||
data-confirm data-confirm-question="Unshare?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-share"></use>
|
||||
</svg>
|
||||
|
||||
@@ -27,10 +27,8 @@
|
||||
<option value="bulk_snapshot">Create HTML snapshot</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
<div class="tag-autocomplete d-none" ld-tag-autocomplete>
|
||||
<input name="bulk_tag_string" class="form-input input-sm" placeholder="Tag names..." variant="small">
|
||||
</div>
|
||||
<button ld-confirm-button type="submit" name="bulk_execute" class="btn btn-link btn-sm">
|
||||
<ld-tag-autocomplete input-name="bulk_tag_string" input-placeholder="Tag names..." variant="small"></ld-tag-autocomplete>
|
||||
<button data-confirm type="submit" name="bulk_execute" class="btn btn-link btn-sm">
|
||||
<span>Execute</span>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<section aria-labelledby="bundles-heading">
|
||||
<div class="section-header no-wrap">
|
||||
<h2 id="bundles-heading">Bundles</h2>
|
||||
<div ld-dropdown class="dropdown dropdown-right ml-auto">
|
||||
<ld-dropdown class="dropdown dropdown-right ml-auto">
|
||||
<button class="btn btn-noborder dropdown-toggle" aria-label="Bundles menu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -23,7 +23,7 @@
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</ld-dropdown>
|
||||
</div>
|
||||
<ul class="bundle-menu">
|
||||
{% for bundle in bundles.bundles %}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<a class="btn btn-link" href="{% url 'linkding:assets.view' asset.id %}" target="_blank">View</a>
|
||||
{% endif %}
|
||||
{% if details.is_editable %}
|
||||
<button ld-confirm-button type="submit" name="remove_asset" value="{{ asset.id }}" class="btn btn-link">
|
||||
<button data-confirm type="submit" name="remove_asset" value="{{ asset.id }}" class="btn btn-link">
|
||||
Remove
|
||||
</button>
|
||||
{% endif %}
|
||||
@@ -39,11 +39,13 @@
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if details.uploads_enabled %}
|
||||
<button ld-upload-button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="submit"
|
||||
class="btn btn-sm">Upload file
|
||||
</button>
|
||||
<ld-upload-button>
|
||||
<button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="submit"
|
||||
class="btn btn-sm">Upload file
|
||||
</button>
|
||||
<input id="upload-asset-file" name="upload_asset_file" type="file" class="d-hide">
|
||||
</ld-upload-button>
|
||||
{% endif %}
|
||||
<input id="upload-asset-file" name="upload_asset_file" type="file" class="d-hide">
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -1,112 +1,114 @@
|
||||
{% load static %}
|
||||
{% load shared %}
|
||||
|
||||
<form action="{{ details.action_url }}" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="update_state" value="{{ details.bookmark.id }}">
|
||||
<ld-form>
|
||||
<form action="{{ details.action_url }}" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="update_state" value="{{ details.bookmark.id }}">
|
||||
|
||||
<div class="weblinks">
|
||||
<a class="weblink" href="{{ details.bookmark.url }}" rel="noopener"
|
||||
target="{{ details.profile.bookmark_link_target }}">
|
||||
{% if details.show_link_icons %}
|
||||
<img class="favicon" src="{% static details.bookmark.favicon_file %}" alt="">
|
||||
<div class="weblinks">
|
||||
<a class="weblink" href="{{ details.bookmark.url }}" rel="noopener"
|
||||
target="{{ details.profile.bookmark_link_target }}">
|
||||
{% if details.show_link_icons %}
|
||||
<img class="favicon" src="{% static details.bookmark.favicon_file %}" alt="">
|
||||
{% endif %}
|
||||
<span>{{ details.bookmark.url }}</span>
|
||||
</a>
|
||||
{% if details.latest_snapshot %}
|
||||
<a class="weblink" href="{% url 'linkding:assets.read' details.latest_snapshot.id %}"
|
||||
target="{{ details.profile.bookmark_link_target }}">
|
||||
{% if details.show_link_icons %}
|
||||
<svg class="favicon" xmlns="http://www.w3.org/2000/svg">
|
||||
<use xlink:href="#ld-icon-unread"></use>
|
||||
</svg>
|
||||
{% endif %}
|
||||
<span>Reader mode</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if details.web_archive_snapshot_url %}
|
||||
<a class="weblink" href="{{ details.web_archive_snapshot_url }}"
|
||||
target="{{ details.profile.bookmark_link_target }}">
|
||||
{% if details.show_link_icons %}
|
||||
<svg class="favicon" viewBox="0 0 76 86" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="m76 82v4h-76l.00080851-4zm-3-6v5h-70v-5zm-62.6696277-54 .8344146.4217275.4176066 6.7436084.4176065 10.9576581v10.5383496l-.4176065 13.1364492-.0694681 8.8498268-1.1825531.3523804h-4.17367003l-1.25202116-.3523804-.48627608-8.8498268-.41840503-13.0662957v-10.5375432l.41840503-11.028618.38167482-6.7798947.87034634-.3854412zm60.0004653 0 .8353798.4217275.4168913 6.7436084.4168913 10.9576581v10.5383496l-.4168913 13.1364492-.0686832 8.8498268-1.1835879.3523804h-4.1737047l-1.2522712-.3523804-.4879704-8.8498268-.4168913-13.0662957v-10.5375432l.4168913-11.028618.3833483-6.7798947.8697215-.3854412zm-42.000632 0 .8344979.4217275.4176483 6.7436084.4176482 10.9576581v10.5383496l-.4176482 13.1364492-.0686764 8.8498268-1.1834698.3523804h-4.1740866l-1.2529447-.3523804-.4863246-8.8498268-.4168497-13.0662957v-10.5375432l.4168497-11.028618.38331-6.7798947.8688361-.3854412zm23 0 .8344979.4217275.4176483 6.7436084.4176482 10.9576581v10.5383496l-.4176482 13.1364492-.0686764 8.8498268-1.1834698.3523804h-4.1740866l-1.2521462-.3523804-.4871231-8.8498268-.4168497-13.0662957v-10.5375432l.4168497-11.028618.38331-6.7798947.8696347-.3854412zm21.6697944-9v7h-70v-7zm-35.7200748-13 36.7200748 8.4088317-1.4720205 2.5911683h-70.32799254l-2.19998696-2.10140371z"
|
||||
fill="currentColor" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
<span>Internet Archive</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<span>{{ details.bookmark.url }}</span>
|
||||
</a>
|
||||
{% if details.latest_snapshot %}
|
||||
<a class="weblink" href="{% url 'linkding:assets.read' details.latest_snapshot.id %}"
|
||||
target="{{ details.profile.bookmark_link_target }}">
|
||||
{% if details.show_link_icons %}
|
||||
<svg class="favicon" xmlns="http://www.w3.org/2000/svg">
|
||||
<use xlink:href="#ld-icon-unread"></use>
|
||||
</svg>
|
||||
{% endif %}
|
||||
<span>Reader mode</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if details.web_archive_snapshot_url %}
|
||||
<a class="weblink" href="{{ details.web_archive_snapshot_url }}"
|
||||
target="{{ details.profile.bookmark_link_target }}">
|
||||
{% if details.show_link_icons %}
|
||||
<svg class="favicon" viewBox="0 0 76 86" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="m76 82v4h-76l.00080851-4zm-3-6v5h-70v-5zm-62.6696277-54 .8344146.4217275.4176066 6.7436084.4176065 10.9576581v10.5383496l-.4176065 13.1364492-.0694681 8.8498268-1.1825531.3523804h-4.17367003l-1.25202116-.3523804-.48627608-8.8498268-.41840503-13.0662957v-10.5375432l.41840503-11.028618.38167482-6.7798947.87034634-.3854412zm60.0004653 0 .8353798.4217275.4168913 6.7436084.4168913 10.9576581v10.5383496l-.4168913 13.1364492-.0686832 8.8498268-1.1835879.3523804h-4.1737047l-1.2522712-.3523804-.4879704-8.8498268-.4168913-13.0662957v-10.5375432l.4168913-11.028618.3833483-6.7798947.8697215-.3854412zm-42.000632 0 .8344979.4217275.4176483 6.7436084.4176482 10.9576581v10.5383496l-.4176482 13.1364492-.0686764 8.8498268-1.1834698.3523804h-4.1740866l-1.2529447-.3523804-.4863246-8.8498268-.4168497-13.0662957v-10.5375432l.4168497-11.028618.38331-6.7798947.8688361-.3854412zm23 0 .8344979.4217275.4176483 6.7436084.4176482 10.9576581v10.5383496l-.4176482 13.1364492-.0686764 8.8498268-1.1834698.3523804h-4.1740866l-1.2521462-.3523804-.4871231-8.8498268-.4168497-13.0662957v-10.5375432l.4168497-11.028618.38331-6.7798947.8696347-.3854412zm21.6697944-9v7h-70v-7zm-35.7200748-13 36.7200748 8.4088317-1.4720205 2.5911683h-70.32799254l-2.19998696-2.10140371z"
|
||||
fill="currentColor" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
<span>Internet Archive</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if details.preview_image_enabled and details.bookmark.preview_image_file %}
|
||||
<div class="preview-image">
|
||||
<img src="{% static details.bookmark.preview_image_file %}" alt=""/>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="sections grid columns-2 columns-sm-1 gap-0">
|
||||
{% if details.is_editable %}
|
||||
<section class="status col-2">
|
||||
<h3>Status</h3>
|
||||
<div class="d-flex" style="gap: .8rem">
|
||||
<div class="form-group">
|
||||
<label class="form-switch">
|
||||
<input ld-auto-submit type="checkbox" name="is_archived"
|
||||
{% if details.bookmark.is_archived %}checked{% endif %}>
|
||||
<i class="form-icon"></i> Archived
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-switch">
|
||||
<input ld-auto-submit type="checkbox" name="unread"
|
||||
{% if details.bookmark.unread %}checked{% endif %}>
|
||||
<i class="form-icon"></i> Unread
|
||||
</label>
|
||||
</div>
|
||||
{% if details.profile.enable_sharing %}
|
||||
{% if details.preview_image_enabled and details.bookmark.preview_image_file %}
|
||||
<div class="preview-image">
|
||||
<img src="{% static details.bookmark.preview_image_file %}" alt=""/>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="sections grid columns-2 columns-sm-1 gap-0">
|
||||
{% if details.is_editable %}
|
||||
<section class="status col-2">
|
||||
<h3>Status</h3>
|
||||
<div class="d-flex" style="gap: .8rem">
|
||||
<div class="form-group">
|
||||
<label class="form-switch">
|
||||
<input ld-auto-submit type="checkbox" name="shared"
|
||||
{% if details.bookmark.shared %}checked{% endif %}>
|
||||
<i class="form-icon"></i> Shared
|
||||
<input data-submit-on-change type="checkbox" name="is_archived"
|
||||
{% if details.bookmark.is_archived %}checked{% endif %}>
|
||||
<i class="form-icon"></i> Archived
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
<section class="files col-2">
|
||||
<h3>Files</h3>
|
||||
<div>
|
||||
{% include 'bookmarks/details/assets.html' %}
|
||||
</div>
|
||||
</section>
|
||||
{% if details.bookmark.tag_names %}
|
||||
<section class="tags col-1">
|
||||
<h3 id="details-modal-tags-title">Tags</h3>
|
||||
<div class="form-group">
|
||||
<label class="form-switch">
|
||||
<input data-submit-on-change type="checkbox" name="unread"
|
||||
{% if details.bookmark.unread %}checked{% endif %}>
|
||||
<i class="form-icon"></i> Unread
|
||||
</label>
|
||||
</div>
|
||||
{% if details.profile.enable_sharing %}
|
||||
<div class="form-group">
|
||||
<label class="form-switch">
|
||||
<input data-submit-on-change type="checkbox" name="shared"
|
||||
{% if details.bookmark.shared %}checked{% endif %}>
|
||||
<i class="form-icon"></i> Shared
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
<section class="files col-2">
|
||||
<h3>Files</h3>
|
||||
<div>
|
||||
{% for tag in details.tags %}
|
||||
<a href="{% url 'linkding:bookmarks.index' %}?{{ tag.query_string }}">#{{ tag.name }}</a>
|
||||
{% endfor %}
|
||||
{% include 'bookmarks/details/assets.html' %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
<section class="date-added col-1">
|
||||
<h3>Date added</h3>
|
||||
<div>
|
||||
<span>{{ details.bookmark.date_added }}</span>
|
||||
</div>
|
||||
</section>
|
||||
{% if details.bookmark.resolved_description %}
|
||||
<section class="description col-2">
|
||||
<h3>Description</h3>
|
||||
<div>{{ details.bookmark.resolved_description }}</div>
|
||||
{% if details.bookmark.tag_names %}
|
||||
<section class="tags col-1">
|
||||
<h3 id="details-modal-tags-title">Tags</h3>
|
||||
<div>
|
||||
{% for tag in details.tags %}
|
||||
<a href="{% url 'linkding:bookmarks.index' %}?{{ tag.query_string }}">#{{ tag.name }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
<section class="date-added col-1">
|
||||
<h3>Date added</h3>
|
||||
<div>
|
||||
<span>{{ details.bookmark.date_added }}</span>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% if details.bookmark.notes %}
|
||||
<section class="notes col-2">
|
||||
<h3>Notes</h3>
|
||||
<div class="markdown">{% markdown details.bookmark.notes %}</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
{% if details.bookmark.resolved_description %}
|
||||
<section class="description col-2">
|
||||
<h3>Description</h3>
|
||||
<div>{{ details.bookmark.resolved_description }}</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% if details.bookmark.notes %}
|
||||
<section class="notes col-2">
|
||||
<h3>Notes</h3>
|
||||
<div class="markdown">{% markdown details.bookmark.notes %}</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</ld-form>
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="modal active bookmark-details" ld-details-modal
|
||||
data-bookmark-id="{{ details.bookmark.id }}" data-close-url="{{ details.close_url }}"
|
||||
data-turbo-frame="details-modal">
|
||||
<ld-details-modal class="modal active bookmark-details"
|
||||
data-bookmark-id="{{ details.bookmark.id }}" data-close-url="{{ details.close_url }}"
|
||||
data-turbo-frame="details-modal">
|
||||
<div class="modal-overlay"></div>
|
||||
<div class="modal-container" role="dialog" aria-modal="true">
|
||||
<div class="modal-header">
|
||||
@@ -31,7 +31,7 @@
|
||||
<form action="{{ details.delete_url }}" method="post" data-turbo-action="replace">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="disable_turbo" value="true">
|
||||
<button ld-confirm-button class="btn btn-error btn-wide"
|
||||
<button data-confirm class="btn btn-error btn-wide"
|
||||
type="submit" name="remove" value="{{ details.bookmark.id }}">
|
||||
Delete
|
||||
</button>
|
||||
@@ -41,4 +41,4 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</ld-details-modal>
|
||||
|
||||
@@ -12,10 +12,12 @@
|
||||
<div class="section-header">
|
||||
<h1 id="main-heading">Edit bookmark</h1>
|
||||
</div>
|
||||
<form action="{% url 'linkding:bookmarks.edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post"
|
||||
novalidate ld-form-submit>
|
||||
{% include 'bookmarks/form.html' %}
|
||||
</form>
|
||||
<ld-form data-submit-on-ctrl-enter>
|
||||
<form action="{% url 'linkding:bookmarks.edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post"
|
||||
novalidate>
|
||||
{% include 'bookmarks/form.html' %}
|
||||
</form>
|
||||
</ld-form>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -21,9 +21,11 @@
|
||||
The form has been pre-filled with the existing bookmark, and saving the form will update the existing bookmark.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ld-tag-autocomplete>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label>
|
||||
{{ form.tag_string|form_field:"help"|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
|
||||
<ld-tag-autocomplete input-id="{{ form.tag_string.auto_id }}" input-name="{{ form.tag_string.html_name }}" input-value="{{ form.tag_string.value|default_if_none:'' }}"
|
||||
input-aria-describedby="{{ form.tag_string.auto_id }}_help">
|
||||
</ld-tag-autocomplete>
|
||||
<div id="{{ form.tag_string.auto_id }}_help" class="form-input-hint">
|
||||
Enter any number of tags separated by space and <strong>without</strong> the hash (#).
|
||||
If a tag does not exist it will be automatically created.
|
||||
@@ -36,10 +38,11 @@
|
||||
<label for="{{ form.title.id_for_label }}" class="form-label">Title</label>
|
||||
<div class="flex">
|
||||
<button id="refresh-button" class="btn btn-link suffix-button" type="button">Refresh from website</button>
|
||||
<button ld-clear-button data-for="{{ form.title.id_for_label }}"
|
||||
class="ml-2 btn btn-link suffix-button clear-button"
|
||||
type="button">Clear
|
||||
</button>
|
||||
<ld-clear-button data-for="{{ form.title.id_for_label }}">
|
||||
<button class="ml-2 btn btn-link suffix-button" type="button">
|
||||
Clear
|
||||
</button>
|
||||
</ld-clear-button>
|
||||
</div>
|
||||
</div>
|
||||
{{ form.title|add_class:"form-input"|attr:"autocomplete:off" }}
|
||||
@@ -48,10 +51,11 @@
|
||||
<div class="form-group">
|
||||
<div class="d-flex justify-between align-baseline">
|
||||
<label for="{{ form.description.id_for_label }}" class="form-label">Description</label>
|
||||
<button ld-clear-button data-for="{{ form.description.id_for_label }}"
|
||||
class="btn btn-link suffix-button clear-button"
|
||||
type="button">Clear
|
||||
</button>
|
||||
<ld-clear-button data-for="{{ form.description.id_for_label }}">
|
||||
<button class="btn btn-link suffix-button" type="button">
|
||||
Clear
|
||||
</button>
|
||||
</ld-clear-button>
|
||||
</div>
|
||||
{{ form.description|add_class:"form-input"|attr:"rows:3" }}
|
||||
{{ form.description.errors }}
|
||||
@@ -105,146 +109,146 @@
|
||||
<a href="{{ return_url }}" class="btn">Cancel</a>
|
||||
</div>
|
||||
<script type="application/javascript">
|
||||
/**
|
||||
* - Pre-fill title and description with metadata from website as soon as URL changes
|
||||
* - Show hint if URL is already bookmarked
|
||||
*/
|
||||
(function init() {
|
||||
const urlInput = document.getElementById('{{ form.url.id_for_label }}');
|
||||
const titleInput = document.getElementById('{{ form.title.id_for_label }}');
|
||||
const descriptionInput = document.getElementById('{{ form.description.id_for_label }}');
|
||||
const notesDetails = document.querySelector('form details.notes');
|
||||
const notesInput = document.getElementById('{{ form.notes.id_for_label }}');
|
||||
const unreadCheckbox = document.getElementById('{{ form.unread.id_for_label }}');
|
||||
const sharedCheckbox = document.getElementById('{{ form.shared.id_for_label }}');
|
||||
const refreshButton = document.getElementById('refresh-button');
|
||||
const bookmarkExistsHint = document.querySelector('.form-input-hint.bookmark-exists');
|
||||
const editedBookmarkId = {{ form.instance.id|default:0 }};
|
||||
let isTitleModified = !!titleInput.value;
|
||||
let isDescriptionModified = !!descriptionInput.value;
|
||||
/**
|
||||
* - Pre-fill title and description with metadata from website as soon as URL changes
|
||||
* - Show hint if URL is already bookmarked
|
||||
*/
|
||||
(function init() {
|
||||
const urlInput = document.getElementById('{{ form.url.id_for_label }}');
|
||||
const titleInput = document.getElementById('{{ form.title.id_for_label }}');
|
||||
const descriptionInput = document.getElementById('{{ form.description.id_for_label }}');
|
||||
const notesDetails = document.querySelector('form details.notes');
|
||||
const notesInput = document.getElementById('{{ form.notes.id_for_label }}');
|
||||
const unreadCheckbox = document.getElementById('{{ form.unread.id_for_label }}');
|
||||
const sharedCheckbox = document.getElementById('{{ form.shared.id_for_label }}');
|
||||
const refreshButton = document.getElementById('refresh-button');
|
||||
const bookmarkExistsHint = document.querySelector('.form-input-hint.bookmark-exists');
|
||||
const editedBookmarkId = {{ form.instance.id|default:0 }};
|
||||
let isTitleModified = !!titleInput.value;
|
||||
let isDescriptionModified = !!descriptionInput.value;
|
||||
|
||||
function toggleLoadingIcon(input, show) {
|
||||
const icon = input.parentNode.querySelector('i.form-icon');
|
||||
icon.style['visibility'] = show ? 'visible' : 'hidden';
|
||||
}
|
||||
function toggleLoadingIcon(input, show) {
|
||||
const icon = input.parentNode.querySelector('i.form-icon');
|
||||
icon.style['visibility'] = show ? 'visible' : 'hidden';
|
||||
}
|
||||
|
||||
function updateInput(input, value) {
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event('value-changed'));
|
||||
}
|
||||
|
||||
function updateCheckbox(input, value) {
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
input.checked = value;
|
||||
}
|
||||
|
||||
function checkUrl() {
|
||||
if (!urlInput.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
toggleLoadingIcon(urlInput, true);
|
||||
|
||||
const websiteUrl = encodeURIComponent(urlInput.value);
|
||||
const requestUrl = `{% url 'linkding:api-root' %}bookmarks/check?url=${websiteUrl}`;
|
||||
fetch(requestUrl)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const metadata = data.metadata;
|
||||
toggleLoadingIcon(urlInput, false);
|
||||
|
||||
// Display hint if URL is already bookmarked
|
||||
const existingBookmark = data.bookmark;
|
||||
bookmarkExistsHint.style['display'] = existingBookmark ? 'block' : 'none';
|
||||
refreshButton.style['display'] = existingBookmark ? 'inline-block' : 'none';
|
||||
|
||||
// Prefill form with existing bookmark data
|
||||
if (existingBookmark) {
|
||||
// Workaround: tag input will be replaced by tag autocomplete, so
|
||||
// defer getting the input until we need it
|
||||
const tagsInput = document.getElementById('{{ form.tag_string.id_for_label }}');
|
||||
|
||||
bookmarkExistsHint.style['display'] = 'block';
|
||||
notesDetails.open = !!existingBookmark.notes;
|
||||
updateInput(titleInput, existingBookmark.title);
|
||||
updateInput(descriptionInput, existingBookmark.description);
|
||||
updateInput(notesInput, existingBookmark.notes);
|
||||
updateInput(tagsInput, existingBookmark.tag_names.join(" "));
|
||||
updateCheckbox(unreadCheckbox, existingBookmark.unread);
|
||||
updateCheckbox(sharedCheckbox, existingBookmark.shared);
|
||||
} else {
|
||||
// Update title and description with website metadata, unless they have been modified
|
||||
if (!isTitleModified) {
|
||||
updateInput(titleInput, metadata.title);
|
||||
function updateInput(input, value) {
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
if (!isDescriptionModified) {
|
||||
updateInput(descriptionInput, metadata.description);
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event('value-changed'));
|
||||
}
|
||||
|
||||
function updateCheckbox(input, value) {
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
input.checked = value;
|
||||
}
|
||||
|
||||
// Preview auto tags
|
||||
const autoTags = data.auto_tags;
|
||||
const autoTagsHint = document.querySelector('.form-input-hint.auto-tags');
|
||||
function checkUrl() {
|
||||
if (!urlInput.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoTags.length > 0) {
|
||||
autoTags.sort();
|
||||
autoTagsHint.style['display'] = 'block';
|
||||
autoTagsHint.innerHTML = `Auto tags: ${autoTags.join(" ")}`;
|
||||
} else {
|
||||
autoTagsHint.style['display'] = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
toggleLoadingIcon(urlInput, true);
|
||||
|
||||
function refreshMetadata() {
|
||||
if (!urlInput.value) {
|
||||
return;
|
||||
}
|
||||
const websiteUrl = encodeURIComponent(urlInput.value);
|
||||
const requestUrl = `{% url 'linkding:api-root' %}bookmarks/check?url=${websiteUrl}`;
|
||||
fetch(requestUrl)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const metadata = data.metadata;
|
||||
toggleLoadingIcon(urlInput, false);
|
||||
|
||||
toggleLoadingIcon(urlInput, true);
|
||||
// Display hint if URL is already bookmarked
|
||||
const existingBookmark = data.bookmark;
|
||||
bookmarkExistsHint.style['display'] = existingBookmark ? 'block' : 'none';
|
||||
refreshButton.style['display'] = existingBookmark ? 'inline-block' : 'none';
|
||||
|
||||
const websiteUrl = encodeURIComponent(urlInput.value);
|
||||
const requestUrl = `{% url 'linkding:api-root' %}bookmarks/check?url=${websiteUrl}&ignore_cache=true`;
|
||||
// Prefill form with existing bookmark data
|
||||
if (existingBookmark) {
|
||||
// Workaround: tag input will be replaced by tag autocomplete, so
|
||||
// defer getting the input until we need it
|
||||
const tagsInput = document.getElementById('{{ form.tag_string.id_for_label }}');
|
||||
|
||||
fetch(requestUrl)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const metadata = data.metadata;
|
||||
const existingBookmark = data.bookmark;
|
||||
toggleLoadingIcon(urlInput, false);
|
||||
bookmarkExistsHint.style['display'] = 'block';
|
||||
notesDetails.open = !!existingBookmark.notes;
|
||||
updateInput(titleInput, existingBookmark.title);
|
||||
updateInput(descriptionInput, existingBookmark.description);
|
||||
updateInput(notesInput, existingBookmark.notes);
|
||||
updateInput(tagsInput, existingBookmark.tag_names.join(" "));
|
||||
updateCheckbox(unreadCheckbox, existingBookmark.unread);
|
||||
updateCheckbox(sharedCheckbox, existingBookmark.shared);
|
||||
} else {
|
||||
// Update title and description with website metadata, unless they have been modified
|
||||
if (!isTitleModified) {
|
||||
updateInput(titleInput, metadata.title);
|
||||
}
|
||||
if (!isDescriptionModified) {
|
||||
updateInput(descriptionInput, metadata.description);
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata.title && metadata.title !== existingBookmark?.title) {
|
||||
titleInput.value = metadata.title;
|
||||
titleInput.classList.add("modified");
|
||||
}
|
||||
// Preview auto tags
|
||||
const autoTags = data.auto_tags;
|
||||
const autoTagsHint = document.querySelector('.form-input-hint.auto-tags');
|
||||
|
||||
if (metadata.description && metadata.description !== existingBookmark?.description) {
|
||||
descriptionInput.value = metadata.description;
|
||||
descriptionInput.classList.add("modified");
|
||||
}
|
||||
});
|
||||
}
|
||||
if (autoTags.length > 0) {
|
||||
autoTags.sort();
|
||||
autoTagsHint.style['display'] = 'block';
|
||||
autoTagsHint.innerHTML = `Auto tags: ${autoTags.join(" ")}`;
|
||||
} else {
|
||||
autoTagsHint.style['display'] = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
refreshButton.addEventListener('click', refreshMetadata);
|
||||
function refreshMetadata() {
|
||||
if (!urlInput.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch website metadata when page loads and when URL changes, unless we are editing an existing bookmark
|
||||
if (!editedBookmarkId) {
|
||||
checkUrl();
|
||||
urlInput.addEventListener('input', checkUrl);
|
||||
titleInput.addEventListener('input', () => {
|
||||
isTitleModified = true;
|
||||
});
|
||||
descriptionInput.addEventListener('input', () => {
|
||||
isDescriptionModified = true;
|
||||
});
|
||||
} else {
|
||||
refreshButton.style['display'] = 'inline-block';
|
||||
}
|
||||
})();
|
||||
toggleLoadingIcon(urlInput, true);
|
||||
|
||||
const websiteUrl = encodeURIComponent(urlInput.value);
|
||||
const requestUrl = `{% url 'linkding:api-root' %}bookmarks/check?url=${websiteUrl}&ignore_cache=true`;
|
||||
|
||||
fetch(requestUrl)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const metadata = data.metadata;
|
||||
const existingBookmark = data.bookmark;
|
||||
toggleLoadingIcon(urlInput, false);
|
||||
|
||||
if (metadata.title && metadata.title !== existingBookmark?.title) {
|
||||
titleInput.value = metadata.title;
|
||||
titleInput.classList.add("modified");
|
||||
}
|
||||
|
||||
if (metadata.description && metadata.description !== existingBookmark?.description) {
|
||||
descriptionInput.value = metadata.description;
|
||||
descriptionInput.classList.add("modified");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
refreshButton.addEventListener('click', refreshMetadata);
|
||||
|
||||
// Fetch website metadata when page loads and when URL changes, unless we are editing an existing bookmark
|
||||
if (!editedBookmarkId) {
|
||||
checkUrl();
|
||||
urlInput.addEventListener('input', checkUrl);
|
||||
titleInput.addEventListener('input', () => {
|
||||
isTitleModified = true;
|
||||
});
|
||||
descriptionInput.addEventListener('input', () => {
|
||||
isDescriptionModified = true;
|
||||
});
|
||||
} else {
|
||||
refreshButton.style['display'] = 'inline-block';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
{% block title %}Bookmarks - Linkding{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div ld-bulk-edit
|
||||
<ld-bookmark-page
|
||||
class="bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}">
|
||||
|
||||
{# Bookmark list #}
|
||||
@@ -16,7 +16,9 @@
|
||||
<div class="header-controls">
|
||||
{% bookmark_search bookmark_list.search %}
|
||||
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||
<button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
|
||||
<ld-filter-drawer-trigger>
|
||||
<button class="btn ml-2">Filters</button>
|
||||
</ld-filter-drawer-trigger>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +39,7 @@
|
||||
{% include 'bookmarks/bundle_section.html' %}
|
||||
{% include 'bookmarks/tag_section.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</ld-bookmark-page>
|
||||
{% endblock %}
|
||||
|
||||
{% block overlays %}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{# Use data attributes as storage for access in static scripts #}
|
||||
<html lang="en" data-api-base-url="{% url 'linkding:api-root' %}">
|
||||
{% block head %}{% include 'bookmarks/head.html' %}{% endblock %}
|
||||
<body ld-global-shortcuts>
|
||||
<body>
|
||||
|
||||
<div class="d-none">
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{# Basic menu list #}
|
||||
<div class="hide-md">
|
||||
<a href="{% url 'linkding:bookmarks.new' %}" class="btn btn-primary mr-2">Add bookmark</a>
|
||||
<div ld-dropdown class="dropdown">
|
||||
<ld-dropdown class="dropdown">
|
||||
<button class="btn btn-link dropdown-toggle" tabindex="0">
|
||||
Bookmarks
|
||||
</button>
|
||||
@@ -26,8 +26,8 @@
|
||||
<a href="{% url 'linkding:bookmarks.index' %}?q=!untagged" class="menu-link">Untagged</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div ld-dropdown class="dropdown">
|
||||
</ld-dropdown>
|
||||
<ld-dropdown class="dropdown">
|
||||
<button class="btn btn-link dropdown-toggle" tabindex="0">
|
||||
Settings
|
||||
</button>
|
||||
@@ -44,7 +44,7 @@
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</ld-dropdown>
|
||||
<form class="d-inline" action="{% url 'logout' %}" method="post" data-turbo="false">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-link">Logout</button>
|
||||
@@ -58,7 +58,7 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<div ld-dropdown class="dropdown dropdown-right">
|
||||
<ld-dropdown class="dropdown dropdown-right">
|
||||
<button class="btn btn-link dropdown-toggle" aria-label="Navigation menu" tabindex="0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
style="width: 24px; height: 24px">
|
||||
@@ -104,6 +104,6 @@
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</ld-dropdown>
|
||||
</div>
|
||||
{% endhtmlmin %}
|
||||
|
||||
@@ -12,9 +12,11 @@
|
||||
<div class="section-header">
|
||||
<h1 id="main-heading">New bookmark</h1>
|
||||
</div>
|
||||
<form action="{% url 'linkding:bookmarks.new' %}" method="post" novalidate ld-form-submit>
|
||||
{% include 'bookmarks/form.html' %}
|
||||
</form>
|
||||
<ld-form data-submit-on-ctrl-enter>
|
||||
<form action="{% url 'linkding:bookmarks.new' %}" method="post" novalidate>
|
||||
{% include 'bookmarks/form.html' %}
|
||||
</form>
|
||||
</ld-form>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
{% load widget_tweaks %}
|
||||
|
||||
<div ld-search-autocomplete class="search-container">
|
||||
<div class="search-container">
|
||||
<form id="search" action="" method="get" role="search">
|
||||
<input type="search" class="form-input" name="q" placeholder="Search for words or #tags"
|
||||
value="{{ search.q }}"
|
||||
data-link-target="{{ request.user_profile.bookmark_link_target }}"
|
||||
data-mode="{{ mode }}"
|
||||
data-user="{{ search.user }}"
|
||||
data-shared="{{ search.shared }}"
|
||||
data-unread="{{ search.unread }}">
|
||||
<ld-search-autocomplete
|
||||
input-name="q"
|
||||
input-placeholder="Search for words or #tags"
|
||||
input-value="{{ search.q|default_if_none:'' }}"
|
||||
target="{{ request.user_profile.bookmark_link_target }}"
|
||||
mode="{{ mode }}"
|
||||
user="{{ search.user }}"
|
||||
shared="{{ search.shared }}"
|
||||
unread="{{ search.unread }}">
|
||||
</ld-search-autocomplete>
|
||||
<input type="submit" value="Search" class="d-none">
|
||||
{% for hidden_field in search_form.hidden_fields %}
|
||||
{{ hidden_field }}
|
||||
{% endfor %}
|
||||
</form>
|
||||
<div ld-dropdown class="search-options dropdown dropdown-right">
|
||||
<ld-dropdown class="search-options dropdown dropdown-right">
|
||||
<button type="button" aria-label="Search preferences"
|
||||
class="btn dropdown-toggle{% if search.has_modified_preferences %} badge{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" stroke-width="2"
|
||||
@@ -83,5 +86,5 @@
|
||||
{% endfor %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</ld-dropdown>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{% load bookmarks %}
|
||||
|
||||
{% block content %}
|
||||
<div
|
||||
<ld-bookmark-page no-bulk-edit
|
||||
class="bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}">
|
||||
|
||||
{# Bookmark list #}
|
||||
@@ -13,7 +13,9 @@
|
||||
<h1 id="main-heading">Shared bookmarks</h1>
|
||||
<div class="header-controls">
|
||||
{% bookmark_search bookmark_list.search mode='shared' %}
|
||||
<button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
|
||||
<ld-filter-drawer-trigger>
|
||||
<button class="btn ml-2">Filters</button>
|
||||
</ld-filter-drawer-trigger>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +42,7 @@
|
||||
</section>
|
||||
{% include 'bookmarks/tag_section.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</ld-bookmark-page>
|
||||
{% endblock %}
|
||||
|
||||
{% block overlays %}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="section-header no-wrap">
|
||||
<h2 id="tags-heading">Tags</h2>
|
||||
{% if user.is_authenticated %}
|
||||
<div ld-dropdown class="dropdown dropdown-right ml-auto">
|
||||
<ld-dropdown class="dropdown dropdown-right ml-auto">
|
||||
<button class="btn btn-noborder dropdown-toggle" aria-label="Tags menu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -17,7 +17,7 @@
|
||||
<a href="{% url 'linkding:tags.index' %}" class="menu-link">Manage tags</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</ld-dropdown>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div id="tag-cloud-container">
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
{% load widget_tweaks %}
|
||||
|
||||
<form id="user-select" action="" method="get">
|
||||
{% for hidden_field in form.hidden_fields %}
|
||||
{{ hidden_field }}
|
||||
{% endfor %}
|
||||
<div class="form-group">
|
||||
<div class="d-flex">
|
||||
{% render_field form.user class+="form-select" ld-auto-submit="" %}
|
||||
<noscript>
|
||||
<button type="submit" class="btn btn-link ml-2">Apply</button>
|
||||
</noscript>
|
||||
<ld-form data-form-reset>
|
||||
<form id="user-select" action="" method="get">
|
||||
{% for hidden_field in form.hidden_fields %}
|
||||
{{ hidden_field }}
|
||||
{% endfor %}
|
||||
<div class="form-group">
|
||||
<div class="d-flex">
|
||||
{% render_field form.user class+="form-select" data-submit-on-change="" %}
|
||||
<noscript>
|
||||
<button type="submit" class="btn btn-link ml-2">Apply</button>
|
||||
</noscript>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</ld-form>
|
||||
@@ -23,25 +23,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ld-tag-autocomplete>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.any_tags.id_for_label }}" class="form-label">Tags</label>
|
||||
{{ form.any_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
|
||||
<ld-tag-autocomplete input-id="{{ form.any_tags.auto_id }}" input-name="{{ form.any_tags.html_name }}"
|
||||
input-value="{{ form.any_tags.value|default_if_none:'' }}">
|
||||
</ld-tag-autocomplete>
|
||||
<div class="form-input-hint">
|
||||
At least one of these tags must be present in a bookmark to match.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ld-tag-autocomplete>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.all_tags.id_for_label }}" class="form-label">Required tags</label>
|
||||
{{ form.all_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
|
||||
<ld-tag-autocomplete input-id="{{ form.all_tags.auto_id }}" input-name="{{ form.all_tags.html_name }}"
|
||||
input-value="{{ form.all_tags.value|default_if_none:'' }}">
|
||||
</ld-tag-autocomplete>
|
||||
<div class="form-input-hint">
|
||||
All of these tags must be present in a bookmark to match.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ld-tag-autocomplete>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.excluded_tags.id_for_label }}" class="form-label">Excluded tags</label>
|
||||
{{ form.excluded_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
|
||||
<ld-tag-autocomplete input-id="{{ form.excluded_tags.auto_id }}" input-name="{{ form.excluded_tags.html_name }}"
|
||||
input-value="{{ form.excluded_tags.value|default_if_none:'' }}">
|
||||
</ld-tag-autocomplete>
|
||||
<div class="form-input-hint">
|
||||
None of these tags must be present in a bookmark to match.
|
||||
</div>
|
||||
@@ -54,37 +60,37 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function init() {
|
||||
const bundleForm = document.getElementById('bundle-form');
|
||||
const previewLink = document.getElementById('preview-link');
|
||||
(function init() {
|
||||
const bundleForm = document.getElementById('bundle-form');
|
||||
const previewLink = document.getElementById('preview-link');
|
||||
|
||||
let pendingUpdate;
|
||||
let pendingUpdate;
|
||||
|
||||
function scheduleUpdate() {
|
||||
if (pendingUpdate) {
|
||||
clearTimeout(pendingUpdate);
|
||||
}
|
||||
pendingUpdate = setTimeout(() => {
|
||||
// Ignore if link has been removed (e.g. form submit or navigation)
|
||||
if (!previewLink.isConnected) {
|
||||
return;
|
||||
function scheduleUpdate() {
|
||||
if (pendingUpdate) {
|
||||
clearTimeout(pendingUpdate);
|
||||
}
|
||||
pendingUpdate = setTimeout(() => {
|
||||
// Ignore if link has been removed (e.g. form submit or navigation)
|
||||
if (!previewLink.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = previewLink.href.split('?')[0];
|
||||
const params = new URLSearchParams();
|
||||
const inputs = bundleForm.querySelectorAll('input[type="text"]:not([name="csrfmiddlewaretoken"]), textarea, select');
|
||||
|
||||
inputs.forEach(input => {
|
||||
if (input.name && input.value.trim()) {
|
||||
params.set(input.name, input.value.trim());
|
||||
}
|
||||
});
|
||||
|
||||
previewLink.href = params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;
|
||||
previewLink.click();
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const baseUrl = previewLink.href.split('?')[0];
|
||||
const params = new URLSearchParams();
|
||||
const inputs = bundleForm.querySelectorAll('input[type="text"]:not([name="csrfmiddlewaretoken"]), textarea, select');
|
||||
|
||||
inputs.forEach(input => {
|
||||
if (input.name && input.value.trim()) {
|
||||
params.set(input.name, input.value.trim());
|
||||
}
|
||||
});
|
||||
|
||||
previewLink.href = params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;
|
||||
previewLink.click();
|
||||
}, 500)
|
||||
}
|
||||
|
||||
bundleForm.addEventListener('input', scheduleUpdate);
|
||||
})();
|
||||
bundleForm.addEventListener('input', scheduleUpdate);
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
</td>
|
||||
<td class="actions">
|
||||
<a class="btn btn-link" href="{% url 'linkding:bundles.edit' bundle.id %}">Edit</a>
|
||||
<button ld-confirm-button type="submit" name="remove_bundle" value="{{ bundle.id }}"
|
||||
<button data-confirm type="submit" name="remove_bundle" value="{{ bundle.id }}"
|
||||
class="btn btn-link">Remove
|
||||
</button>
|
||||
</td>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<form method="post" action="{% url 'linkding:settings.integrations.create_api_token' %}"
|
||||
data-turbo-frame="api-section">
|
||||
{% csrf_token %}
|
||||
<div class="modal active" ld-modal data-close-url="{% url 'linkding:settings.integrations' %}"
|
||||
<ld-modal class="modal active" data-close-url="{% url 'linkding:settings.integrations' %}"
|
||||
data-turbo-frame="api-modal">
|
||||
<div class="modal-overlay"></div>
|
||||
<div class="modal-container" role="dialog" aria-modal="true">
|
||||
@@ -40,6 +40,6 @@
|
||||
<button type="submit" class="btn btn-primary">Create Token</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ld-modal>
|
||||
</form>
|
||||
</turbo-frame>
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
<td>{{ token.created|date:"M d, Y H:i" }}</td>
|
||||
<td class="actions">
|
||||
{% csrf_token %}
|
||||
<button ld-confirm-button name="token_id" value="{{ token.id }}" type="submit"
|
||||
<button data-confirm name="token_id" value="{{ token.id }}" type="submit"
|
||||
class="btn btn-link">Delete
|
||||
</button>
|
||||
</td>
|
||||
|
||||
@@ -23,40 +23,42 @@
|
||||
|
||||
{# Filters #}
|
||||
<div class="crud-filters">
|
||||
<form method="get" class="mb-2" ld-form-reset>
|
||||
<div class="form-group">
|
||||
<label class="form-label text-assistive" for="search">Search tags</label>
|
||||
<div class="input-group">
|
||||
<input type="text" id="search" name="search" value="{{ search }}" placeholder="Search tags..."
|
||||
class="form-input">
|
||||
<button type="submit" class="btn input-group-btn">Search</button>
|
||||
<ld-form data-form-reset>
|
||||
<form method="get" class="mb-2">
|
||||
<div class="form-group">
|
||||
<label class="form-label text-assistive" for="search">Search tags</label>
|
||||
<div class="input-group">
|
||||
<input type="text" id="search" name="search" value="{{ search }}" placeholder="Search tags..."
|
||||
class="form-input">
|
||||
<button type="submit" class="btn input-group-btn">Search</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label text-assistive" for="sort">Sort by</label>
|
||||
<div class="input-group">
|
||||
<div class="form-group">
|
||||
<label class="form-label text-assistive" for="sort">Sort by</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-addon text-secondary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path
|
||||
stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 9l4 -4l4 4m-4 -4v14"/><path
|
||||
d="M21 15l-4 4l-4 -4m4 4v-14"/></svg>
|
||||
</span>
|
||||
<select id="sort" name="sort" class="form-select" ld-auto-submit>
|
||||
<option value="name-asc" {% if sort == "name-asc" %}selected{% endif %}>Name A-Z</option>
|
||||
<option value="name-desc" {% if sort == "name-desc" %}selected{% endif %}>Name Z-A</option>
|
||||
<option value="count-asc" {% if sort == "count-asc" %}selected{% endif %}>Fewest bookmarks</option>
|
||||
<option value="count-desc" {% if sort == "count-desc" %}selected{% endif %}>Most bookmarks</option>
|
||||
</select>
|
||||
<select id="sort" name="sort" class="form-select" data-submit-on-change>
|
||||
<option value="name-asc" {% if sort == "name-asc" %}selected{% endif %}>Name A-Z</option>
|
||||
<option value="name-desc" {% if sort == "name-desc" %}selected{% endif %}>Name Z-A</option>
|
||||
<option value="count-asc" {% if sort == "count-asc" %}selected{% endif %}>Fewest bookmarks</option>
|
||||
<option value="count-desc" {% if sort == "count-desc" %}selected{% endif %}>Most bookmarks</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-checkbox">
|
||||
<input type="checkbox" name="unused" value="true" {% if unused_only %}checked{% endif %} ld-auto-submit>
|
||||
<i class="form-icon"></i> Show only unused tags
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-checkbox">
|
||||
<input type="checkbox" name="unused" value="true" {% if unused_only %}checked{% endif %}
|
||||
data-submit-on-change>
|
||||
<i class="form-icon"></i> Show only unused tags
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</ld-form>
|
||||
{# Tags count #}
|
||||
<p class="text-secondary text-small m-0">
|
||||
{% if search or unused_only %}
|
||||
@@ -96,7 +98,7 @@
|
||||
<td class="actions">
|
||||
<a class="btn btn-link" href="{% url 'linkding:tags.edit' tag.id %}">Edit</a>
|
||||
<button type="submit" name="delete_tag" value="{{ tag.id }}" class="btn btn-link text-error"
|
||||
ld-confirm-button>
|
||||
data-confirm>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
|
||||
@@ -30,9 +30,11 @@
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="form-group {% if form.target_tag.errors %}has-error{% endif %}" ld-tag-autocomplete>
|
||||
<div class="form-group {% if form.target_tag.errors %}has-error{% endif %}">
|
||||
<label for="{{ form.target_tag.id_for_label }}" class="form-label">Target tag</label>
|
||||
{{ form.target_tag|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off"|attr:"placeholder: " }}
|
||||
<ld-tag-autocomplete input-id="{{ form.target_tag.auto_id }}" input-name="{{ form.target_tag.html_name }}"
|
||||
input-value="{{ form.target_tag.value|default_if_none:'' }}">
|
||||
</ld-tag-autocomplete>
|
||||
<div class="form-input-hint">
|
||||
Enter the name of the tag you want to keep. The tags entered below will be merged into this one.
|
||||
</div>
|
||||
@@ -43,9 +45,11 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="form-group {% if form.merge_tags.errors %}has-error{% endif %}" ld-tag-autocomplete>
|
||||
<div class="form-group {% if form.merge_tags.errors %}has-error{% endif %}">
|
||||
<label for="{{ form.merge_tags.id_for_label }}" class="form-label">Tags to merge</label>
|
||||
{{ form.merge_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off"|attr:"placeholder: " }}
|
||||
<ld-tag-autocomplete input-id="{{ form.merge_tags.auto_id }}" input-name="{{ form.merge_tags.html_name }}"
|
||||
input-value="{{ form.merge_tags.value|default_if_none:'' }}">
|
||||
</ld-tag-autocomplete>
|
||||
<div class="form-input-hint">Enter the names of tags to merge into the target tag, separated by spaces. These
|
||||
tags will be deleted after merging.
|
||||
</div>
|
||||
|
||||
@@ -315,12 +315,12 @@ class BookmarkListTestMixin(TestCase, HtmlTestMixin):
|
||||
)
|
||||
self.assertIsNotNone(bookmark_list)
|
||||
|
||||
bookmark_items = bookmark_list.select("li[ld-bookmark-item]")
|
||||
bookmark_items = bookmark_list.select("ul.bookmark-list > li")
|
||||
self.assertEqual(len(bookmark_items), len(bookmarks))
|
||||
|
||||
for bookmark in bookmarks:
|
||||
bookmark_item = bookmark_list.select_one(
|
||||
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]'
|
||||
f'ul.bookmark-list > li a[href="{bookmark.url}"][target="{link_target}"]'
|
||||
)
|
||||
self.assertIsNotNone(bookmark_item)
|
||||
|
||||
@@ -331,7 +331,7 @@ class BookmarkListTestMixin(TestCase, HtmlTestMixin):
|
||||
|
||||
for bookmark in bookmarks:
|
||||
bookmark_item = soup.select_one(
|
||||
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]'
|
||||
f'ul.bookmark-list > li a[href="{bookmark.url}"][target="{link_target}"]'
|
||||
)
|
||||
self.assertIsNone(bookmark_item)
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ class BookmarkArchivedViewPerformanceTestCase(
|
||||
response = self.client.get(reverse("linkding:bookmarks.archived"))
|
||||
html = response.content.decode("utf-8")
|
||||
soup = self.make_soup(html)
|
||||
list_items = soup.select("li[ld-bookmark-item]")
|
||||
list_items = soup.select("ul.bookmark-list > li")
|
||||
self.assertEqual(len(list_items), num_initial_bookmarks)
|
||||
|
||||
number_of_queries = context.final_queries
|
||||
@@ -49,7 +49,7 @@ class BookmarkArchivedViewPerformanceTestCase(
|
||||
response = self.client.get(reverse("linkding:bookmarks.archived"))
|
||||
html = response.content.decode("utf-8")
|
||||
soup = self.make_soup(html)
|
||||
list_items = soup.select("li[ld-bookmark-item]")
|
||||
list_items = soup.select("ul.bookmark-list > li")
|
||||
self.assertEqual(
|
||||
len(list_items), num_initial_bookmarks + num_additional_bookmarks
|
||||
)
|
||||
|
||||
@@ -24,17 +24,17 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
url = reverse("linkding:bookmarks.index") + f"?details={bookmark.id}"
|
||||
response = self.client.get(url)
|
||||
soup = self.make_soup(response.content.decode())
|
||||
return soup.select_one("div.modal.bookmark-details")
|
||||
return soup.select_one("ld-details-modal")
|
||||
|
||||
def get_shared_details_modal(self, bookmark):
|
||||
url = reverse("linkding:bookmarks.shared") + f"?details={bookmark.id}"
|
||||
response = self.client.get(url)
|
||||
soup = self.make_soup(response.content.decode())
|
||||
return soup.select_one("div.modal.bookmark-details")
|
||||
return soup.select_one("ld-details-modal")
|
||||
|
||||
def has_details_modal(self, response):
|
||||
soup = self.make_soup(response.content.decode())
|
||||
return soup.select_one("div.modal.bookmark-details") is not None
|
||||
return soup.select_one("ld-details-modal") is not None
|
||||
|
||||
def find_section_content(self, soup, section_name):
|
||||
h3 = soup.find("h3", string=section_name)
|
||||
|
||||
@@ -122,8 +122,9 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
tag_string = build_tag_string(bookmark.tag_names, " ")
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<input type="text" name="tag_string" value="{tag_string}"
|
||||
autocomplete="off" autocapitalize="off" class="form-input" id="id_tag_string" aria-describedby="id_tag_string_help">
|
||||
<ld-tag-autocomplete input-id="id_tag_string" input-name="tag_string" input-value="{tag_string}"
|
||||
input-aria-describedby="id_tag_string_help">
|
||||
</ld-tag-autocomplete>
|
||||
""",
|
||||
html,
|
||||
)
|
||||
|
||||
@@ -34,7 +34,7 @@ class BookmarkIndexViewPerformanceTestCase(
|
||||
response = self.client.get(reverse("linkding:bookmarks.index"))
|
||||
html = response.content.decode("utf-8")
|
||||
soup = self.make_soup(html)
|
||||
list_items = soup.select("li[ld-bookmark-item]")
|
||||
list_items = soup.select("ul.bookmark-list > li")
|
||||
self.assertEqual(len(list_items), num_initial_bookmarks)
|
||||
|
||||
number_of_queries = context.final_queries
|
||||
@@ -49,7 +49,7 @@ class BookmarkIndexViewPerformanceTestCase(
|
||||
response = self.client.get(reverse("linkding:bookmarks.index"))
|
||||
html = response.content.decode("utf-8")
|
||||
soup = self.make_soup(html)
|
||||
list_items = soup.select("li[ld-bookmark-item]")
|
||||
list_items = soup.select("ul.bookmark-list > li")
|
||||
self.assertEqual(
|
||||
len(list_items), num_initial_bookmarks + num_additional_bookmarks
|
||||
)
|
||||
|
||||
@@ -118,8 +118,9 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<input type="text" name="tag_string" value="tag1 tag2 tag3"
|
||||
aria-describedby="id_tag_string_help" autocapitalize="off" autocomplete="off" class="form-input" id="id_tag_string">
|
||||
<ld-tag-autocomplete input-id="id_tag_string" input-name="tag_string" input-value="tag1 tag2 tag3"
|
||||
input-aria-describedby="id_tag_string_help">
|
||||
</ld-tag-autocomplete>
|
||||
""",
|
||||
html,
|
||||
)
|
||||
|
||||
@@ -38,11 +38,11 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
self.assertIsNone(element)
|
||||
|
||||
def assertSearchInput(self, form: BeautifulSoup, name: str, value: str = None):
|
||||
element = form.select_one(f'input[name="{name}"][type="search"]')
|
||||
element = form.select_one(f'ld-search-autocomplete[input-name="{name}"]')
|
||||
self.assertIsNotNone(element)
|
||||
|
||||
if value is not None:
|
||||
self.assertEqual(element["value"], value)
|
||||
self.assertEqual(element["input-value"], value)
|
||||
|
||||
def assertSelect(self, form: BeautifulSoup, name: str, value: str = None):
|
||||
select = form.select_one(f'select[name="{name}"]')
|
||||
|
||||
@@ -38,7 +38,7 @@ class BookmarkSharedViewTestCase(
|
||||
f'<option value="{user.username}">{user.username}</option>'
|
||||
)
|
||||
user_select_html = f"""
|
||||
<select name="user" class="form-select" id="id_user" ld-auto-submit>
|
||||
<select name="user" class="form-select" id="id_user" data-submit-on-change>
|
||||
{''.join(user_options)}
|
||||
</select>
|
||||
"""
|
||||
|
||||
@@ -35,7 +35,7 @@ class BookmarkSharedViewPerformanceTestCase(
|
||||
response = self.client.get(reverse("linkding:bookmarks.shared"))
|
||||
html = response.content.decode("utf-8")
|
||||
soup = self.make_soup(html)
|
||||
list_items = soup.select("li[ld-bookmark-item]")
|
||||
list_items = soup.select("ul.bookmark-list > li")
|
||||
self.assertEqual(len(list_items), num_initial_bookmarks)
|
||||
|
||||
number_of_queries = context.final_queries
|
||||
@@ -51,7 +51,7 @@ class BookmarkSharedViewPerformanceTestCase(
|
||||
response = self.client.get(reverse("linkding:bookmarks.shared"))
|
||||
html = response.content.decode("utf-8")
|
||||
soup = self.make_soup(html)
|
||||
list_items = soup.select("li[ld-bookmark-item]")
|
||||
list_items = soup.select("ul.bookmark-list > li")
|
||||
self.assertEqual(
|
||||
len(list_items), num_initial_bookmarks + num_additional_bookmarks
|
||||
)
|
||||
|
||||
@@ -97,7 +97,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
def assertDeleteLinkCount(self, html: str, bookmark: Bookmark, count=1):
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<button ld-confirm-button type="submit" name="remove" value="{bookmark.id}"
|
||||
<button data-confirm type="submit" name="remove" value="{bookmark.id}"
|
||||
class="btn btn-link btn-sm">Remove</button>
|
||||
""",
|
||||
html,
|
||||
@@ -231,7 +231,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
f"""
|
||||
<button type="submit" name="unshare" value="{bookmark.id}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-question="Unshare?">
|
||||
data-confirm data-confirm-question="Unshare?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-share"></use>
|
||||
</svg>
|
||||
@@ -247,7 +247,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
f"""
|
||||
<button type="submit" name="mark_as_read" value="{bookmark.id}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-question="Mark as read?">
|
||||
data-confirm data-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>
|
||||
@@ -613,7 +613,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
html = self.render_template()
|
||||
soup = self.make_soup(html)
|
||||
|
||||
list_item = soup.select_one("li[ld-bookmark-item]")
|
||||
list_item = soup.select_one("ul.bookmark-list > li")
|
||||
self.assertIsNotNone(list_item)
|
||||
self.assertListEqual(["unread"], list_item["class"])
|
||||
|
||||
@@ -626,7 +626,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
html = self.render_template()
|
||||
soup = self.make_soup(html)
|
||||
|
||||
list_item = soup.select_one("li[ld-bookmark-item]")
|
||||
list_item = soup.select_one("ul.bookmark-list > li")
|
||||
self.assertIsNotNone(list_item)
|
||||
self.assertListEqual(["shared"], list_item["class"])
|
||||
|
||||
@@ -639,7 +639,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
html = self.render_template()
|
||||
soup = self.make_soup(html)
|
||||
|
||||
list_item = soup.select_one("li[ld-bookmark-item]")
|
||||
list_item = soup.select_one("ul.bookmark-list > li")
|
||||
self.assertIsNotNone(list_item)
|
||||
self.assertListEqual(["unread", "shared"], list_item["class"])
|
||||
|
||||
@@ -1086,7 +1086,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
html = self.render_template()
|
||||
|
||||
soup = self.make_soup(html)
|
||||
bookmarks = soup.select("li[ld-bookmark-item]")
|
||||
bookmarks = soup.select("ul.bookmark-list > li")
|
||||
self.assertEqual(30, len(bookmarks))
|
||||
|
||||
def test_items_per_page_is_configurable(self):
|
||||
@@ -1097,7 +1097,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
html = self.render_template()
|
||||
|
||||
soup = self.make_soup(html)
|
||||
bookmarks = soup.select("li[ld-bookmark-item]")
|
||||
bookmarks = soup.select("ul.bookmark-list > li")
|
||||
self.assertEqual(10, len(bookmarks))
|
||||
|
||||
def test_no_actions_rendered_when_is_preview(self):
|
||||
|
||||
@@ -55,37 +55,44 @@ class BundleEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(
|
||||
f'<input type="text" name="name" value="{bundle.name}" '
|
||||
'autocomplete="off" placeholder=" " class="form-input" '
|
||||
'maxlength="256" required id="id_name">',
|
||||
f"""
|
||||
<input type="text" name="name" value="{bundle.name}"
|
||||
autocomplete="off" placeholder=" " class="form-input"
|
||||
maxlength="256" required id="id_name">
|
||||
""",
|
||||
html,
|
||||
)
|
||||
|
||||
self.assertInHTML(
|
||||
f'<input type="text" name="search" value="{bundle.search}" '
|
||||
'autocomplete="off" placeholder=" " class="form-input" '
|
||||
'maxlength="256" id="id_search">',
|
||||
f"""
|
||||
<input type="text" name="search" value="{bundle.search}"
|
||||
autocomplete="off" placeholder=" " class="form-input"
|
||||
maxlength="256" id="id_search">
|
||||
""",
|
||||
html,
|
||||
)
|
||||
|
||||
self.assertInHTML(
|
||||
f'<input type="text" name="any_tags" value="{bundle.any_tags}" '
|
||||
'autocomplete="off" autocapitalize="off" class="form-input" '
|
||||
'maxlength="1024" id="id_any_tags">',
|
||||
f"""
|
||||
<ld-tag-autocomplete input-name="any_tags" input-value="{bundle.any_tags}"
|
||||
input-id="id_any_tags">
|
||||
""",
|
||||
html,
|
||||
)
|
||||
|
||||
self.assertInHTML(
|
||||
f'<input type="text" name="all_tags" value="{bundle.all_tags}" '
|
||||
'autocomplete="off" autocapitalize="off" class="form-input" '
|
||||
'maxlength="1024" id="id_all_tags">',
|
||||
f"""
|
||||
<ld-tag-autocomplete input-name="all_tags" input-value="{bundle.all_tags}"
|
||||
input-id="id_all_tags">
|
||||
""",
|
||||
html,
|
||||
)
|
||||
|
||||
self.assertInHTML(
|
||||
f'<input type="text" name="excluded_tags" value="{bundle.excluded_tags}" '
|
||||
'autocomplete="off" autocapitalize="off" class="form-input" '
|
||||
'maxlength="1024" id="id_excluded_tags">',
|
||||
f"""
|
||||
<ld-tag-autocomplete input-name="excluded_tags" input-value="{bundle.excluded_tags}"
|
||||
input-id="id_excluded_tags">
|
||||
""",
|
||||
html,
|
||||
)
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ class BundleIndexViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
</td>
|
||||
<td class="actions">
|
||||
<a class="btn btn-link" href="{reverse("linkding:bundles.edit", args=[bundle.id])}">Edit</a>
|
||||
<button ld-confirm-button type="submit" name="remove_bundle" value="{bundle.id}" class="btn btn-link">Remove</button>
|
||||
<button data-confirm type="submit" name="remove_bundle" value="{bundle.id}" class="btn btn-link">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
@@ -84,10 +84,10 @@ class BundleNewViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
|
||||
soup = self.make_soup(response.content.decode())
|
||||
search_field = soup.select_one('input[name="search"]')
|
||||
all_tags_field = soup.select_one('input[name="all_tags"]')
|
||||
all_tags_field = soup.select_one('ld-tag-autocomplete[input-name="all_tags"]')
|
||||
|
||||
self.assertEqual(search_field.get("value"), "machine learning")
|
||||
self.assertEqual(all_tags_field.get("value"), "python ai")
|
||||
self.assertEqual(all_tags_field.get("input-value"), "python ai")
|
||||
|
||||
def test_should_ignore_special_search_commands(self):
|
||||
query = "python tutorial !untagged !unread"
|
||||
@@ -96,20 +96,20 @@ class BundleNewViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
|
||||
soup = self.make_soup(response.content.decode())
|
||||
search_field = soup.select_one('input[name="search"]')
|
||||
all_tags_field = soup.select_one('input[name="all_tags"]')
|
||||
all_tags_field = soup.select_one('ld-tag-autocomplete[input-name="all_tags"]')
|
||||
|
||||
self.assertEqual(search_field.get("value"), "python tutorial")
|
||||
self.assertIsNone(all_tags_field.get("value"))
|
||||
self.assertEqual(all_tags_field.get("input-value"), "")
|
||||
|
||||
def test_should_not_prefill_when_no_query_parameter(self):
|
||||
response = self.client.get(reverse("linkding:bundles.new"))
|
||||
|
||||
soup = self.make_soup(response.content.decode())
|
||||
search_field = soup.select_one('input[name="search"]')
|
||||
all_tags_field = soup.select_one('input[name="all_tags"]')
|
||||
all_tags_field = soup.select_one('ld-tag-autocomplete[input-name="all_tags"]')
|
||||
|
||||
self.assertIsNone(search_field.get("value"))
|
||||
self.assertIsNone(all_tags_field.get("value"))
|
||||
self.assertEqual(all_tags_field.get("input-value"), "")
|
||||
|
||||
def test_should_not_prefill_when_editing_existing_bundle(self):
|
||||
bundle = self.setup_bundle(
|
||||
@@ -126,10 +126,10 @@ class BundleNewViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
|
||||
soup = self.make_soup(response.content.decode())
|
||||
search_field = soup.select_one('input[name="search"]')
|
||||
all_tags_field = soup.select_one('input[name="all_tags"]')
|
||||
all_tags_field = soup.select_one('ld-tag-autocomplete[input-name="all_tags"]')
|
||||
|
||||
self.assertEqual(search_field.get("value"), "Tutorial")
|
||||
self.assertEqual(all_tags_field.get("value"), "java spring")
|
||||
self.assertEqual(all_tags_field.get("input-value"), "java spring")
|
||||
|
||||
def test_should_show_correct_preview_with_prefilled_values(self):
|
||||
bundle_tag = self.setup_tag()
|
||||
|
||||
@@ -77,7 +77,7 @@ class BundlePreviewViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
response = self.client.get(reverse("linkding:bundles.preview"))
|
||||
soup = self.make_soup(response.content.decode())
|
||||
|
||||
list_item = soup.select_one("li[ld-bookmark-item]")
|
||||
list_item = soup.select_one("ul.bookmark-list > li")
|
||||
actions = list_item.select(".actions > *")
|
||||
self.assertEqual(len(actions), 1)
|
||||
|
||||
|
||||
@@ -255,7 +255,7 @@ class TagsIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<select id="sort" name="sort" class="form-select" ld-auto-submit>
|
||||
<select id="sort" name="sort" class="form-select" data-submit-on-change>
|
||||
<option value="name-asc" selected>Name A-Z</option>
|
||||
<option value="name-desc">Name Z-A</option>
|
||||
<option value="count-asc">Fewest bookmarks</option>
|
||||
@@ -270,7 +270,7 @@ class TagsIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<select id="sort" name="sort" class="form-select" ld-auto-submit>
|
||||
<select id="sort" name="sort" class="form-select" data-submit-on-change>
|
||||
<option value="name-asc">Name A-Z</option>
|
||||
<option value="name-desc" selected>Name Z-A</option>
|
||||
<option value="count-asc">Fewest bookmarks</option>
|
||||
|
||||
@@ -15,6 +15,11 @@ class TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
input_element = soup.find("input", {"name": input_name})
|
||||
if input_element:
|
||||
return input_element.find_parent("div", class_="form-group")
|
||||
autocomplete_element = soup.find(
|
||||
"ld-tag-autocomplete", {"input-name": input_name}
|
||||
)
|
||||
if autocomplete_element:
|
||||
return autocomplete_element.find_parent("div", class_="form-group")
|
||||
return None
|
||||
|
||||
def test_merge_tags(self):
|
||||
|
||||
@@ -144,7 +144,6 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
|
||||
# verify bookmark is deleted
|
||||
self.locate_bookmark(bookmark.title)
|
||||
expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
|
||||
|
||||
self.assertEqual(Bookmark.objects.count(), 0)
|
||||
|
||||
@@ -5,7 +5,7 @@ from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase
|
||||
from bookmarks.models import Bookmark
|
||||
|
||||
|
||||
class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
class BookmarkPageBulkEditE2ETestCase(LinkdingE2ETestCase):
|
||||
def setup_test_data(self):
|
||||
self.setup_numbered_bookmarks(50)
|
||||
self.setup_numbered_bookmarks(50, archived=True)
|
||||
@@ -39,7 +39,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("linkding:bookmarks.index"), p)
|
||||
|
||||
bookmark_list = self.locate_bookmark_list()
|
||||
bookmark_list = self.locate_bookmark_list().element_handle()
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
@@ -48,7 +48,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
bookmark_list.wait_for_element_state("hidden", timeout=1000)
|
||||
|
||||
self.assertEqual(
|
||||
0,
|
||||
@@ -77,7 +77,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("linkding:bookmarks.archived"), p)
|
||||
|
||||
bookmark_list = self.locate_bookmark_list()
|
||||
bookmark_list = self.locate_bookmark_list().element_handle()
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
@@ -86,7 +86,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
bookmark_list.wait_for_element_state("hidden", timeout=1000)
|
||||
|
||||
self.assertEqual(
|
||||
50,
|
||||
@@ -115,7 +115,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("linkding:bookmarks.index") + "?q=foo", p)
|
||||
|
||||
bookmark_list = self.locate_bookmark_list()
|
||||
bookmark_list = self.locate_bookmark_list().element_handle()
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
@@ -124,7 +124,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
bookmark_list.wait_for_element_state("hidden", timeout=1000)
|
||||
|
||||
self.assertEqual(
|
||||
50,
|
||||
@@ -153,7 +153,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("linkding:bookmarks.archived") + "?q=foo", p)
|
||||
|
||||
bookmark_list = self.locate_bookmark_list()
|
||||
bookmark_list = self.locate_bookmark_list().element_handle()
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
@@ -162,7 +162,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
bookmark_list.wait_for_element_state("hidden", timeout=1000)
|
||||
|
||||
self.assertEqual(
|
||||
50,
|
||||
@@ -281,7 +281,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
url = reverse("linkding:bookmarks.index")
|
||||
page = self.open(url, p)
|
||||
|
||||
bookmark_list = self.locate_bookmark_list()
|
||||
bookmark_list = self.locate_bookmark_list().element_handle()
|
||||
|
||||
# Select all bookmarks, enable select across
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
@@ -294,7 +294,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
bookmark_list.wait_for_element_state("hidden", timeout=1000)
|
||||
|
||||
# Verify bulk edit checkboxes are reset
|
||||
checkboxes = page.locator("label.bulk-edit-checkbox input")
|
||||
@@ -313,7 +313,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
url = reverse("linkding:bookmarks.index")
|
||||
self.open(url, p)
|
||||
|
||||
bookmark_list = self.locate_bookmark_list()
|
||||
bookmark_list = self.locate_bookmark_list().element_handle()
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
|
||||
@@ -325,7 +325,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
bookmark_list.wait_for_element_state("hidden", timeout=1000)
|
||||
|
||||
expect(self.locate_bulk_edit_select_all()).not_to_be_checked()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
|
||||
@@ -24,7 +24,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
)
|
||||
|
||||
def assertVisibleBookmarks(self, titles: List[str]):
|
||||
bookmark_tags = self.page.locator("li[ld-bookmark-item]")
|
||||
bookmark_tags = self.page.locator("ul.bookmark-list > li")
|
||||
expect(bookmark_tags).to_have_count(len(titles))
|
||||
|
||||
for title in titles:
|
||||
@@ -59,12 +59,12 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
url = reverse("linkding:bookmarks.index") + "?sort=title_asc"
|
||||
page = self.open(url, p)
|
||||
|
||||
first_item = page.locator("li[ld-bookmark-item]").first
|
||||
first_item = page.locator("ul.bookmark-list > li").first
|
||||
expect(first_item).to_contain_text("foo 1")
|
||||
|
||||
first_item.get_by_text("Archive").click()
|
||||
|
||||
first_item = page.locator("li[ld-bookmark-item]").first
|
||||
first_item = page.locator("ul.bookmark-list > li").first
|
||||
expect(first_item).to_contain_text("foo 2")
|
||||
|
||||
def test_partial_update_respects_page(self):
|
||||
|
||||
@@ -12,13 +12,13 @@ class CollapseSidePanelE2ETestCase(LinkdingE2ETestCase):
|
||||
def assertSidePanelIsVisible(self):
|
||||
expect(self.page.locator(".bookmarks-page .side-panel")).to_be_visible()
|
||||
expect(
|
||||
self.page.locator(".bookmarks-page [ld-filter-drawer-trigger]")
|
||||
self.page.locator(".bookmarks-page ld-filter-drawer-trigger")
|
||||
).not_to_be_visible()
|
||||
|
||||
def assertSidePanelIsHidden(self):
|
||||
expect(self.page.locator(".bookmarks-page .side-panel")).not_to_be_visible()
|
||||
expect(
|
||||
self.page.locator(".bookmarks-page [ld-filter-drawer-trigger]")
|
||||
self.page.locator(".bookmarks-page ld-filter-drawer-trigger")
|
||||
).to_be_visible()
|
||||
|
||||
def test_side_panel_should_be_visible_by_default(self):
|
||||
|
||||
128
bookmarks/tests_e2e/e2e_test_dropdown.py
Normal file
128
bookmarks/tests_e2e/e2e_test_dropdown.py
Normal file
@@ -0,0 +1,128 @@
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase
|
||||
|
||||
|
||||
class DropdownE2ETestCase(LinkdingE2ETestCase):
|
||||
def locate_dropdown(self):
|
||||
# Use bookmarks dropdown as an example instance to test
|
||||
return self.page.locator("ld-dropdown").filter(
|
||||
has=self.page.get_by_role("button", name="Bookmarks")
|
||||
)
|
||||
|
||||
def locate_dropdown_toggle(self):
|
||||
return self.locate_dropdown().locator(".dropdown-toggle")
|
||||
|
||||
def locate_dropdown_menu(self):
|
||||
return self.locate_dropdown().locator(".menu")
|
||||
|
||||
def test_click_toggle_opens_and_closes_dropdown(self):
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("linkding:bookmarks.index"), p)
|
||||
|
||||
toggle = self.locate_dropdown_toggle()
|
||||
menu = self.locate_dropdown_menu()
|
||||
|
||||
# Open dropdown
|
||||
toggle.click()
|
||||
expect(menu).to_be_visible()
|
||||
|
||||
# Click toggle again to close
|
||||
toggle.click()
|
||||
expect(menu).not_to_be_visible()
|
||||
|
||||
def test_outside_click_closes_dropdown(self):
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("linkding:bookmarks.index"), p)
|
||||
|
||||
toggle = self.locate_dropdown_toggle()
|
||||
menu = self.locate_dropdown_menu()
|
||||
|
||||
# Open dropdown
|
||||
toggle.click()
|
||||
expect(menu).to_be_visible()
|
||||
|
||||
# Click outside the dropdown (on the page body)
|
||||
self.page.locator("main").click()
|
||||
expect(menu).not_to_be_visible()
|
||||
|
||||
def test_escape_closes_dropdown_and_restores_focus(self):
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("linkding:bookmarks.index"), p)
|
||||
|
||||
toggle = self.locate_dropdown_toggle()
|
||||
menu = self.locate_dropdown_menu()
|
||||
|
||||
# Open dropdown
|
||||
toggle.click()
|
||||
expect(menu).to_be_visible()
|
||||
|
||||
# Press Escape
|
||||
self.page.keyboard.press("Escape")
|
||||
|
||||
# Menu should be closed
|
||||
expect(menu).not_to_be_visible()
|
||||
|
||||
# Focus should be back on toggle
|
||||
expect(toggle).to_be_focused()
|
||||
|
||||
def test_focus_out_closes_dropdown(self):
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("linkding:bookmarks.index"), p)
|
||||
|
||||
toggle = self.locate_dropdown_toggle()
|
||||
menu = self.locate_dropdown_menu()
|
||||
|
||||
# Open dropdown
|
||||
toggle.click()
|
||||
expect(menu).to_be_visible()
|
||||
|
||||
# Shift+Tab to move focus out of the dropdown
|
||||
self.page.keyboard.press("Shift+Tab")
|
||||
|
||||
# Menu should be closed after focus leaves
|
||||
expect(menu).not_to_be_visible()
|
||||
|
||||
def test_aria_expanded_attribute(self):
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("linkding:bookmarks.index"), p)
|
||||
|
||||
toggle = self.locate_dropdown_toggle()
|
||||
menu = self.locate_dropdown_menu()
|
||||
|
||||
# Initially aria-expanded should be false
|
||||
expect(toggle).to_have_attribute("aria-expanded", "false")
|
||||
|
||||
# Open dropdown
|
||||
toggle.click()
|
||||
expect(menu).to_be_visible()
|
||||
|
||||
# aria-expanded should be true
|
||||
expect(toggle).to_have_attribute("aria-expanded", "true")
|
||||
|
||||
# Close dropdown
|
||||
toggle.click()
|
||||
expect(menu).not_to_be_visible()
|
||||
|
||||
# aria-expanded should be false again
|
||||
expect(toggle).to_have_attribute("aria-expanded", "false")
|
||||
|
||||
def test_can_click_menu_item(self):
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse("linkding:bookmarks.index"), p)
|
||||
|
||||
toggle = self.locate_dropdown_toggle()
|
||||
menu = self.locate_dropdown_menu()
|
||||
|
||||
# Open dropdown
|
||||
toggle.click()
|
||||
expect(menu).to_be_visible()
|
||||
|
||||
# Click on "Archived" menu item
|
||||
menu.get_by_text("Archived", exact=True).click()
|
||||
|
||||
# Should navigate to archived page
|
||||
expect(self.page).to_have_url(
|
||||
self.live_server_url + reverse("linkding:bookmarks.archived")
|
||||
)
|
||||
@@ -20,7 +20,7 @@ class FilterDrawerE2ETestCase(LinkdingE2ETestCase):
|
||||
drawer_trigger.click()
|
||||
|
||||
# verify drawer is visible
|
||||
drawer = page.locator(".modal.drawer.filter-drawer")
|
||||
drawer = page.locator("ld-filter-drawer")
|
||||
expect(drawer).to_be_visible()
|
||||
expect(drawer.locator("h2")).to_have_text("Filters")
|
||||
|
||||
@@ -51,7 +51,7 @@ class FilterDrawerE2ETestCase(LinkdingE2ETestCase):
|
||||
drawer_trigger.click()
|
||||
|
||||
# verify tags are displayed
|
||||
drawer = page.locator(".modal.drawer.filter-drawer")
|
||||
drawer = page.locator("ld-filter-drawer")
|
||||
unselected_tags = drawer.locator(".unselected-tags")
|
||||
expect(unselected_tags.get_by_text("cooking")).to_be_visible()
|
||||
expect(unselected_tags.get_by_text("hiking")).to_be_visible()
|
||||
|
||||
@@ -174,10 +174,10 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
|
||||
page = self.open(reverse("linkding:bookmarks.new"), p)
|
||||
|
||||
title_field = page.get_by_label("Title")
|
||||
title_clear_button = page.locator("[ld-clear-button][data-for='id_title']")
|
||||
title_clear_button = page.locator("ld-clear-button[data-for='id_title']")
|
||||
description_field = page.get_by_label("Description")
|
||||
description_clear_button = page.locator(
|
||||
"[ld-clear-button][data-for='id_description']"
|
||||
"ld-clear-button[data-for='id_description']"
|
||||
)
|
||||
|
||||
# Initially, clear buttons should be hidden because fields are empty
|
||||
|
||||
@@ -43,18 +43,18 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
|
||||
self.num_loads = 0
|
||||
|
||||
def locate_bookmark_list(self):
|
||||
return self.page.locator("ul[ld-bookmark-list]")
|
||||
return self.page.locator("ul.bookmark-list")
|
||||
|
||||
def locate_bookmark(self, title: str):
|
||||
bookmark_tags = self.page.locator("li[ld-bookmark-item]")
|
||||
bookmark_tags = self.page.locator("ul.bookmark-list > li")
|
||||
return bookmark_tags.filter(has_text=title)
|
||||
|
||||
def count_bookmarks(self):
|
||||
bookmark_tags = self.page.locator("li[ld-bookmark-item]")
|
||||
bookmark_tags = self.page.locator("ul.bookmark-list > li")
|
||||
return bookmark_tags.count()
|
||||
|
||||
def locate_details_modal(self):
|
||||
return self.page.locator(".modal.bookmark-details")
|
||||
return self.page.locator("ld-details-modal")
|
||||
|
||||
def open_details_modal(self, bookmark):
|
||||
details_button = self.locate_bookmark(bookmark.title).get_by_text("View")
|
||||
@@ -94,4 +94,4 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
|
||||
self.page.locator("nav").get_by_text(main_menu_item, exact=True).click()
|
||||
|
||||
def locate_confirm_dialog(self):
|
||||
return self.page.locator(".dropdown.confirm-dropdown")
|
||||
return self.page.locator("ld-confirm-dropdown")
|
||||
|
||||
Reference in New Issue
Block a user