mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
Update logic
This commit is contained in:
@@ -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>
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user