Add bundles for organizing bookmarks (#1097)

* add bundle model and query logic

* cleanup tests

* add basic form

* add success message

* Add form tests

* Add bundle list view

* fix edit view

* Add remove button

* Add basic preview logic

* Make pagination use absolute URLs

* Hide bookmark edits when rendering preview

* Render bookmark list in preview

* Reorder bundles

* Show bundles in bookmark view

* Make bookmark search respect selected bundle

* UI tweaks

* Fix bookmark scope

* Improve bundle preview

* Skip preview if form is submitted

* Show correct preview after invalid form submission

* Add option to hide bundles

* Merge new migrations

* Add tests for bundle menu

* Improve check for preview being removed
This commit is contained in:
Sascha Ißbrücker
2025-06-19 16:47:29 +02:00
committed by GitHub
parent 8be72a5d1f
commit 1672dc0152
59 changed files with 2290 additions and 267 deletions

View File

@@ -0,0 +1,33 @@
{% extends 'bookmarks/layout.html' %}
{% load widget_tweaks %}
{% block head %}
{% with page_title="Edit bundle - Linkding" %}
{{ block.super }}
{% endwith %}
{% endblock %}
{% block content %}
<div class="bundles-editor-page grid columns-md-1">
<main aria-labelledby="main-heading">
<div class="section-header">
<h1 id="main-heading">Edit bundle</h1>
</div>
{% include 'shared/messages.html' %}
<form id="bundle-form" action="{% url 'linkding:bundles.edit' bundle.id %}" method="post" novalidate>
{% csrf_token %}
{% include 'bundles/form.html' %}
</form>
</main>
<aside class="col-2" aria-labelledby="preview-heading">
<div class="section-header">
<h2 id="preview-heading">Preview</h2>
</div>
{% include 'bundles/preview.html' %}
</aside>
</div>
{% endblock %}

View File

@@ -0,0 +1,91 @@
{% load widget_tweaks %}
<div class="form-group {% if form.name.errors %}has-error{% endif %}">
<label for="{{ form.name.id_for_label }}" class="form-label">Name</label>
{{ form.name|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }}
{% if form.name.errors %}
<div class="form-input-hint">
{{ form.name.errors }}
</div>
{% endif %}
</div>
<div class="form-group {% if form.search.errors %}has-error{% endif %}">
<label for="{{ form.search.id_for_label }}" class="form-label">Search</label>
{{ form.search|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }}
{% if form.search.errors %}
<div class="form-input-hint">
{{ form.search.errors }}
</div>
{% endif %}
<div class="form-input-hint">
Search terms to match bookmarks in this bundle.
</div>
</div>
<div class="form-group" ld-tag-autocomplete>
<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" }}
<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>
<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" }}
<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>
<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" }}
<div class="form-input-hint">
None of these tags must be present in a bookmark to match.
</div>
</div>
<div class="form-footer d-flex mt-4">
<input type="submit" name="save" value="Save" class="btn btn-primary btn-wide">
<a href="{% url 'linkding:bundles.index' %}" class="btn btn-wide ml-auto">Cancel</a>
<a href="{% url 'linkding:bundles.preview' %}" data-turbo-frame="preview" class="d-none" id="preview-link"></a>
</div>
<script>
(function init() {
const bundleForm = document.getElementById('bundle-form');
const previewLink = document.getElementById('preview-link');
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;
}
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('change', scheduleUpdate);
})();
</script>

View File

@@ -0,0 +1,124 @@
{% extends "bookmarks/layout.html" %}
{% block head %}
{% with page_title="Bundles - Linkding" %}
{{ block.super }}
{% endwith %}
{% endblock %}
{% block content %}
<main class="bundles-page" aria-labelledby="main-heading">
<h1 id="main-heading">Bundles</h1>
{% include 'shared/messages.html' %}
{% if bundles %}
<form action="{% url 'linkding:bundles.action' %}" method="post">
{% csrf_token %}
<div class="item-list bundles">
{% for bundle in bundles %}
<div class="list-item" data-bundle-id="{{ bundle.id }}" draggable="true">
<div class="list-item-icon text-secondary">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" 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="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
</svg>
</div>
<div class="list-item-text">
<span class="truncate">{{ bundle.name }}</span>
</div>
<div class="list-item-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 }}"
class="btn btn-link">Remove
</button>
</div>
</div>
{% endfor %}
</div>
<input type="submit" name="move_bundle" value="" class="d-none">
<input type="hidden" name="move_position" value="">
</form>
{% else %}
<div class="empty">
<p class="empty-title h5">You have no bundles yet</p>
<p class="empty-subtitle">Create your first bundle to get started</p>
</div>
{% endif %}
<div class="mt-4">
<a href="{% url 'linkding:bundles.new' %}" class="btn btn-primary">Add new bundle</a>
</div>
</main>
<script>
(function init() {
const bundlesList = document.querySelector(".item-list.bundles");
if (!bundlesList) return;
let draggedElement = null;
const listItems = bundlesList.querySelectorAll('.list-item');
listItems.forEach((item) => {
item.addEventListener('dragstart', handleDragStart);
item.addEventListener('dragend', handleDragEnd);
item.addEventListener('dragover', handleDragOver);
item.addEventListener('dragenter', handleDragEnter);
});
function handleDragStart(e) {
draggedElement = this;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.dropEffect = 'move';
this.classList.add('drag-start');
setTimeout(() => {
this.classList.remove('drag-start');
this.classList.add('dragging');
}, 0);
}
function handleDragEnd() {
this.classList.remove('dragging');
const moveBundleInput = document.querySelector('input[name="move_bundle"]');
const movePositionInput = document.querySelector('input[name="move_position"]');
moveBundleInput.value = draggedElement.getAttribute('data-bundle-id');
movePositionInput.value = Array.from(bundlesList.children).indexOf(draggedElement);
const form = this.closest('form');
form.requestSubmit(moveBundleInput);
draggedElement = null;
}
function handleDragOver(e) {
if (e.preventDefault) {
e.preventDefault();
}
return false;
}
function handleDragEnter() {
if (this !== draggedElement) {
const listItems = Array.from(bundlesList.children);
const draggedIndex = listItems.indexOf(draggedElement);
const currentIndex = listItems.indexOf(this);
if (draggedIndex < currentIndex) {
this.insertAdjacentElement('afterend', draggedElement);
} else {
this.insertAdjacentElement('beforebegin', draggedElement);
}
}
}
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,33 @@
{% extends 'bookmarks/layout.html' %}
{% load widget_tweaks %}
{% block head %}
{% with page_title="New bundle - Linkding" %}
{{ block.super }}
{% endwith %}
{% endblock %}
{% block content %}
<div class="bundles-editor-page grid columns-md-1">
<main aria-labelledby="main-heading">
<div class="section-header">
<h1 id="main-heading">New bundle</h1>
</div>
{% include 'shared/messages.html' %}
<form id="bundle-form" action="{% url 'linkding:bundles.new' %}" method="post" novalidate>
{% csrf_token %}
{% include 'bundles/form.html' %}
</form>
</main>
<aside class="col-2" aria-labelledby="preview-heading">
<div class="section-header">
<h2 id="preview-heading">Preview</h2>
</div>
{% include 'bundles/preview.html' %}
</aside>
</div>
{% endblock %}

View File

@@ -0,0 +1,12 @@
<turbo-frame id="preview">
{% if bookmark_list.is_empty %}
<div>
No bookmarks match the current bundle.
</div>
{% else %}
<div class="mb-4">
Found {{ bookmark_list.bookmarks_total }} bookmarks matching this bundle.
</div>
{% include 'bookmarks/bookmark_list.html' %}
{% endif %}
</turbo-frame>