diff --git a/bookmarks/frontend/behaviors/bookmark-page.js b/bookmarks/frontend/behaviors/bookmark-page.js
deleted file mode 100644
index 681f5f6..0000000
--- a/bookmarks/frontend/behaviors/bookmark-page.js
+++ /dev/null
@@ -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);
diff --git a/bookmarks/frontend/behaviors/bulk-edit.js b/bookmarks/frontend/behaviors/bulk-edit.js
deleted file mode 100644
index 9d3bb15..0000000
--- a/bookmarks/frontend/behaviors/bulk-edit.js
+++ /dev/null
@@ -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);
diff --git a/bookmarks/frontend/behaviors/clear-button.js b/bookmarks/frontend/behaviors/clear-button.js
deleted file mode 100644
index 3f0b476..0000000
--- a/bookmarks/frontend/behaviors/clear-button.js
+++ /dev/null
@@ -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);
diff --git a/bookmarks/frontend/behaviors/confirm-button.js b/bookmarks/frontend/behaviors/confirm-button.js
deleted file mode 100644
index a457896..0000000
--- a/bookmarks/frontend/behaviors/confirm-button.js
+++ /dev/null
@@ -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);
diff --git a/bookmarks/frontend/behaviors/details-modal.js b/bookmarks/frontend/behaviors/details-modal.js
deleted file mode 100644
index 97a1397..0000000
--- a/bookmarks/frontend/behaviors/details-modal.js
+++ /dev/null
@@ -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);
diff --git a/bookmarks/frontend/behaviors/dropdown.js b/bookmarks/frontend/behaviors/dropdown.js
deleted file mode 100644
index bc4c580..0000000
--- a/bookmarks/frontend/behaviors/dropdown.js
+++ /dev/null
@@ -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);
diff --git a/bookmarks/frontend/behaviors/filter-drawer.js b/bookmarks/frontend/behaviors/filter-drawer.js
deleted file mode 100644
index 62c4413..0000000
--- a/bookmarks/frontend/behaviors/filter-drawer.js
+++ /dev/null
@@ -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 = `
-
-
- `;
- 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);
diff --git a/bookmarks/frontend/behaviors/form.js b/bookmarks/frontend/behaviors/form.js
deleted file mode 100644
index 98cbc66..0000000
--- a/bookmarks/frontend/behaviors/form.js
+++ /dev/null
@@ -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);
diff --git a/bookmarks/frontend/behaviors/global-shortcuts.js b/bookmarks/frontend/behaviors/global-shortcuts.js
deleted file mode 100644
index 68e16f7..0000000
--- a/bookmarks/frontend/behaviors/global-shortcuts.js
+++ /dev/null
@@ -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);
diff --git a/bookmarks/frontend/behaviors/index.js b/bookmarks/frontend/behaviors/index.js
deleted file mode 100644
index ab7e13c..0000000
--- a/bookmarks/frontend/behaviors/index.js
+++ /dev/null
@@ -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;
- });
- });
-}
diff --git a/bookmarks/frontend/behaviors/search-autocomplete.js b/bookmarks/frontend/behaviors/search-autocomplete.js
deleted file mode 100644
index 3259744..0000000
--- a/bookmarks/frontend/behaviors/search-autocomplete.js
+++ /dev/null
@@ -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);
diff --git a/bookmarks/frontend/behaviors/tag-autocomplete.js b/bookmarks/frontend/behaviors/tag-autocomplete.js
deleted file mode 100644
index c1f6fe3..0000000
--- a/bookmarks/frontend/behaviors/tag-autocomplete.js
+++ /dev/null
@@ -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);
diff --git a/bookmarks/frontend/components/bookmark-page.js b/bookmarks/frontend/components/bookmark-page.js
new file mode 100644
index 0000000..d2c3c60
--- /dev/null
+++ b/bookmarks/frontend/components/bookmark-page.js
@@ -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);
diff --git a/bookmarks/frontend/components/clear-button.js b/bookmarks/frontend/components/clear-button.js
new file mode 100644
index 0000000..34f8fe3
--- /dev/null
+++ b/bookmarks/frontend/components/clear-button.js
@@ -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);
diff --git a/bookmarks/frontend/components/confirm-dropdown.js b/bookmarks/frontend/components/confirm-dropdown.js
new file mode 100644
index 0000000..2e091f7
--- /dev/null
+++ b/bookmarks/frontend/components/confirm-dropdown.js
@@ -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`
+
+ `;
+ }
+
+ 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);
diff --git a/bookmarks/frontend/components/details-modal.js b/bookmarks/frontend/components/details-modal.js
new file mode 100644
index 0000000..090a7bc
--- /dev/null
+++ b/bookmarks/frontend/components/details-modal.js
@@ -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);
diff --git a/bookmarks/frontend/components/dropdown.js b/bookmarks/frontend/components/dropdown.js
new file mode 100644
index 0000000..86bdf16
--- /dev/null
+++ b/bookmarks/frontend/components/dropdown.js
@@ -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);
diff --git a/bookmarks/frontend/components/filter-drawer.js b/bookmarks/frontend/components/filter-drawer.js
new file mode 100644
index 0000000..37d1a93
--- /dev/null
+++ b/bookmarks/frontend/components/filter-drawer.js
@@ -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`
+
+
+ `,
+ 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);
diff --git a/bookmarks/frontend/components/form.js b/bookmarks/frontend/components/form.js
new file mode 100644
index 0000000..212d3a3
--- /dev/null
+++ b/bookmarks/frontend/components/form.js
@@ -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);
diff --git a/bookmarks/frontend/behaviors/modal.js b/bookmarks/frontend/components/modal.js
similarity index 50%
rename from bookmarks/frontend/behaviors/modal.js
rename to bookmarks/frontend/components/modal.js
index 2f8c1d5..3366ec6 100644
--- a/bookmarks/frontend/behaviors/modal.js
+++ b/bookmarks/frontend/components/modal.js
@@ -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);
diff --git a/bookmarks/frontend/components/SearchAutocomplete.js b/bookmarks/frontend/components/search-autocomplete.js
similarity index 84%
rename from bookmarks/frontend/components/SearchAutocomplete.js
rename to bookmarks/frontend/components/search-autocomplete.js
index 9dffc0f..f7f4c39 100644
--- a/bookmarks/frontend/components/SearchAutocomplete.js
+++ b/bookmarks/frontend/components/search-autocomplete.js
@@ -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 {
{
+ 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);
diff --git a/bookmarks/frontend/index.js b/bookmarks/frontend/index.js
index dfe13c7..5639a52 100644
--- a/bookmarks/frontend/index.js
+++ b/bookmarks/frontend/index.js
@@ -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";
diff --git a/bookmarks/frontend/shortcuts.js b/bookmarks/frontend/shortcuts.js
new file mode 100644
index 0000000..a223fb5
--- /dev/null
+++ b/bookmarks/frontend/shortcuts.js
@@ -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");
+ }
+});
diff --git a/bookmarks/frontend/utils/element.js b/bookmarks/frontend/utils/element.js
new file mode 100644
index 0000000..2cf82fc
--- /dev/null
+++ b/bookmarks/frontend/utils/element.js
@@ -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 = "";
+ }
+ }
+}
diff --git a/bookmarks/frontend/behaviors/focus-utils.js b/bookmarks/frontend/utils/focus.js
similarity index 100%
rename from bookmarks/frontend/behaviors/focus-utils.js
rename to bookmarks/frontend/utils/focus.js
diff --git a/bookmarks/frontend/util.js b/bookmarks/frontend/utils/input.js
similarity index 86%
rename from bookmarks/frontend/util.js
rename to bookmarks/frontend/utils/input.js
index 83347a6..5c9a974 100644
--- a/bookmarks/frontend/util.js
+++ b/bookmarks/frontend/utils/input.js
@@ -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;
diff --git a/bookmarks/frontend/behaviors/position-controller.js b/bookmarks/frontend/utils/position-controller.js
similarity index 100%
rename from bookmarks/frontend/behaviors/position-controller.js
rename to bookmarks/frontend/utils/position-controller.js
diff --git a/bookmarks/frontend/components/SearchHistory.js b/bookmarks/frontend/utils/search-history.js
similarity index 100%
rename from bookmarks/frontend/components/SearchHistory.js
rename to bookmarks/frontend/utils/search-history.js
diff --git a/bookmarks/frontend/cache.js b/bookmarks/frontend/utils/tag-cache.js
similarity index 88%
rename from bookmarks/frontend/cache.js
rename to bookmarks/frontend/utils/tag-cache.js
index 0bcf9e5..4dfcd88 100644
--- a/bookmarks/frontend/cache.js
+++ b/bookmarks/frontend/utils/tag-cache.js
@@ -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);
diff --git a/bookmarks/styles/bookmark-form.css b/bookmarks/styles/bookmark-form.css
index 6b52aa4..fbdfd88 100644
--- a/bookmarks/styles/bookmark-form.css
+++ b/bookmarks/styles/bookmark-form.css
@@ -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;
}
diff --git a/bookmarks/styles/bookmark-page.css b/bookmarks/styles/bookmark-page.css
index db069c8..c6aa021 100644
--- a/bookmarks/styles/bookmark-page.css
+++ b/bookmarks/styles/bookmark-page.css
@@ -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;
+ }
+ }
}
diff --git a/bookmarks/styles/theme/autocomplete.css b/bookmarks/styles/theme/autocomplete.css
index 2ce7ea0..96952bd 100644
--- a/bookmarks/styles/theme/autocomplete.css
+++ b/bookmarks/styles/theme/autocomplete.css
@@ -23,7 +23,7 @@
flex: 1 0 auto;
line-height: var(--unit-4);
width: 100%;
- height: 100%;
+ height: 100% !important;
margin: 0;
border: none;
diff --git a/bookmarks/templates/bookmarks/archive.html b/bookmarks/templates/bookmarks/archive.html
index 5e699d5..a0d632c 100644
--- a/bookmarks/templates/bookmarks/archive.html
+++ b/bookmarks/templates/bookmarks/archive.html
@@ -4,8 +4,8 @@
{% load bookmarks %}
{% block content %}
-
+
{# Bookmark list #}
@@ -14,7 +14,9 @@
@@ -35,7 +37,7 @@
{% include 'bookmarks/bundle_section.html' %}
{% include 'bookmarks/tag_section.html' %}
-
+
{% endblock %}
{% block overlays %}
diff --git a/bookmarks/templates/bookmarks/bookmark_list.html b/bookmarks/templates/bookmarks/bookmark_list.html
index 7af15d4..41d76c6 100644
--- a/bookmarks/templates/bookmarks/bookmark_list.html
+++ b/bookmarks/templates/bookmarks/bookmark_list.html
@@ -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 %}
-
-
+ {% if not bookmark_list.is_preview %}
+
+ {% endif %}
{% if bookmark_item.favicon_file and bookmark_list.show_favicons %}

{% endif %}
@@ -85,7 +87,7 @@
{# View link is visible for both owned and shared bookmarks #}
{% if bookmark_list.show_view_action %}
View
+ data-turbo-action="replace" data-turbo-frame="details-modal">View
{% endif %}
{% if bookmark_item.is_editable %}
{# Bookmark owner actions #}
@@ -104,7 +106,7 @@
{% endif %}
{% endif %}
{% if bookmark_list.show_remove_action %}
-
{% endif %}
@@ -120,7 +122,7 @@
{% if bookmark_item.show_mark_as_read %}
{% endif %}
\ No newline at end of file
diff --git a/bookmarks/templates/bookmarks/details/form.html b/bookmarks/templates/bookmarks/details/form.html
index b6f29a0..f2cecb9 100644
--- a/bookmarks/templates/bookmarks/details/form.html
+++ b/bookmarks/templates/bookmarks/details/form.html
@@ -1,112 +1,114 @@
{% load static %}
{% load shared %}
-