Template improvements

This commit is contained in:
Sascha Ißbrücker
2026-01-01 13:40:57 +01:00
parent 38d450a916
commit ffc1a69085
69 changed files with 1847 additions and 1605 deletions

View File

@@ -16,6 +16,7 @@ test:
format: format:
uv run black bookmarks uv run black bookmarks
uv run djlint bookmarks/templates --reformat --quiet
npx prettier bookmarks/frontend --write npx prettier bookmarks/frontend --write
npx prettier bookmarks/styles --write npx prettier bookmarks/styles --write

View File

@@ -98,7 +98,7 @@ make test
### Formatting ### Formatting
Format Python code with black, and JavaScript code with prettier: Format Python code with black, Django templates with djlint, and JavaScript code with prettier:
``` ```
make format make format
``` ```

View File

@@ -1,24 +1,23 @@
{% extends "admin/base_site.html" %} {% extends "admin/base_site.html" %}
{% block content %} {% block content %}
<table style="width: 100%"> <table style="width: 100%">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>Name</th> <th>Name</th>
<th>Args</th> <th>Args</th>
<th>Retries</th> <th>Retries</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for task in tasks %} {% for task in tasks %}
<tr> <tr>
<td>{{ task.id }}</td> <td>{{ task.id }}</td>
<td>{{ task.name }}</td> <td>{{ task.name }}</td>
<td>{{ task.args }}</td> <td>{{ task.args }}</td>
<td>{{ task.retries }}</td> <td>{{ task.retries }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<p class="paginator"> <p class="paginator">

View File

@@ -1,12 +1,9 @@
{% extends "bookmarks/layout.html" %} {% extends "shared/layout.html" %}
{% load static %} {% load static %}
{% load shared %} {% load shared %}
{% load bookmarks %} {% load bookmarks %}
{% block content %} {% block content %}
<ld-bookmark-page <ld-bookmark-page class="bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}">
class="bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}">
{# Bookmark list #} {# Bookmark list #}
<main class="main col-2" aria-labelledby="main-heading"> <main class="main col-2" aria-labelledby="main-heading">
<div class="section-header mb-0"> <div class="section-header mb-0">
@@ -19,19 +16,15 @@
</ld-filter-drawer-trigger> </ld-filter-drawer-trigger>
</div> </div>
</div> </div>
<form class="bookmark-actions" <form class="bookmark-actions"
action="{{ bookmark_list.action_url|safe }}" action="{{ bookmark_list.action_url|safe }}"
method="post" autocomplete="off"> method="post"
autocomplete="off">
{% csrf_token %} {% csrf_token %}
{% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_archive' %} {% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_archive' %}
<div id="bookmark-list-container">{% include 'bookmarks/bookmark_list.html' %}</div>
<div id="bookmark-list-container">
{% include 'bookmarks/bookmark_list.html' %}
</div>
</form> </form>
</main> </main>
{# Filters #} {# Filters #}
<div class="side-panel col-1 hide-md"> <div class="side-panel col-1 hide-md">
{% include 'bookmarks/bundle_section.html' %} {% include 'bookmarks/bundle_section.html' %}
@@ -39,12 +32,6 @@
</div> </div>
</ld-bookmark-page> </ld-bookmark-page>
{% endblock %} {% endblock %}
{% block overlays %} {% block overlays %}
{# Bookmark details #} {% include 'bookmarks/details/modal.html' %}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
{% endblock %} {% endblock %}

View File

@@ -1,17 +1,18 @@
{% load static %} {% load static %}
{% load shared %} {% load shared %}
{% load pagination %} {% load pagination %}
{% if bookmark_list.is_empty %} {% if bookmark_list.is_empty %}
{% include 'bookmarks/empty_bookmarks.html' %} {% include 'bookmarks/empty_bookmarks.html' %}
{% else %} {% else %}
<section aria-label="Bookmark list"> <section aria-label="Bookmark list">
<ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}" <ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}"
role="list" tabindex="-1" role="list"
style="--ld-bookmark-description-max-lines:{{ bookmark_list.description_max_lines }};" tabindex="-1"
style="--ld-bookmark-description-max-lines:{{ bookmark_list.description_max_lines }}"
data-bookmarks-total="{{ bookmark_list.bookmarks_total }}"> data-bookmarks-total="{{ bookmark_list.bookmarks_total }}">
{% for bookmark_item in bookmark_list.items %} {% for bookmark_item in bookmark_list.items %}
<li 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 %}> {% if bookmark_item.css_classes %}class="{{ bookmark_item.css_classes }}"{% endif %}>
<div class="content"> <div class="content">
<div class="title"> <div class="title">
@@ -24,41 +25,35 @@
{% if bookmark_item.favicon_file and bookmark_list.show_favicons %} {% if bookmark_item.favicon_file and bookmark_list.show_favicons %}
<img class="favicon" src="{% static bookmark_item.favicon_file %}" alt=""> <img class="favicon" src="{% static bookmark_item.favicon_file %}" alt="">
{% endif %} {% endif %}
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"> <a href="{{ bookmark_item.url }}"
target="{{ bookmark_list.link_target }}"
rel="noopener">
<span>{{ bookmark_item.title }}</span> <span>{{ bookmark_item.title }}</span>
</a> </a>
</div> </div>
{% if bookmark_list.show_url %} {% if bookmark_list.show_url %}
<div class="url-path truncate"> <div class="url-path truncate">
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener" <a href="{{ bookmark_item.url }}"
class="url-display"> target="{{ bookmark_list.link_target }}"
{{ bookmark_item.url }} rel="noopener"
</a> class="url-display">{{ bookmark_item.url }}</a>
</div> </div>
{% endif %} {% endif %}
{% if bookmark_list.description_display == 'inline' %} {% if bookmark_list.description_display == 'inline' %}
<div class="description inline truncate"> <div class="description inline truncate">
{% if bookmark_item.tags %} {% if bookmark_item.tags %}
<span class="tags"> <span class="tags">
{% for tag in bookmark_item.tags %} {% for tag in bookmark_item.tags %}<a href="?{{ tag.query_string }}">#{{ tag.name }}</a>{% endfor %}
<a href="?{{ tag.query_string }}">#{{ tag.name }}</a> </span>
{% endfor %}
</span>
{% endif %}
{% if bookmark_item.tags and bookmark_item.description %} | {% endif %}
{% if bookmark_item.description %}
<span>{{ bookmark_item.description }}</span>
{% endif %} {% endif %}
{% if bookmark_item.tags and bookmark_item.description %}|{% endif %}
{% if bookmark_item.description %}<span>{{ bookmark_item.description }}</span>{% endif %}
</div> </div>
{% else %} {% else %}
{% if bookmark_item.description %} {% if bookmark_item.description %}<div class="description separate">{{ bookmark_item.description }}</div>{% endif %}
<div class="description separate">{{ bookmark_item.description }}</div>
{% endif %}
{% if bookmark_item.tags %} {% if bookmark_item.tags %}
<div class="tags"> <div class="tags">
{% for tag in bookmark_item.tags %} {% for tag in bookmark_item.tags %}<a href="?{{ tag.query_string }}">#{{ tag.name }}</a>{% endfor %}
<a href="?{{ tag.query_string }}">#{{ tag.name }}</a>
{% endfor %}
</div> </div>
{% endif %} {% endif %}
{% endif %} {% endif %}
@@ -73,21 +68,19 @@
<a href="{{ bookmark_item.snapshot_url }}" <a href="{{ bookmark_item.snapshot_url }}"
title="{{ bookmark_item.snapshot_title }}" title="{{ bookmark_item.snapshot_title }}"
target="{{ bookmark_list.link_target }}" target="{{ bookmark_list.link_target }}"
rel="noopener"> rel="noopener">{{ bookmark_item.display_date }}</a>
{{ bookmark_item.display_date }}
</a>
{% else %} {% else %}
<span>{{ bookmark_item.display_date }}</span> <span>{{ bookmark_item.display_date }}</span>
{% endif %} {% endif %}
{% if not bookmark_list.is_preview %} {% if not bookmark_list.is_preview %}<span>|</span>{% endif %}
<span>|</span>
{% endif %}
{% endif %} {% endif %}
{% if not bookmark_list.is_preview %} {% if not bookmark_list.is_preview %}
{# View link is visible for both owned and shared bookmarks #} {# View link is visible for both owned and shared bookmarks #}
{% if bookmark_list.show_view_action %} {% if bookmark_list.show_view_action %}
<a href="{{ bookmark_item.details_url }}" class="view-action" <a href="{{ bookmark_item.details_url }}"
data-turbo-action="replace" data-turbo-frame="details-modal">View</a> class="view-action"
data-turbo-action="replace"
data-turbo-frame="details-modal">View</a>
{% endif %} {% endif %}
{% if bookmark_item.is_editable %} {% if bookmark_item.is_editable %}
{# Bookmark owner actions #} {# Bookmark owner actions #}
@@ -96,33 +89,40 @@
{% endif %} {% endif %}
{% if bookmark_list.show_archive_action %} {% if bookmark_list.show_archive_action %}
{% if bookmark_item.is_archived %} {% if bookmark_item.is_archived %}
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}" <button type="submit"
class="btn btn-link btn-sm">Unarchive name="unarchive"
</button> value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Unarchive</button>
{% else %} {% else %}
<button type="submit" name="archive" value="{{ bookmark_item.id }}" <button type="submit"
class="btn btn-link btn-sm">Archive name="archive"
</button> value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Archive</button>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if bookmark_list.show_remove_action %} {% if bookmark_list.show_remove_action %}
<button data-confirm type="submit" name="remove" value="{{ bookmark_item.id }}" <button data-confirm
class="btn btn-link btn-sm">Remove type="submit"
</button> name="remove"
value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Remove</button>
{% endif %} {% endif %}
{% else %} {% else %}
{# Shared bookmark actions #} {# Shared bookmark actions #}
<span>Shared by <span>Shared by
<a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a> <a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
</span> </span>
{% endif %} {% endif %}
{% if bookmark_item.has_extra_actions %} {% if bookmark_item.has_extra_actions %}
<div class="extra-actions"> <div class="extra-actions">
<span class="hide-sm">|</span> <span class="hide-sm">|</span>
{% if bookmark_item.show_mark_as_read %} {% if bookmark_item.show_mark_as_read %}
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}" <button type="submit"
name="mark_as_read"
value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm btn-icon" class="btn btn-link btn-sm btn-icon"
data-confirm data-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"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-unread"></use> <use xlink:href="#ld-icon-unread"></use>
</svg> </svg>
@@ -130,9 +130,12 @@
</button> </button>
{% endif %} {% endif %}
{% if bookmark_item.show_unshare %} {% if bookmark_item.show_unshare %}
<button type="submit" name="unshare" value="{{ bookmark_item.id }}" <button type="submit"
name="unshare"
value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm btn-icon" class="btn btn-link btn-sm btn-icon"
data-confirm data-confirm-question="Unshare?"> data-confirm
data-confirm-question="Unshare?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-share"></use> <use xlink:href="#ld-icon-share"></use>
</svg> </svg>
@@ -154,19 +157,22 @@
</div> </div>
{% if bookmark_list.show_preview_images %} {% if bookmark_list.show_preview_images %}
{% if bookmark_item.preview_image_file %} {% if bookmark_item.preview_image_file %}
<img class="preview-image" src="{% static bookmark_item.preview_image_file %}" loading="lazy"/> <img class="preview-image"
src="{% static bookmark_item.preview_image_file %}"
loading="lazy" />
{% else %} {% else %}
<div class="preview-image placeholder"> <div class="preview-image placeholder">
<div class="img"/> <div class="img" /></div>
</div>
{% endif %} {% endif %}
{% endif %} {% endif %}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
<div class="bookmark-pagination{% if request.user_profile.sticky_pagination %} sticky{% endif %}"> <div class="bookmark-pagination{% if request.user_profile.sticky_pagination %} sticky{% endif %}">
{% pagination bookmark_list.bookmarks_page %} {% pagination bookmark_list.bookmarks_page %}
</div> </div>
</section> </section>
{% endif %} {% endif %}
<script>
document.dispatchEvent(new CustomEvent('bookmark-list-updated'));
</script>

View File

@@ -1,42 +1,41 @@
{% load shared %} {% load shared %}
{% htmlmin %} {% htmlmin %}
<div class="bulk-edit-bar"> <div class="bulk-edit-bar">
<div class="bulk-edit-actions"> <div class="bulk-edit-actions">
<label class="form-checkbox bulk-edit-checkbox all"> <label class="form-checkbox bulk-edit-checkbox all">
<input type="checkbox"> <input type="checkbox">
<i class="form-icon"></i> <i class="form-icon"></i>
</label> </label>
<select name="bulk_action" class="form-select select-sm"> <select name="bulk_action" class="form-select select-sm">
{% if not 'bulk_archive' in disable_actions %} {% if not 'bulk_archive' in disable_actions %}<option value="bulk_archive">Archive</option>{% endif %}
<option value="bulk_archive">Archive</option> {% if not 'bulk_unarchive' in disable_actions %}<option value="bulk_unarchive">Unarchive</option>{% endif %}
{% endif %} <option value="bulk_delete">Delete</option>
{% if not 'bulk_unarchive' in disable_actions %} <option value="bulk_tag">Add tags</option>
<option value="bulk_unarchive">Unarchive</option> <option value="bulk_untag">Remove tags</option>
{% endif %} <option value="bulk_read">Mark as read</option>
<option value="bulk_delete">Delete</option> <option value="bulk_unread">Mark as unread</option>
<option value="bulk_tag">Add tags</option> {% if request.user_profile.enable_sharing %}
<option value="bulk_untag">Remove tags</option> <option value="bulk_share">Share</option>
<option value="bulk_read">Mark as read</option> <option value="bulk_unshare">Unshare</option>
<option value="bulk_unread">Mark as unread</option> {% endif %}
{% if request.user_profile.enable_sharing %} <option value="bulk_refresh">Refresh from website</option>
<option value="bulk_share">Share</option> {% if bookmark_list.snapshot_feature_enabled %}<option value="bulk_snapshot">Create HTML snapshot</option>{% endif %}
<option value="bulk_unshare">Unshare</option> </select>
{% endif %} <ld-tag-autocomplete input-name="bulk_tag_string"
<option value="bulk_refresh">Refresh from website</option> input-placeholder="Tag names..."
{% if bookmark_list.snapshot_feature_enabled %} variant="small">
<option value="bulk_snapshot">Create HTML snapshot</option> </ld-tag-autocomplete>
{% endif %} <button data-confirm
</select> type="submit"
<ld-tag-autocomplete input-name="bulk_tag_string" input-placeholder="Tag names..." variant="small"></ld-tag-autocomplete> name="bulk_execute"
<button data-confirm type="submit" name="bulk_execute" class="btn btn-link btn-sm"> class="btn btn-link btn-sm">
<span>Execute</span> <span>Execute</span>
</button> </button>
<label class="form-checkbox select-across d-none">
<label class="form-checkbox select-across d-none"> <input type="checkbox" name="bulk_select_across">
<input type="checkbox" name="bulk_select_across"> <i class="form-icon"></i>
<i class="form-icon"></i> All pages (<span class="total">{{ bookmark_list.bookmarks_total }}</span> bookmarks)
All pages (<span class="total">{{ bookmark_list.bookmarks_total }}</span> bookmarks) </label>
</label>
</div>
</div> </div>
</div>
{% endhtmlmin %} {% endhtmlmin %}

View File

@@ -1,9 +1,16 @@
<button class="btn hide-sm ml-2 bulk-edit-active-toggle" title="Bulk edit"> <button class="btn hide-sm ml-2 bulk-edit-active-toggle" title="Bulk edit">
<svg xmlns="http://www.w3.org/2000/svg" width="20px" height="20px" viewBox="0 0 24 24" fill="none" <svg xmlns="http://www.w3.org/2000/svg"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> width="20px"
<path stroke="none" d="M0 0h24v24H0z" fill="none"/> height="20px"
<path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"/> viewBox="0 0 24 24"
<path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415z"/> fill="none"
<path d="M16 5l3 3"/> stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1" />
<path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415z" />
<path d="M16 5l3 3" />
</svg> </svg>
</button> </button>

View File

@@ -4,12 +4,19 @@
<h2 id="bundles-heading">Bundles</h2> <h2 id="bundles-heading">Bundles</h2>
<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"> <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" <svg xmlns="http://www.w3.org/2000/svg"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> width="20"
<path stroke="none" d="M0 0h24v24H0z" fill="none"/> height="20"
<path d="M4 6l16 0"/> viewBox="0 0 24 24"
<path d="M4 12l16 0"/> fill="none"
<path d="M4 18l16 0"/> stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4 6l16 0" />
<path d="M4 12l16 0" />
<path d="M4 18l16 0" />
</svg> </svg>
</button> </button>
<ul class="menu" role="list" tabindex="-1"> <ul class="menu" role="list" tabindex="-1">
@@ -18,8 +25,9 @@
</li> </li>
{% if bookmark_list.search.q %} {% if bookmark_list.search.q %}
<li class="menu-item"> <li class="menu-item">
<a href="{% url 'linkding:bundles.new' %}?q={{ bookmark_list.search.q|urlencode }}" class="menu-link">Create <a href="{% url 'linkding:bundles.new' %}?q={{ bookmark_list.search.q|urlencode }}"
bundle from search</a> class="menu-link">Create
bundle from search</a>
</li> </li>
{% endif %} {% endif %}
</ul> </ul>

View File

@@ -1,9 +1,7 @@
{% extends "bookmarks/layout.html" %} {% extends "shared/layout.html" %}
{% block content %} {% block content %}
<script type="application/javascript"> <script type="application/javascript">
window.close() window.close()
</script> </script>
<p>You can now close this window.</p> <p>You can now close this window.</p>
{% endblock %} {% endblock %}

View File

@@ -1,42 +1,70 @@
{% if asset.content_type == 'text/html' %} {% if asset.content_type == 'text/html' %}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" <svg xmlns="http://www.w3.org/2000/svg"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> width="24"
<path stroke="none" d="M0 0h24v24H0z" fill="none"/> height="24"
<path d="M14 3v4a1 1 0 0 0 1 1h4"/> viewBox="0 0 24 24"
<path d="M5 12v-7a2 2 0 0 1 2 -2h7l5 5v4"/> fill="none"
<path d="M2 21v-6"/> stroke="currentColor"
<path d="M5 15v6"/> stroke-width="2"
<path d="M2 18h3"/> stroke-linecap="round"
<path d="M20 15v6h2"/> stroke-linejoin="round">
<path d="M13 21v-6l2 3l2 -3v6"/> <path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M7.5 15h3"/> <path d="M14 3v4a1 1 0 0 0 1 1h4" />
<path d="M9 15v6"/> <path d="M5 12v-7a2 2 0 0 1 2 -2h7l5 5v4" />
<path d="M2 21v-6" />
<path d="M5 15v6" />
<path d="M2 18h3" />
<path d="M20 15v6h2" />
<path d="M13 21v-6l2 3l2 -3v6" />
<path d="M7.5 15h3" />
<path d="M9 15v6" />
</svg> </svg>
{% elif asset.content_type == 'application/pdf' %} {% elif asset.content_type == 'application/pdf' %}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" <svg xmlns="http://www.w3.org/2000/svg"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> width="24"
<path stroke="none" d="M0 0h24v24H0z" fill="none"/> height="24"
<path d="M14 3v4a1 1 0 0 0 1 1h4"/> viewBox="0 0 24 24"
<path d="M5 12v-7a2 2 0 0 1 2 -2h7l5 5v4"/> fill="none"
<path d="M5 18h1.5a1.5 1.5 0 0 0 0 -3h-1.5v6"/> stroke="currentColor"
<path d="M17 18h2"/> stroke-width="2"
<path d="M20 15h-3v6"/> stroke-linecap="round"
<path d="M11 15v6h1a2 2 0 0 0 2 -2v-2a2 2 0 0 0 -2 -2h-1z"/> stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
<path d="M5 12v-7a2 2 0 0 1 2 -2h7l5 5v4" />
<path d="M5 18h1.5a1.5 1.5 0 0 0 0 -3h-1.5v6" />
<path d="M17 18h2" />
<path d="M20 15h-3v6" />
<path d="M11 15v6h1a2 2 0 0 0 2 -2v-2a2 2 0 0 0 -2 -2h-1z" />
</svg> </svg>
{% elif asset.content_type == 'image/png' or asset.content_type == 'image/jpeg' or asset.content_type == 'image.gif' %} {% elif asset.content_type == 'image/png' or asset.content_type == 'image/jpeg' or asset.content_type == 'image.gif' %}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" <svg xmlns="http://www.w3.org/2000/svg"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> width="24"
<path stroke="none" d="M0 0h24v24H0z" fill="none"/> height="24"
<path d="M15 8h.01"/> viewBox="0 0 24 24"
<path d="M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12z"/> fill="none"
<path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5"/> stroke="currentColor"
<path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3"/> stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M15 8h.01" />
<path d="M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12z" />
<path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5" />
<path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3" />
</svg> </svg>
{% else %} {% else %}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" <svg xmlns="http://www.w3.org/2000/svg"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> width="24"
<path stroke="none" d="M0 0h24v24H0z" fill="none"/> height="24"
<path d="M14 3v4a1 1 0 0 0 1 1h4"/> viewBox="0 0 24 24"
<path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z"/> fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
<path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z" />
</svg> </svg>
{% endif %} {% endif %}

View File

@@ -3,49 +3,55 @@
<div class="item-list assets"> <div class="item-list assets">
{% for asset in details.assets %} {% for asset in details.assets %}
<div class="list-item" data-asset-id="{{ asset.id }}"> <div class="list-item" data-asset-id="{{ asset.id }}">
<div class="list-item-icon {{ asset.icon_classes }}"> <div class="list-item-icon {{ asset.icon_classes }}">{% include 'bookmarks/details/asset_icon.html' %}</div>
{% include 'bookmarks/details/asset_icon.html' %}
</div>
<div class="list-item-text {{ asset.text_classes }}"> <div class="list-item-text {{ asset.text_classes }}">
<span class="truncate"> <span class="truncate">
{{ asset.display_name }} {{ asset.display_name }}
{% if asset.status == 'pending' %}(queued){% endif %} {% if asset.status == 'pending' %}(queued){% endif %}
{% if asset.status == 'failure' %}(failed){% endif %} {% if asset.status == 'failure' %}(failed){% endif %}
</span> </span>
{% if asset.file_size %} {% if asset.file_size %}<span class="filesize">{{ asset.file_size|filesizeformat }}</span>{% endif %}
<span class="filesize">{{ asset.file_size|filesizeformat }}</span>
{% endif %}
</div> </div>
<div class="list-item-actions"> <div class="list-item-actions">
{% if asset.file %} {% if asset.file %}
<a class="btn btn-link" href="{% url 'linkding:assets.view' asset.id %}" target="_blank">View</a> <a class="btn btn-link"
href="{% url 'linkding:assets.view' asset.id %}"
target="_blank">View</a>
{% endif %} {% endif %}
{% if details.is_editable %} {% if details.is_editable %}
<button data-confirm type="submit" name="remove_asset" value="{{ asset.id }}" class="btn btn-link"> <button data-confirm
Remove type="submit"
</button> name="remove_asset"
value="{{ asset.id }}"
class="btn btn-link">Remove</button>
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
{% if details.is_editable %} {% if details.is_editable %}
<div class="assets-actions"> <div class="assets-actions">
{% if details.snapshots_enabled %} {% if details.snapshots_enabled %}
<button type="submit" name="create_html_snapshot" value="{{ details.bookmark.id }}" class="btn btn-sm" <button type="submit"
{% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot name="create_html_snapshot"
</button> value="{{ details.bookmark.id }}"
class="btn btn-sm"
{% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot</button>
{% endif %} {% endif %}
{% if details.uploads_enabled %} {% if details.uploads_enabled %}
<ld-upload-button> <ld-upload-button>
<button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="submit" <button id="upload-asset"
class="btn btn-sm">Upload file name="upload_asset"
</button> value="{{ details.bookmark.id }}"
<input id="upload-asset-file" name="upload_asset_file" type="file" class="d-hide"> 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> </ld-upload-button>
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
</div> </div>

View File

@@ -1,21 +1,26 @@
{% load static %} {% load static %}
{% load shared %} {% load shared %}
<ld-form> <ld-form>
<form action="{{ details.action_url }}" method="post" enctype="multipart/form-data"> <form action="{{ details.action_url }}"
method="post"
enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="update_state" value="{{ details.bookmark.id }}"> <input type="hidden" name="update_state" value="{{ details.bookmark.id }}">
<div class="weblinks"> <div class="weblinks">
<a class="weblink" href="{{ details.bookmark.url }}" rel="noopener" <a class="weblink"
href="{{ details.bookmark.url }}"
rel="noopener"
target="{{ details.profile.bookmark_link_target }}"> target="{{ details.profile.bookmark_link_target }}">
{% if details.show_link_icons %} {% if details.show_link_icons %}
<img class="favicon" src="{% static details.bookmark.favicon_file %}" alt=""> <img class="favicon"
src="{% static details.bookmark.favicon_file %}"
alt="">
{% endif %} {% endif %}
<span>{{ details.bookmark.url }}</span> <span>{{ details.bookmark.url }}</span>
</a> </a>
{% if details.latest_snapshot %} {% if details.latest_snapshot %}
<a class="weblink" href="{% url 'linkding:assets.read' details.latest_snapshot.id %}" <a class="weblink"
href="{% url 'linkding:assets.read' details.latest_snapshot.id %}"
target="{{ details.profile.bookmark_link_target }}"> target="{{ details.profile.bookmark_link_target }}">
{% if details.show_link_icons %} {% if details.show_link_icons %}
<svg class="favicon" xmlns="http://www.w3.org/2000/svg"> <svg class="favicon" xmlns="http://www.w3.org/2000/svg">
@@ -26,13 +31,14 @@
</a> </a>
{% endif %} {% endif %}
{% if details.web_archive_snapshot_url %} {% if details.web_archive_snapshot_url %}
<a class="weblink" href="{{ details.web_archive_snapshot_url }}" <a class="weblink"
href="{{ details.web_archive_snapshot_url }}"
target="{{ details.profile.bookmark_link_target }}"> target="{{ details.profile.bookmark_link_target }}">
{% if details.show_link_icons %} {% if details.show_link_icons %}
<svg class="favicon" viewBox="0 0 76 86" xmlns="http://www.w3.org/2000/svg"> <svg class="favicon"
<path viewBox="0 0 76 86"
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" xmlns="http://www.w3.org/2000/svg">
fill="currentColor" fill-rule="evenodd"/> <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> </svg>
{% endif %} {% endif %}
<span>Internet Archive</span> <span>Internet Archive</span>
@@ -41,7 +47,7 @@
</div> </div>
{% if details.preview_image_enabled and details.bookmark.preview_image_file %} {% if details.preview_image_enabled and details.bookmark.preview_image_file %}
<div class="preview-image"> <div class="preview-image">
<img src="{% static details.bookmark.preview_image_file %}" alt=""/> <img src="{% static details.bookmark.preview_image_file %}" alt="" />
</div> </div>
{% endif %} {% endif %}
<div class="sections grid columns-2 columns-sm-1 gap-0"> <div class="sections grid columns-2 columns-sm-1 gap-0">
@@ -51,14 +57,18 @@
<div class="d-flex" style="gap: .8rem"> <div class="d-flex" style="gap: .8rem">
<div class="form-group"> <div class="form-group">
<label class="form-switch"> <label class="form-switch">
<input data-submit-on-change type="checkbox" name="is_archived" <input data-submit-on-change
type="checkbox"
name="is_archived"
{% if details.bookmark.is_archived %}checked{% endif %}> {% if details.bookmark.is_archived %}checked{% endif %}>
<i class="form-icon"></i> Archived <i class="form-icon"></i> Archived
</label> </label>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-switch"> <label class="form-switch">
<input data-submit-on-change type="checkbox" name="unread" <input data-submit-on-change
type="checkbox"
name="unread"
{% if details.bookmark.unread %}checked{% endif %}> {% if details.bookmark.unread %}checked{% endif %}>
<i class="form-icon"></i> Unread <i class="form-icon"></i> Unread
</label> </label>
@@ -66,7 +76,9 @@
{% if details.profile.enable_sharing %} {% if details.profile.enable_sharing %}
<div class="form-group"> <div class="form-group">
<label class="form-switch"> <label class="form-switch">
<input data-submit-on-change type="checkbox" name="shared" <input data-submit-on-change
type="checkbox"
name="shared"
{% if details.bookmark.shared %}checked{% endif %}> {% if details.bookmark.shared %}checked{% endif %}>
<i class="form-icon"></i> Shared <i class="form-icon"></i> Shared
</label> </label>
@@ -77,9 +89,7 @@
{% endif %} {% endif %}
<section class="files col-2"> <section class="files col-2">
<h3>Files</h3> <h3>Files</h3>
<div> <div>{% include 'bookmarks/details/assets.html' %}</div>
{% include 'bookmarks/details/assets.html' %}
</div>
</section> </section>
{% if details.bookmark.tag_names %} {% if details.bookmark.tag_names %}
<section class="tags col-1"> <section class="tags col-1">
@@ -111,4 +121,4 @@
{% endif %} {% endif %}
</div> </div>
</form> </form>
</ld-form> </ld-form>

View File

@@ -1,31 +1,37 @@
<ld-details-modal class="modal active bookmark-details" <turbo-frame id="details-modal" target="_top">
data-bookmark-id="{{ details.bookmark.id }}" data-close-url="{{ details.close_url }}" {% if details %}
data-turbo-frame="details-modal"> <ld-details-modal class="modal active bookmark-details"
<div class="modal-overlay" data-close-modal></div> data-bookmark-id="{{ details.bookmark.id }}"
<div class="modal-container" role="dialog" aria-modal="true"> data-close-url="{{ details.close_url }}"
{% include 'shared/modal_header.html' with title=details.bookmark.resolved_title %} data-turbo-frame="details-modal">
<div class="modal-body"> <div class="modal-overlay" data-close-modal></div>
{% include 'bookmarks/details/form.html' %} <div class="modal-container" role="dialog" aria-modal="true">
</div> {% include 'shared/modal_header.html' with title=details.bookmark.resolved_title %}
{% if details.is_editable %} <div class="modal-body">{% include 'bookmarks/details/form.html' %}</div>
<div class="modal-footer"> {% if details.is_editable %}
<div class="actions"> <div class="modal-footer">
<div class="left-actions"> <div class="actions">
<a class="btn btn-wide" <div class="left-actions">
href="{% url 'linkding:bookmarks.edit' details.bookmark.id %}?return_url={{ details.edit_return_url|urlencode }}">Edit</a> <a class="btn btn-wide"
</div> href="{% url 'linkding:bookmarks.edit' details.bookmark.id %}?return_url={{ details.edit_return_url|urlencode }}">Edit</a>
<div class="right-actions"> </div>
<form action="{{ details.delete_url }}" method="post" data-turbo-action="replace"> <div class="right-actions">
{% csrf_token %} <form action="{{ details.delete_url }}"
<input type="hidden" name="disable_turbo" value="true"> method="post"
<button data-confirm class="btn btn-error btn-wide" data-turbo-action="replace">
type="submit" name="remove" value="{{ details.bookmark.id }}"> {% csrf_token %}
Delete <input type="hidden" name="disable_turbo" value="true">
</button> <button data-confirm
</form> class="btn btn-error btn-wide"
type="submit"
name="remove"
value="{{ details.bookmark.id }}">Delete</button>
</form>
</div>
</div> </div>
</div> </div>
</div> {% endif %}
{% endif %} </div>
</div> </ld-details-modal>
</ld-details-modal> {% endif %}
</turbo-frame>

View File

@@ -1,11 +1,7 @@
{% extends 'bookmarks/layout.html' %} {% extends 'shared/layout.html' %}
{% block head %} {% block head %}
{% with page_title="Edit bookmark - Linkding" %} {% with page_title="Edit bookmark - Linkding" %}{{ block.super }}{% endwith %}
{{ block.super }}
{% endwith %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="bookmarks-form-page"> <div class="bookmarks-form-page">
<main aria-labelledby="main-heading"> <main aria-labelledby="main-heading">
@@ -13,7 +9,8 @@
<h1 id="main-heading">Edit bookmark</h1> <h1 id="main-heading">Edit bookmark</h1>
</div> </div>
<ld-form data-submit-on-ctrl-enter> <ld-form data-submit-on-ctrl-enter>
<form action="{% url 'linkding:bookmarks.edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post" <form action="{% url 'linkding:bookmarks.edit' bookmark_id %}?return_url={{ return_url|urlencode }}"
method="post"
novalidate> novalidate>
{% include 'bookmarks/form.html' %} {% include 'bookmarks/form.html' %}
</form> </form>

View File

@@ -10,8 +10,7 @@
<p class="empty-subtitle"> <p class="empty-subtitle">
You can get started by <a href="{% url 'linkding:bookmarks.new' %}">adding</a> bookmarks, You can get started by <a href="{% url 'linkding:bookmarks.new' %}">adding</a> bookmarks,
<a href="{% url 'linkding:settings.general' %}">importing</a> your existing bookmarks or configuring the <a href="{% url 'linkding:settings.general' %}">importing</a> your existing bookmarks or configuring the
<a href="{% url 'linkding:settings.integrations' %}">browser extension</a> or the <a <a href="{% url 'linkding:settings.integrations' %}">browser extension</a> or the <a href="{% url 'linkding:settings.integrations' %}">bookmarklet</a>.
href="{% url 'linkding:settings.integrations' %}">bookmarklet</a>.
</p> </p>
{% endif %} {% endif %}
</div> </div>

View File

@@ -1,7 +1,6 @@
{% load widget_tweaks %} {% load widget_tweaks %}
{% load static %} {% load static %}
{% load shared %} {% load shared %}
<div class="bookmarks-form"> <div class="bookmarks-form">
{% csrf_token %} {% csrf_token %}
{{ form.auto_close|attr:"type:hidden" }} {{ form.auto_close|attr:"type:hidden" }}
@@ -11,11 +10,7 @@
{{ form.url|form_field:"validation"|add_class:"form-input"|attr:"autofocus" }} {{ form.url|form_field:"validation"|add_class:"form-input"|attr:"autofocus" }}
<i class="form-icon loading"></i> <i class="form-icon loading"></i>
</div> </div>
{% if form.url.errors %} {% if form.url.errors %}<div class="form-input-hint">{{ form.url.errors }}</div>{% endif %}
<div class="form-input-hint">
{{ form.url.errors }}
</div>
{% endif %}
<div class="form-input-hint bookmark-exists"> <div class="form-input-hint bookmark-exists">
This URL is already bookmarked. This URL is already bookmarked.
The form has been pre-filled with the existing bookmark, and saving the form will update the existing bookmark. The form has been pre-filled with the existing bookmark, and saving the form will update the existing bookmark.
@@ -23,7 +18,9 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label> <label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label>
<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:'' }}" <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"> input-aria-describedby="{{ form.tag_string.auto_id }}_help">
</ld-tag-autocomplete> </ld-tag-autocomplete>
<div id="{{ form.tag_string.auto_id }}_help" class="form-input-hint"> <div id="{{ form.tag_string.auto_id }}_help" class="form-input-hint">
@@ -39,9 +36,7 @@
<div class="flex"> <div class="flex">
<button id="refresh-button" class="btn btn-link suffix-button" type="button">Refresh from website</button> <button id="refresh-button" class="btn btn-link suffix-button" type="button">Refresh from website</button>
<ld-clear-button data-for="{{ form.title.id_for_label }}"> <ld-clear-button data-for="{{ form.title.id_for_label }}">
<button class="ml-2 btn btn-link suffix-button" type="button"> <button class="ml-2 btn btn-link suffix-button" type="button">Clear</button>
Clear
</button>
</ld-clear-button> </ld-clear-button>
</div> </div>
</div> </div>
@@ -52,9 +47,7 @@
<div class="d-flex justify-between align-baseline"> <div class="d-flex justify-between align-baseline">
<label for="{{ form.description.id_for_label }}" class="form-label">Description</label> <label for="{{ form.description.id_for_label }}" class="form-label">Description</label>
<ld-clear-button data-for="{{ form.description.id_for_label }}"> <ld-clear-button data-for="{{ form.description.id_for_label }}">
<button class="btn btn-link suffix-button" type="button"> <button class="btn btn-link suffix-button" type="button">Clear</button>
Clear
</button>
</ld-clear-button> </ld-clear-button>
</div> </div>
{{ form.description|add_class:"form-input"|attr:"rows:3" }} {{ form.description|add_class:"form-input"|attr:"rows:3" }}
@@ -67,9 +60,7 @@
</summary> </summary>
<label for="{{ form.notes.id_for_label }}" class="text-assistive">Notes</label> <label for="{{ form.notes.id_for_label }}" class="text-assistive">Notes</label>
{{ form.notes|form_field:"help"|add_class:"form-input"|attr:"rows:8" }} {{ form.notes|form_field:"help"|add_class:"form-input"|attr:"rows:8" }}
<div id="{{ form.notes.auto_id }}_help" class="form-input-hint"> <div id="{{ form.notes.auto_id }}_help" class="form-input-hint">Additional notes, supports Markdown.</div>
Additional notes, supports Markdown.
</div>
</details> </details>
{{ form.notes.errors }} {{ form.notes.errors }}
</div> </div>
@@ -104,151 +95,153 @@
{% if form.is_auto_close %} {% if form.is_auto_close %}
<input type="submit" value="Save and close" class="btn btn-primary btn-wide"> <input type="submit" value="Save and close" class="btn btn-primary btn-wide">
{% else %} {% else %}
<input type="submit" value="Save" class="btn btn-primary btn btn-primary btn-wide"> <input type="submit"
value="Save"
class="btn btn-primary btn btn-primary btn-wide">
{% endif %} {% endif %}
<a href="{{ return_url }}" class="btn">Cancel</a> <a href="{{ return_url }}" class="btn">Cancel</a>
</div> </div>
<script type="application/javascript"> <script type="application/javascript">
/** /**
* - Pre-fill title and description with metadata from website as soon as URL changes * - Pre-fill title and description with metadata from website as soon as URL changes
* - Show hint if URL is already bookmarked * - Show hint if URL is already bookmarked
*/ */
(function init() { (function init() {
const urlInput = document.getElementById('{{ form.url.id_for_label }}'); const urlInput = document.getElementById('{{ form.url.id_for_label }}');
const titleInput = document.getElementById('{{ form.title.id_for_label }}'); const titleInput = document.getElementById('{{ form.title.id_for_label }}');
const descriptionInput = document.getElementById('{{ form.description.id_for_label }}'); const descriptionInput = document.getElementById('{{ form.description.id_for_label }}');
const notesDetails = document.querySelector('form details.notes'); const notesDetails = document.querySelector('form details.notes');
const notesInput = document.getElementById('{{ form.notes.id_for_label }}'); const notesInput = document.getElementById('{{ form.notes.id_for_label }}');
const unreadCheckbox = document.getElementById('{{ form.unread.id_for_label }}'); const unreadCheckbox = document.getElementById('{{ form.unread.id_for_label }}');
const sharedCheckbox = document.getElementById('{{ form.shared.id_for_label }}'); const sharedCheckbox = document.getElementById('{{ form.shared.id_for_label }}');
const refreshButton = document.getElementById('refresh-button'); const refreshButton = document.getElementById('refresh-button');
const bookmarkExistsHint = document.querySelector('.form-input-hint.bookmark-exists'); const bookmarkExistsHint = document.querySelector('.form-input-hint.bookmark-exists');
const editedBookmarkId = {{ form.instance.id|default:0 }}; const editedBookmarkId = parseInt('{{ form.instance.id|default:0 }}');
let isTitleModified = !!titleInput.value; let isTitleModified = !!titleInput.value;
let isDescriptionModified = !!descriptionInput.value; let isDescriptionModified = !!descriptionInput.value;
function toggleLoadingIcon(input, show) { function toggleLoadingIcon(input, show) {
const icon = input.parentNode.querySelector('i.form-icon'); const icon = input.parentNode.querySelector('i.form-icon');
icon.style['visibility'] = show ? 'visible' : 'hidden'; icon.style['visibility'] = show ? 'visible' : 'hidden';
} }
function updateInput(input, value) { function updateInput(input, value) {
if (!input) { if (!input) {
return; 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);
} }
input.value = value; if (!isDescriptionModified) {
input.dispatchEvent(new Event('value-changed')); updateInput(descriptionInput, metadata.description);
}
function updateCheckbox(input, value) {
if (!input) {
return;
} }
input.checked = value; }
}
function checkUrl() { // Preview auto tags
if (!urlInput.value) { const autoTags = data.auto_tags;
return; const autoTagsHint = document.querySelector('.form-input-hint.auto-tags');
}
toggleLoadingIcon(urlInput, true); if (autoTags.length > 0) {
autoTags.sort();
autoTagsHint.style['display'] = 'block';
autoTagsHint.innerHTML = `Auto tags: ${autoTags.join(" ")}`;
} else {
autoTagsHint.style['display'] = 'none';
}
});
}
const websiteUrl = encodeURIComponent(urlInput.value); function refreshMetadata() {
const requestUrl = `{% url 'linkding:api-root' %}bookmarks/check?url=${websiteUrl}`; if (!urlInput.value) {
fetch(requestUrl) return;
.then(response => response.json()) }
.then(data => {
const metadata = data.metadata;
toggleLoadingIcon(urlInput, false);
// Display hint if URL is already bookmarked toggleLoadingIcon(urlInput, true);
const existingBookmark = data.bookmark;
bookmarkExistsHint.style['display'] = existingBookmark ? 'block' : 'none';
refreshButton.style['display'] = existingBookmark ? 'inline-block' : 'none';
// Prefill form with existing bookmark data const websiteUrl = encodeURIComponent(urlInput.value);
if (existingBookmark) { const requestUrl = `{% url 'linkding:api-root' %}bookmarks/check?url=${websiteUrl}&ignore_cache=true`;
// 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'; fetch(requestUrl)
notesDetails.open = !!existingBookmark.notes; .then(response => response.json())
updateInput(titleInput, existingBookmark.title); .then(data => {
updateInput(descriptionInput, existingBookmark.description); const metadata = data.metadata;
updateInput(notesInput, existingBookmark.notes); const existingBookmark = data.bookmark;
updateInput(tagsInput, existingBookmark.tag_names.join(" ")); toggleLoadingIcon(urlInput, false);
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);
}
}
// Preview auto tags if (metadata.title && metadata.title !== existingBookmark?.title) {
const autoTags = data.auto_tags; titleInput.value = metadata.title;
const autoTagsHint = document.querySelector('.form-input-hint.auto-tags'); titleInput.classList.add("modified");
}
if (autoTags.length > 0) { if (metadata.description && metadata.description !== existingBookmark?.description) {
autoTags.sort(); descriptionInput.value = metadata.description;
autoTagsHint.style['display'] = 'block'; descriptionInput.classList.add("modified");
autoTagsHint.innerHTML = `Auto tags: ${autoTags.join(" ")}`; }
} else { });
autoTagsHint.style['display'] = 'none'; }
}
});
}
function refreshMetadata() { refreshButton.addEventListener('click', refreshMetadata);
if (!urlInput.value) {
return;
}
toggleLoadingIcon(urlInput, true); // Fetch website metadata when page loads and when URL changes, unless we are editing an existing bookmark
if (!editedBookmarkId) {
const websiteUrl = encodeURIComponent(urlInput.value); checkUrl();
const requestUrl = `{% url 'linkding:api-root' %}bookmarks/check?url=${websiteUrl}&ignore_cache=true`; urlInput.addEventListener('input', checkUrl);
titleInput.addEventListener('input', () => {
fetch(requestUrl) isTitleModified = true;
.then(response => response.json()) });
.then(data => { descriptionInput.addEventListener('input', () => {
const metadata = data.metadata; isDescriptionModified = true;
const existingBookmark = data.bookmark; });
toggleLoadingIcon(urlInput, false); } else {
refreshButton.style['display'] = 'inline-block';
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> </script>
</div> </div>

View File

@@ -1,44 +0,0 @@
{% load static %}
<head>
<meta charset="UTF-8">
<link rel="icon" href="{% static 'favicon.ico' %}" sizes="48x48">
<link rel="icon" href="{% static 'favicon.svg' %}" sizes="any" type="image/svg+xml">
<link rel="apple-touch-icon" sizes="180x180" href="{% static 'apple-touch-icon.png' %}">
<link rel="mask-icon" href="{% static 'safari-pinned-tab.svg' %}" color="#5856e0">
<link rel="manifest" href="{% url 'linkding:manifest' %}">
<link rel="search" type="application/opensearchdescription+xml" title="Linkding" href="{% url 'linkding:opensearch' %}"/>
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
<meta name="description" content="Self-hosted bookmark service">
<meta name="robots" content="index,follow">
<meta name="author" content="Sascha Ißbrücker">
<title>{{ page_title|default:'Linkding' }}</title>
{# Include specific theme variant based on user profile setting #}
{% if request.user_profile.theme == 'light' %}
<link href="{% static 'theme-light.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
<meta name="theme-color" content="#5856e0">
{% elif request.user_profile.theme == 'dark' %}
<link href="{% static 'theme-dark.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
<meta name="theme-color" content="#161822">
{% else %}
{# Use auto theme as fallback #}
<link href="{% static 'theme-dark.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
media="(prefers-color-scheme: dark)"/>
<link href="{% static 'theme-light.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
media="(prefers-color-scheme: light)"/>
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#161822">
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
{% endif %}
{% if request.user_profile.custom_css %}
<link href="{% url 'linkding:custom_css' %}?hash={{ request.user_profile.custom_css_hash }}" rel="stylesheet" type="text/css"/>
{% endif %}
<meta name="turbo-cache-control" content="no-preview">
{% if not request.global_settings.enable_link_prefetch %}
<meta name="turbo-prefetch" content="false">
{% endif %}
{% if rss_feed_url %}
<link rel="alternate" type="application/rss+xml" href="{{ rss_feed_url }}" />
{% endif %}
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
</head>

View File

@@ -1,14 +1,10 @@
{% extends "bookmarks/layout.html" %} {% extends "shared/layout.html" %}
{% load static %} {% load static %}
{% load shared %} {% load shared %}
{% load bookmarks %} {% load bookmarks %}
{% block title %}Bookmarks - Linkding{% endblock %} {% block title %}Bookmarks - Linkding{% endblock %}
{% block content %} {% block content %}
<ld-bookmark-page <ld-bookmark-page class="bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}">
class="bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}">
{# Bookmark list #} {# Bookmark list #}
<main class="main col-2" aria-labelledby="main-heading"> <main class="main col-2" aria-labelledby="main-heading">
<div class="section-header mb-0"> <div class="section-header mb-0">
@@ -21,19 +17,15 @@
</ld-filter-drawer-trigger> </ld-filter-drawer-trigger>
</div> </div>
</div> </div>
<form class="bookmark-actions" <form class="bookmark-actions"
action="{{ bookmark_list.action_url|safe }}" action="{{ bookmark_list.action_url|safe }}"
method="post" autocomplete="off"> method="post"
autocomplete="off">
{% csrf_token %} {% csrf_token %}
{% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_unarchive' %} {% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_unarchive' %}
<div id="bookmark-list-container">{% include 'bookmarks/bookmark_list.html' %}</div>
<div id="bookmark-list-container">
{% include 'bookmarks/bookmark_list.html' %}
</div>
</form> </form>
</main> </main>
{# Filters #} {# Filters #}
<div class="side-panel col-1 hide-md"> <div class="side-panel col-1 hide-md">
{% include 'bookmarks/bundle_section.html' %} {% include 'bookmarks/bundle_section.html' %}
@@ -41,12 +33,6 @@
</div> </div>
</ld-bookmark-page> </ld-bookmark-page>
{% endblock %} {% endblock %}
{% block overlays %} {% block overlays %}
{# Bookmark details #} {% include 'bookmarks/details/modal.html' %}
<turbo-frame id="details-modal" target="_top"> {% endblock %}
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
{% endblock %}

View File

@@ -1,83 +0,0 @@
{% load static %}
<!DOCTYPE html>
{# 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>
<div class="d-none">
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ld-icon-unread" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M3 19a9 9 0 0 1 9 0a9 9 0 0 1 9 0"></path>
<path d="M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0"></path>
<path d="M3 6l0 13"></path>
<path d="M12 6l0 13"></path>
<path d="M21 6l0 13"></path>
</symbol>
</svg>
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ld-icon-share" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M6 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M18 6m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M18 18m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M8.7 10.7l6.6 -3.4"></path>
<path d="M8.7 13.3l6.6 3.4"></path>
</symbol>
</svg>
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ld-icon-note" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M5 3m0 2a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z"></path>
<path d="M9 7l6 0"></path>
<path d="M9 11l6 0"></path>
<path d="M9 15l4 0"></path>
</symbol>
</svg>
</div>
<header class="container">
{% if has_toasts %}
<div class="message-list">
<form action="{% url 'linkding:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post">
{% csrf_token %}
{% for toast in toast_messages %}
<div class="toast d-flex">
{{ toast.message }}
<button type="submit" name="toast" value="{{ toast.id }}" class="btn btn-clear"></button>
</div>
{% endfor %}
</form>
</div>
{% endif %}
<div class="d-flex justify-between">
<a href="{% url 'linkding:root' %}" class="app-link d-flex align-center">
<img class="app-logo" src="{% static 'logo.png' %}" alt="Application logo">
<span class="app-name">LINKDING</span>
</a>
<nav>
{% if request.user.is_authenticated %}
{# Only show nav items menu when logged in #}
{% include 'bookmarks/nav_menu.html' %}
{% else %}
{# Otherwise show login link #}
<a href="{% url 'login' %}" class="btn btn-link">Login</a>
{% endif %}
</nav>
</div>
</header>
<div class="content container">
{% block content %}
{% endblock %}
</div>
<div class="modals">
{% block overlays %}
{% endblock %}
</div>
</body>
</html>

View File

@@ -1,109 +0,0 @@
{% load shared %}
{% htmlmin %}
{# Basic menu list #}
<div class="hide-md">
<a href="{% url 'linkding:bookmarks.new' %}" class="btn btn-primary mr-2">Add bookmark</a>
<ld-dropdown class="dropdown">
<button class="btn btn-link dropdown-toggle" tabindex="0">
Bookmarks
</button>
<ul class="menu" role="list" tabindex="-1">
<li class="menu-item">
<a href="{% url 'linkding:bookmarks.index' %}" class="menu-link">Active</a>
</li>
<li class="menu-item">
<a href="{% url 'linkding:bookmarks.archived' %}" class="menu-link">Archived</a>
</li>
{% if request.user_profile.enable_sharing %}
<li class="menu-item">
<a href="{% url 'linkding:bookmarks.shared' %}" class="menu-link">Shared</a>
</li>
{% endif %}
<li class="menu-item">
<a href="{% url 'linkding:bookmarks.index' %}?unread=yes" class="menu-link">Unread</a>
</li>
<li class="menu-item">
<a href="{% url 'linkding:bookmarks.index' %}?q=!untagged" class="menu-link">Untagged</a>
</li>
</ul>
</ld-dropdown>
<ld-dropdown class="dropdown">
<button class="btn btn-link dropdown-toggle" tabindex="0">
Settings
</button>
<ul class="menu" role="list" tabindex="-1">
<li class="menu-item">
<a href="{% url 'linkding:settings.general' %}" class="menu-link">General</a>
</li>
<li class="menu-item">
<a href="{% url 'linkding:settings.integrations' %}" class="menu-link">Integrations</a>
</li>
{% if request.user.is_superuser %}
<li class="menu-item">
<a href="{% url 'admin:index' %}" class="menu-link" data-turbo="false">Admin</a>
</li>
{% endif %}
</ul>
</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>
</form>
</div>
{# Menu drop-down for smaller devices #}
<div class="show-md">
<a href="{% url 'linkding:bookmarks.new' %}" aria-label="Add bookmark" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
style="width: 24px; height: 24px">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
</a>
<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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
<!-- menu component -->
<ul class="menu" role="list" tabindex="-1">
<li class="menu-item">
<a href="{% url 'linkding:bookmarks.index' %}" class="menu-link">Bookmarks</a>
</li>
<li class="menu-item">
<a href="{% url 'linkding:bookmarks.archived' %}" class="menu-link">Archived bookmarks</a>
</li>
{% if request.user_profile.enable_sharing %}
<li class="menu-item">
<a href="{% url 'linkding:bookmarks.shared' %}" class="menu-link">Shared bookmarks</a>
</li>
{% endif %}
<li class="menu-item">
<a href="{% url 'linkding:bookmarks.index' %}?unread=yes" class="menu-link">Unread</a>
</li>
<li class="menu-item">
<a href="{% url 'linkding:bookmarks.index' %}?q=!untagged" class="menu-link">Untagged</a>
</li>
<div class="divider"></div>
<li class="menu-item">
<a href="{% url 'linkding:settings.general' %}" class="menu-link">Settings</a>
</li>
<li class="menu-item">
<a href="{% url 'linkding:settings.integrations' %}" class="menu-link">Integrations</a>
</li>
{% if request.user.is_superuser %}
<li class="menu-item">
<a href="{% url 'admin:index' %}" class="menu-link" data-turbo="false">Admin</a>
</li>
{% endif %}
<div class="divider"></div>
<li class="menu-item">
<form class="d-inline" action="{% url 'logout' %}" method="post" data-turbo="false">
{% csrf_token %}
<button type="submit" class="btn btn-link menu-link">Logout</button>
</form>
</li>
</ul>
</ld-dropdown>
</div>
{% endhtmlmin %}

View File

@@ -1,11 +1,7 @@
{% extends 'bookmarks/layout.html' %} {% extends 'shared/layout.html' %}
{% block head %} {% block head %}
{% with page_title="New bookmark - Linkding" %} {% with page_title="New bookmark - Linkding" %}{{ block.super }}{% endwith %}
{{ block.super }}
{% endwith %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="bookmarks-form-page"> <div class="bookmarks-form-page">
<main aria-labelledby="main-heading"> <main aria-labelledby="main-heading">

View File

@@ -1,90 +1,106 @@
{% load static %} {% load static %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="reader-mode"> <html lang="en" class="reader-mode">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Reader view</title> <title>Reader view</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui"> <meta name="viewport"
{# Include specific theme variant based on user profile setting #} content="width=device-width, initial-scale=1.0, minimal-ui">
{% if request.user_profile.theme == 'light' %} {# Include specific theme variant based on user profile setting #}
<link href="{% static 'theme-light.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/> {% if request.user_profile.theme == 'light' %}
<meta name="theme-color" content="#5856e0"> <link href="{% static 'theme-light.css' %}?v={{ app_version }}"
{% elif request.user_profile.theme == 'dark' %} rel="stylesheet"
<link href="{% static 'theme-dark.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/> type="text/css" />
<meta name="theme-color" content="#161822"> <meta name="theme-color" content="#5856e0">
{% else %} {% elif request.user_profile.theme == 'dark' %}
{# Use auto theme as fallback #} <link href="{% static 'theme-dark.css' %}?v={{ app_version }}"
<link href="{% static 'theme-dark.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css" rel="stylesheet"
media="(prefers-color-scheme: dark)"/> type="text/css" />
<link href="{% static 'theme-light.css' %}?v={{ app_version }}" rel="stylesheet" type="text/css" <meta name="theme-color" content="#161822">
media="(prefers-color-scheme: light)"/> {% else %}
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#161822"> {# Use auto theme as fallback #}
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0"> <link href="{% static 'theme-dark.css' %}?v={{ app_version }}"
{% endif %} rel="stylesheet"
{% if request.user_profile.custom_css %} type="text/css"
<link href="{% url 'linkding:custom_css' %}?hash={{ request.user_profile.custom_css_hash }}" rel="stylesheet" type="text/css"/> media="(prefers-color-scheme: dark)" />
{% endif %} <link href="{% static 'theme-light.css' %}?v={{ app_version }}"
</head> rel="stylesheet"
<body> type="text/css"
<template id="content">{{ content|safe }}</template> media="(prefers-color-scheme: light)" />
<script src="{% static 'vendor/Readability.js' %}" type="application/javascript"></script> <meta name="theme-color"
<script type="application/javascript"> media="(prefers-color-scheme: dark)"
function estimateReadingTime(charCount, wordsPerMinute) { content="#161822">
const avgWordLength = 5; <meta name="theme-color"
const totalWords = charCount / avgWordLength; media="(prefers-color-scheme: light)"
return Math.ceil(totalWords / wordsPerMinute); content="#5856e0">
} {% endif %}
{% if request.user_profile.custom_css %}
<link href="{% url 'linkding:custom_css' %}?hash={{ request.user_profile.custom_css_hash }}"
rel="stylesheet"
type="text/css" />
{% endif %}
</head>
<body>
<template id="content">{{ content|safe }}</template>
<script src="{% static 'vendor/Readability.js' %}"
type="application/javascript"></script>
<script type="application/javascript">
function estimateReadingTime(charCount, wordsPerMinute) {
const avgWordLength = 5;
const totalWords = charCount / avgWordLength;
return Math.ceil(totalWords / wordsPerMinute);
}
function postProcess(articleContent) { function postProcess(articleContent) {
articleContent.querySelectorAll('table').forEach(table => { articleContent.querySelectorAll('table').forEach(table => {
table.classList.add('table'); table.classList.add('table');
}); });
} }
function makeReadable() { function makeReadable() {
const content = document.getElementById('content'); const content = document.getElementById('content');
const contentHtml = content.innerHTML; const contentHtml = content.innerHTML;
const dom = new DOMParser().parseFromString(contentHtml, 'text/html'); const dom = new DOMParser().parseFromString(contentHtml, 'text/html');
const article = new Readability(dom).parse(); const article = new Readability(dom).parse();
document.title = article.title; document.title = article.title;
const container = document.createElement('div'); const container = document.createElement('div');
container.classList.add('container'); container.classList.add('container');
const articleTitle = document.createElement('h1'); const articleTitle = document.createElement('h1');
articleTitle.textContent = article.title; articleTitle.textContent = article.title;
container.append(articleTitle); container.append(articleTitle);
const byline = [article.byline, article.siteName].filter(Boolean); const byline = [article.byline, article.siteName].filter(Boolean);
if (byline.length > 0) { if (byline.length > 0) {
const articleByline = document.createElement('p'); const articleByline = document.createElement('p');
articleByline.textContent = byline.join(' | '); articleByline.textContent = byline.join(' | ');
articleByline.classList.add('byline'); articleByline.classList.add('byline');
container.append(articleByline); container.append(articleByline);
} }
if(article.length) { if (article.length) {
const minTime = estimateReadingTime(article.length, 225); const minTime = estimateReadingTime(article.length, 225);
const maxTime = estimateReadingTime(article.length, 175); const maxTime = estimateReadingTime(article.length, 175);
const articleReadingTime = document.createElement('p'); const articleReadingTime = document.createElement('p');
articleReadingTime.textContent = `${minTime}-${maxTime} minutes`; articleReadingTime.textContent = `${minTime}-${maxTime} minutes`;
articleReadingTime.classList.add('reading-time'); articleReadingTime.classList.add('reading-time');
container.append(articleReadingTime); container.append(articleReadingTime);
} }
const divider = document.createElement('hr'); const divider = document.createElement('hr');
container.append(divider); container.append(divider);
const articleContent = document.createElement('div'); const articleContent = document.createElement('div');
articleContent.innerHTML = article.content; articleContent.innerHTML = article.content;
postProcess(articleContent); postProcess(articleContent);
container.append(articleContent); container.append(articleContent);
content.replaceWith(container); content.replaceWith(container);
} }
makeReadable(); makeReadable();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -1,27 +1,31 @@
{% load widget_tweaks %} {% load widget_tweaks %}
<div class="search-container"> <div class="search-container">
<form id="search" action="" method="get" role="search"> <form id="search" action="" method="get" role="search">
<ld-search-autocomplete <ld-search-autocomplete input-name="q"
input-name="q" input-placeholder="Search for words or #tags"
input-placeholder="Search for words or #tags" input-value="{{ search.q|default_if_none:'' }}"
input-value="{{ search.q|default_if_none:'' }}" target="{{ request.user_profile.bookmark_link_target }}"
target="{{ request.user_profile.bookmark_link_target }}" mode="{{ mode }}"
mode="{{ mode }}" user="{{ search.user }}"
user="{{ search.user }}" shared="{{ search.shared }}"
shared="{{ search.shared }}" unread="{{ search.unread }}">
unread="{{ search.unread }}">
</ld-search-autocomplete> </ld-search-autocomplete>
<input type="submit" value="Search" class="d-none"> <input type="submit" value="Search" class="d-none">
{% for hidden_field in search_form.hidden_fields %} {% for hidden_field in search_form.hidden_fields %}{{ hidden_field }}{% endfor %}
{{ hidden_field }}
{% endfor %}
</form> </form>
<ld-dropdown class="search-options dropdown dropdown-right"> <ld-dropdown class="search-options dropdown dropdown-right">
<button type="button" aria-label="Search preferences" <button type="button"
aria-label="Search preferences"
class="btn dropdown-toggle{% if search.has_modified_preferences %} badge{% endif %}"> class="btn dropdown-toggle{% if search.has_modified_preferences %} badge{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" stroke-width="2" <svg xmlns="http://www.w3.org/2000/svg"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> width="20"
height="20"
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 stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M4 10a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"></path> <path d="M4 10a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"></path>
<path d="M6 4v4"></path> <path d="M6 4v4"></path>
@@ -45,7 +49,9 @@
</div> </div>
{% endif %} {% endif %}
{% if 'shared' in preferences_form.editable_fields %} {% if 'shared' in preferences_form.editable_fields %}
<div class="form-group radio-group" role="radiogroup" aria-labelledby="search-shared-label"> <div class="form-group radio-group"
role="radiogroup"
aria-labelledby="search-shared-label">
<label id="search-shared-label" <label id="search-shared-label"
class="form-label{% if 'shared' in search.modified_params %} text-bold{% endif %}"> class="form-label{% if 'shared' in search.modified_params %} text-bold{% endif %}">
Shared filter Shared filter
@@ -60,7 +66,9 @@
</div> </div>
{% endif %} {% endif %}
{% if 'unread' in preferences_form.editable_fields %} {% if 'unread' in preferences_form.editable_fields %}
<div class="form-group radio-group" role="radiogroup" aria-labelledby="search-unread-label"> <div class="form-group radio-group"
role="radiogroup"
aria-labelledby="search-unread-label">
<label id="search-unread-label" <label id="search-unread-label"
class="form-label{% if 'unread' in search.modified_params %} text-bold{% endif %}"> class="form-label{% if 'unread' in search.modified_params %} text-bold{% endif %}">
Unread filter Unread filter
@@ -80,10 +88,7 @@
<button type="submit" class="btn btn-sm" name="save">Save as default</button> <button type="submit" class="btn btn-sm" name="save">Save as default</button>
{% endif %} {% endif %}
</div> </div>
{% for hidden_field in preferences_form.hidden_fields %}{{ hidden_field }}{% endfor %}
{% for hidden_field in preferences_form.hidden_fields %}
{{ hidden_field }}
{% endfor %}
</form> </form>
</div> </div>
</ld-dropdown> </ld-dropdown>

View File

@@ -1,12 +1,10 @@
{% extends "bookmarks/layout.html" %} {% extends "shared/layout.html" %}
{% load static %} {% load static %}
{% load shared %} {% load shared %}
{% load bookmarks %} {% load bookmarks %}
{% block content %} {% block content %}
<ld-bookmark-page no-bulk-edit <ld-bookmark-page no-bulk-edit
class="bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}"> class="bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}">
{# Bookmark list #} {# Bookmark list #}
<main class="main col-2" aria-labelledby="main-heading"> <main class="main col-2" aria-labelledby="main-heading">
<div class="section-header"> <div class="section-header">
@@ -18,17 +16,14 @@
</ld-filter-drawer-trigger> </ld-filter-drawer-trigger>
</div> </div>
</div> </div>
<form class="bookmark-actions" <form class="bookmark-actions"
action="{{ bookmark_list.action_url|safe }}" action="{{ bookmark_list.action_url|safe }}"
method="post" autocomplete="off"> method="post"
autocomplete="off">
{% csrf_token %} {% csrf_token %}
<div id="bookmark-list-container"> <div id="bookmark-list-container">{% include 'bookmarks/bookmark_list.html' %}</div>
{% include 'bookmarks/bookmark_list.html' %}
</div>
</form> </form>
</main> </main>
{# Filters #} {# Filters #}
<div class="side-panel col-1 hide-md"> <div class="side-panel col-1 hide-md">
<section aria-labelledby="user-heading"> <section aria-labelledby="user-heading">
@@ -44,12 +39,6 @@
</div> </div>
</ld-bookmark-page> </ld-bookmark-page>
{% endblock %} {% endblock %}
{% block overlays %} {% block overlays %}
{# Bookmark details #} {% include 'bookmarks/details/modal.html' %}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
{% endblock %} {% endblock %}

View File

@@ -1,37 +1,33 @@
{% load shared %} {% load shared %}
{% htmlmin %} {% htmlmin %}
<div class="tag-cloud"> <div class="tag-cloud">
{% if tag_cloud.has_selected_tags %} {% if tag_cloud.has_selected_tags %}
<p class="selected-tags"> <p class="selected-tags">
{% for tag in tag_cloud.selected_tags %} {% for tag in tag_cloud.selected_tags %}
<a href="?{{ tag.query_string }}" <a href="?{{ tag.query_string }}" class="text-bold mr-2">
class="text-bold mr-2"> <span>-{{ tag.name }}</span>
<span>-{{ tag.name }}</span> </a>
</a> {% endfor %}
</p>
{% endif %}
<div class="unselected-tags">
{% for group in tag_cloud.groups %}
<p class="group">
{% for tag in group.tags %}
{# Highlight first char of first tag in group if grouping is enabled #}
{% if group.highlight_first_char and forloop.counter == 1 %}
<a href="?{{ tag.query_string }}" class="mr-2" data-is-tag-item>
<span class="highlight-char">{{ tag.name|first_char }}</span><span>{{ tag.name|remaining_chars:1 }}</span>
</a>
{% else %}
{# Render tags normally #}
<a href="?{{ tag.query_string }}" class="mr-2" data-is-tag-item>
<span>{{ tag.name }}</span>
</a>
{% endif %}
{% endfor %} {% endfor %}
</p> </p>
{% endif %} {% endfor %}
<div class="unselected-tags">
{% for group in tag_cloud.groups %}
<p class="group">
{% for tag in group.tags %}
{# Highlight first char of first tag in group if grouping is enabled #}
{% if group.highlight_first_char and forloop.counter == 1 %}
<a href="?{{ tag.query_string }}"
class="mr-2" data-is-tag-item>
<span
class="highlight-char">{{ tag.name|first_char }}</span><span>{{ tag.name|remaining_chars:1 }}</span>
</a>
{% else %}
{# Render tags normally #}
<a href="?{{ tag.query_string }}"
class="mr-2" data-is-tag-item>
<span>{{ tag.name }}</span>
</a>
{% endif %}
{% endfor %}
</p>
{% endfor %}
</div>
</div> </div>
</div>
{% endhtmlmin %} {% endhtmlmin %}

View File

@@ -4,12 +4,19 @@
{% if user.is_authenticated %} {% if user.is_authenticated %}
<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"> <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" <svg xmlns="http://www.w3.org/2000/svg"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> width="20"
<path stroke="none" d="M0 0h24v24H0z" fill="none"/> height="20"
<path d="M4 6l16 0"/> viewBox="0 0 24 24"
<path d="M4 12l16 0"/> fill="none"
<path d="M4 18l16 0"/> stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4 6l16 0" />
<path d="M4 12l16 0" />
<path d="M4 18l16 0" />
</svg> </svg>
</button> </button>
<ul class="menu" role="list" tabindex="-1"> <ul class="menu" role="list" tabindex="-1">
@@ -20,7 +27,5 @@
</ld-dropdown> </ld-dropdown>
{% endif %} {% endif %}
</div> </div>
<div id="tag-cloud-container"> <div id="tag-cloud-container">{% include 'bookmarks/tag_cloud.html' %}</div>
{% include 'bookmarks/tag_cloud.html' %}
</div>
</section> </section>

View File

@@ -1,21 +0,0 @@
<turbo-stream action="update" target="bookmark-list-container">
<template>
{% include 'bookmarks/bookmark_list.html' %}
<script>
document.dispatchEvent(new CustomEvent('bookmark-list-updated'));
</script>
</template>
</turbo-stream>
<turbo-stream action="update" target="tag-cloud-container">
<template>
{% include 'bookmarks/tag_cloud.html' %}
</template>
</turbo-stream>
<turbo-stream action="update" method="morph" target="details-modal">
<template>
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</template>
</turbo-stream>

View File

@@ -1,10 +0,0 @@
<html>
{% include 'bookmarks/head.html' %}
<body>
<turbo-frame id="details-modal">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
</body>
</html>

View File

@@ -1,10 +1,7 @@
{% load widget_tweaks %} {% load widget_tweaks %}
<ld-form data-form-reset> <ld-form data-form-reset>
<form id="user-select" action="" method="get"> <form id="user-select" action="" method="get">
{% for hidden_field in form.hidden_fields %} {% for hidden_field in form.hidden_fields %}{{ hidden_field }}{% endfor %}
{{ hidden_field }}
{% endfor %}
<div class="form-group"> <div class="form-group">
<div class="d-flex"> <div class="d-flex">
{% render_field form.user class+="form-select" data-submit-on-change="" %} {% render_field form.user class+="form-select" data-submit-on-change="" %}
@@ -14,4 +11,4 @@
</div> </div>
</div> </div>
</form> </form>
</ld-form> </ld-form>

View File

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

View File

@@ -1,96 +1,83 @@
{% load widget_tweaks %} {% load widget_tweaks %}
<div class="form-group {% if form.name.errors %}has-error{% endif %}"> <div class="form-group {% if form.name.errors %}has-error{% endif %}">
<label for="{{ form.name.id_for_label }}" class="form-label">Name</label> <label for="{{ form.name.id_for_label }}" class="form-label">Name</label>
{{ form.name|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }} {{ form.name|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }}
{% if form.name.errors %} {% if form.name.errors %}<div class="form-input-hint">{{ form.name.errors }}</div>{% endif %}
<div class="form-input-hint">
{{ form.name.errors }}
</div>
{% endif %}
</div> </div>
<div class="form-group {% if form.search.errors %}has-error{% endif %}"> <div class="form-group {% if form.search.errors %}has-error{% endif %}">
<label for="{{ form.search.id_for_label }}" class="form-label">Search terms</label> <label for="{{ form.search.id_for_label }}" class="form-label">Search terms</label>
{{ form.search|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }} {{ form.search|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }}
{% if form.search.errors %} {% if form.search.errors %}<div class="form-input-hint">{{ form.search.errors }}</div>{% endif %}
<div class="form-input-hint"> <div class="form-input-hint">All of these search terms must be present in a bookmark to match.</div>
{{ form.search.errors }}
</div>
{% endif %}
<div class="form-input-hint">
All of these search terms must be present in a bookmark to match.
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.any_tags.id_for_label }}" class="form-label">Tags</label> <label for="{{ form.any_tags.id_for_label }}" class="form-label">Tags</label>
<ld-tag-autocomplete input-id="{{ form.any_tags.auto_id }}" input-name="{{ form.any_tags.html_name }}" <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:'' }}"> input-value="{{ form.any_tags.value|default_if_none:'' }}">
</ld-tag-autocomplete> </ld-tag-autocomplete>
<div class="form-input-hint"> <div class="form-input-hint">At least one of these tags must be present in a bookmark to match.</div>
At least one of these tags must be present in a bookmark to match.
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.all_tags.id_for_label }}" class="form-label">Required tags</label> <label for="{{ form.all_tags.id_for_label }}" class="form-label">Required tags</label>
<ld-tag-autocomplete input-id="{{ form.all_tags.auto_id }}" input-name="{{ form.all_tags.html_name }}" <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:'' }}"> input-value="{{ form.all_tags.value|default_if_none:'' }}">
</ld-tag-autocomplete> </ld-tag-autocomplete>
<div class="form-input-hint"> <div class="form-input-hint">All of these tags must be present in a bookmark to match.</div>
All of these tags must be present in a bookmark to match.
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.excluded_tags.id_for_label }}" class="form-label">Excluded tags</label> <label for="{{ form.excluded_tags.id_for_label }}" class="form-label">Excluded tags</label>
<ld-tag-autocomplete input-id="{{ form.excluded_tags.auto_id }}" input-name="{{ form.excluded_tags.html_name }}" <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:'' }}"> input-value="{{ form.excluded_tags.value|default_if_none:'' }}">
</ld-tag-autocomplete> </ld-tag-autocomplete>
<div class="form-input-hint"> <div class="form-input-hint">None of these tags must be present in a bookmark to match.</div>
None of these tags must be present in a bookmark to match.
</div>
</div> </div>
<div class="form-footer d-flex mt-4"> <div class="form-footer d-flex mt-4">
<input type="submit" name="save" value="Save" class="btn btn-primary btn-wide"> <input type="submit"
<a href="{% url 'linkding:bundles.index' %}" class="btn btn-wide ml-auto">Cancel</a> name="save"
<a href="{% url 'linkding:bundles.preview' %}" data-turbo-frame="preview" class="d-none" id="preview-link"></a> 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> </div>
<script> <script>
(function init() { (function init() {
const bundleForm = document.getElementById('bundle-form'); const bundleForm = document.getElementById('bundle-form');
const previewLink = document.getElementById('preview-link'); const previewLink = document.getElementById('preview-link');
let pendingUpdate; let pendingUpdate;
function scheduleUpdate() { function scheduleUpdate() {
if (pendingUpdate) { if (pendingUpdate) {
clearTimeout(pendingUpdate); clearTimeout(pendingUpdate);
} }
pendingUpdate = setTimeout(() => { pendingUpdate = setTimeout(() => {
// Ignore if link has been removed (e.g. form submit or navigation) // Ignore if link has been removed (e.g. form submit or navigation)
if (!previewLink.isConnected) { if (!previewLink.isConnected) {
return; 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); 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);
})();
</script> </script>

View File

@@ -1,61 +1,65 @@
{% extends "bookmarks/layout.html" %} {% extends "shared/layout.html" %}
{% block head %} {% block head %}
{% with page_title="Bundles - Linkding" %} {% with page_title="Bundles - Linkding" %}{{ block.super }}{% endwith %}
{{ block.super }}
{% endwith %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<main class="bundles-page crud-page" aria-labelledby="main-heading"> <main class="bundles-page crud-page" aria-labelledby="main-heading">
<div class="crud-header"> <div class="crud-header">
<h1 id="main-heading">Bundles</h1> <h1 id="main-heading">Bundles</h1>
<a href="{% url 'linkding:bundles.new' %}" class="btn">Add bundle</a> <a href="{% url 'linkding:bundles.new' %}" class="btn">Add bundle</a>
</div> </div>
{% include 'shared/messages.html' %} {% include 'shared/messages.html' %}
{% if bundles %} {% if bundles %}
<form action="{% url 'linkding:bundles.action' %}" method="post"> <form action="{% url 'linkding:bundles.action' %}" method="post">
{% csrf_token %} {% csrf_token %}
<table class="table crud-table"> <table class="table crud-table">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Name</th>
<th class="actions"> <th class="actions">
<span class="text-assistive">Actions</span> <span class="text-assistive">Actions</span>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for bundle in bundles %} {% for bundle in bundles %}
<tr data-bundle-id="{{ bundle.id }}" draggable="true"> <tr data-bundle-id="{{ bundle.id }}" draggable="true">
<td> <td>
<div class="d-flex align-center"> <div class="d-flex align-center">
<svg xmlns="http://www.w3.org/2000/svg" class="text-secondary mr-1" width="16" height="16" viewBox="0 0 24 24" fill="none" <svg xmlns="http://www.w3.org/2000/svg"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> class="text-secondary mr-1"
<path stroke="none" d="M0 0h24v24H0z" fill="none"/> width="16"
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/> height="16"
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/> viewBox="0 0 24 24"
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/> fill="none"
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/> stroke="currentColor"
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/> stroke-width="2"
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/> stroke-linecap="round"
</svg> stroke-linejoin="round">
<span>{{ bundle.name }}</span> <path stroke="none" d="M0 0h24v24H0z" fill="none" />
</div> <path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
</td> <path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<td class="actions"> <path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<a class="btn btn-link" href="{% url 'linkding:bundles.edit' bundle.id %}">Edit</a> <path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<button data-confirm type="submit" name="remove_bundle" value="{{ bundle.id }}" <path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
class="btn btn-link">Remove <path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
</button> </svg>
</td> <span>{{ bundle.name }}</span>
</tr> </div>
{% endfor %} </td>
<td class="actions">
<a class="btn btn-link"
href="{% url 'linkding:bundles.edit' bundle.id %}">Edit</a>
<button data-confirm
type="submit"
name="remove_bundle"
value="{{ bundle.id }}"
class="btn btn-link">Remove</button>
</td>
</tr>
{% endfor %}
</tbody> </tbody>
</table> </table>
<input type="submit" name="move_bundle" value="" class="d-none"> <input type="submit" name="move_bundle" value="" class="d-none">
<input type="hidden" name="move_position" value=""> <input type="hidden" name="move_position" value="">
</form> </form>
@@ -66,7 +70,6 @@
</div> </div>
{% endif %} {% endif %}
</main> </main>
<script> <script>
(function init() { (function init() {
const tableBody = document.querySelector(".crud-table tbody"); const tableBody = document.querySelector(".crud-table tbody");

View File

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

View File

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

View File

@@ -1,19 +1,13 @@
{% extends 'bookmarks/layout.html' %} {% extends 'shared/layout.html' %}
{% load widget_tweaks %} {% load widget_tweaks %}
{% block head %} {% block head %}
{% with page_title="Registration complete - Linkding" %} {% with page_title="Registration complete - Linkding" %}{{ block.super }}{% endwith %}
{{ block.super }}
{% endwith %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<main class="mx-auto width-50 width-md-100" aria-labelledby="main-heading"> <main class="mx-auto width-50 width-md-100" aria-labelledby="main-heading">
<div class="section-header"> <div class="section-header">
<h1 id="main-heading">Registration complete</h1> <h1 id="main-heading">Registration complete</h1>
</div> </div>
<p class="text-success"> <p class="text-success">You can now use the application.</p>
You can now use the application.
</p>
</main> </main>
{% endblock %} {% endblock %}

View File

@@ -1,18 +1,16 @@
{% extends 'bookmarks/layout.html' %} {% extends 'shared/layout.html' %}
{% load widget_tweaks %} {% load widget_tweaks %}
{% block head %} {% block head %}
{% with page_title="Registration - Linkding" %} {% with page_title="Registration - Linkding" %}{{ block.super }}{% endwith %}
{{ block.super }}
{% endwith %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<main class="mx-auto width-50 width-md-100" aria-labelledby="main-heading"> <main class="mx-auto width-50 width-md-100" aria-labelledby="main-heading">
<div class="section-header"> <div class="section-header">
<h1 id="main-heading">Register</h1> <h1 id="main-heading">Register</h1>
</div> </div>
<form method="post" action="{% url 'django_registration_register' %}" novalidate> <form method="post"
action="{% url 'django_registration_register' %}"
novalidate>
{% csrf_token %} {% csrf_token %}
<div class="form-group {% if form.errors.username %}has-error{% endif %}"> <div class="form-group {% if form.errors.username %}has-error{% endif %}">
<label class="form-label" for="{{ form.username.id_for_label }}">Username</label> <label class="form-label" for="{{ form.username.id_for_label }}">Username</label>
@@ -34,7 +32,7 @@
{{ form.password2|add_class:'form-input'|attr:"placeholder: " }} {{ form.password2|add_class:'form-input'|attr:"placeholder: " }}
<div class="form-input-hint">{{ form.errors.password2 }}</div> <div class="form-input-hint">{{ form.errors.password2 }}</div>
</div> </div>
<br/> <br />
<input type="submit" value="Register" class="btn btn-primary btn-wide"> <input type="submit" value="Register" class="btn btn-primary btn-wide">
<input type="hidden" name="next" value="{{ next }}"> <input type="hidden" name="next" value="{{ next }}">
</form> </form>

View File

@@ -1,12 +1,8 @@
{% extends 'bookmarks/layout.html' %} {% extends 'shared/layout.html' %}
{% load widget_tweaks %} {% load widget_tweaks %}
{% block head %} {% block head %}
{% with page_title="Login - Linkding" %} {% with page_title="Login - Linkding" %}{{ block.super }}{% endwith %}
{{ block.super }}
{% endwith %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<main class="mx-auto width-50 width-md-100" aria-labelledby="main-heading"> <main class="mx-auto width-50 width-md-100" aria-labelledby="main-heading">
<div class="section-header"> <div class="section-header">
@@ -27,13 +23,14 @@
<label class="form-label" for="{{ form.password.id_for_label }}">Password</label> <label class="form-label" for="{{ form.password.id_for_label }}">Password</label>
{{ form.password|add_class:'form-input'|attr:'placeholder: ' }} {{ form.password|add_class:'form-input'|attr:'placeholder: ' }}
</div> </div>
<br />
<br/>
<div class="d-flex justify-between"> <div class="d-flex justify-between">
<input type="submit" value="Login" class="btn btn-primary btn-wide"/> <input type="submit" value="Login" class="btn btn-primary btn-wide" />
<input type="hidden" name="next" value="{{ next }}"/> <input type="hidden" name="next" value="{{ next }}" />
{% if enable_oidc %} {% if enable_oidc %}
<a class="btn btn-link" href="{% url 'oidc_authentication_init' %}" data-turbo="false">Login with OIDC</a> <a class="btn btn-link"
href="{% url 'oidc_authentication_init' %}"
data-turbo="false">Login with OIDC</a>
{% endif %} {% endif %}
{% if allow_registration %} {% if allow_registration %}
<a href="{% url 'django_registration_register' %}" class="btn btn-link">Register</a> <a href="{% url 'django_registration_register' %}" class="btn btn-link">Register</a>

View File

@@ -1,19 +1,13 @@
{% extends 'bookmarks/layout.html' %} {% extends 'shared/layout.html' %}
{% load widget_tweaks %} {% load widget_tweaks %}
{% block head %} {% block head %}
{% with page_title="Password changed - Linkding" %} {% with page_title="Password changed - Linkding" %}{{ block.super }}{% endwith %}
{{ block.super }}
{% endwith %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<main class="mx-auto width-50 width-md-100" aria-labelledby="main-heading"> <main class="mx-auto width-50 width-md-100" aria-labelledby="main-heading">
<div class="section-header"> <div class="section-header">
<h1 id="main-heading">Password Changed</h1> <h1 id="main-heading">Password Changed</h1>
</div> </div>
<p class="text-success"> <p class="text-success">Your password was changed successfully.</p>
Your password was changed successfully.
</p>
</main> </main>
{% endblock %} {% endblock %}

View File

@@ -1,12 +1,8 @@
{% extends 'bookmarks/layout.html' %} {% extends 'shared/layout.html' %}
{% load widget_tweaks %} {% load widget_tweaks %}
{% block head %} {% block head %}
{% with page_title="Change password - Linkding" %} {% with page_title="Change password - Linkding" %}{{ block.super }}{% endwith %}
{{ block.super }}
{% endwith %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<main class="mx-auto width-50 width-md-100" aria-labelledby="main-heading"> <main class="mx-auto width-50 width-md-100" aria-labelledby="main-heading">
<div class="section-header"> <div class="section-header">
@@ -17,33 +13,22 @@
<div class="form-group {% if form.old_password.errors %}has-error{% endif %}"> <div class="form-group {% if form.old_password.errors %}has-error{% endif %}">
<label class="form-label" for="{{ form.old_password.id_for_label }}">Old password</label> <label class="form-label" for="{{ form.old_password.id_for_label }}">Old password</label>
{{ form.old_password|add_class:'form-input'|attr:"placeholder: " }} {{ form.old_password|add_class:'form-input'|attr:"placeholder: " }}
{% if form.old_password.errors %} {% if form.old_password.errors %}<div class="form-input-hint">{{ form.old_password.errors }}</div>{% endif %}
<div class="form-input-hint">
{{ form.old_password.errors }}
</div>
{% endif %}
</div> </div>
<div class="form-group {% if form.new_password1.errors %}has-error{% endif %}"> <div class="form-group {% if form.new_password1.errors %}has-error{% endif %}">
<label class="form-label" for="{{ form.new_password1.id_for_label }}">New password</label> <label class="form-label" for="{{ form.new_password1.id_for_label }}">New password</label>
{{ form.new_password1|add_class:'form-input'|attr:"placeholder: " }} {{ form.new_password1|add_class:'form-input'|attr:"placeholder: " }}
{% if form.new_password1.errors %} {% if form.new_password1.errors %}<div class="form-input-hint">{{ form.new_password1.errors }}</div>{% endif %}
<div class="form-input-hint">
{{ form.new_password1.errors }}
</div>
{% endif %}
</div> </div>
<div class="form-group {% if form.new_password2.errors %}has-error{% endif %}"> <div class="form-group {% if form.new_password2.errors %}has-error{% endif %}">
<label class="form-label" for="{{ form.new_password2.id_for_label }}">Confirm new password</label> <label class="form-label" for="{{ form.new_password2.id_for_label }}">Confirm new password</label>
{{ form.new_password2|add_class:'form-input'|attr:"placeholder: " }} {{ form.new_password2|add_class:'form-input'|attr:"placeholder: " }}
{% if form.new_password2.errors %} {% if form.new_password2.errors %}<div class="form-input-hint">{{ form.new_password2.errors }}</div>{% endif %}
<div class="form-input-hint">
{{ form.new_password2.errors }}
</div>
{% endif %}
</div> </div>
<br />
<br/> <input type="submit"
<input type="submit" value="Change Password" class="btn btn-primary btn-wide"> value="Change Password"
class="btn btn-primary btn-wide">
</form> </form>
</main> </main>
{% endblock %} {% endblock %}

View File

@@ -1,30 +1,32 @@
<turbo-frame id="api-modal"> <turbo-frame id="api-modal">
<form method="post" action="{% url 'linkding:settings.integrations.create_api_token' %}" <form method="post"
data-turbo-frame="api-section"> action="{% url 'linkding:settings.integrations.create_api_token' %}"
{% csrf_token %} data-turbo-frame="api-section">
<ld-modal class="modal active" data-close-url="{% url 'linkding:settings.integrations' %}" {% csrf_token %}
data-turbo-frame="api-modal"> <ld-modal class="modal active"
<div class="modal-overlay" data-close-modal></div> data-close-url="{% url 'linkding:settings.integrations' %}"
<div class="modal-container" role="dialog" aria-modal="true"> data-turbo-frame="api-modal">
{% include 'shared/modal_header.html' with title="Create API Token" %} <div class="modal-overlay" data-close-modal></div>
<div class="modal-body"> <div class="modal-container" role="dialog" aria-modal="true">
<div class="form-group"> {% include 'shared/modal_header.html' with title="Create API Token" %}
<label class="form-label" for="token-name">Token name</label> <div class="modal-body">
<input type="text" <div class="form-group">
class="form-input" <label class="form-label" for="token-name">Token name</label>
id="token-name" <input type="text"
name="name" class="form-input"
placeholder="e.g., Browser Extension, Mobile App" id="token-name"
value="API Token" name="name"
maxlength="128"> placeholder="e.g., Browser Extension, Mobile App"
<p class="form-input-hint">A descriptive name to identify the purpose of the token</p> value="API Token"
</div> maxlength="128">
</div> <p class="form-input-hint">A descriptive name to identify the purpose of the token</p>
<div class="modal-footer d-flex justify-between">
<button type="button" class="btn btn-wide" data-close-modal>Cancel</button>
<button type="submit" class="btn btn-primary">Create Token</button>
</div> </div>
</div> </div>
</ld-modal> <div class="modal-footer d-flex justify-between">
</form> <button type="button" class="btn btn-wide" data-close-modal>Cancel</button>
<button type="submit" class="btn btn-primary">Create Token</button>
</div>
</div>
</ld-modal>
</form>
</turbo-frame> </turbo-frame>

View File

@@ -1,30 +1,23 @@
{% extends "bookmarks/layout.html" %} {% extends "shared/layout.html" %}
{% load widget_tweaks %} {% load widget_tweaks %}
{% block head %} {% block head %}
{% with page_title="Settings - Linkding" %} {% with page_title="Settings - Linkding" %}{{ block.super }}{% endwith %}
{{ block.super }}
{% endwith %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<main class="settings-page" aria-labelledby="main-heading"> <main class="settings-page" aria-labelledby="main-heading">
<h1 id="main-heading">Settings</h1> <h1 id="main-heading">Settings</h1>
{# Profile section #} {# Profile section #}
{% if success_message %} {% if success_message %}<div class="toast toast-success mb-4">{{ success_message }}</div>{% endif %}
<div class="toast toast-success mb-4">{{ success_message }}</div> {% if error_message %}<div class="toast toast-error mb-4">{{ error_message }}</div>{% endif %}
{% endif %}
{% if error_message %}
<div class="toast toast-error mb-4">{{ error_message }}</div>
{% endif %}
<section aria-labelledby="profile-heading"> <section aria-labelledby="profile-heading">
<h2 id="profile-heading">Profile</h2> <h2 id="profile-heading">Profile</h2>
<p> <p>
<a href="{% url 'change_password' %}">Change password</a> <a href="{% url 'change_password' %}">Change password</a>
</p> </p>
<form action="{% url 'linkding:settings.update' %}" method="post" novalidate data-turbo="false"> <form action="{% url 'linkding:settings.update' %}"
method="post"
novalidate
data-turbo="false">
{% csrf_token %} {% csrf_token %}
<div class="form-group"> <div class="form-group">
<label for="{{ form.theme.id_for_label }}" class="form-label">Theme</label> <label for="{{ form.theme.id_for_label }}" class="form-label">Theme</label>
@@ -34,7 +27,8 @@
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.bookmark_date_display.id_for_label }}" class="form-label">Bookmark date format</label> <label for="{{ form.bookmark_date_display.id_for_label }}"
class="form-label">Bookmark date format</label>
{{ form.bookmark_date_display|add_class:"form-select width-25 width-sm-100" }} {{ form.bookmark_date_display|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint"> <div class="form-input-hint">
Whether to show bookmark dates as relative (how long ago), or as absolute dates. Alternatively the date can Whether to show bookmark dates as relative (how long ago), or as absolute dates. Alternatively the date can
@@ -42,30 +36,31 @@
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.bookmark_description_display.id_for_label }}" class="form-label">Bookmark <label for="{{ form.bookmark_description_display.id_for_label }}"
description</label> class="form-label">
Bookmark
description
</label>
{{ form.bookmark_description_display|add_class:"form-select width-25 width-sm-100" }} {{ form.bookmark_description_display|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint"> <div class="form-input-hint">
Whether to show bookmark descriptions and tags in the same line, or as separate blocks. Whether to show bookmark descriptions and tags in the same line, or as separate blocks.
</div> </div>
</div> </div>
<div <div class="form-group {% if request.user_profile.bookmark_description_display == 'inline' %}d-hide{% endif %}">
class="form-group {% if request.user_profile.bookmark_description_display == 'inline' %}d-hide{% endif %}"> <label for="{{ form.bookmark_description_max_lines.id_for_label }}"
<label for="{{ form.bookmark_description_max_lines.id_for_label }}" class="form-label">Bookmark description class="form-label">
max lines</label> Bookmark description
max lines
</label>
{{ form.bookmark_description_max_lines|add_class:"form-input width-25 width-sm-100" }} {{ form.bookmark_description_max_lines|add_class:"form-input width-25 width-sm-100" }}
<div class="form-input-hint"> <div class="form-input-hint">Limits the number of lines that are displayed for the bookmark description.</div>
Limits the number of lines that are displayed for the bookmark description.
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.display_url.id_for_label }}" class="form-checkbox"> <label for="{{ form.display_url.id_for_label }}" class="form-checkbox">
{{ form.display_url }} {{ form.display_url }}
<i class="form-icon"></i> Show bookmark URL <i class="form-icon"></i> Show bookmark URL
</label> </label>
<div class="form-input-hint"> <div class="form-input-hint">When enabled, this setting displays the bookmark URL below the title.</div>
When enabled, this setting displays the bookmark URL below the title.
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.permanent_notes.id_for_label }}" class="form-checkbox"> <label for="{{ form.permanent_notes.id_for_label }}" class="form-checkbox">
@@ -79,45 +74,41 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Bookmark actions</label> <label class="form-label">Bookmark actions</label>
<label for="{{ form.display_view_bookmark_action.id_for_label }}" class="form-checkbox"> <label for="{{ form.display_view_bookmark_action.id_for_label }}"
class="form-checkbox">
{{ form.display_view_bookmark_action }} {{ form.display_view_bookmark_action }}
<i class="form-icon"></i> View <i class="form-icon"></i> View
</label> </label>
<label for="{{ form.display_edit_bookmark_action.id_for_label }}" class="form-checkbox"> <label for="{{ form.display_edit_bookmark_action.id_for_label }}"
class="form-checkbox">
{{ form.display_edit_bookmark_action }} {{ form.display_edit_bookmark_action }}
<i class="form-icon"></i> Edit <i class="form-icon"></i> Edit
</label> </label>
<label for="{{ form.display_archive_bookmark_action.id_for_label }}" class="form-checkbox"> <label for="{{ form.display_archive_bookmark_action.id_for_label }}"
class="form-checkbox">
{{ form.display_archive_bookmark_action }} {{ form.display_archive_bookmark_action }}
<i class="form-icon"></i> Archive <i class="form-icon"></i> Archive
</label> </label>
<label for="{{ form.display_remove_bookmark_action.id_for_label }}" class="form-checkbox"> <label for="{{ form.display_remove_bookmark_action.id_for_label }}"
class="form-checkbox">
{{ form.display_remove_bookmark_action }} {{ form.display_remove_bookmark_action }}
<i class="form-icon"></i> Remove <i class="form-icon"></i> Remove
</label> </label>
<div class="form-input-hint"> <div class="form-input-hint">Which actions to display for each bookmark.</div>
Which actions to display for each bookmark.
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.bookmark_link_target.id_for_label }}" class="form-label">Open bookmarks in</label> <label for="{{ form.bookmark_link_target.id_for_label }}" class="form-label">Open bookmarks in</label>
{{ form.bookmark_link_target|add_class:"form-select width-25 width-sm-100" }} {{ form.bookmark_link_target|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint"> <div class="form-input-hint">Whether to open bookmarks a new page or in the same page.</div>
Whether to open bookmarks a new page or in the same page.
</div>
</div> </div>
<div class="form-group{% if form.items_per_page.errors %} has-error{% endif %}"> <div class="form-group{% if form.items_per_page.errors %} has-error{% endif %}">
<label for="{{ form.items_per_page.id_for_label }}" class="form-label">Items per page</label> <label for="{{ form.items_per_page.id_for_label }}" class="form-label">Items per page</label>
{{ form.items_per_page|add_class:"form-input width-25 width-sm-100"|attr:"min:10" }} {{ form.items_per_page|add_class:"form-input width-25 width-sm-100"|attr:"min:10" }}
{% if form.items_per_page.errors %} {% if form.items_per_page.errors %}
<div class="form-input-hint is-error"> <div class="form-input-hint is-error">{{ form.items_per_page.errors }}</div>
{{ form.items_per_page.errors }}
</div>
{% else %} {% else %}
{% endif %} {% endif %}
<div class="form-input-hint"> <div class="form-input-hint">The number of bookmarks to display per page.</div>
The number of bookmarks to display per page.
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.sticky_pagination.id_for_label }}" class="form-checkbox"> <label for="{{ form.sticky_pagination.id_for_label }}" class="form-checkbox">
@@ -130,7 +121,8 @@
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.collapse_side_panel.id_for_label }}" class="form-checkbox"> <label for="{{ form.collapse_side_panel.id_for_label }}"
class="form-checkbox">
{{ form.collapse_side_panel }} {{ form.collapse_side_panel }}
<i class="form-icon"></i> Collapse side panel <i class="form-icon"></i> Collapse side panel
</label> </label>
@@ -144,9 +136,7 @@
{{ form.hide_bundles }} {{ form.hide_bundles }}
<i class="form-icon"></i> Hide bundles <i class="form-icon"></i> Hide bundles
</label> </label>
<div class="form-input-hint"> <div class="form-input-hint">Allows to hide the bundles in the side panel if you don't intend to use them.</div>
Allows to hide the bundles in the side panel if you don't intend to use them.
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.tag_search.id_for_label }}" class="form-label">Tag search</label> <label for="{{ form.tag_search.id_for_label }}" class="form-label">Tag search</label>
@@ -166,7 +156,8 @@
<div class="form-input-hint"> <div class="form-input-hint">
Since version 1.44.0, linkding has a new search engine that supports logical expressions (and, or, not). Since version 1.44.0, linkding has a new search engine that supports logical expressions (and, or, not).
If you run into any issues with the new search, you can enable this option to temporarily switch back to the old search. If you run into any issues with the new search, you can enable this option to temporarily switch back to the old search.
Please report any issues you encounter with the new search on <a href="https://github.com/sissbruecker/linkding/issues" target="_blank">GitHub</a> so they can be addressed. Please report any issues you encounter with the new search on <a href="https://github.com/sissbruecker/linkding/issues"
target="_blank">GitHub</a> so they can be addressed.
This option will be removed in a future version. This option will be removed in a future version.
</div> </div>
</div> </div>
@@ -183,10 +174,9 @@
<summary> <summary>
<span class="form-label d-inline-block">Auto Tagging</span> <span class="form-label d-inline-block">Auto Tagging</span>
</summary> </summary>
<label for="{{ form.auto_tagging_rules.id_for_label }}" class="text-assistive">Auto Tagging</label> <label for="{{ form.auto_tagging_rules.id_for_label }}"
<div> class="text-assistive">Auto Tagging</label>
{{ form.auto_tagging_rules|add_class:"form-input monospace"|attr:"rows:6" }} <div>{{ form.auto_tagging_rules|add_class:"form-input monospace"|attr:"rows:6" }}</div>
</div>
</details> </details>
<div class="form-input-hint"> <div class="form-input-hint">
Automatically adds tags to bookmarks based on predefined rules. Automatically adds tags to bookmarks based on predefined rules.
@@ -204,9 +194,9 @@ reddit.com/r/Music music reddit</pre>
Automatically loads favicons for bookmarked websites and displays them next to each bookmark. Automatically loads favicons for bookmarked websites and displays them next to each bookmark.
Enabling this feature automatically downloads all missing favicons. Enabling this feature automatically downloads all missing favicons.
By default, this feature uses a <b>Google service</b> to download favicons. By default, this feature uses a <b>Google service</b> to download favicons.
If you don't want to use this service, check the <a If you don't want to use this service, check the
href="https://linkding.link/options/#ld_favicon_provider" <a href="https://linkding.link/options/#ld_favicon_provider"
target="_blank">options documentation</a> on how to configure a custom favicon provider. target="_blank">options documentation</a> on how to configure a custom favicon provider.
Icons are downloaded in the background, and it may take a while for them to show up. Icons are downloaded in the background, and it may take a while for them to show up.
</div> </div>
{% if request.user_profile.enable_favicons and enable_refresh_favicons %} {% if request.user_profile.enable_favicons and enable_refresh_favicons %}
@@ -214,7 +204,8 @@ reddit.com/r/Music music reddit</pre>
{% endif %} {% endif %}
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.enable_preview_images.id_for_label }}" class="form-checkbox"> <label for="{{ form.enable_preview_images.id_for_label }}"
class="form-checkbox">
{{ form.enable_preview_images }} {{ form.enable_preview_images }}
<i class="form-icon"></i> Enable Preview Images <i class="form-icon"></i> Enable Preview Images
</label> </label>
@@ -224,17 +215,18 @@ reddit.com/r/Music music reddit</pre>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.web_archive_integration.id_for_label }}" class="form-label">Internet Archive <label for="{{ form.web_archive_integration.id_for_label }}"
integration</label> class="form-label">
Internet Archive
integration
</label>
{{ form.web_archive_integration|add_class:"form-select width-25 width-sm-100" }} {{ form.web_archive_integration|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint"> <div class="form-input-hint">
Enabling this feature will automatically create snapshots of bookmarked websites on the <a Enabling this feature will automatically create snapshots of bookmarked websites on the
href="https://web.archive.org/" target="_blank" rel="noopener">Internet Archive Wayback <a href="https://web.archive.org/" target="_blank" rel="noopener">Internet Archive Wayback Machine</a>.
Machine</a>.
This allows to preserve, and later access the website as it was at the point in time it was bookmarked, in This allows to preserve, and later access the website as it was at the point in time it was bookmarked, in
case it goes offline or its content is modified. case it goes offline or its content is modified.
Please consider donating to the <a href="https://archive.org/donate" target="_blank" Please consider donating to the <a href="https://archive.org/donate" target="_blank" rel="noopener">Internet Archive</a> if you make use of this feature.
rel="noopener">Internet Archive</a> if you make use of this feature.
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -248,19 +240,20 @@ reddit.com/r/Music music reddit</pre>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.enable_public_sharing.id_for_label }}" class="form-checkbox"> <label for="{{ form.enable_public_sharing.id_for_label }}"
class="form-checkbox">
{{ form.enable_public_sharing }} {{ form.enable_public_sharing }}
<i class="form-icon"></i> Enable public bookmark sharing <i class="form-icon"></i> Enable public bookmark sharing
</label> </label>
<div class="form-input-hint"> <div class="form-input-hint">
Makes shared bookmarks publicly accessible, without requiring a login. Makes shared bookmarks publicly accessible, without requiring a login.
That means that anyone with a link to this instance can view shared bookmarks via the <a That means that anyone with a link to this instance can view shared bookmarks via the <a href="{% url 'linkding:bookmarks.shared' %}">shared bookmarks page</a>.
href="{% url 'linkding:bookmarks.shared' %}">shared bookmarks page</a>.
</div> </div>
</div> </div>
{% if has_snapshot_support %} {% if has_snapshot_support %}
<div class="form-group"> <div class="form-group">
<label for="{{ form.enable_automatic_html_snapshots.id_for_label }}" class="form-checkbox"> <label for="{{ form.enable_automatic_html_snapshots.id_for_label }}"
class="form-checkbox">
{{ form.enable_automatic_html_snapshots }} {{ form.enable_automatic_html_snapshots }}
<i class="form-icon"></i> Automatically create HTML snapshots <i class="form-icon"></i> Automatically create HTML snapshots
</label> </label>
@@ -272,7 +265,8 @@ reddit.com/r/Music music reddit</pre>
</div> </div>
{% endif %} {% endif %}
<div class="form-group"> <div class="form-group">
<label for="{{ form.default_mark_unread.id_for_label }}" class="form-checkbox"> <label for="{{ form.default_mark_unread.id_for_label }}"
class="form-checkbox">
{{ form.default_mark_unread }} {{ form.default_mark_unread }}
<i class="form-icon"></i> Create bookmarks as unread by default <i class="form-icon"></i> Create bookmarks as unread by default
</label> </label>
@@ -283,7 +277,8 @@ reddit.com/r/Music music reddit</pre>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.default_mark_shared.id_for_label }}" class="form-checkbox"> <label for="{{ form.default_mark_shared.id_for_label }}"
class="form-checkbox">
{{ form.default_mark_shared }} {{ form.default_mark_shared }}
<i class="form-icon"></i> Create bookmarks as shared by default <i class="form-icon"></i> Create bookmarks as shared by default
</label> </label>
@@ -299,36 +294,39 @@ reddit.com/r/Music music reddit</pre>
<span class="form-label d-inline-block">Custom CSS</span> <span class="form-label d-inline-block">Custom CSS</span>
</summary> </summary>
<label for="{{ form.custom_css.id_for_label }}" class="text-assistive">Custom CSS</label> <label for="{{ form.custom_css.id_for_label }}" class="text-assistive">Custom CSS</label>
<div> <div>{{ form.custom_css|add_class:"form-input monospace"|attr:"rows:6" }}</div>
{{ form.custom_css|add_class:"form-input monospace"|attr:"rows:6" }}
</div>
</details> </details>
<div class="form-input-hint"> <div class="form-input-hint">Allows to add custom CSS to the page.</div>
Allows to add custom CSS to the page.
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<input type="submit" name="update_profile" value="Save" class="btn btn-primary btn-wide mt-2"> <input type="submit"
name="update_profile"
value="Save"
class="btn btn-primary btn-wide mt-2">
</div> </div>
</form> </form>
</section> </section>
{# Global settings section #} {# Global settings section #}
{% if global_settings_form %} {% if global_settings_form %}
<section aria-labelledby="global-settings-heading"> <section aria-labelledby="global-settings-heading">
<h2 id="global-settings-heading">Global settings</h2> <h2 id="global-settings-heading">Global settings</h2>
<form action="{% url 'linkding:settings.update' %}" method="post" novalidate data-turbo="false"> <form action="{% url 'linkding:settings.update' %}"
method="post"
novalidate
data-turbo="false">
{% csrf_token %} {% csrf_token %}
<div class="form-group"> <div class="form-group">
<label for="{{ global_settings_form.landing_page.id_for_label }}" class="form-label">Landing page</label> <label for="{{ global_settings_form.landing_page.id_for_label }}"
class="form-label">Landing page</label>
{{ global_settings_form.landing_page|add_class:"form-select width-25 width-sm-100" }} {{ global_settings_form.landing_page|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint"> <div class="form-input-hint">The page that unauthenticated users are redirected to when accessing the root URL.</div>
The page that unauthenticated users are redirected to when accessing the root URL.
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="{{ global_settings_form.guest_profile_user.id_for_label }}" class="form-label">Guest user <label for="{{ global_settings_form.guest_profile_user.id_for_label }}"
profile</label> class="form-label">
Guest user
profile
</label>
{{ global_settings_form.guest_profile_user|add_class:"form-select width-25 width-sm-100" }} {{ global_settings_form.guest_profile_user|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint"> <div class="form-input-hint">
The user profile to use for users that are not logged in. This will affect how publicly shared bookmarks The user profile to use for users that are not logged in. This will affect how publicly shared bookmarks
@@ -337,7 +335,8 @@ reddit.com/r/Music music reddit</pre>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="{{ global_settings_form.enable_link_prefetch.id_for_label }}" class="form-checkbox"> <label for="{{ global_settings_form.enable_link_prefetch.id_for_label }}"
class="form-checkbox">
{{ global_settings_form.enable_link_prefetch }} {{ global_settings_form.enable_link_prefetch }}
<i class="form-icon"></i> Enable prefetching links on hover <i class="form-icon"></i> Enable prefetching links on hover
</label> </label>
@@ -346,20 +345,25 @@ reddit.com/r/Music music reddit</pre>
navigating application, but also increases the load on the server as well as bandwidth usage. navigating application, but also increases the load on the server as well as bandwidth usage.
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<input type="submit" name="update_global_settings" value="Save" class="btn btn-primary btn-wide mt-2"> <input type="submit"
name="update_global_settings"
value="Save"
class="btn btn-primary btn-wide mt-2">
</div> </div>
</form> </form>
</section> </section>
{% endif %} {% endif %}
{# Import section #} {# Import section #}
<section aria-labelledby="import-heading"> <section aria-labelledby="import-heading">
<h2 id="import-heading">Import</h2> <h2 id="import-heading">Import</h2>
<p>Import bookmarks and tags in the Netscape HTML format. This will execute a sync where new bookmarks are <p>
added and existing ones are updated.</p> Import bookmarks and tags in the Netscape HTML format. This will execute a sync where new bookmarks are
<form method="post" enctype="multipart/form-data" action="{% url 'linkding:settings.import' %}"> added and existing ones are updated.
</p>
<form method="post"
enctype="multipart/form-data"
action="{% url 'linkding:settings.import' %}">
{% csrf_token %} {% csrf_token %}
<div class="form-group"> <div class="form-group">
<label for="import_map_private_flag" class="form-checkbox"> <label for="import_map_private_flag" class="form-checkbox">
@@ -381,48 +385,43 @@ reddit.com/r/Music music reddit</pre>
</div> </div>
</form> </form>
</section> </section>
{# Export section #} {# Export section #}
<section aria-labelledby="export-heading"> <section aria-labelledby="export-heading">
<h2 id="export-heading">Export</h2> <h2 id="export-heading">Export</h2>
<p>Export all bookmarks in Netscape HTML format.</p> <p>Export all bookmarks in Netscape HTML format.</p>
<a class="btn btn-primary" target="_blank" href="{% url 'linkding:settings.export' %}">Download (.html)</a> <a class="btn btn-primary"
target="_blank"
href="{% url 'linkding:settings.export' %}">Download (.html)</a>
{% if export_error %} {% if export_error %}
<div class="has-error"> <div class="has-error">
<p class="form-input-hint"> <p class="form-input-hint">{{ export_error }}</p>
{{ export_error }}
</p>
</div> </div>
{% endif %} {% endif %}
</section> </section>
{# About section #} {# About section #}
<section class="about" aria-labelledby="about-heading"> <section class="about" aria-labelledby="about-heading">
<h2 id="about-heading">About</h2> <h2 id="about-heading">About</h2>
<table class="table"> <table class="table">
<tbody> <tbody>
<tr> <tr>
<td>Version</td> <td>Version</td>
<td>{{ version_info }}</td> <td>{{ version_info }}</td>
</tr> </tr>
<tr> <tr>
<td style="vertical-align: top">Links</td> <td style="vertical-align: top">Links</td>
<td> <td>
<div class="d-flex flex-column gap-2"> <div class="d-flex flex-column gap-2">
<a href="https://github.com/sissbruecker/linkding/" <a href="https://github.com/sissbruecker/linkding/" target="_blank">GitHub</a>
target="_blank">GitHub</a> <a href="https://linkding.link/" target="_blank">Documentation</a>
<a href="https://linkding.link/" <a href="https://github.com/sissbruecker/linkding/blob/master/CHANGELOG.md"
target="_blank">Documentation</a> target="_blank">Changelog</a>
<a href="https://github.com/sissbruecker/linkding/blob/master/CHANGELOG.md" </div>
target="_blank">Changelog</a> </td>
</div> </tr>
</td>
</tr>
</tbody> </tbody>
</table> </table>
</section> </section>
</main> </main>
<script> <script>
(function init() { (function init() {
const enableSharing = document.getElementById("{{ form.enable_sharing.id_for_label }}"); const enableSharing = document.getElementById("{{ form.enable_sharing.id_for_label }}");
@@ -460,5 +459,4 @@ reddit.com/r/Music music reddit</pre>
bookmarkDescriptionDisplay.addEventListener("change", updateBookmarkDescriptionMaxLines); bookmarkDescriptionDisplay.addEventListener("change", updateBookmarkDescriptionMaxLines);
})(); })();
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -1,33 +1,40 @@
{% extends "bookmarks/layout.html" %} {% extends "shared/layout.html" %}
{% block head %} {% block head %}
{% with page_title="Integrations - Linkding" %} {% with page_title="Integrations - Linkding" %}{{ block.super }}{% endwith %}
{{ block.super }}
{% endwith %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<main class="settings-page" aria-labelledby="main-heading"> <main class="settings-page" aria-labelledby="main-heading">
<h1 id="main-heading">Integrations</h1> <h1 id="main-heading">Integrations</h1>
<section aria-labelledby="browser-extension-heading"> <section aria-labelledby="browser-extension-heading">
<h2 id="browser-extension-heading">Browser Extension</h2> <h2 id="browser-extension-heading">Browser Extension</h2>
<p>The browser extension allows you to quickly add new bookmarks without leaving the page that you are on. The <p>
extension is available in the official extension stores for:</p> The browser extension allows you to quickly add new bookmarks without leaving the page that you are on. The
extension is available in the official extension stores for:
</p>
<ul> <ul>
<li><a href="https://addons.mozilla.org/firefox/addon/linkding-extension/" target="_blank">Firefox</a></li> <li>
<li><a href="https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe" <a href="https://addons.mozilla.org/firefox/addon/linkding-extension/"
target="_blank">Chrome</a></li> target="_blank">Firefox</a>
</li>
<li>
<a href="https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe"
target="_blank">Chrome</a>
</li>
</ul> </ul>
<p>The extension is <a href="https://github.com/sissbruecker/linkding-extension" target="_blank">open source</a> <p>
as well, which enables you to build and manually load it into any browser that supports Chrome extensions.</p> The extension is <a href="https://github.com/sissbruecker/linkding-extension"
target="_blank">open source</a>
as well, which enables you to build and manually load it into any browser that supports Chrome extensions.
</p>
<h2>Bookmarklet</h2> <h2>Bookmarklet</h2>
<p>The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding <p>
application first. Here's how it works:</p> The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding
application first. Here's how it works:
</p>
<ul> <ul>
<li>Choose your preferred method for detecting website titles and descriptions below (<a <li>
href="https://linkding.link/troubleshooting/#automatically-detected-title-and-description-are-incorrect" Choose your preferred method for detecting website titles and descriptions below (<a href="https://linkding.link/troubleshooting/#automatically-detected-title-and-description-are-incorrect"
target="_blank">Help</a>) target="_blank">Help</a>)
</li> </li>
<li>Drag the bookmarklet below into your browser's bookmark bar / toolbar</li> <li>Drag the bookmarklet below into your browser's bookmark bar / toolbar</li>
<li>Open the website that you want to bookmark</li> <li>Open the website that you want to bookmark</li>
@@ -35,86 +42,94 @@
<li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li> <li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
<li>After saving the bookmark, the linkding window closes, and you are back on your website</li> <li>After saving the bookmark, the linkding window closes, and you are back on your website</li>
</ul> </ul>
<div class="form-group radio-group"
<div class="form-group radio-group" role="radiogroup" aria-labelledby="detection-method-label"> role="radiogroup"
aria-labelledby="detection-method-label">
<p id="detection-method-label">Choose your preferred bookmarklet:</p> <p id="detection-method-label">Choose your preferred bookmarklet:</p>
<label for="detection-method-server" class="form-radio"> <label for="detection-method-server" class="form-radio">
<input id="detection-method-server" type="radio" name="bookmarklet-type" value="server" checked> <input id="detection-method-server"
type="radio"
name="bookmarklet-type"
value="server"
checked>
<i class="form-icon"></i> <i class="form-icon"></i>
Detect title and description on the server Detect title and description on the server
</label> </label>
<label for="detection-method-client" class="form-radio"> <label for="detection-method-client" class="form-radio">
<input id="detection-method-client" type="radio" name="bookmarklet-type" value="client"> <input id="detection-method-client"
type="radio"
name="bookmarklet-type"
value="client">
<i class="form-icon"></i> <i class="form-icon"></i>
Detect title and description in the browser Detect title and description in the browser
</label> </label>
</div> </div>
<div class="bookmarklet-container"> <div class="bookmarklet-container">
<a id="bookmarklet-server" href="javascript: {% include 'bookmarks/bookmarklet.js' %}" data-turbo="false" <a id="bookmarklet-server"
href="javascript: {% include 'settings/bookmarklet.js' %}"
data-turbo="false"
class="btn btn-primary">📎 Add bookmark</a> class="btn btn-primary">📎 Add bookmark</a>
<a id="bookmarklet-client" href="javascript: {% include 'bookmarks/bookmarklet_clientside.js' %}" <a id="bookmarklet-client"
data-turbo="false" class="btn btn-primary" style="display: none;">📎 Add bookmark</a> href="javascript: {% include 'settings/bookmarklet_clientside.js' %}"
data-turbo="false"
class="btn btn-primary"
style="display: none">📎 Add bookmark</a>
</div> </div>
<script> <script>
(function init() { (function init() {
// Bookmarklet type toggle // Bookmarklet type toggle
const radioButtons = document.querySelectorAll('input[name="bookmarklet-type"]'); const radioButtons = document.querySelectorAll('input[name="bookmarklet-type"]');
const serverBookmarklet = document.getElementById('bookmarklet-server'); const serverBookmarklet = document.getElementById('bookmarklet-server');
const clientBookmarklet = document.getElementById('bookmarklet-client'); const clientBookmarklet = document.getElementById('bookmarklet-client');
function toggleBookmarklet() { function toggleBookmarklet() {
const selectedValue = document.querySelector('input[name="bookmarklet-type"]:checked').value; const selectedValue = document.querySelector('input[name="bookmarklet-type"]:checked').value;
if (selectedValue === 'server') { if (selectedValue === 'server') {
serverBookmarklet.style.display = 'inline-block'; serverBookmarklet.style.display = 'inline-block';
clientBookmarklet.style.display = 'none'; clientBookmarklet.style.display = 'none';
} else { } else {
serverBookmarklet.style.display = 'none'; serverBookmarklet.style.display = 'none';
clientBookmarklet.style.display = 'inline-block'; clientBookmarklet.style.display = 'inline-block';
} }
} }
toggleBookmarklet(); toggleBookmarklet();
radioButtons.forEach(function (radio) { radioButtons.forEach(function(radio) {
radio.addEventListener('change', toggleBookmarklet); radio.addEventListener('change', toggleBookmarklet);
}); });
})(); })();
</script> </script>
</section> </section>
<turbo-frame id="api-section"> <turbo-frame id="api-section">
<section aria-labelledby="rest-api-heading"> <section aria-labelledby="rest-api-heading">
<h2 id="rest-api-heading">REST API</h2> <h2 id="rest-api-heading">REST API</h2>
{% if api_success_message %}<div class="toast toast-success mb-2">{{ api_success_message }}</div>{% endif %}
{% if api_success_message %} {% if api_token_name and api_token_key %}
<div class="toast toast-success mb-2"> <div class="mt-4 mb-6">
{{ api_success_message }} <p class="mb-2">
<strong>Copy this token now, it will only be shown once:</strong>
</p>
<label class="text-assistive" for="new-token-key">New token key</label>
<div class="input-group">
<input class="form-input"
value="{{ api_token_key }}"
readonly
id="new-token-key">
<button id="copy-new-token-key" class="btn input-group-btn" type="button">Copy</button>
</div> </div>
{% endif %} </div>
{% endif %}
{% if api_token_name and api_token_key %} <p>
<div class="mt-4 mb-6"> API tokens can be used to authenticate 3rd-party applications against the REST API. <strong>Please treat
<p class="mb-2"><strong>Copy this token now, it will only be shown once:</strong></p> tokens as you would any other credential.</strong> Any party with access to a token can access and manage all
<label class="text-assistive" for="new-token-key">New token key</label> your bookmarks.
<div class="input-group"> </p>
<input class="form-input" value="{{ api_token_key }}" readonly id="new-token-key"> {% if api_tokens %}
<button id="copy-new-token-key" class="btn input-group-btn" type="button">Copy</button> <form method="post"
</div> action="{% url 'linkding:settings.integrations.delete_api_token' %}"
</div> data-turbo-frame="api-section">
{% endif %} <table class="table crud-table mb-6">
<thead>
<p>
API tokens can be used to authenticate 3rd-party applications against the REST API. <strong>Please treat
tokens as you would any other credential.</strong> Any party with access to a token can access and manage all
your bookmarks.
</p>
{% if api_tokens %}
<form method="post"
action="{% url 'linkding:settings.integrations.delete_api_token' %}"
data-turbo-frame="api-section">
<table class="table crud-table mb-6">
<thead>
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>Created</th> <th>Created</th>
@@ -122,87 +137,96 @@
<span class="text-assistive">Actions</span> <span class="text-assistive">Actions</span>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for token in api_tokens %} {% for token in api_tokens %}
<tr> <tr>
<td>{{ token.name }}</td> <td>{{ token.name }}</td>
<td>{{ token.created|date:"M d, Y H:i" }}</td> <td>{{ token.created|date:"M d, Y H:i" }}</td>
<td class="actions"> <td class="actions">
{% csrf_token %} {% csrf_token %}
<button data-confirm name="token_id" value="{{ token.id }}" type="submit" <button data-confirm
class="btn btn-link">Delete name="token_id"
</button> value="{{ token.id }}"
type="submit"
class="btn btn-link">Delete</button>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</form> </form>
{% endif %} {% endif %}
<a class="btn"
<a class="btn" href="{% url 'linkding:settings.integrations.create_api_token' %}" href="{% url 'linkding:settings.integrations.create_api_token' %}"
data-turbo-frame="api-modal">Create API token</a> data-turbo-frame="api-modal">Create API token</a>
</section> </section>
<turbo-frame id="api-modal"></turbo-frame>
<turbo-frame id="api-modal"></turbo-frame> <script>
(function init() {
<script> // Copy new token key to clipboard
(function init() { const copyButton = document.getElementById('copy-new-token-key');
// Copy new token key to clipboard if (copyButton) {
const copyButton = document.getElementById('copy-new-token-key'); copyButton.addEventListener('click', () => {
if (copyButton) { const tokenInput = document.getElementById('new-token-key');
copyButton.addEventListener('click', () => { const tokenValue = tokenInput.value;
const tokenInput = document.getElementById('new-token-key'); navigator.clipboard.writeText(tokenValue).then(() => {
const tokenValue = tokenInput.value; copyButton.textContent = 'Copied!';
navigator.clipboard.writeText(tokenValue).then(() => { setTimeout(() => {
copyButton.textContent = 'Copied!'; copyButton.textContent = 'Copy';
setTimeout(() => { }, 2000);
copyButton.textContent = 'Copy'; }, (err) => {
}, 2000); console.error('Could not copy text: ', err);
}, (err) => { });
console.error('Could not copy text: ', err); });
}); }
}); })();
} </script>
})();
</script>
</turbo-frame> </turbo-frame>
<section aria-labelledby="rss-feeds-heading"> <section aria-labelledby="rss-feeds-heading">
<h2 id="rss-feeds-heading">RSS Feeds</h2> <h2 id="rss-feeds-heading">RSS Feeds</h2>
<p>The following URLs provide RSS feeds for your bookmarks:</p> <p>The following URLs provide RSS feeds for your bookmarks:</p>
<ul style="list-style-position: outside;"> <ul style="list-style-position: outside;">
<li><a target="_blank" href="{{ all_feed_url }}">All bookmarks</a></li> <li>
<li><a target="_blank" href="{{ unread_feed_url }}">Unread bookmarks</a></li> <a target="_blank" href="{{ all_feed_url }}">All bookmarks</a>
<li><a target="_blank" href="{{ shared_feed_url }}">Shared bookmarks</a></li> </li>
<li><a target="_blank" href="{{ public_shared_feed_url }}">Public shared bookmarks</a><br><span <li>
class="text-small text-secondary">The public shared feed does not contain an authentication token and can be shared with other people. Only shows shared bookmarks from users who have explicitly enabled public sharing.</span> <a target="_blank" href="{{ unread_feed_url }}">Unread bookmarks</a>
</li>
<li>
<a target="_blank" href="{{ shared_feed_url }}">Shared bookmarks</a>
</li>
<li>
<a target="_blank" href="{{ public_shared_feed_url }}">Public shared bookmarks</a>
<br>
<span class="text-small text-secondary">The public shared feed does not contain an authentication token and can be shared with other people. Only shows shared bookmarks from users who have explicitly enabled public sharing.</span>
</li> </li>
</ul> </ul>
<p> <p>All URLs support the following URL parameters:</p>
All URLs support the following URL parameters:
</p>
<ul style="list-style-position: outside;"> <ul style="list-style-position: outside;">
<li>A <code>limit</code> parameter for specifying the maximum number of bookmarks to include in the feed. By <li>
A <code>limit</code> parameter for specifying the maximum number of bookmarks to include in the feed. By
default, only the latest 100 matching bookmarks are included. default, only the latest 100 matching bookmarks are included.
</li> </li>
<li>A <code>q</code> URL parameter for specifying a search query. You can get an example by doing a search in <li>
A <code>q</code> URL parameter for specifying a search query. You can get an example by doing a search in
the bookmarks view and then copying the parameter from the URL. the bookmarks view and then copying the parameter from the URL.
</li> </li>
<li>An <code>unread</code> parameter for filtering for unread or read bookmarks. Use <code>yes</code> for unread <li>
An <code>unread</code> parameter for filtering for unread or read bookmarks. Use <code>yes</code> for unread
bookmarks and <code>no</code> for read bookmarks. bookmarks and <code>no</code> for read bookmarks.
</li> </li>
<li>A <code>shared</code> parameter for filtering for shared or unshared bookmarks. Use <code>yes</code> for <li>
A <code>shared</code> parameter for filtering for shared or unshared bookmarks. Use <code>yes</code> for
shared bookmarks and <code>no</code> for unshared bookmarks. shared bookmarks and <code>no</code> for unshared bookmarks.
</li> </li>
</ul> </ul>
<p> <p>
<strong>Please note that these URLs include an authentication token that should be treated like any other <strong>Please note that these URLs include an authentication token that should be treated like any other
credential.</strong> credential.</strong>
Any party with access to these URLs can read all your bookmarks. Any party with access to these URLs can read all your bookmarks.
If you think that a URL was compromised you can delete the feed token for your user in the <a If you think that a URL was compromised you can delete the feed token for your user in the <a target="_blank"
target="_blank" href="{% url 'admin:bookmarks_feedtoken_changelist' %}">admin panel</a>. href="{% url 'admin:bookmarks_feedtoken_changelist' %}">admin panel</a>.
After deleting the feed token, new URLs will be generated when you reload this settings page. After deleting the feed token, new URLs will be generated when you reload this settings page.
</p> </p>
</section> </section>

View File

@@ -1,6 +1,10 @@
{% load i18n %} {% load i18n %}
{# Force rendering validation errors in English language to align with the rest of the app #} {# Force rendering validation errors in English language to align with the rest of the app #}
{% language 'en-us' %} {% language 'en-us' %}
{% if errors %}<ul class="{{ error_class }}"{% if errors.field_id %} id="{{ errors.field_id }}_error"{% endif %}>{% for error in errors %}<li>{{ error }}</li>{% endfor %}</ul>{% endif %} {% if errors %}
{% endlanguage %} <ul class="{{ error_class }}"
{% if errors.field_id %}id="{{ errors.field_id }}_error"{% endif %}>
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
</ul>
{% endif %}
{% endlanguage %}

View File

@@ -0,0 +1,64 @@
{% load static %}
<head>
<meta charset="UTF-8">
<link rel="icon" href="{% static 'favicon.ico' %}" sizes="48x48">
<link rel="icon"
href="{% static 'favicon.svg' %}"
sizes="any"
type="image/svg+xml">
<link rel="apple-touch-icon"
sizes="180x180"
href="{% static 'apple-touch-icon.png' %}">
<link rel="mask-icon"
href="{% static 'safari-pinned-tab.svg' %}"
color="#5856e0">
<link rel="manifest" href="{% url 'linkding:manifest' %}">
<link rel="search"
type="application/opensearchdescription+xml"
title="Linkding"
href="{% url 'linkding:opensearch' %}" />
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="viewport"
content="width=device-width, initial-scale=1.0, minimal-ui">
<meta name="description" content="Self-hosted bookmark service">
<meta name="robots" content="index,follow">
<meta name="author" content="Sascha Ißbrücker">
<title>{{ page_title|default:'Linkding' }}</title>
{# Include specific theme variant based on user profile setting #}
{% if request.user_profile.theme == 'light' %}
<link href="{% static 'theme-light.css' %}?v={{ app_version }}"
rel="stylesheet"
type="text/css" />
<meta name="theme-color" content="#5856e0">
{% elif request.user_profile.theme == 'dark' %}
<link href="{% static 'theme-dark.css' %}?v={{ app_version }}"
rel="stylesheet"
type="text/css" />
<meta name="theme-color" content="#161822">
{% else %}
{# Use auto theme as fallback #}
<link href="{% static 'theme-dark.css' %}?v={{ app_version }}"
rel="stylesheet"
type="text/css"
media="(prefers-color-scheme: dark)" />
<link href="{% static 'theme-light.css' %}?v={{ app_version }}"
rel="stylesheet"
type="text/css"
media="(prefers-color-scheme: light)" />
<meta name="theme-color"
media="(prefers-color-scheme: dark)"
content="#161822">
<meta name="theme-color"
media="(prefers-color-scheme: light)"
content="#5856e0">
{% endif %}
{% if request.user_profile.custom_css %}
<link href="{% url 'linkding:custom_css' %}?hash={{ request.user_profile.custom_css_hash }}"
rel="stylesheet"
type="text/css" />
{% endif %}
<meta name="turbo-cache-control" content="no-preview">
{% if not request.global_settings.enable_link_prefetch %}<meta name="turbo-prefetch" content="false">{% endif %}
{% if rss_feed_url %}<link rel="alternate" type="application/rss+xml" href="{{ rss_feed_url }}" />{% endif %}
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
</head>

View File

@@ -0,0 +1,81 @@
{% load static %}
<!DOCTYPE html>
{# Use data attributes as storage for access in static scripts #}
<html lang="en" data-api-base-url="{% url 'linkding:api-root' %}">
{% block head %}
{% include 'shared/head.html' %}
{% endblock %}
<body>
<div class="d-none">
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ld-icon-unread" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M3 19a9 9 0 0 1 9 0a9 9 0 0 1 9 0"></path>
<path d="M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0"></path>
<path d="M3 6l0 13"></path>
<path d="M12 6l0 13"></path>
<path d="M21 6l0 13"></path>
</symbol>
</svg>
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ld-icon-share" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M6 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M18 6m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M18 18m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M8.7 10.7l6.6 -3.4"></path>
<path d="M8.7 13.3l6.6 3.4"></path>
</symbol>
</svg>
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ld-icon-note" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M5 3m0 2a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z"></path>
<path d="M9 7l6 0"></path>
<path d="M9 11l6 0"></path>
<path d="M9 15l4 0"></path>
</symbol>
</svg>
</div>
<header class="container">
{% if has_toasts %}
<div class="message-list">
<form action="{% url 'linkding:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}"
method="post">
{% csrf_token %}
{% for toast in toast_messages %}
<div class="toast d-flex">
{{ toast.message }}
<button type="submit"
name="toast"
value="{{ toast.id }}"
class="btn btn-clear"></button>
</div>
{% endfor %}
</form>
</div>
{% endif %}
<div class="d-flex justify-between">
<a href="{% url 'linkding:root' %}" class="app-link d-flex align-center">
<img class="app-logo" src="{% static 'logo.png' %}" alt="Application logo">
<span class="app-name">LINKDING</span>
</a>
<nav>
{% if request.user.is_authenticated %}
{# Only show nav items menu when logged in #}
{% include 'shared/nav_menu.html' %}
{% else %}
{# Otherwise show login link #}
<a href="{% url 'login' %}" class="btn btn-link">Login</a>
{% endif %}
</nav>
</div>
</header>
<div class="content container">
{% block content %}{% endblock %}
</div>
<div class="modals">
{% block overlays %}{% endblock %}
</div>
</body>
</html>

View File

@@ -1,9 +1,5 @@
{% if messages %} {% if messages %}
<div class="message-list"> <div class="message-list">
{% for message in messages %} {% for message in messages %}<div class="toast toast-{{ message.tags }}" role="alert">{{ message }}</div>{% endfor %}
<div class="toast toast-{{ message.tags }}" role="alert">
{{ message }}
</div>
{% endfor %}
</div> </div>
{% endif %} {% endif %}

View File

@@ -1,8 +1,18 @@
<div class="modal-header"> <div class="modal-header">
<h2 class="title">{{ title }}</h2> <h2 class="title">{{ title }}</h2>
<button type="button" class="btn btn-noborder close" aria-label="Close dialog" data-close-modal> <button type="button"
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" class="btn btn-noborder close"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> aria-label="Close dialog"
data-close-modal>
<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 stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path> <path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path> <path d="M6 6l12 12"></path>

View File

@@ -0,0 +1,128 @@
{% load shared %}
{% htmlmin %}
{# Basic menu list #}
<div class="hide-md">
<a href="{% url 'linkding:bookmarks.new' %}"
class="btn btn-primary mr-2">Add bookmark</a>
<ld-dropdown class="dropdown">
<button class="btn btn-link dropdown-toggle" tabindex="0">Bookmarks</button>
<ul class="menu" role="list" tabindex="-1">
<li class="menu-item">
<a href="{% url 'linkding:bookmarks.index' %}" class="menu-link">Active</a>
</li>
<li class="menu-item">
<a href="{% url 'linkding:bookmarks.archived' %}" class="menu-link">Archived</a>
</li>
{% if request.user_profile.enable_sharing %}
<li class="menu-item">
<a href="{% url 'linkding:bookmarks.shared' %}" class="menu-link">Shared</a>
</li>
{% endif %}
<li class="menu-item">
<a href="{% url 'linkding:bookmarks.index' %}?unread=yes"
class="menu-link">Unread</a>
</li>
<li class="menu-item">
<a href="{% url 'linkding:bookmarks.index' %}?q=!untagged"
class="menu-link">Untagged</a>
</li>
</ul>
</ld-dropdown>
<ld-dropdown class="dropdown">
<button class="btn btn-link dropdown-toggle" tabindex="0">Settings</button>
<ul class="menu" role="list" tabindex="-1">
<li class="menu-item">
<a href="{% url 'linkding:settings.general' %}" class="menu-link">General</a>
</li>
<li class="menu-item">
<a href="{% url 'linkding:settings.integrations' %}" class="menu-link">Integrations</a>
</li>
{% if request.user.is_superuser %}
<li class="menu-item">
<a href="{% url 'admin:index' %}" class="menu-link" data-turbo="false">Admin</a>
</li>
{% endif %}
</ul>
</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>
</form>
</div>
{# Menu drop-down for smaller devices #}
<div class="show-md">
<a href="{% url 'linkding:bookmarks.new' %}"
aria-label="Add bookmark"
class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
style="width: 24px;
height: 24px">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
</a>
<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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
<!-- menu component -->
<ul class="menu" role="list" tabindex="-1">
<li class="menu-item">
<a href="{% url 'linkding:bookmarks.index' %}" class="menu-link">Bookmarks</a>
</li>
<li class="menu-item">
<a href="{% url 'linkding:bookmarks.archived' %}" class="menu-link">Archived bookmarks</a>
</li>
{% if request.user_profile.enable_sharing %}
<li class="menu-item">
<a href="{% url 'linkding:bookmarks.shared' %}" class="menu-link">Shared bookmarks</a>
</li>
{% endif %}
<li class="menu-item">
<a href="{% url 'linkding:bookmarks.index' %}?unread=yes"
class="menu-link">Unread</a>
</li>
<li class="menu-item">
<a href="{% url 'linkding:bookmarks.index' %}?q=!untagged"
class="menu-link">Untagged</a>
</li>
<div class="divider"></div>
<li class="menu-item">
<a href="{% url 'linkding:settings.general' %}" class="menu-link">Settings</a>
</li>
<li class="menu-item">
<a href="{% url 'linkding:settings.integrations' %}" class="menu-link">Integrations</a>
</li>
{% if request.user.is_superuser %}
<li class="menu-item">
<a href="{% url 'admin:index' %}" class="menu-link" data-turbo="false">Admin</a>
</li>
{% endif %}
<div class="divider"></div>
<li class="menu-item">
<form class="d-inline"
action="{% url 'logout' %}"
method="post"
data-turbo="false">
{% csrf_token %}
<button type="submit" class="btn btn-link menu-link">Logout</button>
</form>
</li>
</ul>
</ld-dropdown>
</div>
{% endhtmlmin %}

View File

@@ -1,5 +1,4 @@
{% load shared %} {% load shared %}
<ul class="pagination"> <ul class="pagination">
{% if prev_link %} {% if prev_link %}
<li class="page-item"> <li class="page-item">
@@ -10,7 +9,6 @@
<a href="#" tabindex="-1">Previous</a> <a href="#" tabindex="-1">Previous</a>
</li> </li>
{% endif %} {% endif %}
{% for page_link in page_links %} {% for page_link in page_links %}
{% if page_link %} {% if page_link %}
<li class="page-item {% if page_link.active %}active{% endif %}"> <li class="page-item {% if page_link.active %}active{% endif %}">
@@ -22,7 +20,6 @@
</li> </li>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if next_link %} {% if next_link %}
<li class="page-item"> <li class="page-item">
<a href="{{ next_link }}" tabindex="-1">Next</a> <a href="{{ next_link }}" tabindex="-1">Next</a>
@@ -32,4 +29,4 @@
<a href="#" tabindex="-1">Next</a> <a href="#" tabindex="-1">Next</a>
</li> </li>
{% endif %} {% endif %}
</ul> </ul>

View File

@@ -0,0 +1,6 @@
<html lang="en">
{% include 'shared/head.html' %}
<body>
<!--content-->
</body>
</html>

View File

@@ -1,19 +1,21 @@
<turbo-frame id="tag-modal"> <turbo-frame id="tag-modal">
<form method="post" action="{% url 'linkding:tags.edit' tag.id %}" data-turbo-frame="_top" novalidate> <form method="post"
{% csrf_token %} action="{% url 'linkding:tags.edit' tag.id %}"
<ld-modal class="modal tag-edit-modal active" data-close-url="{% url 'linkding:tags.index' %}" data-turbo-frame="_top"
data-turbo-frame="tag-modal"> novalidate>
<div class="modal-overlay" data-close-modal></div> {% csrf_token %}
<div class="modal-container" role="dialog" aria-modal="true"> <ld-modal class="modal tag-edit-modal active"
{% include 'shared/modal_header.html' with title="Edit Tag" %} data-close-url="{% url 'linkding:tags.index' %}"
<div class="modal-body"> data-turbo-frame="tag-modal">
{% include 'tags/form.html' %} <div class="modal-overlay" data-close-modal></div>
</div> <div class="modal-container" role="dialog" aria-modal="true">
<div class="modal-footer d-flex justify-between"> {% include 'shared/modal_header.html' with title="Edit Tag" %}
<button type="button" class="btn btn-wide" data-close-modal>Cancel</button> <div class="modal-body">{% include 'tags/form.html' %}</div>
<button type="submit" class="btn btn-primary btn-wide">Save</button> <div class="modal-footer d-flex justify-between">
</div> <button type="button" class="btn btn-wide" data-close-modal>Cancel</button>
<button type="submit" class="btn btn-primary btn-wide">Save</button>
</div> </div>
</ld-modal> </div>
</form> </ld-modal>
</form>
</turbo-frame> </turbo-frame>

View File

@@ -1,14 +1,9 @@
{% load widget_tweaks %} {% load widget_tweaks %}
<div class="form-group {% if form.name.errors %}has-error{% endif %}"> <div class="form-group {% if form.name.errors %}has-error{% endif %}">
<label class="form-label" for="{{ form.name.id_for_label }}">Name</label> <label class="form-label" for="{{ form.name.id_for_label }}">Name</label>
{{ form.name|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: "|attr:"autofocus" }} {{ form.name|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: "|attr:"autofocus" }}
<div class="form-input-hint">Tag names are case-insensitive and cannot contain spaces (spaces will be replaced with <div class="form-input-hint">
hyphens). Tag names are case-insensitive and cannot contain spaces (spaces will be replaced with hyphens).
</div> </div>
{% if form.name.errors %} {% if form.name.errors %}<div class="form-input-hint">{{ form.name.errors }}</div>{% endif %}
<div class="form-input-hint">
{{ form.name.errors }}
</div>
{% endif %}
</div> </div>

View File

@@ -1,26 +1,24 @@
{% extends "bookmarks/layout.html" %} {% extends "shared/layout.html" %}
{% load shared %} {% load shared %}
{% load pagination %} {% load pagination %}
{% block head %} {% block head %}
{% with page_title="Tags - Linkding" %} {% with page_title="Tags - Linkding" %}{{ block.super }}{% endwith %}
{{ block.super }}
{% endwith %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="tags-page crud-page"> <div class="tags-page crud-page">
<main aria-labelledby="main-heading"> <main aria-labelledby="main-heading">
<div class="crud-header"> <div class="crud-header">
<h1 id="main-heading">Tags</h1> <h1 id="main-heading">Tags</h1>
<div class="d-flex gap-2 ml-auto"> <div class="d-flex gap-2 ml-auto">
<a href="{% url 'linkding:tags.new' %}" data-turbo-frame="tag-modal" class="btn">Create Tag</a> <a href="{% url 'linkding:tags.new' %}"
<a href="{% url 'linkding:tags.merge' %}" data-turbo-frame="tag-modal" class="btn">Merge Tags</a> data-turbo-frame="tag-modal"
class="btn">Create Tag</a>
<a href="{% url 'linkding:tags.merge' %}"
data-turbo-frame="tag-modal"
class="btn">Merge Tags</a>
</div> </div>
</div> </div>
{% include 'shared/messages.html' %} {% include 'shared/messages.html' %}
{# Filters #} {# Filters #}
<div class="crud-filters"> <div class="crud-filters">
<ld-form data-form-reset> <ld-form data-form-reset>
@@ -28,7 +26,11 @@
<div class="form-group"> <div class="form-group">
<label class="form-label text-assistive" for="search">Search tags</label> <label class="form-label text-assistive" for="search">Search tags</label>
<div class="input-group"> <div class="input-group">
<input type="text" id="search" name="search" value="{{ search }}" placeholder="Search tags..." <input type="text"
id="search"
name="search"
value="{{ search }}"
placeholder="Search tags..."
class="form-input"> class="form-input">
<button type="submit" class="btn input-group-btn">Search</button> <button type="submit" class="btn input-group-btn">Search</button>
</div> </div>
@@ -36,12 +38,21 @@
<div class="form-group"> <div class="form-group">
<label class="form-label text-assistive" for="sort">Sort by</label> <label class="form-label text-assistive" for="sort">Sort by</label>
<div class="input-group"> <div class="input-group">
<span class="input-group-addon text-secondary"> <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" <svg xmlns="http://www.w3.org/2000/svg"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path width="20"
stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 9l4 -4l4 4m-4 -4v14"/><path height="20"
d="M21 15l-4 4l-4 -4m4 4v-14"/></svg> viewBox="0 0 24 24"
</span> 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" data-submit-on-change> <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-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="name-desc" {% if sort == "name-desc" %}selected{% endif %}>Name Z-A</option>
@@ -52,7 +63,10 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-checkbox"> <label class="form-checkbox">
<input type="checkbox" name="unused" value="true" {% if unused_only %}checked{% endif %} <input type="checkbox"
name="unused"
value="true"
{% if unused_only %}checked{% endif %}
data-submit-on-change> data-submit-on-change>
<i class="form-icon"></i> Show only unused tags <i class="form-icon"></i> Show only unused tags
</label> </label>
@@ -68,50 +82,46 @@
{% endif %} {% endif %}
</p> </p>
</div> </div>
{# Tags List #} {# Tags List #}
{% if page.object_list %} {% if page.object_list %}
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
<table class="table crud-table"> <table class="table crud-table">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Name</th>
<th style="width: 25%">Bookmarks</th> <th style="width: 25%">Bookmarks</th>
<th class="actions"> <th class="actions">
<span class="text-assistive">Actions</span> <span class="text-assistive">Actions</span>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for tag in page.object_list %} {% for tag in page.object_list %}
<tr> <tr>
<td> <td>{{ tag.name }}</td>
{{ tag.name }} <td style="width: 25%">
</td> <a class="btn btn-link"
<td style="width: 25%"> href="{% url 'linkding:bookmarks.index' %}?q=%23{{ tag.name|urlencode }}">
<a class="btn btn-link" href="{% url 'linkding:bookmarks.index' %}?q=%23{{ tag.name|urlencode }}"> {{ tag.bookmark_count }}
{{ tag.bookmark_count }} </a>
</a> </td>
</td> <td class="actions">
<td class="actions"> <a class="btn btn-link"
<a class="btn btn-link" href="{% url 'linkding:tags.edit' tag.id %}" href="{% url 'linkding:tags.edit' tag.id %}"
data-turbo-frame="tag-modal">Edit</a> data-turbo-frame="tag-modal">Edit</a>
<button type="submit" name="delete_tag" value="{{ tag.id }}" class="btn btn-link text-error" <button type="submit"
data-confirm> name="delete_tag"
Remove value="{{ tag.id }}"
</button> class="btn btn-link text-error"
</td> data-confirm>Remove</button>
</tr> </td>
{% endfor %} </tr>
{% endfor %}
</tbody> </tbody>
</table> </table>
</form> </form>
{% pagination page %} {% pagination page %}
{% else %} {% else %}
<div class="empty"> <div class="empty">
{% if search or unused_only %} {% if search or unused_only %}

View File

@@ -1,60 +1,57 @@
{% load widget_tweaks %} {% load widget_tweaks %}
<turbo-frame id="tag-modal"> <turbo-frame id="tag-modal">
<form method="post" action="{% url 'linkding:tags.merge' %}" data-turbo-frame="_top" novalidate> <form method="post"
{% csrf_token %} action="{% url 'linkding:tags.merge' %}"
<ld-modal class="modal active" data-close-url="{% url 'linkding:tags.index' %}" data-turbo-frame="tag-modal"> data-turbo-frame="_top"
<div class="modal-overlay" data-close-modal></div> novalidate>
<div class="modal-container" role="dialog" aria-modal="true"> {% csrf_token %}
{% include 'shared/modal_header.html' with title="Merge Tags" %} <ld-modal class="modal active"
<div class="modal-body"> data-close-url="{% url 'linkding:tags.index' %}"
<details class="mb-4"> data-turbo-frame="tag-modal">
<summary> <div class="modal-overlay" data-close-modal></div>
<span class="text-bold mb-1">How to merge tags</span> <div class="modal-container" role="dialog" aria-modal="true">
</summary> {% include 'shared/modal_header.html' with title="Merge Tags" %}
<ol> <div class="modal-body">
<li>Enter the name of the tag you want to keep</li> <details class="mb-4">
<li>Enter the names of tags to merge into the target tag</li> <summary>
<li>The target tag is added to all bookmarks that have any of the merge tags</li> <span class="text-bold mb-1">How to merge tags</span>
<li>The merged tags are deleted</li> </summary>
</ol> <ol>
</details> <li>Enter the name of the tag you want to keep</li>
<li>Enter the names of tags to merge into the target tag</li>
<div class="form-group {% if form.target_tag.errors %}has-error{% endif %}"> <li>The target tag is added to all bookmarks that have any of the merge tags</li>
<label for="{{ form.target_tag.id_for_label }}" class="form-label">Target tag</label> <li>The merged tags are deleted</li>
<ld-tag-autocomplete input-id="{{ form.target_tag.auto_id }}" input-name="{{ form.target_tag.html_name }}" </ol>
input-value="{{ form.target_tag.value|default_if_none:'' }}"> </details>
</ld-tag-autocomplete> <div class="form-group {% if form.target_tag.errors %}has-error{% endif %}">
<div class="form-input-hint"> <label for="{{ form.target_tag.id_for_label }}" class="form-label">Target tag</label>
Enter the name of the tag you want to keep. The tags entered below will be merged into this one. <ld-tag-autocomplete input-id="{{ form.target_tag.auto_id }}"
</div> input-name="{{ form.target_tag.html_name }}"
{% if form.target_tag.errors %} input-value="{{ form.target_tag.value|default_if_none:'' }}">
<div class="form-input-hint"> </ld-tag-autocomplete>
{{ form.target_tag.errors }} <div class="form-input-hint">
</div> Enter the name of the tag you want to keep. The tags entered below will be merged into this one.
{% endif %}
</div>
<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>
<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>
{% if form.merge_tags.errors %}
<div class="form-input-hint">
{{ form.merge_tags.errors }}
</div>
{% endif %}
</div> </div>
{% if form.target_tag.errors %}<div class="form-input-hint">{{ form.target_tag.errors }}</div>{% endif %}
</div> </div>
<div class="modal-footer d-flex justify-between"> <div class="form-group {% if form.merge_tags.errors %}has-error{% endif %}">
<button type="button" class="btn btn-wide" data-close-modal>Cancel</button> <label for="{{ form.merge_tags.id_for_label }}" class="form-label">Tags to merge</label>
<button type="submit" class="btn btn-primary btn-wide">Merge Tags</button> <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>
{% if form.merge_tags.errors %}<div class="form-input-hint">{{ form.merge_tags.errors }}</div>{% endif %}
</div> </div>
</div> </div>
</ld-modal> <div class="modal-footer d-flex justify-between">
</form> <button type="button" class="btn btn-wide" data-close-modal>Cancel</button>
<button type="submit" class="btn btn-primary btn-wide">Merge Tags</button>
</div>
</div>
</ld-modal>
</form>
</turbo-frame> </turbo-frame>

View File

@@ -1,19 +1,21 @@
<turbo-frame id="tag-modal"> <turbo-frame id="tag-modal">
<form method="post" action="{% url 'linkding:tags.new' %}" data-turbo-frame="_top" novalidate> <form method="post"
{% csrf_token %} action="{% url 'linkding:tags.new' %}"
<ld-modal class="modal tag-edit-modal active" data-close-url="{% url 'linkding:tags.index' %}" data-turbo-frame="_top"
data-turbo-frame="tag-modal"> novalidate>
<div class="modal-overlay" data-close-modal></div> {% csrf_token %}
<div class="modal-container" role="dialog" aria-modal="true"> <ld-modal class="modal tag-edit-modal active"
{% include 'shared/modal_header.html' with title="Create Tag" %} data-close-url="{% url 'linkding:tags.index' %}"
<div class="modal-body"> data-turbo-frame="tag-modal">
{% include 'tags/form.html' %} <div class="modal-overlay" data-close-modal></div>
</div> <div class="modal-container" role="dialog" aria-modal="true">
<div class="modal-footer d-flex justify-between"> {% include 'shared/modal_header.html' with title="Create Tag" %}
<button type="button" class="btn btn-wide" data-close-modal>Cancel</button> <div class="modal-body">{% include 'tags/form.html' %}</div>
<button type="submit" class="btn btn-primary btn-wide">Save</button> <div class="modal-footer d-flex justify-between">
</div> <button type="button" class="btn btn-wide" data-close-modal>Cancel</button>
<button type="submit" class="btn btn-primary btn-wide">Save</button>
</div> </div>
</ld-modal> </div>
</form> </ld-modal>
</form>
</turbo-frame> </turbo-frame>

View File

@@ -9,9 +9,7 @@ NUM_ADJACENT_PAGES = 2
register = template.Library() register = template.Library()
@register.inclusion_tag( @register.inclusion_tag("shared/pagination.html", name="pagination", takes_context=True)
"bookmarks/pagination.html", name="pagination", takes_context=True
)
def pagination(context, page: Page): def pagination(context, page: Page):
request = context["request"] request = context["request"]
base_url = request.path base_url = request.path

View File

@@ -448,7 +448,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
soup = self.make_soup(html) soup = self.make_soup(html)
bookmark_list = soup.select_one("ul.bookmark-list") bookmark_list = soup.select_one("ul.bookmark-list")
style = bookmark_list["style"] style = bookmark_list["style"]
self.assertIn("--ld-bookmark-description-max-lines:1;", style) self.assertIn("--ld-bookmark-description-max-lines:1", style)
profile = self.get_or_create_test_user().profile profile = self.get_or_create_test_user().profile
profile.bookmark_description_max_lines = 3 profile.bookmark_description_max_lines = 3
@@ -458,7 +458,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
soup = self.make_soup(html) soup = self.make_soup(html)
bookmark_list = soup.select_one("ul.bookmark-list") bookmark_list = soup.select_one("ul.bookmark-list")
style = bookmark_list["style"] style = bookmark_list["style"]
self.assertIn("--ld-bookmark-description-max-lines:3;", style) self.assertIn("--ld-bookmark-description-max-lines:3", style)
def test_bookmark_tag_ordering(self): def test_bookmark_tag_ordering(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()

View File

@@ -48,7 +48,7 @@ class TagsEditViewTestCase(TestCase, BookmarkFactoryMixin):
reverse("linkding:tags.edit", args=[tag.id]), {"name": ""} reverse("linkding:tags.edit", args=[tag.id]), {"name": ""}
) )
self.assertContains(response, "This field is required", status_code=422) self.assertContains(response, "This field is required")
tag.refresh_from_db() tag.refresh_from_db()
self.assertEqual(tag.name, "tag1") self.assertEqual(tag.name, "tag1")
@@ -60,9 +60,7 @@ class TagsEditViewTestCase(TestCase, BookmarkFactoryMixin):
reverse("linkding:tags.edit", args=[tag1.id]), {"name": "tag2"} reverse("linkding:tags.edit", args=[tag1.id]), {"name": "tag2"}
) )
self.assertContains( self.assertContains(response, "Tag &quot;tag2&quot; already exists")
response, "Tag &quot;tag2&quot; already exists", status_code=422
)
tag1.refresh_from_db() tag1.refresh_from_db()
self.assertEqual(tag1.name, "tag1") self.assertEqual(tag1.name, "tag1")
@@ -74,9 +72,7 @@ class TagsEditViewTestCase(TestCase, BookmarkFactoryMixin):
reverse("linkding:tags.edit", args=[tag1.id]), {"name": "TAG2"} reverse("linkding:tags.edit", args=[tag1.id]), {"name": "TAG2"}
) )
self.assertContains( self.assertContains(response, "Tag &quot;TAG2&quot; already exists")
response, "Tag &quot;TAG2&quot; already exists", status_code=422
)
tag1.refresh_from_db() tag1.refresh_from_db()
self.assertEqual(tag1.name, "tag1") self.assertEqual(tag1.name, "tag1")

View File

@@ -113,7 +113,6 @@ class TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
reverse("linkding:tags.merge"), reverse("linkding:tags.merge"),
{"target_tag": "target_tag", "merge_tags": "merge_tag"}, {"target_tag": "target_tag", "merge_tags": "merge_tag"},
) )
self.assertEqual(response.status_code, 422)
target_tag_group = self.get_form_group(response, "target_tag") target_tag_group = self.get_form_group(response, "target_tag")
self.assertIn( self.assertIn(
@@ -131,8 +130,6 @@ class TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
{"target_tag": "", "merge_tags": "merge_tag"}, {"target_tag": "", "merge_tags": "merge_tag"},
) )
self.assertEqual(response.status_code, 422)
target_tag_group = self.get_form_group(response, "target_tag") target_tag_group = self.get_form_group(response, "target_tag")
self.assertIn("This field is required", self.get_text(target_tag_group)) self.assertIn("This field is required", self.get_text(target_tag_group))
self.assertTrue(Tag.objects.filter(id=merge_tag.id).exists()) self.assertTrue(Tag.objects.filter(id=merge_tag.id).exists())
@@ -145,7 +142,6 @@ class TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
{"target_tag": "target_tag", "merge_tags": ""}, {"target_tag": "target_tag", "merge_tags": ""},
) )
self.assertEqual(response.status_code, 422)
merge_tags_group = self.get_form_group(response, "merge_tags") merge_tags_group = self.get_form_group(response, "merge_tags")
self.assertIn("This field is required", self.get_text(merge_tags_group)) self.assertIn("This field is required", self.get_text(merge_tags_group))
@@ -157,8 +153,6 @@ class TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
{"target_tag": "nonexistent_tag", "merge_tags": "merge_tag"}, {"target_tag": "nonexistent_tag", "merge_tags": "merge_tag"},
) )
self.assertEqual(response.status_code, 422)
target_tag_group = self.get_form_group(response, "target_tag") target_tag_group = self.get_form_group(response, "target_tag")
self.assertIn( self.assertIn(
'Tag "nonexistent_tag" does not exist', self.get_text(target_tag_group) 'Tag "nonexistent_tag" does not exist', self.get_text(target_tag_group)
@@ -173,7 +167,6 @@ class TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
{"target_tag": "target_tag", "merge_tags": "merge_tag1 nonexistent_tag"}, {"target_tag": "target_tag", "merge_tags": "merge_tag1 nonexistent_tag"},
) )
self.assertEqual(response.status_code, 422)
merge_tags_group = self.get_form_group(response, "merge_tags") merge_tags_group = self.get_form_group(response, "merge_tags")
self.assertIn( self.assertIn(
'Tag "nonexistent_tag" does not exist', self.get_text(merge_tags_group) 'Tag "nonexistent_tag" does not exist', self.get_text(merge_tags_group)
@@ -188,8 +181,6 @@ class TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
{"target_tag": "target_tag1 target_tag2", "merge_tags": "some_tag"}, {"target_tag": "target_tag1 target_tag2", "merge_tags": "some_tag"},
) )
self.assertEqual(response.status_code, 422)
target_tag_group = self.get_form_group(response, "target_tag") target_tag_group = self.get_form_group(response, "target_tag")
self.assertIn( self.assertIn(
"Please enter only one tag name for the target tag", "Please enter only one tag name for the target tag",
@@ -205,8 +196,6 @@ class TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
{"target_tag": "target_tag", "merge_tags": "target_tag merge_tag"}, {"target_tag": "target_tag", "merge_tags": "target_tag merge_tag"},
) )
self.assertEqual(response.status_code, 422)
merge_tags_group = self.get_form_group(response, "merge_tags") merge_tags_group = self.get_form_group(response, "merge_tags")
self.assertIn( self.assertIn(
"The target tag cannot be selected for merging", "The target tag cannot be selected for merging",

View File

@@ -20,7 +20,7 @@ class TagsNewViewTestCase(TestCase, BookmarkFactoryMixin):
def test_show_error_for_empty_name(self): def test_show_error_for_empty_name(self):
response = self.client.post(reverse("linkding:tags.new"), {"name": ""}) response = self.client.post(reverse("linkding:tags.new"), {"name": ""})
self.assertContains(response, "This field is required", status_code=422) self.assertContains(response, "This field is required")
self.assertEqual(Tag.objects.count(), 0) self.assertEqual(Tag.objects.count(), 0)
def test_show_error_for_duplicate_name(self): def test_show_error_for_duplicate_name(self):
@@ -30,9 +30,7 @@ class TagsNewViewTestCase(TestCase, BookmarkFactoryMixin):
reverse("linkding:tags.new"), {"name": "existing_tag"} reverse("linkding:tags.new"), {"name": "existing_tag"}
) )
self.assertContains( self.assertContains(response, "Tag &quot;existing_tag&quot; already exists")
response, "Tag &quot;existing_tag&quot; already exists", status_code=422
)
self.assertEqual(Tag.objects.count(), 1) self.assertEqual(Tag.objects.count(), 1)
def test_show_error_for_duplicate_name_different_casing(self): def test_show_error_for_duplicate_name_different_casing(self):
@@ -42,9 +40,7 @@ class TagsNewViewTestCase(TestCase, BookmarkFactoryMixin):
reverse("linkding:tags.new"), {"name": "existing_TAG"} reverse("linkding:tags.new"), {"name": "existing_TAG"}
) )
self.assertContains( self.assertContains(response, "Tag &quot;existing_TAG&quot; already exists")
response, "Tag &quot;existing_TAG&quot; already exists", status_code=422
)
self.assertEqual(Tag.objects.count(), 1) self.assertEqual(Tag.objects.count(), 1)
def test_no_error_for_duplicate_name_different_user(self): def test_no_error_for_duplicate_name_different_user(self):

View File

@@ -5,12 +5,13 @@ from django.urls import reverse
from bookmarks.models import Toast from bookmarks.models import Toast
from bookmarks.tests.helpers import ( from bookmarks.tests.helpers import (
BookmarkFactoryMixin, BookmarkFactoryMixin,
HtmlTestMixin,
random_sentence, random_sentence,
disable_logging, disable_logging,
) )
class ToastsViewTestCase(TestCase, BookmarkFactoryMixin): class ToastsViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def setUp(self) -> None: def setUp(self) -> None:
user = self.get_or_create_test_user() user = self.get_or_create_test_user()
@@ -72,11 +73,13 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
def test_form_tag(self): def test_form_tag(self):
self.create_toast() self.create_toast()
expected_form_tag = f'<form action="{reverse("linkding:toasts.acknowledge")}?return_url={reverse("linkding:bookmarks.index")}" method="post">' expected_action = f'{reverse("linkding:toasts.acknowledge")}?return_url={reverse("linkding:bookmarks.index")}'
response = self.client.get(reverse("linkding:bookmarks.index")) response = self.client.get(reverse("linkding:bookmarks.index"))
soup = self.make_soup(response.content.decode())
form = soup.find("form", attrs={"action": expected_action, "method": "post"})
self.assertContains(response, expected_form_tag) self.assertIsNotNone(form)
def test_toast_content(self): def test_toast_content(self):
toast = self.create_toast() toast = self.create_toast()

View File

@@ -35,7 +35,7 @@ from bookmarks.services.bookmarks import (
) )
from bookmarks.type_defs import HttpRequest from bookmarks.type_defs import HttpRequest
from bookmarks.utils import get_safe_return_url from bookmarks.utils import get_safe_return_url
from bookmarks.views import access, contexts, partials, turbo from bookmarks.views import access, contexts, turbo
@login_required @login_required
@@ -66,6 +66,18 @@ def index(request: HttpRequest):
) )
def index_update(request: HttpRequest):
search = BookmarkSearch.from_request(
request, request.GET, request.user_profile.search_preferences
)
bookmark_list = contexts.ActiveBookmarkListContext(request, search)
tag_cloud = contexts.ActiveTagCloudContext(request, search)
details = contexts.get_details_context(
request, contexts.ActiveBookmarkDetailsContext
)
return render_bookmarks_update(request, bookmark_list, tag_cloud, details)
@login_required @login_required
def archived(request: HttpRequest): def archived(request: HttpRequest):
if request.method == "POST": if request.method == "POST":
@@ -94,6 +106,18 @@ def archived(request: HttpRequest):
) )
def archived_update(request: HttpRequest):
search = BookmarkSearch.from_request(
request, request.GET, request.user_profile.search_preferences
)
bookmark_list = contexts.ArchivedBookmarkListContext(request, search)
tag_cloud = contexts.ArchivedTagCloudContext(request, search)
details = contexts.get_details_context(
request, contexts.ArchivedBookmarkDetailsContext
)
return render_bookmarks_update(request, bookmark_list, tag_cloud, details)
def shared(request: HttpRequest): def shared(request: HttpRequest):
if request.method == "POST": if request.method == "POST":
return search_action(request) return search_action(request)
@@ -124,16 +148,24 @@ def shared(request: HttpRequest):
) )
def shared_update(request: HttpRequest):
search = BookmarkSearch.from_request(
request, request.GET, request.user_profile.search_preferences
)
bookmark_list = contexts.SharedBookmarkListContext(request, search)
tag_cloud = contexts.SharedTagCloudContext(request, search)
details = contexts.get_details_context(
request, contexts.SharedBookmarkDetailsContext
)
return render_bookmarks_update(request, bookmark_list, tag_cloud, details)
def render_bookmarks_view(request: HttpRequest, template_name, context): def render_bookmarks_view(request: HttpRequest, template_name, context):
if context["details"]: if context["details"]:
context["page_title"] = "Bookmark details - Linkding" context["page_title"] = "Bookmark details - Linkding"
if turbo.is_frame(request, "details-modal"): if turbo.is_frame(request, "details-modal"):
return render( return turbo.frame(request, "bookmarks/details/modal.html", context)
request,
"bookmarks/updates/details-modal-frame.html",
context,
)
return render( return render(
request, request,
@@ -142,6 +174,30 @@ def render_bookmarks_view(request: HttpRequest, template_name, context):
) )
def render_bookmarks_update(request, bookmark_list, tag_cloud, details):
return turbo.stream(
turbo.update(
request,
"bookmark-list-container",
"bookmarks/bookmark_list.html",
{"bookmark_list": bookmark_list},
),
turbo.update(
request,
"tag-cloud-container",
"bookmarks/tag_cloud.html",
{"tag_cloud": tag_cloud},
),
turbo.replace(
request,
"details-modal",
"bookmarks/details/modal.html",
{"details": details},
method="morph",
),
)
def search_action(request: HttpRequest): def search_action(request: HttpRequest):
if "save" in request.POST: if "save" in request.POST:
if not request.user.is_authenticated: if not request.user.is_authenticated:
@@ -272,7 +328,7 @@ def index_action(request: HttpRequest):
return response return response
if turbo.accept(request): if turbo.accept(request):
return partials.active_bookmark_update(request) return index_update(request)
return utils.redirect_with_query(request, reverse("linkding:bookmarks.index")) return utils.redirect_with_query(request, reverse("linkding:bookmarks.index"))
@@ -289,7 +345,7 @@ def archived_action(request: HttpRequest):
return response return response
if turbo.accept(request): if turbo.accept(request):
return partials.archived_bookmark_update(request) return archived_update(request)
return utils.redirect_with_query(request, reverse("linkding:bookmarks.archived")) return utils.redirect_with_query(request, reverse("linkding:bookmarks.archived"))
@@ -304,7 +360,7 @@ def shared_action(request: HttpRequest):
return response return response
if turbo.accept(request): if turbo.accept(request):
return partials.shared_bookmark_update(request) return shared_update(request)
return utils.redirect_with_query(request, reverse("linkding:bookmarks.shared")) return utils.redirect_with_query(request, reverse("linkding:bookmarks.shared"))

View File

@@ -1,50 +0,0 @@
from bookmarks.models import BookmarkSearch
from bookmarks.views import contexts, turbo
def render_bookmark_update(request, bookmark_list, tag_cloud, details):
return turbo.stream(
request,
"bookmarks/updates/bookmark_view_stream.html",
{
"bookmark_list": bookmark_list,
"tag_cloud": tag_cloud,
"details": details,
},
)
def active_bookmark_update(request):
search = BookmarkSearch.from_request(
request, request.GET, request.user_profile.search_preferences
)
bookmark_list = contexts.ActiveBookmarkListContext(request, search)
tag_cloud = contexts.ActiveTagCloudContext(request, search)
details = contexts.get_details_context(
request, contexts.ActiveBookmarkDetailsContext
)
return render_bookmark_update(request, bookmark_list, tag_cloud, details)
def archived_bookmark_update(request):
search = BookmarkSearch.from_request(
request, request.GET, request.user_profile.search_preferences
)
bookmark_list = contexts.ArchivedBookmarkListContext(request, search)
tag_cloud = contexts.ArchivedTagCloudContext(request, search)
details = contexts.get_details_context(
request, contexts.ArchivedBookmarkDetailsContext
)
return render_bookmark_update(request, bookmark_list, tag_cloud, details)
def shared_bookmark_update(request):
search = BookmarkSearch.from_request(
request, request.GET, request.user_profile.search_preferences
)
bookmark_list = contexts.SharedBookmarkListContext(request, search)
tag_cloud = contexts.SharedTagCloudContext(request, search)
details = contexts.get_details_context(
request, contexts.SharedBookmarkDetailsContext
)
return render_bookmark_update(request, bookmark_list, tag_cloud, details)

View File

@@ -78,8 +78,14 @@ def tag_new(request: HttpRequest):
messages.success(request, f'Tag "{tag.name}" created successfully.') messages.success(request, f'Tag "{tag.name}" created successfully.')
return HttpResponseRedirect(reverse("linkding:tags.index")) return HttpResponseRedirect(reverse("linkding:tags.index"))
else: else:
return turbo.replace( return turbo.stream(
request, "tag-modal", "tags/new.html", {"form": form}, status=422 turbo.replace(
request,
"tag-modal",
"tags/new.html",
{"form": form},
method="morph",
)
) )
return render(request, "tags/new.html", {"form": form}) return render(request, "tags/new.html", {"form": form})
@@ -97,12 +103,14 @@ def tag_edit(request: HttpRequest, tag_id: int):
messages.success(request, f'Tag "{tag.name}" updated successfully.') messages.success(request, f'Tag "{tag.name}" updated successfully.')
return HttpResponseRedirect(reverse("linkding:tags.index")) return HttpResponseRedirect(reverse("linkding:tags.index"))
else: else:
return turbo.replace( return turbo.stream(
request, turbo.replace(
"tag-modal", request,
"tags/edit.html", "tag-modal",
{"tag": tag, "form": form}, "tags/edit.html",
status=422, {"tag": tag, "form": form},
method="morph",
)
) )
return render(request, "tags/edit.html", {"tag": tag, "form": form}) return render(request, "tags/edit.html", {"tag": tag, "form": form})
@@ -154,12 +162,14 @@ def tag_merge(request: HttpRequest):
return HttpResponseRedirect(reverse("linkding:tags.index")) return HttpResponseRedirect(reverse("linkding:tags.index"))
else: else:
return turbo.replace( return turbo.stream(
request, turbo.replace(
"tag-modal", request,
"tags/merge.html", "tag-modal",
{"form": form}, "tags/merge.html",
status=422, {"form": form},
method="morph",
)
) )
return render(request, "tags/merge.html", {"form": form}) return render(request, "tags/merge.html", {"form": form})

View File

@@ -1,5 +1,4 @@
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.shortcuts import render as django_render
from django.template import loader from django.template import loader
@@ -14,24 +13,49 @@ def is_frame(request: HttpRequest, frame: str) -> bool:
return request.headers.get("Turbo-Frame") == frame return request.headers.get("Turbo-Frame") == frame
def stream(request: HttpRequest, template_name: str, context: dict) -> HttpResponse: def frame(request: HttpRequest, template_name: str, context: dict) -> HttpResponse:
response = django_render(request, template_name, context) """
response["Content-Type"] = "text/vnd.turbo-stream.html" Renders the specified template into an HTML skeleton including <head> with
respective metadata. The template should only contain a frame. Used for
Turbo Frame requests that modify the top frame's URL.
"""
html = loader.render_to_string("shared/top_frame.html", context, request)
content = loader.render_to_string(template_name, context, request)
html = html.replace("<!--content-->", content)
response = HttpResponse(html, status=200)
return response return response
def update(
request: HttpRequest,
target: str,
template_name: str,
context: dict,
method: str | None = "",
) -> str:
"""Render a template wrapped in an update turbo-stream element."""
content = loader.render_to_string(template_name, context, request)
method_attr = f' method="{method}"' if method else ""
return f'<turbo-stream action="update"{method_attr} target="{target}"><template>{content}</template></turbo-stream>'
def replace( def replace(
request: HttpRequest, target_id: str, template_name: str, context: dict, status=None request: HttpRequest,
) -> HttpResponse: target: str,
""" template_name: str,
Returns a Turbo steam for replacing a specific target with the rendered context: dict,
template. Mostly useful for updating forms in place after failed submissions, method: str | None = "",
without having to create a separate template. ) -> str:
""" """Render a template wrapped in a replace turbo-stream element."""
if status is None:
status = 200
content = loader.render_to_string(template_name, context, request) content = loader.render_to_string(template_name, context, request)
stream_content = f'<turbo-stream action="replace" method="morph" target="{target_id}"><template>{content}</template></turbo-stream>' method_attr = f' method="{method}"' if method else ""
response = HttpResponse(stream_content, status=status) return f'<turbo-stream action="replace"{method_attr} target="{target}"><template>{content}</template></turbo-stream>'
response["Content-Type"] = "text/vnd.turbo-stream.html"
return response
def stream(*streams: str) -> HttpResponse:
"""Combine multiple stream elements into a turbo-stream response."""
return HttpResponse(
"\n".join(streams),
status=200,
content_type="text/vnd.turbo-stream.html",
)

View File

@@ -27,6 +27,7 @@ dev = [
"black>=25.1.0", "black>=25.1.0",
"coverage>=7.10.4", "coverage>=7.10.4",
"django-debug-toolbar>=6.0.0", "django-debug-toolbar>=6.0.0",
"djlint>=1.36.4",
"playwright>=1.54.0", "playwright>=1.54.0",
"psycopg[binary]>=3.2.9", "psycopg[binary]>=3.2.9",
"pytest>=8.4.1", "pytest>=8.4.1",
@@ -47,3 +48,12 @@ postgres = [
[tool.uv] [tool.uv]
# Prefer system Python for now, less complications when copying the venv in the Docker build # Prefer system Python for now, less complications when copying the venv in the Docker build
python-preference = "system" python-preference = "system"
[tool.djlint]
custom_html="ld-\\w+,ld-\\w+-\\w+"
indent=2
format_js=true
profile="django"
[tool.djlint.js]
indent_size=2

183
uv.lock generated
View File

@@ -245,6 +245,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/bc/16e0276078c2de3ceef6b5a34b965f4436215efac45313df90d55f0ba2d2/cryptography-45.0.6-cp37-abi3-win_amd64.whl", hash = "sha256:20d15aed3ee522faac1a39fbfdfee25d17b1284bafd808e1640a74846d7c4d1b", size = 3390459, upload-time = "2025-08-05T23:59:03.358Z" }, { url = "https://files.pythonhosted.org/packages/0a/bc/16e0276078c2de3ceef6b5a34b965f4436215efac45313df90d55f0ba2d2/cryptography-45.0.6-cp37-abi3-win_amd64.whl", hash = "sha256:20d15aed3ee522faac1a39fbfdfee25d17b1284bafd808e1640a74846d7c4d1b", size = 3390459, upload-time = "2025-08-05T23:59:03.358Z" },
] ]
[[package]]
name = "cssbeautifier"
version = "1.15.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "editorconfig" },
{ name = "jsbeautifier" },
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f7/01/fdf41c1e5f93d359681976ba10410a04b299d248e28ecce1d4e88588dde4/cssbeautifier-1.15.4.tar.gz", hash = "sha256:9bb08dc3f64c101a01677f128acf01905914cf406baf87434dcde05b74c0acf5", size = 25376, upload-time = "2025-02-27T17:53:51.341Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/51/ef6c5628e46092f0a54c7cee69acc827adc6b6aab57b55d344fefbdf28f1/cssbeautifier-1.15.4-py3-none-any.whl", hash = "sha256:78c84d5e5378df7d08622bbd0477a1abdbd209680e95480bf22f12d5701efc98", size = 123667, upload-time = "2025-02-27T17:53:43.594Z" },
]
[[package]] [[package]]
name = "django" name = "django"
version = "5.2.5" version = "5.2.5"
@@ -306,6 +320,39 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" }, { url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" },
] ]
[[package]]
name = "djlint"
version = "1.36.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "colorama" },
{ name = "cssbeautifier" },
{ name = "jsbeautifier" },
{ name = "json5" },
{ name = "pathspec" },
{ name = "pyyaml" },
{ name = "regex" },
{ name = "tqdm" },
]
sdist = { url = "https://files.pythonhosted.org/packages/74/89/ecf5be9f5c59a0c53bcaa29671742c5e269cc7d0e2622e3f65f41df251bf/djlint-1.36.4.tar.gz", hash = "sha256:17254f218b46fe5a714b224c85074c099bcb74e3b2e1f15c2ddc2cf415a408a1", size = 47849, upload-time = "2024-12-24T13:06:36.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/83/88b4c885812921739f5529a29085c3762705154d41caf7eb9a8886a3380c/djlint-1.36.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ead475013bcac46095b1bbc8cf97ed2f06e83422335734363f8a76b4ba7e47c2", size = 354384, upload-time = "2024-12-24T13:06:20.809Z" },
{ url = "https://files.pythonhosted.org/packages/32/38/67695f7a150b3d9d62fadb65242213d96024151570c3cf5d966effa68b0e/djlint-1.36.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6c601dfa68ea253311deb4a29a7362b7a64933bdfcfb5a06618f3e70ad1fa835", size = 322971, upload-time = "2024-12-24T13:06:22.185Z" },
{ url = "https://files.pythonhosted.org/packages/ac/7a/cd851393291b12e7fe17cf5d4d8874b8ea133aebbe9235f5314aabc96a52/djlint-1.36.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bda5014f295002363381969864addeb2db13955f1b26e772657c3b273ed7809f", size = 410972, upload-time = "2024-12-24T13:06:24.077Z" },
{ url = "https://files.pythonhosted.org/packages/6c/31/56469120394b970d4f079a552fde21ed27702ca729595ab0ed459eb6d240/djlint-1.36.4-cp313-cp313-win_amd64.whl", hash = "sha256:16ce37e085afe5a30953b2bd87cbe34c37843d94c701fc68a2dda06c1e428ff4", size = 362053, upload-time = "2024-12-24T13:06:25.432Z" },
{ url = "https://files.pythonhosted.org/packages/4b/67/f7aeea9be6fb3bd984487af8d0d80225a0b1e5f6f7126e3332d349fb13fe/djlint-1.36.4-py3-none-any.whl", hash = "sha256:e9699b8ac3057a6ed04fb90835b89bee954ed1959c01541ce4f8f729c938afdd", size = 52290, upload-time = "2024-12-24T13:06:33.76Z" },
]
[[package]]
name = "editorconfig"
version = "0.17.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/88/3a/a61d9a1f319a186b05d14df17daea42fcddea63c213bcd61a929fb3a6796/editorconfig-0.17.1.tar.gz", hash = "sha256:23c08b00e8e08cc3adcddb825251c497478df1dada6aefeb01e626ad37303745", size = 14695, upload-time = "2025-06-09T08:21:37.097Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/fd/a40c621ff207f3ce8e484aa0fc8ba4eb6e3ecf52e15b42ba764b457a9550/editorconfig-0.17.1-py3-none-any.whl", hash = "sha256:1eda9c2c0db8c16dbd50111b710572a5e6de934e39772de1959d41f64fc17c82", size = 16360, upload-time = "2025-06-09T08:21:35.654Z" },
]
[[package]] [[package]]
name = "execnet" name = "execnet"
version = "2.1.1" version = "2.1.1"
@@ -379,6 +426,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/47/0e/e248f059986e376b87e546cd840d244370b9f7ef2b336456f0c2cfb2fef0/josepy-2.1.0-py3-none-any.whl", hash = "sha256:0eadf09b96821bdae9a8b14145425cb9fe0bbee64c6fdfce3ccd4ceb7d7efbbd", size = 29065, upload-time = "2025-07-08T17:20:53.504Z" }, { url = "https://files.pythonhosted.org/packages/47/0e/e248f059986e376b87e546cd840d244370b9f7ef2b336456f0c2cfb2fef0/josepy-2.1.0-py3-none-any.whl", hash = "sha256:0eadf09b96821bdae9a8b14145425cb9fe0bbee64c6fdfce3ccd4ceb7d7efbbd", size = 29065, upload-time = "2025-07-08T17:20:53.504Z" },
] ]
[[package]]
name = "jsbeautifier"
version = "1.15.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "editorconfig" },
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ea/98/d6cadf4d5a1c03b2136837a435682418c29fdeb66be137128544cecc5b7a/jsbeautifier-1.15.4.tar.gz", hash = "sha256:5bb18d9efb9331d825735fbc5360ee8f1aac5e52780042803943aa7f854f7592", size = 75257, upload-time = "2025-02-27T17:53:53.252Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2d/14/1c65fccf8413d5f5c6e8425f84675169654395098000d8bddc4e9d3390e1/jsbeautifier-1.15.4-py3-none-any.whl", hash = "sha256:72f65de312a3f10900d7685557f84cb61a9733c50dcc27271a39f5b0051bf528", size = 94707, upload-time = "2025-02-27T17:53:46.152Z" },
]
[[package]]
name = "json5"
version = "0.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/12/ae/929aee9619e9eba9015207a9d2c1c54db18311da7eb4dcf6d41ad6f0eb67/json5-0.12.1.tar.gz", hash = "sha256:b2743e77b3242f8d03c143dd975a6ec7c52e2f2afe76ed934e53503dd4ad4990", size = 52191, upload-time = "2025-08-12T19:47:42.583Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/85/e2/05328bd2621be49a6fed9e3030b1e51a2d04537d3f816d211b9cc53c5262/json5-0.12.1-py3-none-any.whl", hash = "sha256:d9c9b3bc34a5f54d43c35e11ef7cb87d8bdd098c6ace87117a7b7e83e705c1d5", size = 36119, upload-time = "2025-08-12T19:47:41.131Z" },
]
[[package]] [[package]]
name = "linkding" name = "linkding"
version = "1.44.2" version = "1.44.2"
@@ -406,6 +475,7 @@ dev = [
{ name = "black" }, { name = "black" },
{ name = "coverage" }, { name = "coverage" },
{ name = "django-debug-toolbar" }, { name = "django-debug-toolbar" },
{ name = "djlint" },
{ name = "playwright" }, { name = "playwright" },
{ name = "psycopg", extra = ["binary"] }, { name = "psycopg", extra = ["binary"] },
{ name = "pytest" }, { name = "pytest" },
@@ -440,6 +510,7 @@ dev = [
{ name = "black", specifier = ">=25.1.0" }, { name = "black", specifier = ">=25.1.0" },
{ name = "coverage", specifier = ">=7.10.4" }, { name = "coverage", specifier = ">=7.10.4" },
{ name = "django-debug-toolbar", specifier = ">=6.0.0" }, { name = "django-debug-toolbar", specifier = ">=6.0.0" },
{ name = "djlint", specifier = ">=1.36.4" },
{ name = "playwright", specifier = ">=1.54.0" }, { name = "playwright", specifier = ">=1.54.0" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2.9" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2.9" },
{ name = "pytest", specifier = ">=8.4.1" }, { name = "pytest", specifier = ">=8.4.1" },
@@ -663,6 +734,106 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
] ]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "regex"
version = "2025.11.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cc/a9/546676f25e573a4cf00fe8e119b78a37b6a8fe2dc95cda877b30889c9c45/regex-2025.11.3.tar.gz", hash = "sha256:1fedc720f9bb2494ce31a58a1631f9c82df6a09b49c19517ea5cc280b4541e01", size = 414669, upload-time = "2025-11-03T21:34:22.089Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/a7/dda24ebd49da46a197436ad96378f17df30ceb40e52e859fc42cac45b850/regex-2025.11.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c1e448051717a334891f2b9a620fe36776ebf3dd8ec46a0b877c8ae69575feb4", size = 489081, upload-time = "2025-11-03T21:31:55.9Z" },
{ url = "https://files.pythonhosted.org/packages/19/22/af2dc751aacf88089836aa088a1a11c4f21a04707eb1b0478e8e8fb32847/regex-2025.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9b5aca4d5dfd7fbfbfbdaf44850fcc7709a01146a797536a8f84952e940cca76", size = 291123, upload-time = "2025-11-03T21:31:57.758Z" },
{ url = "https://files.pythonhosted.org/packages/a3/88/1a3ea5672f4b0a84802ee9891b86743438e7c04eb0b8f8c4e16a42375327/regex-2025.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:04d2765516395cf7dda331a244a3282c0f5ae96075f728629287dfa6f76ba70a", size = 288814, upload-time = "2025-11-03T21:32:01.12Z" },
{ url = "https://files.pythonhosted.org/packages/fb/8c/f5987895bf42b8ddeea1b315c9fedcfe07cadee28b9c98cf50d00adcb14d/regex-2025.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d9903ca42bfeec4cebedba8022a7c97ad2aab22e09573ce9976ba01b65e4361", size = 798592, upload-time = "2025-11-03T21:32:03.006Z" },
{ url = "https://files.pythonhosted.org/packages/99/2a/6591ebeede78203fa77ee46a1c36649e02df9eaa77a033d1ccdf2fcd5d4e/regex-2025.11.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:639431bdc89d6429f6721625e8129413980ccd62e9d3f496be618a41d205f160", size = 864122, upload-time = "2025-11-03T21:32:04.553Z" },
{ url = "https://files.pythonhosted.org/packages/94/d6/be32a87cf28cf8ed064ff281cfbd49aefd90242a83e4b08b5a86b38e8eb4/regex-2025.11.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f117efad42068f9715677c8523ed2be1518116d1c49b1dd17987716695181efe", size = 912272, upload-time = "2025-11-03T21:32:06.148Z" },
{ url = "https://files.pythonhosted.org/packages/62/11/9bcef2d1445665b180ac7f230406ad80671f0fc2a6ffb93493b5dd8cd64c/regex-2025.11.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4aecb6f461316adf9f1f0f6a4a1a3d79e045f9b71ec76055a791affa3b285850", size = 803497, upload-time = "2025-11-03T21:32:08.162Z" },
{ url = "https://files.pythonhosted.org/packages/e5/a7/da0dc273d57f560399aa16d8a68ae7f9b57679476fc7ace46501d455fe84/regex-2025.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3b3a5f320136873cc5561098dfab677eea139521cb9a9e8db98b7e64aef44cbc", size = 787892, upload-time = "2025-11-03T21:32:09.769Z" },
{ url = "https://files.pythonhosted.org/packages/da/4b/732a0c5a9736a0b8d6d720d4945a2f1e6f38f87f48f3173559f53e8d5d82/regex-2025.11.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:75fa6f0056e7efb1f42a1c34e58be24072cb9e61a601340cc1196ae92326a4f9", size = 858462, upload-time = "2025-11-03T21:32:11.769Z" },
{ url = "https://files.pythonhosted.org/packages/0c/f5/a2a03df27dc4c2d0c769220f5110ba8c4084b0bfa9ab0f9b4fcfa3d2b0fc/regex-2025.11.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:dbe6095001465294f13f1adcd3311e50dd84e5a71525f20a10bd16689c61ce0b", size = 850528, upload-time = "2025-11-03T21:32:13.906Z" },
{ url = "https://files.pythonhosted.org/packages/d6/09/e1cd5bee3841c7f6eb37d95ca91cdee7100b8f88b81e41c2ef426910891a/regex-2025.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:454d9b4ae7881afbc25015b8627c16d88a597479b9dea82b8c6e7e2e07240dc7", size = 789866, upload-time = "2025-11-03T21:32:15.748Z" },
{ url = "https://files.pythonhosted.org/packages/eb/51/702f5ea74e2a9c13d855a6a85b7f80c30f9e72a95493260193c07f3f8d74/regex-2025.11.3-cp313-cp313-win32.whl", hash = "sha256:28ba4d69171fc6e9896337d4fc63a43660002b7da53fc15ac992abcf3410917c", size = 266189, upload-time = "2025-11-03T21:32:17.493Z" },
{ url = "https://files.pythonhosted.org/packages/8b/00/6e29bb314e271a743170e53649db0fdb8e8ff0b64b4f425f5602f4eb9014/regex-2025.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:bac4200befe50c670c405dc33af26dad5a3b6b255dd6c000d92fe4629f9ed6a5", size = 277054, upload-time = "2025-11-03T21:32:19.042Z" },
{ url = "https://files.pythonhosted.org/packages/25/f1/b156ff9f2ec9ac441710764dda95e4edaf5f36aca48246d1eea3f1fd96ec/regex-2025.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:2292cd5a90dab247f9abe892ac584cb24f0f54680c73fcb4a7493c66c2bf2467", size = 270325, upload-time = "2025-11-03T21:32:21.338Z" },
{ url = "https://files.pythonhosted.org/packages/20/28/fd0c63357caefe5680b8ea052131acbd7f456893b69cc2a90cc3e0dc90d4/regex-2025.11.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:1eb1ebf6822b756c723e09f5186473d93236c06c579d2cc0671a722d2ab14281", size = 491984, upload-time = "2025-11-03T21:32:23.466Z" },
{ url = "https://files.pythonhosted.org/packages/df/ec/7014c15626ab46b902b3bcc4b28a7bae46d8f281fc7ea9c95e22fcaaa917/regex-2025.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1e00ec2970aab10dc5db34af535f21fcf32b4a31d99e34963419636e2f85ae39", size = 292673, upload-time = "2025-11-03T21:32:25.034Z" },
{ url = "https://files.pythonhosted.org/packages/23/ab/3b952ff7239f20d05f1f99e9e20188513905f218c81d52fb5e78d2bf7634/regex-2025.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a4cb042b615245d5ff9b3794f56be4138b5adc35a4166014d31d1814744148c7", size = 291029, upload-time = "2025-11-03T21:32:26.528Z" },
{ url = "https://files.pythonhosted.org/packages/21/7e/3dc2749fc684f455f162dcafb8a187b559e2614f3826877d3844a131f37b/regex-2025.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44f264d4bf02f3176467d90b294d59bf1db9fe53c141ff772f27a8b456b2a9ed", size = 807437, upload-time = "2025-11-03T21:32:28.363Z" },
{ url = "https://files.pythonhosted.org/packages/1b/0b/d529a85ab349c6a25d1ca783235b6e3eedf187247eab536797021f7126c6/regex-2025.11.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7be0277469bf3bd7a34a9c57c1b6a724532a0d235cd0dc4e7f4316f982c28b19", size = 873368, upload-time = "2025-11-03T21:32:30.4Z" },
{ url = "https://files.pythonhosted.org/packages/7d/18/2d868155f8c9e3e9d8f9e10c64e9a9f496bb8f7e037a88a8bed26b435af6/regex-2025.11.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0d31e08426ff4b5b650f68839f5af51a92a5b51abd8554a60c2fbc7c71f25d0b", size = 914921, upload-time = "2025-11-03T21:32:32.123Z" },
{ url = "https://files.pythonhosted.org/packages/2d/71/9d72ff0f354fa783fe2ba913c8734c3b433b86406117a8db4ea2bf1c7a2f/regex-2025.11.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e43586ce5bd28f9f285a6e729466841368c4a0353f6fd08d4ce4630843d3648a", size = 812708, upload-time = "2025-11-03T21:32:34.305Z" },
{ url = "https://files.pythonhosted.org/packages/e7/19/ce4bf7f5575c97f82b6e804ffb5c4e940c62609ab2a0d9538d47a7fdf7d4/regex-2025.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0f9397d561a4c16829d4e6ff75202c1c08b68a3bdbfe29dbfcdb31c9830907c6", size = 795472, upload-time = "2025-11-03T21:32:36.364Z" },
{ url = "https://files.pythonhosted.org/packages/03/86/fd1063a176ffb7b2315f9a1b08d17b18118b28d9df163132615b835a26ee/regex-2025.11.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:dd16e78eb18ffdb25ee33a0682d17912e8cc8a770e885aeee95020046128f1ce", size = 868341, upload-time = "2025-11-03T21:32:38.042Z" },
{ url = "https://files.pythonhosted.org/packages/12/43/103fb2e9811205e7386366501bc866a164a0430c79dd59eac886a2822950/regex-2025.11.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:ffcca5b9efe948ba0661e9df0fa50d2bc4b097c70b9810212d6b62f05d83b2dd", size = 854666, upload-time = "2025-11-03T21:32:40.079Z" },
{ url = "https://files.pythonhosted.org/packages/7d/22/e392e53f3869b75804762c7c848bd2dd2abf2b70fb0e526f58724638bd35/regex-2025.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c56b4d162ca2b43318ac671c65bd4d563e841a694ac70e1a976ac38fcf4ca1d2", size = 799473, upload-time = "2025-11-03T21:32:42.148Z" },
{ url = "https://files.pythonhosted.org/packages/4f/f9/8bd6b656592f925b6845fcbb4d57603a3ac2fb2373344ffa1ed70aa6820a/regex-2025.11.3-cp313-cp313t-win32.whl", hash = "sha256:9ddc42e68114e161e51e272f667d640f97e84a2b9ef14b7477c53aac20c2d59a", size = 268792, upload-time = "2025-11-03T21:32:44.13Z" },
{ url = "https://files.pythonhosted.org/packages/e5/87/0e7d603467775ff65cd2aeabf1b5b50cc1c3708556a8b849a2fa4dd1542b/regex-2025.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:7a7c7fdf755032ffdd72c77e3d8096bdcb0eb92e89e17571a196f03d88b11b3c", size = 280214, upload-time = "2025-11-03T21:32:45.853Z" },
{ url = "https://files.pythonhosted.org/packages/8d/d0/2afc6f8e94e2b64bfb738a7c2b6387ac1699f09f032d363ed9447fd2bb57/regex-2025.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:df9eb838c44f570283712e7cff14c16329a9f0fb19ca492d21d4b7528ee6821e", size = 271469, upload-time = "2025-11-03T21:32:48.026Z" },
{ url = "https://files.pythonhosted.org/packages/31/e9/f6e13de7e0983837f7b6d238ad9458800a874bf37c264f7923e63409944c/regex-2025.11.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9697a52e57576c83139d7c6f213d64485d3df5bf84807c35fa409e6c970801c6", size = 489089, upload-time = "2025-11-03T21:32:50.027Z" },
{ url = "https://files.pythonhosted.org/packages/a3/5c/261f4a262f1fa65141c1b74b255988bd2fa020cc599e53b080667d591cfc/regex-2025.11.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e18bc3f73bd41243c9b38a6d9f2366cd0e0137a9aebe2d8ff76c5b67d4c0a3f4", size = 291059, upload-time = "2025-11-03T21:32:51.682Z" },
{ url = "https://files.pythonhosted.org/packages/8e/57/f14eeb7f072b0e9a5a090d1712741fd8f214ec193dba773cf5410108bb7d/regex-2025.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:61a08bcb0ec14ff4e0ed2044aad948d0659604f824cbd50b55e30b0ec6f09c73", size = 288900, upload-time = "2025-11-03T21:32:53.569Z" },
{ url = "https://files.pythonhosted.org/packages/3c/6b/1d650c45e99a9b327586739d926a1cd4e94666b1bd4af90428b36af66dc7/regex-2025.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9c30003b9347c24bcc210958c5d167b9e4f9be786cb380a7d32f14f9b84674f", size = 799010, upload-time = "2025-11-03T21:32:55.222Z" },
{ url = "https://files.pythonhosted.org/packages/99/ee/d66dcbc6b628ce4e3f7f0cbbb84603aa2fc0ffc878babc857726b8aab2e9/regex-2025.11.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4e1e592789704459900728d88d41a46fe3969b82ab62945560a31732ffc19a6d", size = 864893, upload-time = "2025-11-03T21:32:57.239Z" },
{ url = "https://files.pythonhosted.org/packages/bf/2d/f238229f1caba7ac87a6c4153d79947fb0261415827ae0f77c304260c7d3/regex-2025.11.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6538241f45eb5a25aa575dbba1069ad786f68a4f2773a29a2bd3dd1f9de787be", size = 911522, upload-time = "2025-11-03T21:32:59.274Z" },
{ url = "https://files.pythonhosted.org/packages/bd/3d/22a4eaba214a917c80e04f6025d26143690f0419511e0116508e24b11c9b/regex-2025.11.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce22519c989bb72a7e6b36a199384c53db7722fe669ba891da75907fe3587db", size = 803272, upload-time = "2025-11-03T21:33:01.393Z" },
{ url = "https://files.pythonhosted.org/packages/84/b1/03188f634a409353a84b5ef49754b97dbcc0c0f6fd6c8ede505a8960a0a4/regex-2025.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:66d559b21d3640203ab9075797a55165d79017520685fb407b9234d72ab63c62", size = 787958, upload-time = "2025-11-03T21:33:03.379Z" },
{ url = "https://files.pythonhosted.org/packages/99/6a/27d072f7fbf6fadd59c64d210305e1ff865cc3b78b526fd147db768c553b/regex-2025.11.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:669dcfb2e38f9e8c69507bace46f4889e3abbfd9b0c29719202883c0a603598f", size = 859289, upload-time = "2025-11-03T21:33:05.374Z" },
{ url = "https://files.pythonhosted.org/packages/9a/70/1b3878f648e0b6abe023172dacb02157e685564853cc363d9961bcccde4e/regex-2025.11.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:32f74f35ff0f25a5021373ac61442edcb150731fbaa28286bbc8bb1582c89d02", size = 850026, upload-time = "2025-11-03T21:33:07.131Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d5/68e25559b526b8baab8e66839304ede68ff6727237a47727d240006bd0ff/regex-2025.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e6c7a21dffba883234baefe91bc3388e629779582038f75d2a5be918e250f0ed", size = 789499, upload-time = "2025-11-03T21:33:09.141Z" },
{ url = "https://files.pythonhosted.org/packages/fc/df/43971264857140a350910d4e33df725e8c94dd9dee8d2e4729fa0d63d49e/regex-2025.11.3-cp314-cp314-win32.whl", hash = "sha256:795ea137b1d809eb6836b43748b12634291c0ed55ad50a7d72d21edf1cd565c4", size = 271604, upload-time = "2025-11-03T21:33:10.9Z" },
{ url = "https://files.pythonhosted.org/packages/01/6f/9711b57dc6894a55faf80a4c1b5aa4f8649805cb9c7aef46f7d27e2b9206/regex-2025.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f95fbaa0ee1610ec0fc6b26668e9917a582ba80c52cc6d9ada15e30aa9ab9ad", size = 280320, upload-time = "2025-11-03T21:33:12.572Z" },
{ url = "https://files.pythonhosted.org/packages/f1/7e/f6eaa207d4377481f5e1775cdeb5a443b5a59b392d0065f3417d31d80f87/regex-2025.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:dfec44d532be4c07088c3de2876130ff0fbeeacaa89a137decbbb5f665855a0f", size = 273372, upload-time = "2025-11-03T21:33:14.219Z" },
{ url = "https://files.pythonhosted.org/packages/c3/06/49b198550ee0f5e4184271cee87ba4dfd9692c91ec55289e6282f0f86ccf/regex-2025.11.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ba0d8a5d7f04f73ee7d01d974d47c5834f8a1b0224390e4fe7c12a3a92a78ecc", size = 491985, upload-time = "2025-11-03T21:33:16.555Z" },
{ url = "https://files.pythonhosted.org/packages/ce/bf/abdafade008f0b1c9da10d934034cb670432d6cf6cbe38bbb53a1cfd6cf8/regex-2025.11.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:442d86cf1cfe4faabf97db7d901ef58347efd004934da045c745e7b5bd57ac49", size = 292669, upload-time = "2025-11-03T21:33:18.32Z" },
{ url = "https://files.pythonhosted.org/packages/f9/ef/0c357bb8edbd2ad8e273fcb9e1761bc37b8acbc6e1be050bebd6475f19c1/regex-2025.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fd0a5e563c756de210bb964789b5abe4f114dacae9104a47e1a649b910361536", size = 291030, upload-time = "2025-11-03T21:33:20.048Z" },
{ url = "https://files.pythonhosted.org/packages/79/06/edbb67257596649b8fb088d6aeacbcb248ac195714b18a65e018bf4c0b50/regex-2025.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf3490bcbb985a1ae97b2ce9ad1c0f06a852d5b19dde9b07bdf25bf224248c95", size = 807674, upload-time = "2025-11-03T21:33:21.797Z" },
{ url = "https://files.pythonhosted.org/packages/f4/d9/ad4deccfce0ea336296bd087f1a191543bb99ee1c53093dcd4c64d951d00/regex-2025.11.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3809988f0a8b8c9dcc0f92478d6501fac7200b9ec56aecf0ec21f4a2ec4b6009", size = 873451, upload-time = "2025-11-03T21:33:23.741Z" },
{ url = "https://files.pythonhosted.org/packages/13/75/a55a4724c56ef13e3e04acaab29df26582f6978c000ac9cd6810ad1f341f/regex-2025.11.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f4ff94e58e84aedb9c9fce66d4ef9f27a190285b451420f297c9a09f2b9abee9", size = 914980, upload-time = "2025-11-03T21:33:25.999Z" },
{ url = "https://files.pythonhosted.org/packages/67/1e/a1657ee15bd9116f70d4a530c736983eed997b361e20ecd8f5ca3759d5c5/regex-2025.11.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eb542fd347ce61e1321b0a6b945d5701528dca0cd9759c2e3bb8bd57e47964d", size = 812852, upload-time = "2025-11-03T21:33:27.852Z" },
{ url = "https://files.pythonhosted.org/packages/b8/6f/f7516dde5506a588a561d296b2d0044839de06035bb486b326065b4c101e/regex-2025.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2d5919075a1f2e413c00b056ea0c2f065b3f5fe83c3d07d325ab92dce51d6", size = 795566, upload-time = "2025-11-03T21:33:32.364Z" },
{ url = "https://files.pythonhosted.org/packages/d9/dd/3d10b9e170cc16fb34cb2cef91513cf3df65f440b3366030631b2984a264/regex-2025.11.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3f8bf11a4827cc7ce5a53d4ef6cddd5ad25595d3c1435ef08f76825851343154", size = 868463, upload-time = "2025-11-03T21:33:34.459Z" },
{ url = "https://files.pythonhosted.org/packages/f5/8e/935e6beff1695aa9085ff83195daccd72acc82c81793df480f34569330de/regex-2025.11.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:22c12d837298651e5550ac1d964e4ff57c3f56965fc1812c90c9fb2028eaf267", size = 854694, upload-time = "2025-11-03T21:33:36.793Z" },
{ url = "https://files.pythonhosted.org/packages/92/12/10650181a040978b2f5720a6a74d44f841371a3d984c2083fc1752e4acf6/regex-2025.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ba394a3dda9ad41c7c780f60f6e4a70988741415ae96f6d1bf6c239cf01379", size = 799691, upload-time = "2025-11-03T21:33:39.079Z" },
{ url = "https://files.pythonhosted.org/packages/67/90/8f37138181c9a7690e7e4cb388debbd389342db3c7381d636d2875940752/regex-2025.11.3-cp314-cp314t-win32.whl", hash = "sha256:4bf146dca15cdd53224a1bf46d628bd7590e4a07fbb69e720d561aea43a32b38", size = 274583, upload-time = "2025-11-03T21:33:41.302Z" },
{ url = "https://files.pythonhosted.org/packages/8f/cd/867f5ec442d56beb56f5f854f40abcfc75e11d10b11fdb1869dd39c63aaf/regex-2025.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:adad1a1bcf1c9e76346e091d22d23ac54ef28e1365117d99521631078dfec9de", size = 284286, upload-time = "2025-11-03T21:33:43.324Z" },
{ url = "https://files.pythonhosted.org/packages/20/31/32c0c4610cbc070362bf1d2e4ea86d1ea29014d400a6d6c2486fcfd57766/regex-2025.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:c54f768482cef41e219720013cd05933b6f971d9562544d691c68699bf2b6801", size = 274741, upload-time = "2025-11-03T21:33:45.557Z" },
]
[[package]] [[package]]
name = "requests" name = "requests"
version = "2.32.5" version = "2.32.5"
@@ -714,6 +885,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/65/5e726c372da8a5e35022a94388b12252710aad0c2351699c3d76ae8dba78/supervisor-4.3.0-py2.py3-none-any.whl", hash = "sha256:0bcb763fddafba410f35cbde226aa7f8514b9fb82eb05a0c85f6588d1c13f8db", size = 320736, upload-time = "2025-08-23T18:25:00.767Z" }, { url = "https://files.pythonhosted.org/packages/0e/65/5e726c372da8a5e35022a94388b12252710aad0c2351699c3d76ae8dba78/supervisor-4.3.0-py2.py3-none-any.whl", hash = "sha256:0bcb763fddafba410f35cbde226aa7f8514b9fb82eb05a0c85f6588d1c13f8db", size = 320736, upload-time = "2025-08-23T18:25:00.767Z" },
] ]
[[package]]
name = "tqdm"
version = "4.67.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" },
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.14.1" version = "4.14.1"