feat(home): search-driven link library with mobile card layout

- /search/aliases/ now matches alias/url/description/text/tags and returns
  id, link_type, detail_url, edit_url, click_count for management actions
- Library filter upgraded from client-side row filtering to live search
  cards: click card -> detail page, inline edit/delete, alias jumps
- Mobile: table rows render as cards (kills 620px horizontal scroll),
  hidden cells stay hidden, no viewport overflow
- Hero search no longer autofocuses on touch devices (no keyboard pop);
  desktop keeps autofocus via hover:fine media query
- Nav bar unified to light frosted-glass (was dark green) across all pages
- Fix hero-glow decorative overflow on mobile
- Add tests/test_search_aliases.py (8 cases)
This commit is contained in:
OpenClaw Sub-agent
2026-08-02 10:58:44 +10:00
parent 74557b4c64
commit dda47ee62e
4 changed files with 373 additions and 33 deletions
+31 -2
View File
@@ -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
+231 -27
View File
@@ -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 @@
</div>
</div>
<form method="post" action="{% url 'delete_selected' %}">
<div id="search-card-results" class="search-card-list" hidden aria-live="polite"></div>
<form method="post" action="{% url 'delete_selected' %}" id="library-form">
{% csrf_token %}
<div class="table-scroll">
<table class="apple-table">
@@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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'), '<mark>$1</mark>'); }
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 = '<td colspan="6" style="text-align:center; padding: 2.5rem 1rem; color: var(--apple-gray);">{% trans "No matching links found" %}</td>';
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 '<i class="fas fa-magic"></i>Template';
if (item.link_type === 'ACTION') return '<i class="fas fa-bolt"></i>Action';
if (item.link_type === 'LINK') return '<i class="fas fa-link"></i>Link';
return '<i class="fas fa-code"></i>Custom';
}
const eyeSvg = '<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>';
const pencilSvg = '<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>';
const trashSvg = '<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>';
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(); }
});
}
+4 -4
View File
@@ -94,13 +94,13 @@
<script src="{% static 'js/common.js' %}"></script>
</head>
<body class="bg-gray-100">
<nav id="main-nav" class="bg-custom-blue text-white p-4 fixed w-full top-0 z-50 nav-visible">
<nav id="main-nav" class="p-4 fixed w-full top-0 z-50 nav-visible" style="background: rgba(255, 255, 255, 0.72); -webkit-backdrop-filter: blur(20px) saturate(180%); backdrop-filter: blur(20px) saturate(180%); border-bottom: 1px solid rgba(0, 0, 0, 0.06);">
<div class="container mx-auto flex justify-between items-center">
<a href="{% url 'link_list' %}" class="text-xl font-bold">{% trans "GoLinks" %}</a>
<a href="{% url 'link_list' %}" class="text-xl font-bold" style="color: #1d1d1f;">{% trans "GoLinks" %}</a>
<div class="flex items-center space-x-4">
<!-- "Menus" 下拉菜单 -->
<div class="relative group">
<button id="more-menu-button" class="flex items-center text-white hover:text-gray-200">
<button id="more-menu-button" class="flex items-center" style="color: #1d1d1f;">
<i class="fas fa-bars mr-2"></i>
{% trans "Menu" %}
</button>
@@ -260,7 +260,7 @@
</div>
<!-- 语言选择器 -->
<div class="relative">
<button id="language-selector-button" class="flex items-center text-white hover:text-gray-200">
<button id="language-selector-button" class="flex items-center" style="color: #1d1d1f;">
<i class="fas fa-globe mr-2"></i>
{% get_current_language as LANGUAGE_CODE %}
{% get_language_info for LANGUAGE_CODE as current_language %}
+107
View File
@@ -0,0 +1,107 @@
"""Tests for the enhanced /search/aliases/ endpoint (search-driven library)."""
import pytest
from django.test import Client
from django.urls import reverse
from links.models import Link, Tag
@pytest.fixture
def db_links(db):
"""A small, deterministic link library for search tests."""
l1 = Link.objects.create(
alias="github", original_url="https://github.com", link_type="LINK", click_count=5,
description="Code hosting",
)
l2 = Link.objects.create(
alias="gitlab", original_url="https://gitlab.com", link_type="LINK", click_count=2,
description="Another code host",
)
l3 = Link.objects.create(
alias="docs", original_url="https://docs.example.com/{page,default=index}", link_type="LINK",
click_count=0, description="Template link",
)
l4 = Link.objects.create(
alias="notify", original_url="", link_type="ACTION",
action_config={"action_type": "discord_send"}, click_count=0,
)
tag = Tag.objects.create(name="dev")
l1.tags.add(tag)
return [l1, l2, l3, l4]
@pytest.mark.django_db
def test_search_aliases_matches_alias_and_returns_enriched_fields(client, db_links):
resp = client.get(reverse("search_aliases"), {"q": "git"})
assert resp.status_code == 200
data = resp.json()
aliases = {item["alias"] for item in data}
assert aliases == {"github", "gitlab"}
github = next(i for i in data if i["alias"] == "github")
# Enriched management fields are present and correct
assert github["id"] == db_links[0].id
assert github["link_type"] == "LINK"
assert github["detail_url"] == f"/link/{db_links[0].id}/"
assert github["edit_url"] == f"/link/{db_links[0].id}/edit/"
assert github["click_count"] == 5
assert github["description"] == "Code hosting"
assert github["original_url"] == "https://github.com"
# Backwards-compatible fields (hero autocomplete relies on these)
assert github["url"].endswith("/github/")
@pytest.mark.django_db
def test_search_aliases_matches_original_url(client, db_links):
resp = client.get(reverse("search_aliases"), {"q": "gitlab.com"})
assert resp.status_code == 200
assert [i["alias"] for i in resp.json()] == ["gitlab"]
@pytest.mark.django_db
def test_search_aliases_matches_description(client, db_links):
resp = client.get(reverse("search_aliases"), {"q": "template link"})
assert resp.status_code == 200
assert [i["alias"] for i in resp.json()] == ["docs"]
@pytest.mark.django_db
def test_search_aliases_matches_tag_name(client, db_links):
resp = client.get(reverse("search_aliases"), {"q": "dev"})
assert resp.status_code == 200
assert [i["alias"] for i in resp.json()] == ["github"]
@pytest.mark.django_db
def test_search_aliases_priority_exact_first(client, db_links):
resp = client.get(reverse("search_aliases"), {"q": "github"})
assert resp.status_code == 200
assert resp.json()[0]["alias"] == "github"
@pytest.mark.django_db
def test_search_aliases_empty_query_returns_recent(client, db_links):
# Touch l2 so it becomes the most recently updated (auto_now needs the
# field explicitly listed in update_fields to refresh).
db_links[1].click_count = 3
db_links[1].save(update_fields=["click_count", "updated_at"])
resp = client.get(reverse("search_aliases"))
assert resp.status_code == 200
data = resp.json()
assert len(data) >= 4
assert data[0]["alias"] == "gitlab" # most recently updated first
@pytest.mark.django_db
def test_search_aliases_action_type_surfaces(client, db_links):
resp = client.get(reverse("search_aliases"), {"q": "notify"})
assert resp.status_code == 200
item = resp.json()[0]
assert item["link_type"] == "ACTION"
assert item["detail_url"] == f"/link/{db_links[3].id}/"
@pytest.mark.django_db
def test_search_aliases_no_results(client, db_links):
resp = client.get(reverse("search_aliases"), {"q": "zzzznothing"})
assert resp.status_code == 200
assert resp.json() == []