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

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,16 @@
<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"
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 xmlns="http://www.w3.org/2000/svg"
width="20px"
height="20px"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="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>
</button>

View File

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

View File

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

View File

@@ -1,42 +1,70 @@
{% 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"
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="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 xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
<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>
{% 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"
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="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 xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="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>
{% 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"
stroke="currentColor" 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 xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="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>
{% else %}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="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 xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="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>
{% endif %}
{% endif %}

View File

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

View File

@@ -1,21 +1,26 @@
{% load static %}
{% load shared %}
<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 %}
<input type="hidden" name="update_state" value="{{ details.bookmark.id }}">
<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 }}">
{% 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 %}
<span>{{ details.bookmark.url }}</span>
</a>
{% 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 }}">
{% if details.show_link_icons %}
<svg class="favicon" xmlns="http://www.w3.org/2000/svg">
@@ -26,13 +31,14 @@
</a>
{% endif %}
{% 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 }}">
{% if details.show_link_icons %}
<svg class="favicon" viewBox="0 0 76 86" xmlns="http://www.w3.org/2000/svg">
<path
d="m76 82v4h-76l.00080851-4zm-3-6v5h-70v-5zm-62.6696277-54 .8344146.4217275.4176066 6.7436084.4176065 10.9576581v10.5383496l-.4176065 13.1364492-.0694681 8.8498268-1.1825531.3523804h-4.17367003l-1.25202116-.3523804-.48627608-8.8498268-.41840503-13.0662957v-10.5375432l.41840503-11.028618.38167482-6.7798947.87034634-.3854412zm60.0004653 0 .8353798.4217275.4168913 6.7436084.4168913 10.9576581v10.5383496l-.4168913 13.1364492-.0686832 8.8498268-1.1835879.3523804h-4.1737047l-1.2522712-.3523804-.4879704-8.8498268-.4168913-13.0662957v-10.5375432l.4168913-11.028618.3833483-6.7798947.8697215-.3854412zm-42.000632 0 .8344979.4217275.4176483 6.7436084.4176482 10.9576581v10.5383496l-.4176482 13.1364492-.0686764 8.8498268-1.1834698.3523804h-4.1740866l-1.2529447-.3523804-.4863246-8.8498268-.4168497-13.0662957v-10.5375432l.4168497-11.028618.38331-6.7798947.8688361-.3854412zm23 0 .8344979.4217275.4176483 6.7436084.4176482 10.9576581v10.5383496l-.4176482 13.1364492-.0686764 8.8498268-1.1834698.3523804h-4.1740866l-1.2521462-.3523804-.4871231-8.8498268-.4168497-13.0662957v-10.5375432l.4168497-11.028618.38331-6.7798947.8696347-.3854412zm21.6697944-9v7h-70v-7zm-35.7200748-13 36.7200748 8.4088317-1.4720205 2.5911683h-70.32799254l-2.19998696-2.10140371z"
fill="currentColor" fill-rule="evenodd"/>
<svg class="favicon"
viewBox="0 0 76 86"
xmlns="http://www.w3.org/2000/svg">
<path d="m76 82v4h-76l.00080851-4zm-3-6v5h-70v-5zm-62.6696277-54 .8344146.4217275.4176066 6.7436084.4176065 10.9576581v10.5383496l-.4176065 13.1364492-.0694681 8.8498268-1.1825531.3523804h-4.17367003l-1.25202116-.3523804-.48627608-8.8498268-.41840503-13.0662957v-10.5375432l.41840503-11.028618.38167482-6.7798947.87034634-.3854412zm60.0004653 0 .8353798.4217275.4168913 6.7436084.4168913 10.9576581v10.5383496l-.4168913 13.1364492-.0686832 8.8498268-1.1835879.3523804h-4.1737047l-1.2522712-.3523804-.4879704-8.8498268-.4168913-13.0662957v-10.5375432l.4168913-11.028618.3833483-6.7798947.8697215-.3854412zm-42.000632 0 .8344979.4217275.4176483 6.7436084.4176482 10.9576581v10.5383496l-.4176482 13.1364492-.0686764 8.8498268-1.1834698.3523804h-4.1740866l-1.2529447-.3523804-.4863246-8.8498268-.4168497-13.0662957v-10.5375432l.4168497-11.028618.38331-6.7798947.8688361-.3854412zm23 0 .8344979.4217275.4176483 6.7436084.4176482 10.9576581v10.5383496l-.4176482 13.1364492-.0686764 8.8498268-1.1834698.3523804h-4.1740866l-1.2521462-.3523804-.4871231-8.8498268-.4168497-13.0662957v-10.5375432l.4168497-11.028618.38331-6.7798947.8696347-.3854412zm21.6697944-9v7h-70v-7zm-35.7200748-13 36.7200748 8.4088317-1.4720205 2.5911683h-70.32799254l-2.19998696-2.10140371z" fill="currentColor" fill-rule="evenodd" />
</svg>
{% endif %}
<span>Internet Archive</span>
@@ -41,7 +47,7 @@
</div>
{% if details.preview_image_enabled and details.bookmark.preview_image_file %}
<div class="preview-image">
<img src="{% static details.bookmark.preview_image_file %}" alt=""/>
<img src="{% static details.bookmark.preview_image_file %}" alt="" />
</div>
{% endif %}
<div class="sections grid columns-2 columns-sm-1 gap-0">
@@ -51,14 +57,18 @@
<div class="d-flex" style="gap: .8rem">
<div class="form-group">
<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 %}>
<i class="form-icon"></i> Archived
</label>
</div>
<div class="form-group">
<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 %}>
<i class="form-icon"></i> Unread
</label>
@@ -66,7 +76,9 @@
{% if details.profile.enable_sharing %}
<div class="form-group">
<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 %}>
<i class="form-icon"></i> Shared
</label>
@@ -77,9 +89,7 @@
{% endif %}
<section class="files col-2">
<h3>Files</h3>
<div>
{% include 'bookmarks/details/assets.html' %}
</div>
<div>{% include 'bookmarks/details/assets.html' %}</div>
</section>
{% if details.bookmark.tag_names %}
<section class="tags col-1">
@@ -111,4 +121,4 @@
{% endif %}
</div>
</form>
</ld-form>
</ld-form>

View File

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

View File

@@ -1,11 +1,7 @@
{% extends 'bookmarks/layout.html' %}
{% extends 'shared/layout.html' %}
{% block head %}
{% with page_title="Edit bookmark - Linkding" %}
{{ block.super }}
{% endwith %}
{% with page_title="Edit bookmark - Linkding" %}{{ block.super }}{% endwith %}
{% endblock %}
{% block content %}
<div class="bookmarks-form-page">
<main aria-labelledby="main-heading">
@@ -13,7 +9,8 @@
<h1 id="main-heading">Edit bookmark</h1>
</div>
<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>
{% include 'bookmarks/form.html' %}
</form>

View File

@@ -10,8 +10,7 @@
<p class="empty-subtitle">
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.integrations' %}">browser extension</a> or the <a
href="{% url 'linkding:settings.integrations' %}">bookmarklet</a>.
<a href="{% url 'linkding:settings.integrations' %}">browser extension</a> or the <a href="{% url 'linkding:settings.integrations' %}">bookmarklet</a>.
</p>
{% endif %}
</div>

View File

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

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

View File

@@ -1,90 +1,106 @@
{% load static %}
<!DOCTYPE html>
<html lang="en" class="reader-mode">
<head>
<meta charset="UTF-8">
<title>Reader view</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
{# 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 %}
</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);
}
<head>
<meta charset="UTF-8">
<title>Reader view</title>
<meta name="viewport"
content="width=device-width, initial-scale=1.0, minimal-ui">
{# 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 %}
</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) {
articleContent.querySelectorAll('table').forEach(table => {
table.classList.add('table');
});
}
function postProcess(articleContent) {
articleContent.querySelectorAll('table').forEach(table => {
table.classList.add('table');
});
}
function makeReadable() {
const content = document.getElementById('content');
const contentHtml = content.innerHTML;
const dom = new DOMParser().parseFromString(contentHtml, 'text/html');
const article = new Readability(dom).parse();
function makeReadable() {
const content = document.getElementById('content');
const contentHtml = content.innerHTML;
const dom = new DOMParser().parseFromString(contentHtml, 'text/html');
const article = new Readability(dom).parse();
document.title = article.title;
document.title = article.title;
const container = document.createElement('div');
container.classList.add('container');
const container = document.createElement('div');
container.classList.add('container');
const articleTitle = document.createElement('h1');
articleTitle.textContent = article.title;
container.append(articleTitle);
const articleTitle = document.createElement('h1');
articleTitle.textContent = article.title;
container.append(articleTitle);
const byline = [article.byline, article.siteName].filter(Boolean);
if (byline.length > 0) {
const articleByline = document.createElement('p');
articleByline.textContent = byline.join(' | ');
articleByline.classList.add('byline');
container.append(articleByline);
}
const byline = [article.byline, article.siteName].filter(Boolean);
if (byline.length > 0) {
const articleByline = document.createElement('p');
articleByline.textContent = byline.join(' | ');
articleByline.classList.add('byline');
container.append(articleByline);
}
if(article.length) {
const minTime = estimateReadingTime(article.length, 225);
const maxTime = estimateReadingTime(article.length, 175);
if (article.length) {
const minTime = estimateReadingTime(article.length, 225);
const maxTime = estimateReadingTime(article.length, 175);
const articleReadingTime = document.createElement('p');
articleReadingTime.textContent = `${minTime}-${maxTime} minutes`;
articleReadingTime.classList.add('reading-time');
container.append(articleReadingTime);
}
const articleReadingTime = document.createElement('p');
articleReadingTime.textContent = `${minTime}-${maxTime} minutes`;
articleReadingTime.classList.add('reading-time');
container.append(articleReadingTime);
}
const divider = document.createElement('hr');
container.append(divider);
const divider = document.createElement('hr');
container.append(divider);
const articleContent = document.createElement('div');
articleContent.innerHTML = article.content;
postProcess(articleContent);
container.append(articleContent);
const articleContent = document.createElement('div');
articleContent.innerHTML = article.content;
postProcess(articleContent);
container.append(articleContent);
content.replaceWith(container);
}
makeReadable();
</script>
</body>
content.replaceWith(container);
}
makeReadable();
</script>
</body>
</html>

View File

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

View File

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

View File

@@ -1,37 +1,33 @@
{% load shared %}
{% htmlmin %}
<div class="tag-cloud">
{% if tag_cloud.has_selected_tags %}
<p class="selected-tags">
{% for tag in tag_cloud.selected_tags %}
<a href="?{{ tag.query_string }}"
class="text-bold mr-2">
<span>-{{ tag.name }}</span>
</a>
<div class="tag-cloud">
{% if tag_cloud.has_selected_tags %}
<p class="selected-tags">
{% for tag in tag_cloud.selected_tags %}
<a href="?{{ tag.query_string }}" class="text-bold mr-2">
<span>-{{ tag.name }}</span>
</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 %}
</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 %}
</p>
{% endfor %}
</div>
{% endfor %}
</div>
</div>
{% endhtmlmin %}

View File

@@ -4,12 +4,19 @@
{% if user.is_authenticated %}
<ld-dropdown class="dropdown dropdown-right ml-auto">
<button class="btn btn-noborder dropdown-toggle" aria-label="Tags menu">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M4 6l16 0"/>
<path d="M4 12l16 0"/>
<path d="M4 18l16 0"/>
<svg xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4 6l16 0" />
<path d="M4 12l16 0" />
<path d="M4 18l16 0" />
</svg>
</button>
<ul class="menu" role="list" tabindex="-1">
@@ -20,7 +27,5 @@
</ld-dropdown>
{% endif %}
</div>
<div id="tag-cloud-container">
{% include 'bookmarks/tag_cloud.html' %}
</div>
<div id="tag-cloud-container">{% include 'bookmarks/tag_cloud.html' %}</div>
</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 %}
<ld-form data-form-reset>
<form id="user-select" action="" method="get">
{% for hidden_field in form.hidden_fields %}
{{ hidden_field }}
{% endfor %}
{% for hidden_field in form.hidden_fields %}{{ hidden_field }}{% endfor %}
<div class="form-group">
<div class="d-flex">
{% render_field form.user class+="form-select" data-submit-on-change="" %}
@@ -14,4 +11,4 @@
</div>
</div>
</form>
</ld-form>
</ld-form>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,8 @@
{% extends 'bookmarks/layout.html' %}
{% extends 'shared/layout.html' %}
{% load widget_tweaks %}
{% block head %}
{% with page_title="Login - Linkding" %}
{{ block.super }}
{% endwith %}
{% with page_title="Login - Linkding" %}{{ block.super }}{% endwith %}
{% endblock %}
{% block content %}
<main class="mx-auto width-50 width-md-100" aria-labelledby="main-heading">
<div class="section-header">
@@ -27,13 +23,14 @@
<label class="form-label" for="{{ form.password.id_for_label }}">Password</label>
{{ form.password|add_class:'form-input'|attr:'placeholder: ' }}
</div>
<br/>
<br />
<div class="d-flex justify-between">
<input type="submit" value="Login" class="btn btn-primary btn-wide"/>
<input type="hidden" name="next" value="{{ next }}"/>
<input type="submit" value="Login" class="btn btn-primary btn-wide" />
<input type="hidden" name="next" value="{{ next }}" />
{% 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 %}
{% if allow_registration %}
<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 %}
{% block head %}
{% with page_title="Password changed - Linkding" %}
{{ block.super }}
{% endwith %}
{% with page_title="Password changed - Linkding" %}{{ block.super }}{% endwith %}
{% endblock %}
{% block content %}
<main class="mx-auto width-50 width-md-100" aria-labelledby="main-heading">
<div class="section-header">
<h1 id="main-heading">Password Changed</h1>
</div>
<p class="text-success">
Your password was changed successfully.
</p>
<p class="text-success">Your password was changed successfully.</p>
</main>
{% endblock %}

View File

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

View File

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

View File

@@ -1,30 +1,23 @@
{% extends "bookmarks/layout.html" %}
{% extends "shared/layout.html" %}
{% load widget_tweaks %}
{% block head %}
{% with page_title="Settings - Linkding" %}
{{ block.super }}
{% endwith %}
{% with page_title="Settings - Linkding" %}{{ block.super }}{% endwith %}
{% endblock %}
{% block content %}
<main class="settings-page" aria-labelledby="main-heading">
<h1 id="main-heading">Settings</h1>
{# Profile section #}
{% if success_message %}
<div class="toast toast-success mb-4">{{ success_message }}</div>
{% endif %}
{% if error_message %}
<div class="toast toast-error mb-4">{{ error_message }}</div>
{% endif %}
{% if success_message %}<div class="toast toast-success mb-4">{{ success_message }}</div>{% endif %}
{% if error_message %}<div class="toast toast-error mb-4">{{ error_message }}</div>{% endif %}
<section aria-labelledby="profile-heading">
<h2 id="profile-heading">Profile</h2>
<p>
<a href="{% url 'change_password' %}">Change password</a>
</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 %}
<div class="form-group">
<label for="{{ form.theme.id_for_label }}" class="form-label">Theme</label>
@@ -34,7 +27,8 @@
</div>
</div>
<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" }}
<div class="form-input-hint">
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 class="form-group">
<label for="{{ form.bookmark_description_display.id_for_label }}" class="form-label">Bookmark
description</label>
<label for="{{ form.bookmark_description_display.id_for_label }}"
class="form-label">
Bookmark
description
</label>
{{ form.bookmark_description_display|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint">
Whether to show bookmark descriptions and tags in the same line, or as separate blocks.
</div>
</div>
<div
class="form-group {% if request.user_profile.bookmark_description_display == 'inline' %}d-hide{% endif %}">
<label for="{{ form.bookmark_description_max_lines.id_for_label }}" class="form-label">Bookmark description
max lines</label>
<div class="form-group {% if request.user_profile.bookmark_description_display == 'inline' %}d-hide{% endif %}">
<label for="{{ form.bookmark_description_max_lines.id_for_label }}"
class="form-label">
Bookmark description
max lines
</label>
{{ form.bookmark_description_max_lines|add_class:"form-input width-25 width-sm-100" }}
<div class="form-input-hint">
Limits the number of lines that are displayed for the bookmark description.
</div>
<div class="form-input-hint">Limits the number of lines that are displayed for the bookmark description.</div>
</div>
<div class="form-group">
<label for="{{ form.display_url.id_for_label }}" class="form-checkbox">
{{ form.display_url }}
<i class="form-icon"></i> Show bookmark URL
</label>
<div class="form-input-hint">
When enabled, this setting displays the bookmark URL below the title.
</div>
<div class="form-input-hint">When enabled, this setting displays the bookmark URL below the title.</div>
</div>
<div class="form-group">
<label for="{{ form.permanent_notes.id_for_label }}" class="form-checkbox">
@@ -79,45 +74,41 @@
</div>
<div class="form-group">
<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 }}
<i class="form-icon"></i> View
</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 }}
<i class="form-icon"></i> Edit
</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 }}
<i class="form-icon"></i> Archive
</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 }}
<i class="form-icon"></i> Remove
</label>
<div class="form-input-hint">
Which actions to display for each bookmark.
</div>
<div class="form-input-hint">Which actions to display for each bookmark.</div>
</div>
<div class="form-group">
<label for="{{ form.bookmark_link_target.id_for_label }}" class="form-label">Open bookmarks in</label>
{{ form.bookmark_link_target|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint">
Whether to open bookmarks a new page or in the same page.
</div>
<div class="form-input-hint">Whether to open bookmarks a new page or in the same page.</div>
</div>
<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>
{{ form.items_per_page|add_class:"form-input width-25 width-sm-100"|attr:"min:10" }}
{% if form.items_per_page.errors %}
<div class="form-input-hint is-error">
{{ form.items_per_page.errors }}
</div>
<div class="form-input-hint is-error">{{ form.items_per_page.errors }}</div>
{% else %}
{% endif %}
<div class="form-input-hint">
The number of bookmarks to display per page.
</div>
<div class="form-input-hint">The number of bookmarks to display per page.</div>
</div>
<div class="form-group">
<label for="{{ form.sticky_pagination.id_for_label }}" class="form-checkbox">
@@ -130,7 +121,8 @@
</div>
</div>
<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 }}
<i class="form-icon"></i> Collapse side panel
</label>
@@ -144,9 +136,7 @@
{{ form.hide_bundles }}
<i class="form-icon"></i> Hide bundles
</label>
<div class="form-input-hint">
Allows to hide the bundles in the side panel if you don't intend to use them.
</div>
<div class="form-input-hint">Allows to hide the bundles in the side panel if you don't intend to use them.</div>
</div>
<div class="form-group">
<label for="{{ form.tag_search.id_for_label }}" class="form-label">Tag search</label>
@@ -166,7 +156,8 @@
<div class="form-input-hint">
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.
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.
</div>
</div>
@@ -183,10 +174,9 @@
<summary>
<span class="form-label d-inline-block">Auto Tagging</span>
</summary>
<label for="{{ form.auto_tagging_rules.id_for_label }}" class="text-assistive">Auto Tagging</label>
<div>
{{ form.auto_tagging_rules|add_class:"form-input monospace"|attr:"rows:6" }}
</div>
<label for="{{ form.auto_tagging_rules.id_for_label }}"
class="text-assistive">Auto Tagging</label>
<div>{{ form.auto_tagging_rules|add_class:"form-input monospace"|attr:"rows:6" }}</div>
</details>
<div class="form-input-hint">
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.
Enabling this feature automatically downloads all missing 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
href="https://linkding.link/options/#ld_favicon_provider"
target="_blank">options documentation</a> on how to configure a custom favicon provider.
If you don't want to use this service, check the
<a href="https://linkding.link/options/#ld_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.
</div>
{% if request.user_profile.enable_favicons and enable_refresh_favicons %}
@@ -214,7 +204,8 @@ reddit.com/r/Music music reddit</pre>
{% endif %}
</div>
<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 }}
<i class="form-icon"></i> Enable Preview Images
</label>
@@ -224,17 +215,18 @@ reddit.com/r/Music music reddit</pre>
</div>
</div>
<div class="form-group">
<label for="{{ form.web_archive_integration.id_for_label }}" class="form-label">Internet Archive
integration</label>
<label for="{{ form.web_archive_integration.id_for_label }}"
class="form-label">
Internet Archive
integration
</label>
{{ form.web_archive_integration|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint">
Enabling this feature will automatically create snapshots of bookmarked websites on the <a
href="https://web.archive.org/" target="_blank" rel="noopener">Internet Archive Wayback
Machine</a>.
Enabling this feature will automatically create snapshots of bookmarked websites on the
<a href="https://web.archive.org/" target="_blank" rel="noopener">Internet Archive Wayback Machine</a>.
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.
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.
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.
</div>
</div>
<div class="form-group">
@@ -248,19 +240,20 @@ reddit.com/r/Music music reddit</pre>
</div>
</div>
<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 }}
<i class="form-icon"></i> Enable public bookmark sharing
</label>
<div class="form-input-hint">
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
href="{% url 'linkding:bookmarks.shared' %}">shared bookmarks page</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>.
</div>
</div>
{% if has_snapshot_support %}
<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 }}
<i class="form-icon"></i> Automatically create HTML snapshots
</label>
@@ -272,7 +265,8 @@ reddit.com/r/Music music reddit</pre>
</div>
{% endif %}
<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 }}
<i class="form-icon"></i> Create bookmarks as unread by default
</label>
@@ -283,7 +277,8 @@ reddit.com/r/Music music reddit</pre>
</div>
</div>
<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 }}
<i class="form-icon"></i> Create bookmarks as shared by default
</label>
@@ -299,36 +294,39 @@ reddit.com/r/Music music reddit</pre>
<span class="form-label d-inline-block">Custom CSS</span>
</summary>
<label for="{{ form.custom_css.id_for_label }}" class="text-assistive">Custom CSS</label>
<div>
{{ form.custom_css|add_class:"form-input monospace"|attr:"rows:6" }}
</div>
<div>{{ form.custom_css|add_class:"form-input monospace"|attr:"rows:6" }}</div>
</details>
<div class="form-input-hint">
Allows to add custom CSS to the page.
</div>
<div class="form-input-hint">Allows to add custom CSS to the page.</div>
</div>
<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>
</form>
</section>
{# Global settings section #}
{% if global_settings_form %}
<section aria-labelledby="global-settings-heading">
<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 %}
<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" }}
<div class="form-input-hint">
The page that unauthenticated users are redirected to when accessing the root URL.
</div>
<div class="form-input-hint">The page that unauthenticated users are redirected to when accessing the root URL.</div>
</div>
<div class="form-group">
<label for="{{ global_settings_form.guest_profile_user.id_for_label }}" class="form-label">Guest user
profile</label>
<label for="{{ global_settings_form.guest_profile_user.id_for_label }}"
class="form-label">
Guest user
profile
</label>
{{ global_settings_form.guest_profile_user|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint">
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 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 }}
<i class="form-icon"></i> Enable prefetching links on hover
</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.
</div>
</div>
<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>
</form>
</section>
{% endif %}
{# Import section #}
<section aria-labelledby="import-heading">
<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
added and existing ones are updated.</p>
<form method="post" enctype="multipart/form-data" action="{% url 'linkding:settings.import' %}">
<p>
Import bookmarks and tags in the Netscape HTML format. This will execute a sync where new bookmarks are
added and existing ones are updated.
</p>
<form method="post"
enctype="multipart/form-data"
action="{% url 'linkding:settings.import' %}">
{% csrf_token %}
<div class="form-group">
<label for="import_map_private_flag" class="form-checkbox">
@@ -381,48 +385,43 @@ reddit.com/r/Music music reddit</pre>
</div>
</form>
</section>
{# Export section #}
<section aria-labelledby="export-heading">
<h2 id="export-heading">Export</h2>
<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 %}
<div class="has-error">
<p class="form-input-hint">
{{ export_error }}
</p>
<p class="form-input-hint">{{ export_error }}</p>
</div>
{% endif %}
</section>
{# About section #}
<section class="about" aria-labelledby="about-heading">
<h2 id="about-heading">About</h2>
<table class="table">
<tbody>
<tr>
<td>Version</td>
<td>{{ version_info }}</td>
</tr>
<tr>
<td style="vertical-align: top">Links</td>
<td>
<div class="d-flex flex-column gap-2">
<a href="https://github.com/sissbruecker/linkding/"
target="_blank">GitHub</a>
<a href="https://linkding.link/"
target="_blank">Documentation</a>
<a href="https://github.com/sissbruecker/linkding/blob/master/CHANGELOG.md"
target="_blank">Changelog</a>
</div>
</td>
</tr>
<tr>
<td>Version</td>
<td>{{ version_info }}</td>
</tr>
<tr>
<td style="vertical-align: top">Links</td>
<td>
<div class="d-flex flex-column gap-2">
<a href="https://github.com/sissbruecker/linkding/" target="_blank">GitHub</a>
<a href="https://linkding.link/" target="_blank">Documentation</a>
<a href="https://github.com/sissbruecker/linkding/blob/master/CHANGELOG.md"
target="_blank">Changelog</a>
</div>
</td>
</tr>
</tbody>
</table>
</section>
</main>
<script>
(function init() {
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);
})();
</script>
{% endblock %}

View File

@@ -1,33 +1,40 @@
{% extends "bookmarks/layout.html" %}
{% extends "shared/layout.html" %}
{% block head %}
{% with page_title="Integrations - Linkding" %}
{{ block.super }}
{% endwith %}
{% with page_title="Integrations - Linkding" %}{{ block.super }}{% endwith %}
{% endblock %}
{% block content %}
<main class="settings-page" aria-labelledby="main-heading">
<h1 id="main-heading">Integrations</h1>
<section aria-labelledby="browser-extension-heading">
<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
extension is available in the official extension stores for:</p>
<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>
<li><a href="https://addons.mozilla.org/firefox/addon/linkding-extension/" target="_blank">Firefox</a></li>
<li><a href="https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe"
target="_blank">Chrome</a></li>
<li>
<a href="https://addons.mozilla.org/firefox/addon/linkding-extension/"
target="_blank">Firefox</a>
</li>
<li>
<a href="https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe"
target="_blank">Chrome</a>
</li>
</ul>
<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>
<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>
<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>
<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>
<li>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>)
<li>
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>)
</li>
<li>Drag the bookmarklet below into your browser's bookmark bar / toolbar</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>After saving the bookmark, the linkding window closes, and you are back on your website</li>
</ul>
<div class="form-group radio-group" role="radiogroup" aria-labelledby="detection-method-label">
<div class="form-group radio-group"
role="radiogroup"
aria-labelledby="detection-method-label">
<p id="detection-method-label">Choose your preferred bookmarklet:</p>
<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>
Detect title and description on the server
</label>
<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>
Detect title and description in the browser
</label>
</div>
<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>
<a id="bookmarklet-client" href="javascript: {% include 'bookmarks/bookmarklet_clientside.js' %}"
data-turbo="false" class="btn btn-primary" style="display: none;">📎 Add bookmark</a>
<a id="bookmarklet-client"
href="javascript: {% include 'settings/bookmarklet_clientside.js' %}"
data-turbo="false"
class="btn btn-primary"
style="display: none">📎 Add bookmark</a>
</div>
<script>
(function init() {
// Bookmarklet type toggle
const radioButtons = document.querySelectorAll('input[name="bookmarklet-type"]');
const serverBookmarklet = document.getElementById('bookmarklet-server');
const clientBookmarklet = document.getElementById('bookmarklet-client');
(function init() {
// Bookmarklet type toggle
const radioButtons = document.querySelectorAll('input[name="bookmarklet-type"]');
const serverBookmarklet = document.getElementById('bookmarklet-server');
const clientBookmarklet = document.getElementById('bookmarklet-client');
function toggleBookmarklet() {
const selectedValue = document.querySelector('input[name="bookmarklet-type"]:checked').value;
if (selectedValue === 'server') {
serverBookmarklet.style.display = 'inline-block';
clientBookmarklet.style.display = 'none';
} else {
serverBookmarklet.style.display = 'none';
clientBookmarklet.style.display = 'inline-block';
}
}
function toggleBookmarklet() {
const selectedValue = document.querySelector('input[name="bookmarklet-type"]:checked').value;
if (selectedValue === 'server') {
serverBookmarklet.style.display = 'inline-block';
clientBookmarklet.style.display = 'none';
} else {
serverBookmarklet.style.display = 'none';
clientBookmarklet.style.display = 'inline-block';
}
}
toggleBookmarklet();
radioButtons.forEach(function (radio) {
radio.addEventListener('change', toggleBookmarklet);
});
})();
toggleBookmarklet();
radioButtons.forEach(function(radio) {
radio.addEventListener('change', toggleBookmarklet);
});
})();
</script>
</section>
<turbo-frame id="api-section">
<section aria-labelledby="rest-api-heading">
<h2 id="rest-api-heading">REST API</h2>
{% if api_success_message %}
<div class="toast toast-success mb-2">
{{ api_success_message }}
<section aria-labelledby="rest-api-heading">
<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_token_name and api_token_key %}
<div class="mt-4 mb-6">
<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>
{% endif %}
{% if api_token_name and api_token_key %}
<div class="mt-4 mb-6">
<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 %}
<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>
</div>
{% endif %}
<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>
<th>Name</th>
<th>Created</th>
@@ -122,87 +137,96 @@
<span class="text-assistive">Actions</span>
</th>
</tr>
</thead>
<tbody>
</thead>
<tbody>
{% for token in api_tokens %}
<tr>
<td>{{ token.name }}</td>
<td>{{ token.created|date:"M d, Y H:i" }}</td>
<td class="actions">
{% csrf_token %}
<button data-confirm name="token_id" value="{{ token.id }}" type="submit"
class="btn btn-link">Delete
</button>
<button data-confirm
name="token_id"
value="{{ token.id }}"
type="submit"
class="btn btn-link">Delete</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</form>
{% endif %}
<a class="btn" href="{% url 'linkding:settings.integrations.create_api_token' %}"
data-turbo-frame="api-modal">Create API token</a>
</section>
<turbo-frame id="api-modal"></turbo-frame>
<script>
(function init() {
// Copy new token key to clipboard
const copyButton = document.getElementById('copy-new-token-key');
if (copyButton) {
copyButton.addEventListener('click', () => {
const tokenInput = document.getElementById('new-token-key');
const tokenValue = tokenInput.value;
navigator.clipboard.writeText(tokenValue).then(() => {
copyButton.textContent = 'Copied!';
setTimeout(() => {
copyButton.textContent = 'Copy';
}, 2000);
}, (err) => {
console.error('Could not copy text: ', err);
});
});
}
})();
</script>
</tbody>
</table>
</form>
{% endif %}
<a class="btn"
href="{% url 'linkding:settings.integrations.create_api_token' %}"
data-turbo-frame="api-modal">Create API token</a>
</section>
<turbo-frame id="api-modal"></turbo-frame>
<script>
(function init() {
// Copy new token key to clipboard
const copyButton = document.getElementById('copy-new-token-key');
if (copyButton) {
copyButton.addEventListener('click', () => {
const tokenInput = document.getElementById('new-token-key');
const tokenValue = tokenInput.value;
navigator.clipboard.writeText(tokenValue).then(() => {
copyButton.textContent = 'Copied!';
setTimeout(() => {
copyButton.textContent = 'Copy';
}, 2000);
}, (err) => {
console.error('Could not copy text: ', err);
});
});
}
})();
</script>
</turbo-frame>
<section aria-labelledby="rss-feeds-heading">
<h2 id="rss-feeds-heading">RSS Feeds</h2>
<p>The following URLs provide RSS feeds for your bookmarks:</p>
<ul style="list-style-position: outside;">
<li><a target="_blank" href="{{ all_feed_url }}">All bookmarks</a></li>
<li><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>
<a target="_blank" href="{{ all_feed_url }}">All bookmarks</a>
</li>
<li>
<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>
</ul>
<p>
All URLs support the following URL parameters:
</p>
<p>All URLs support the following URL parameters:</p>
<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.
</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.
</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.
</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.
</li>
</ul>
<p>
<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.
If you think that a URL was compromised you can delete the feed token for your user in the <a
target="_blank" href="{% url 'admin:bookmarks_feedtoken_changelist' %}">admin panel</a>.
If you think that a URL was compromised you can delete the feed token for your user in the <a target="_blank"
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.
</p>
</section>

View File

@@ -1,6 +1,10 @@
{% load i18n %}
{# Force rendering validation errors in English language to align with the rest of the app #}
{% 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 %}
{% endlanguage %}
{% 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 %}
{% 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 %}
<div class="message-list">
{% for message in messages %}
<div class="toast toast-{{ message.tags }}" role="alert">
{{ message }}
</div>
{% endfor %}
{% for message in messages %}<div class="toast toast-{{ message.tags }}" role="alert">{{ message }}</div>{% endfor %}
</div>
{% endif %}

View File

@@ -1,8 +1,18 @@
<div class="modal-header">
<h2 class="title">{{ title }}</h2>
<button type="button" class="btn btn-noborder close" 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">
<button type="button"
class="btn btn-noborder close"
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 d="M18 6l-12 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 %}
<ul class="pagination">
{% if prev_link %}
<li class="page-item">
@@ -10,7 +9,6 @@
<a href="#" tabindex="-1">Previous</a>
</li>
{% endif %}
{% for page_link in page_links %}
{% if page_link %}
<li class="page-item {% if page_link.active %}active{% endif %}">
@@ -22,7 +20,6 @@
</li>
{% endif %}
{% endfor %}
{% if next_link %}
<li class="page-item">
<a href="{{ next_link }}" tabindex="-1">Next</a>
@@ -32,4 +29,4 @@
<a href="#" tabindex="-1">Next</a>
</li>
{% 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">
<form method="post" action="{% url 'linkding:tags.edit' tag.id %}" data-turbo-frame="_top" novalidate>
{% csrf_token %}
<ld-modal class="modal tag-edit-modal active" data-close-url="{% url 'linkding:tags.index' %}"
data-turbo-frame="tag-modal">
<div class="modal-overlay" data-close-modal></div>
<div class="modal-container" role="dialog" aria-modal="true">
{% include 'shared/modal_header.html' with title="Edit Tag" %}
<div class="modal-body">
{% include 'tags/form.html' %}
</div>
<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 btn-wide">Save</button>
</div>
<form method="post"
action="{% url 'linkding:tags.edit' tag.id %}"
data-turbo-frame="_top"
novalidate>
{% csrf_token %}
<ld-modal class="modal tag-edit-modal active"
data-close-url="{% url 'linkding:tags.index' %}"
data-turbo-frame="tag-modal">
<div class="modal-overlay" data-close-modal></div>
<div class="modal-container" role="dialog" aria-modal="true">
{% include 'shared/modal_header.html' with title="Edit Tag" %}
<div class="modal-body">{% include 'tags/form.html' %}</div>
<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 btn-wide">Save</button>
</div>
</ld-modal>
</form>
</div>
</ld-modal>
</form>
</turbo-frame>

View File

@@ -1,14 +1,9 @@
{% load widget_tweaks %}
<div class="form-group {% if form.name.errors %}has-error{% endif %}">
<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" }}
<div class="form-input-hint">Tag names are case-insensitive and cannot contain spaces (spaces will be replaced with
hyphens).
<div class="form-input-hint">
Tag names are case-insensitive and cannot contain spaces (spaces will be replaced with hyphens).
</div>
{% if form.name.errors %}
<div class="form-input-hint">
{{ form.name.errors }}
</div>
{% endif %}
{% if form.name.errors %}<div class="form-input-hint">{{ form.name.errors }}</div>{% endif %}
</div>

View File

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

View File

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

View File

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

View File

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

View File

@@ -448,7 +448,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
soup = self.make_soup(html)
bookmark_list = soup.select_one("ul.bookmark-list")
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.bookmark_description_max_lines = 3
@@ -458,7 +458,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
soup = self.make_soup(html)
bookmark_list = soup.select_one("ul.bookmark-list")
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):
bookmark = self.setup_bookmark()

View File

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

View File

@@ -113,7 +113,6 @@ class TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
reverse("linkding:tags.merge"),
{"target_tag": "target_tag", "merge_tags": "merge_tag"},
)
self.assertEqual(response.status_code, 422)
target_tag_group = self.get_form_group(response, "target_tag")
self.assertIn(
@@ -131,8 +130,6 @@ class TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
{"target_tag": "", "merge_tags": "merge_tag"},
)
self.assertEqual(response.status_code, 422)
target_tag_group = self.get_form_group(response, "target_tag")
self.assertIn("This field is required", self.get_text(target_tag_group))
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": ""},
)
self.assertEqual(response.status_code, 422)
merge_tags_group = self.get_form_group(response, "merge_tags")
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"},
)
self.assertEqual(response.status_code, 422)
target_tag_group = self.get_form_group(response, "target_tag")
self.assertIn(
'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"},
)
self.assertEqual(response.status_code, 422)
merge_tags_group = self.get_form_group(response, "merge_tags")
self.assertIn(
'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"},
)
self.assertEqual(response.status_code, 422)
target_tag_group = self.get_form_group(response, "target_tag")
self.assertIn(
"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"},
)
self.assertEqual(response.status_code, 422)
merge_tags_group = self.get_form_group(response, "merge_tags")
self.assertIn(
"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):
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)
def test_show_error_for_duplicate_name(self):
@@ -30,9 +30,7 @@ class TagsNewViewTestCase(TestCase, BookmarkFactoryMixin):
reverse("linkding:tags.new"), {"name": "existing_tag"}
)
self.assertContains(
response, "Tag &quot;existing_tag&quot; already exists", status_code=422
)
self.assertContains(response, "Tag &quot;existing_tag&quot; already exists")
self.assertEqual(Tag.objects.count(), 1)
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"}
)
self.assertContains(
response, "Tag &quot;existing_TAG&quot; already exists", status_code=422
)
self.assertContains(response, "Tag &quot;existing_TAG&quot; already exists")
self.assertEqual(Tag.objects.count(), 1)
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.tests.helpers import (
BookmarkFactoryMixin,
HtmlTestMixin,
random_sentence,
disable_logging,
)
class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
class ToastsViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
@@ -72,11 +73,13 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
def test_form_tag(self):
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"))
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):
toast = self.create_toast()

View File

@@ -35,7 +35,7 @@ from bookmarks.services.bookmarks import (
)
from bookmarks.type_defs import HttpRequest
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
@@ -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
def archived(request: HttpRequest):
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):
if request.method == "POST":
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):
if context["details"]:
context["page_title"] = "Bookmark details - Linkding"
if turbo.is_frame(request, "details-modal"):
return render(
request,
"bookmarks/updates/details-modal-frame.html",
context,
)
return turbo.frame(request, "bookmarks/details/modal.html", context)
return render(
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):
if "save" in request.POST:
if not request.user.is_authenticated:
@@ -272,7 +328,7 @@ def index_action(request: HttpRequest):
return response
if turbo.accept(request):
return partials.active_bookmark_update(request)
return index_update(request)
return utils.redirect_with_query(request, reverse("linkding:bookmarks.index"))
@@ -289,7 +345,7 @@ def archived_action(request: HttpRequest):
return response
if turbo.accept(request):
return partials.archived_bookmark_update(request)
return archived_update(request)
return utils.redirect_with_query(request, reverse("linkding:bookmarks.archived"))
@@ -304,7 +360,7 @@ def shared_action(request: HttpRequest):
return response
if turbo.accept(request):
return partials.shared_bookmark_update(request)
return shared_update(request)
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.')
return HttpResponseRedirect(reverse("linkding:tags.index"))
else:
return turbo.replace(
request, "tag-modal", "tags/new.html", {"form": form}, status=422
return turbo.stream(
turbo.replace(
request,
"tag-modal",
"tags/new.html",
{"form": form},
method="morph",
)
)
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.')
return HttpResponseRedirect(reverse("linkding:tags.index"))
else:
return turbo.replace(
request,
"tag-modal",
"tags/edit.html",
{"tag": tag, "form": form},
status=422,
return turbo.stream(
turbo.replace(
request,
"tag-modal",
"tags/edit.html",
{"tag": tag, "form": form},
method="morph",
)
)
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"))
else:
return turbo.replace(
request,
"tag-modal",
"tags/merge.html",
{"form": form},
status=422,
return turbo.stream(
turbo.replace(
request,
"tag-modal",
"tags/merge.html",
{"form": form},
method="morph",
)
)
return render(request, "tags/merge.html", {"form": form})

View File

@@ -1,5 +1,4 @@
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render as django_render
from django.template import loader
@@ -14,24 +13,49 @@ def is_frame(request: HttpRequest, frame: str) -> bool:
return request.headers.get("Turbo-Frame") == frame
def stream(request: HttpRequest, template_name: str, context: dict) -> HttpResponse:
response = django_render(request, template_name, context)
response["Content-Type"] = "text/vnd.turbo-stream.html"
def frame(request: HttpRequest, template_name: str, context: dict) -> HttpResponse:
"""
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
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(
request: HttpRequest, target_id: str, template_name: str, context: dict, status=None
) -> HttpResponse:
"""
Returns a Turbo steam for replacing a specific target with the rendered
template. Mostly useful for updating forms in place after failed submissions,
without having to create a separate template.
"""
if status is None:
status = 200
request: HttpRequest,
target: str,
template_name: str,
context: dict,
method: str | None = "",
) -> str:
"""Render a template wrapped in a replace turbo-stream element."""
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>'
response = HttpResponse(stream_content, status=status)
response["Content-Type"] = "text/vnd.turbo-stream.html"
return response
method_attr = f' method="{method}"' if method else ""
return f'<turbo-stream action="replace"{method_attr} target="{target}"><template>{content}</template></turbo-stream>'
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",
)