diff --git a/links/search_views.py b/links/search_views.py index 64e2e4d..9533960 100644 --- a/links/search_views.py +++ b/links/search_views.py @@ -8,6 +8,7 @@ from django.db import models from django.utils.text import slugify from .models import Link, Page, Post, Bookmark from .search_backend import search_backend +from django.urls import reverse from django.views.decorators.http import require_http_methods from django.views.decorators.csrf import csrf_exempt from django.core.cache import cache @@ -243,12 +244,40 @@ def search_api_v2(request): def search_aliases(request): query = request.GET.get('q', '').strip() - links = Link.objects.filter(alias__icontains=query).annotate(priority=Case(When(alias__iexact=query, then=Value(1)), When(alias__istartswith=query, then=Value(2)), default=Value(3), output_field=IntegerField())).order_by('priority', 'alias')[:10] + qs = Link.objects.all() + if query: + qs = qs.filter( + Q(alias__icontains=query) + | Q(original_url__icontains=query) + | Q(description__icontains=query) + | Q(text__icontains=query) + | Q(tags__name__icontains=query) + ).distinct() + qs = qs.annotate(priority=Case( + When(alias__iexact=query, then=Value(1)), + When(alias__istartswith=query, then=Value(2)), + default=Value(3), output_field=IntegerField(), + )).order_by('priority', 'alias') + else: + # No query: return the most recently updated links (used by the + # home-page library as the "recent links" default view). + qs = qs.order_by('-updated_at') + links = qs[:20] results = [] for link in links: try: url = request.build_absolute_uri(f'/{link.alias}/') - results.append({'alias': link.alias, 'url': url}) + results.append({ + 'alias': link.alias, + 'url': url, + 'id': link.id, + 'link_type': link.link_type, + 'detail_url': reverse('link_detail', args=[link.id]), + 'edit_url': reverse('link_update', args=[link.id]), + 'click_count': link.click_count, + 'description': (link.description or '')[:160], + 'original_url': (link.original_url or '')[:180], + }) except Exception as e: logger.error(f"Error creating URL for alias {link.alias}: {str(e)}") continue diff --git a/links/templates/links/link_list.html b/links/templates/links/link_list.html index a078a63..a021d2c 100644 --- a/links/templates/links/link_list.html +++ b/links/templates/links/link_list.html @@ -70,7 +70,7 @@ } .hero-glow { position: absolute; - inset: -12% -6% auto; + inset: -12% 0 auto; height: 340px; z-index: 0; pointer-events: none; @@ -286,6 +286,70 @@ .apple-btn-secondary:hover { background: #ececef; } .apple-btn-secondary[disabled] { opacity: 0.55; cursor: default; pointer-events: none; } + /* ---------- Search-driven result cards (replace the table while searching) ---------- */ + .search-card-list { + display: flex; flex-direction: column; gap: 0.7rem; + padding: 0.2rem 1.8rem 1.4rem; + } + .search-card { + display: flex; align-items: center; gap: 1rem; + background: #fff; border: 1px solid rgba(0, 0, 0, 0.04); + border-radius: 20px; box-shadow: var(--apple-shadow-card); + padding: 0.85rem 1.15rem; + transition: box-shadow 0.3s var(--apple-ease), transform 0.3s var(--apple-ease); + } + .search-card:hover { box-shadow: var(--apple-shadow-lift); transform: translateY(-1px); } + .search-card-main { flex: 1; min-width: 0; cursor: pointer; border-radius: 12px; } + .search-card-main:focus-visible { outline: 2px solid var(--apple-blue); outline-offset: 3px; } + .search-card-title { display: flex; align-items: center; gap: 0.55rem; min-width: 0; } + .search-card-title a.alias-link { + font-size: 1.08rem; font-weight: 600; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .search-card-title a.alias-link:hover { text-decoration: underline; } + .search-card-url { + font-size: 0.82rem; color: var(--apple-gray); margin-top: 0.15rem; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .search-card-desc { + font-size: 0.85rem; color: var(--apple-gray); margin-top: 0.2rem; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .search-card mark { + background: rgba(0, 113, 227, 0.13); color: var(--apple-blue); + border-radius: 4px; padding: 0 1px; + } + .search-card-count { + font-size: 0.72rem; color: #c7c7cc; font-variant-numeric: tabular-nums; + flex: none; white-space: nowrap; + } + .search-card-actions { display: flex; align-items: center; gap: 0.2rem; flex: none; } + .search-empty-card { padding: 2.75rem 1.5rem; text-align: center; color: var(--apple-gray); } + + /* ---------- Mobile: table rows become cards (kills the 620px horizontal scroll) ---------- */ + @media (max-width: 640px) { + .table-scroll { overflow: visible; } + .apple-table { min-width: 0; } + .apple-table thead { display: none; } + .apple-table tbody { display: flex; flex-direction: column; gap: 0.7rem; padding: 0 0.85rem 0.85rem; } + .apple-table tbody tr { + display: flex; align-items: center; gap: 0.8rem; + background: #fff; border-radius: 18px; + box-shadow: var(--apple-shadow-card); + padding: 0.8rem 1rem; + } + .apple-table tbody td { display: block; padding: 0; border-bottom: none !important; } + /* Tailwind's `hidden` helper must win over the block layout above, or the + type/url/clicks cells reappear on mobile and blow the card wider than + the viewport (verified: 564px row on a 390px screen). */ + .apple-table tbody td.hidden { display: none !important; } + .apple-table tbody td:first-child { flex: none; } + .apple-table tbody td:nth-child(2) { flex: 1; min-width: 0; } + .apple-table tbody td:last-child { flex: none; } + /* Empty / no-results rows (colspan) span the whole card width */ + .apple-table tbody tr td[colspan] { flex: 1; text-align: center; padding: 2.25rem 1rem; } + } + /* ---------- First-screen dashboard (posts + images side by side) ---------- */ .dash-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 1.25rem; @@ -585,7 +649,6 @@ id="main-search-input" placeholder="{% trans 'Search aliases...' %}" autocomplete="off" - autofocus role="combobox" aria-expanded="false" aria-controls="main-search-results" @@ -796,7 +859,9 @@ -
+ + + {% csrf_token %}
@@ -1011,6 +1076,12 @@ /* ---------- Hero search: alias autocomplete ---------- */ setupMainSearch('main-search-input', 'main-search-results'); + /* ---------- Desktop-only autofocus (never pop the keyboard on mobile) ---------- */ + const mainSearchInput = document.getElementById('main-search-input'); + if (mainSearchInput && window.matchMedia('(hover: hover) and (pointer: fine)').matches) { + mainSearchInput.focus(); + } + function setupMainSearch(inputId, resultsId) { const searchInput = document.getElementById(inputId); const searchResults = document.getElementById(resultsId); @@ -1145,33 +1216,166 @@ }); } - /* ---------- Link table client-side filter ---------- */ - const filterInput = document.getElementById('search-alias-input'); - const tbody = document.getElementById('links-table-body'); - if (filterInput && tbody) { - filterInput.addEventListener('keyup', function () { - const searchTerm = filterInput.value.toLowerCase().trim(); - let visibleCount = 0; + /* ---------- Link library: search-driven management cards ---------- */ + const libFilter = document.getElementById('search-alias-input'); + const libForm = document.getElementById('library-form'); + const cardResults = document.getElementById('search-card-results'); - tbody.querySelectorAll('.link-row').forEach(row => { - const aliasElement = row.querySelector('td:nth-child(2) a'); - if (!aliasElement) return; - const shouldShow = aliasElement.textContent.toLowerCase().includes(searchTerm); - row.style.display = shouldShow ? '' : 'none'; - if (shouldShow) visibleCount++; - }); + function debounce(fn, wait) { + let t; + return function (...args) { clearTimeout(t); t = setTimeout(() => fn.apply(this, args), wait); }; + } + const escapeHtml = s => s.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + const escapeRegExp = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + function highlightMatch(text, query) { + const safe = escapeHtml(text || ''); + if (!query) return safe; + try { return safe.replace(new RegExp('(' + escapeRegExp(escapeHtml(query)) + ')', 'gi'), '$1'); } + catch (e) { return safe; } + } - const noResultsRow = document.getElementById('no-results-row'); - if (visibleCount === 0) { - if (!noResultsRow) { - const newRow = document.createElement('tr'); - newRow.id = 'no-results-row'; - newRow.innerHTML = ''; - tbody.appendChild(newRow); - } - } else if (noResultsRow) { - noResultsRow.remove(); + function badgeClassFor(item) { + if (item.original_url && item.original_url.includes('{') && item.original_url.includes('}')) return 'badge-template'; + if (item.link_type === 'ACTION') return 'badge-action'; + if (item.link_type === 'LINK') return 'badge-link'; + return 'badge-custom'; + } + function badgeHtmlFor(item) { + if (item.original_url && item.original_url.includes('{') && item.original_url.includes('}')) return 'Template'; + if (item.link_type === 'ACTION') return 'Action'; + if (item.link_type === 'LINK') return 'Link'; + return 'Custom'; + } + + const eyeSvg = ''; + const pencilSvg = ''; + const trashSvg = ''; + + if (libFilter && cardResults && libForm) { + let libFocus = -1; + + function actionLink(href, color, svg, label) { + const a = document.createElement('a'); + a.href = href; + a.className = 'icon-btn ' + color; + a.title = label; + a.setAttribute('aria-label', label); + a.innerHTML = svg; + return a; + } + + function renderLibraryCards(data, query) { + cardResults.innerHTML = ''; + libFocus = -1; + + if (!data.length) { + const empty = document.createElement('div'); + empty.className = 'search-empty-card'; + empty.textContent = '{% trans "No matching links found" %}'; + cardResults.appendChild(empty); + return; } + + data.forEach((item, index) => { + if (!item || typeof item !== 'object' || !('alias' in item)) return; + + const card = document.createElement('div'); + card.className = 'search-card'; + + const main = document.createElement('div'); + main.className = 'search-card-main'; + main.setAttribute('role', 'button'); + main.setAttribute('tabindex', '0'); + main.setAttribute('aria-label', item.alias); + main.dataset.detailUrl = item.detail_url || ''; + + const title = document.createElement('div'); + title.className = 'search-card-title'; + + const aliasLink = document.createElement('a'); + aliasLink.className = 'alias-link'; + aliasLink.href = item.url; + aliasLink.target = '_blank'; + aliasLink.rel = 'noopener noreferrer'; + aliasLink.innerHTML = highlightMatch(item.alias, query); + + const badge = document.createElement('span'); + badge.className = 'badge ' + badgeClassFor(item); + badge.innerHTML = badgeHtmlFor(item); + + title.appendChild(aliasLink); + title.appendChild(badge); + + if (item.click_count) { + const count = document.createElement('span'); + count.className = 'search-card-count'; + count.textContent = item.click_count + ' clicks'; + title.appendChild(count); + } + main.appendChild(title); + + if (item.original_url) { + const urlLine = document.createElement('div'); + urlLine.className = 'search-card-url'; + urlLine.textContent = item.original_url; + main.appendChild(urlLine); + } + if (item.description) { + const desc = document.createElement('div'); + desc.className = 'search-card-desc'; + desc.textContent = item.description; + main.appendChild(desc); + } + + const actions = document.createElement('div'); + actions.className = 'search-card-actions'; + actions.appendChild(actionLink(item.detail_url, 'blue', eyeSvg, '{% trans "View Details" %}')); + actions.appendChild(actionLink(item.edit_url, 'green', pencilSvg, '{% trans "Edit" %}')); + actions.appendChild(actionLink('/delete/' + item.id + '/', 'red', trashSvg, '{% trans "Delete" %}')); + + card.appendChild(main); + card.appendChild(actions); + cardResults.appendChild(card); + + main.addEventListener('click', () => { + if (item.detail_url) window.location.href = item.detail_url; + }); + main.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && item.detail_url) { e.preventDefault(); window.location.href = item.detail_url; } + }); + }); + } + + function setLibFocus(cards, index) { + if (!cards.length) return; + libFocus = ((index % cards.length) + cards.length) % cards.length; + cards[libFocus].focus(); + } + + libFilter.addEventListener('input', debounce(function () { + const q = libFilter.value.trim(); + if (!q) { + cardResults.hidden = true; + libForm.hidden = false; + return; + } + fetch(`/search/aliases/?q=${encodeURIComponent(q)}`) + .then(response => response.json()) + .then(data => { + renderLibraryCards(Array.isArray(data) ? data : [], q); + cardResults.hidden = false; + libForm.hidden = true; + }) + .catch(error => { + console.error('Error searching links:', error); + }); + }, 250)); + + libFilter.addEventListener('keydown', function (e) { + const cards = cardResults.querySelectorAll('.search-card-main'); + if (e.key === 'ArrowDown') { e.preventDefault(); setLibFocus(cards, libFocus + 1); } + else if (e.key === 'ArrowUp') { e.preventDefault(); setLibFocus(cards, libFocus - 1); } + else if (e.key === 'Enter' && libFocus > -1 && cards[libFocus]) { e.preventDefault(); cards[libFocus].click(); } }); } diff --git a/templates/base.html b/templates/base.html index 4a4eb91..ded2a91 100644 --- a/templates/base.html +++ b/templates/base.html @@ -94,13 +94,13 @@ -
{% trans "No matching links found" %}