Update logic

This commit is contained in:
2026-07-18 22:48:59 +10:00
parent 972182f385
commit f804be25db
4 changed files with 167 additions and 61 deletions
+44
View File
@@ -0,0 +1,44 @@
{% load i18n %}
<tr class="link-row">
<td>
<input type="checkbox" name="selected_links" value="{{ link.id }}" aria-label="{% trans 'Select link' %} {{ link.alias }}" class="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50">
</td>
<td>
<a href="{% url 'redirect_to_original' link.alias %}" target="_blank" rel="noopener noreferrer" class="alias-link">{{ link.alias }}</a>
</td>
<td class="hidden sm:table-cell">
{% if '{' in link.original_url and '}' in link.original_url %}
<span class="badge badge-template"><i class="fas fa-magic" aria-hidden="true"></i>{% trans "Template" %}</span>
{% elif link.link_type == 'LINK' %}
<span class="badge badge-link"><i class="fas fa-link" aria-hidden="true"></i>{% trans "Link" %}</span>
{% else %}
<span class="badge badge-custom"><i class="fas fa-code" aria-hidden="true"></i>{% trans "Custom" %}</span>
{% endif %}
</td>
<td class="hidden md:table-cell">
<div class="url-cell" title="{{ link.original_url }}">
<a href="{% url 'redirect_to_original' link.alias %}" target="_blank" rel="noopener noreferrer">{{ link.original_url }}</a>
</div>
</td>
<td class="clicks-cell hidden sm:table-cell">{{ link.click_count }}</td>
<td>
<div class="flex items-center gap-1">
<a href="{% url 'link_detail' link.id %}" class="icon-btn blue" title="{% trans 'View Details' %}" aria-label="{% trans 'View Details' %}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</a>
<a href="{% url 'link_update' link.id %}" class="icon-btn green" title="{% trans 'Edit' %}" aria-label="{% trans 'Edit' %}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</a>
<a href="{% url 'link_delete' link.id %}" class="icon-btn red" title="{% trans 'Delete' %}" aria-label="{% trans 'Delete' %}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</a>
</div>
</td>
</tr>
+63 -44
View File
@@ -101,6 +101,10 @@
/* ---------- Hero search (frosted glass pill) ---------- */
.hero-search { position: relative; max-width: 40rem; margin: 2.6rem auto 0; text-align: left; }
/* Must stack above .hero-stats (which shares the same z-index: 1 from the
">*:not(.hero-glow)" rule above) so the search results dropdown never
renders underneath the stats row. */
.apple-hero > .hero-search { z-index: 20; }
.hero-search .search-icon {
position: absolute; left: 1.4rem; top: 50%; transform: translateY(-50%);
width: 1.25rem; height: 1.25rem; color: var(--apple-gray); pointer-events: none;
@@ -264,6 +268,14 @@
}
.selection-hint { font-size: 0.85rem; color: var(--apple-gray); margin: 0; }
.library-load-more { display: flex; justify-content: center; padding: 0.4rem 1.8rem 1.2rem; }
.apple-btn-secondary {
background: var(--apple-bg); color: var(--apple-text);
border: 1px solid rgba(0, 0, 0, 0.10);
}
.apple-btn-secondary:hover { background: #ececef; }
.apple-btn-secondary[disabled] { opacity: 0.55; cursor: default; pointer-events: none; }
/* ---------- Posts ---------- */
.posts-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1.1rem; list-style: none; margin: 0; padding: 0; }
.post-card {
@@ -428,7 +440,7 @@
<div class="hero-stats hero-anim" style="animation-delay: 0.36s;">
<div class="hero-stat">
<span class="hero-stat-number">{{ links|length }}</span>
<span class="hero-stat-number">{{ total_links_count }}</span>
<span class="hero-stat-label">{% trans "Links" %}</span>
</div>
<span class="hero-stat-divider" aria-hidden="true"></span>
@@ -444,6 +456,9 @@
<div class="library-header">
<div class="library-title-group">
<h2 id="links-heading" class="section-title">{% trans "Link List" %}</h2>
<p class="library-count">
{% blocktrans count counter=total_links_count %}{{ counter }} link{% plural %}{{ counter }} links{% endblocktrans %}
</p>
</div>
<div class="library-actions">
<div class="library-filter">
@@ -516,49 +531,7 @@
</thead>
<tbody id="links-table-body">
{% for link in links %}
<tr class="link-row">
<td>
<input type="checkbox" name="selected_links" value="{{ link.id }}" aria-label="{% trans 'Select link' %} {{ link.alias }}" class="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50">
</td>
<td>
<a href="{% url 'redirect_to_original' link.alias %}" target="_blank" rel="noopener noreferrer" class="alias-link">{{ link.alias }}</a>
</td>
<td class="hidden sm:table-cell">
{% if '{' in link.original_url and '}' in link.original_url %}
<span class="badge badge-template"><i class="fas fa-magic" aria-hidden="true"></i>{% trans "Template" %}</span>
{% elif link.link_type == 'LINK' %}
<span class="badge badge-link"><i class="fas fa-link" aria-hidden="true"></i>{% trans "Link" %}</span>
{% else %}
<span class="badge badge-custom"><i class="fas fa-code" aria-hidden="true"></i>{% trans "Custom" %}</span>
{% endif %}
</td>
<td class="hidden md:table-cell">
<div class="url-cell" title="{{ link.original_url }}">
<a href="{% url 'redirect_to_original' link.alias %}" target="_blank" rel="noopener noreferrer">{{ link.original_url }}</a>
</div>
</td>
<td class="clicks-cell hidden sm:table-cell">{{ link.click_count }}</td>
<td>
<div class="flex items-center gap-1">
<a href="{% url 'link_detail' link.id %}" class="icon-btn blue" title="{% trans 'View Details' %}" aria-label="{% trans 'View Details' %}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</a>
<a href="{% url 'link_update' link.id %}" class="icon-btn green" title="{% trans 'Edit' %}" aria-label="{% trans 'Edit' %}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</a>
<a href="{% url 'link_delete' link.id %}" class="icon-btn red" title="{% trans 'Delete' %}" aria-label="{% trans 'Delete' %}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</a>
</div>
</td>
</tr>
{% include 'links/_link_row.html' %}
{% empty %}
<tr>
<td colspan="6" style="text-align: center; padding: 3rem 1rem; color: var(--apple-gray);">{% trans "No links available" %}</td>
@@ -567,6 +540,20 @@
</tbody>
</table>
</div>
{% if has_more_links %}
<div class="library-load-more">
<button
type="button"
id="load-more-links"
class="apple-btn apple-btn-secondary"
data-offset="{{ links_page_size }}"
data-sort="{{ current_sort }}"
data-order="{{ current_order }}"
>
{% trans "Load more" %}
</button>
</div>
{% endif %}
<div class="library-footer">
<p class="selection-hint" id="selection-hint" aria-live="polite"></p>
<button type="submit" class="apple-btn apple-btn-danger">{% trans "Delete Selected" %}</button>
@@ -880,6 +867,38 @@
});
}
/* ---------- Load more links ---------- */
const loadMoreBtn = document.getElementById('load-more-links');
if (loadMoreBtn && tbody) {
loadMoreBtn.addEventListener('click', function () {
const offset = loadMoreBtn.dataset.offset;
const sort = loadMoreBtn.dataset.sort;
const order = loadMoreBtn.dataset.order;
loadMoreBtn.disabled = true;
loadMoreBtn.textContent = '{% trans "Loading…" %}';
const params = new URLSearchParams({ offset, sort, order });
fetch(`{% url 'load_more_links' %}?${params.toString()}`)
.then(response => response.json())
.then(data => {
tbody.insertAdjacentHTML('beforeend', data.html);
if (data.has_more) {
loadMoreBtn.dataset.offset = data.next_offset;
loadMoreBtn.disabled = false;
loadMoreBtn.textContent = '{% trans "Load more" %}';
} else {
loadMoreBtn.remove();
}
})
.catch(error => {
console.error('Error loading more links:', error);
loadMoreBtn.disabled = false;
loadMoreBtn.textContent = '{% trans "Load more" %}';
});
});
}
/* ---------- Select-all + selection hint ---------- */
const selectAll = document.getElementById('select-all');
const selectionHint = document.getElementById('selection-hint');
+1
View File
@@ -14,6 +14,7 @@ urlpatterns = [
path('update/<int:pk>/', views.LinkUpdateView.as_view(), name='link_update'),
path('delete/<int:pk>/', views.LinkDeleteView.as_view(), name='link_delete'),
path('delete-selected/', views.delete_selected, name='delete_selected'),
path('links/load-more/', views.load_more_links, name='load_more_links'),
path('detail/<int:pk>/', views.LinkDetailView.as_view(), name='link_detail'),
path('ui/search/', search_views.SearchReactView.as_view(), name='search'),
path('link/<int:pk>/', views.LinkDetailView.as_view(), name='link_detail'),
+59 -17
View File
@@ -45,6 +45,27 @@ from .search_views import search_aliases, search
logger = logging.getLogger(__name__)
# Number of link rows rendered on the initial home page load / per "Load more" click.
LINKS_PAGE_SIZE = 50
def _ordering_for(sort_by, order):
"""Shared ordering logic for the link list page and its load-more endpoint."""
if sort_by == 'alias':
return F('alias').asc(nulls_last=True) if order == 'asc' else F('alias').desc(nulls_last=True)
elif sort_by == 'link_type':
return F('link_type').asc(nulls_last=True) if order == 'asc' else F('link_type').desc(nulls_last=True)
elif sort_by == 'original_url':
return F('original_url').asc(nulls_last=True) if order == 'asc' else F('original_url').desc(nulls_last=True)
elif sort_by == 'clicks':
return F('click_count').asc(nulls_last=True) if order == 'asc' else F('click_count').desc(nulls_last=True)
elif sort_by == 'created_at':
return F('created_at').asc(nulls_last=True) if order == 'asc' else F('created_at').desc(nulls_last=True)
elif sort_by == 'updated_at':
return F('updated_at').asc(nulls_last=True) if order == 'asc' else F('updated_at').desc(nulls_last=True)
return F('click_count').desc(nulls_last=True)
class LinkListView(ListView):
model = Link
template_name = 'links/link_list.html'
@@ -54,23 +75,7 @@ class LinkListView(ListView):
queryset = super().get_queryset()
sort_by = self.request.GET.get('sort', 'click_count')
order = self.request.GET.get('order', 'asc')
if sort_by == 'alias':
ordering = F('alias').asc(nulls_last=True) if order == 'asc' else F('alias').desc(nulls_last=True)
elif sort_by == 'link_type':
ordering = F('link_type').asc(nulls_last=True) if order == 'asc' else F('link_type').desc(nulls_last=True)
elif sort_by == 'original_url':
ordering = F('original_url').asc(nulls_last=True) if order == 'asc' else F('original_url').desc(nulls_last=True)
elif sort_by == 'clicks':
ordering = F('click_count').asc(nulls_last=True) if order == 'asc' else F('click_count').desc(nulls_last=True)
elif sort_by == 'created_at':
ordering = F('created_at').asc(nulls_last=True) if order == 'asc' else F('created_at').desc(nulls_last=True)
elif sort_by == 'updated_at':
ordering = F('updated_at').asc(nulls_last=True) if order == 'asc' else F('updated_at').desc(nulls_last=True)
else:
ordering = F('click_count').desc(nulls_last=True)
return queryset.order_by(ordering)
return queryset.order_by(_ordering_for(sort_by, order))
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
@@ -105,8 +110,45 @@ class LinkListView(ListView):
total=Sum('click_count')
)['total'] or 0
# Only render the first page of links up front (fastest by far for
# large link libraries); the rest is fetched on demand via the
# "Load more" button, which calls load_more_links() below.
total_links_count = context['links'].count()
context['total_links_count'] = total_links_count
context['links_page_size'] = LINKS_PAGE_SIZE
context['links'] = context['links'][:LINKS_PAGE_SIZE]
context['has_more_links'] = total_links_count > LINKS_PAGE_SIZE
return context
def load_more_links(request):
"""Return the next page of link rows (rendered as <tr> HTML) for the
home page's "Load more" button, so the initial page load doesn't have
to render the entire link table at once."""
sort_by = request.GET.get('sort', 'click_count')
order = request.GET.get('order', 'asc')
try:
offset = max(int(request.GET.get('offset', 0)), 0)
except (TypeError, ValueError):
offset = 0
queryset = Link.objects.order_by(_ordering_for(sort_by, order))
total_count = queryset.count()
page = queryset[offset:offset + LINKS_PAGE_SIZE]
rows_html = ''.join(
render(request, 'links/_link_row.html', {'link': link}).content.decode('utf-8')
for link in page
)
next_offset = offset + LINKS_PAGE_SIZE
return JsonResponse({
'html': rows_html,
'has_more': next_offset < total_count,
'next_offset': next_offset,
})
class LinkCreateView(CreateView):
model = Link
form_class = LinkForm