feat: ⌘K command palette + restore green nav (fixes mobile menu collapse)

- Command palette (Ctrl/Cmd+K or nav search icon): /search/palette/ groups
  links/pages/posts with jump/detail/edit actions, keyboard navigation
- Hero: stats inline under search pill, ⌘K chip on desktop, Random Images
  broken-image fallback to elegant SVG placeholder (layout untouched)
- Restore original green nav (bg-custom-blue). The frosted-glass nav from
  1.0.401 broke the mobile Menu dropdown: backdrop-filter creates a
  containing block, collapsing the fixed full-screen dropdown to 3px
- Palette tests: +5 cases (grouping, post match, management fields,
  empty query, no results)
This commit is contained in:
OpenClaw Sub-agent
2026-08-02 11:20:11 +10:00
parent ce25637b43
commit 595b99446b
5 changed files with 461 additions and 18 deletions
+2 -1
View File
@@ -1,5 +1,5 @@
from django.urls import path
from .search_views import SearchReactView, search, search_aliases, search_api_v2, search_vector_api
from .search_views import SearchReactView, search, search_aliases, search_api_v2, search_vector_api, search_palette
urlpatterns = [
path('', SearchReactView.as_view(), name='search_main'),
@@ -7,4 +7,5 @@ urlpatterns = [
path('api/v2/', search_api_v2, name='search_api_v2'),
path('api/vector/', search_vector_api, name='search_vector_api'),
path('aliases/', search_aliases, name='search_aliases'),
path('palette/', search_palette, name='search_palette'),
]
+54
View File
@@ -283,5 +283,59 @@ def search_aliases(request):
continue
return JsonResponse(results, safe=False)
def search_palette(request):
"""Command-palette search: grouped results across links / pages / posts.
Called by the ⌘K palette overlay (rendered in base.html). Empty query
returns the most recently updated links as the default view.
"""
query = request.GET.get('q', '').strip()
result = {'links': [], 'pages': [], 'posts': []}
if query:
links = Link.objects.filter(
Q(alias__icontains=query)
| Q(original_url__icontains=query)
| Q(description__icontains=query)
| Q(text__icontains=query)
| Q(tags__name__icontains=query)
).distinct().order_by('-updated_at')[:6]
pages = Page.objects.filter(
Q(title__icontains=query) | Q(url__icontains=query) | Q(summary__icontains=query)
).order_by('-updated_at')[:4]
posts = Post.objects.filter(
Q(title__icontains=query) | Q(summary__icontains=query)
).order_by('-created_at')[:4]
else:
links = Link.objects.order_by('-updated_at')[:6]
pages = []
posts = []
for link in links:
result['links'].append({
'alias': link.alias,
'url': request.build_absolute_uri(f'/{link.alias}/'),
'id': link.id,
'link_type': link.link_type,
'detail_url': reverse('link_detail', args=[link.id]),
'edit_url': reverse('link_update', args=[link.id]),
'description': (link.description or '')[:140],
'original_url': (link.original_url or '')[:140],
})
for page in pages:
result['pages'].append({
'title': page.title or page.url,
'url': reverse('page-detail', args=[page.pk]),
'edit_url': reverse('page-update', args=[page.pk]),
'original_url': (page.url or '')[:140],
})
for post in posts:
result['posts'].append({
'title': post.title,
'url': reverse('post-detail', args=[post.pk]),
'edit_url': reverse('post-update', args=[post.pk]),
})
return JsonResponse(result)
class SearchReactView(TemplateView):
template_name = 'links/search_react.html'
+38 -13
View File
@@ -59,14 +59,14 @@
.app-chip, .post-row, .fan-card, .fan-card img, .fan-card-frame, .fan-lightbox { transition: none !important; animation: none !important; }
}
/* ---------- Hero (compact: search + stats share one row on desktop) ---------- */
/* ---------- Hero (compact: search + stats stack vertically) ---------- */
.apple-hero {
position: relative;
text-align: center;
padding: clamp(1.5rem, 4vh, 2.5rem) 1rem clamp(1.25rem, 3vh, 1.75rem);
}
@media (min-width: 768px) {
.apple-hero { display: flex; align-items: center; gap: 2.25rem; text-align: left; }
.apple-hero { display: flex; flex-direction: column; align-items: center; gap: 0.9rem; text-align: center; }
}
.hero-glow {
position: absolute;
@@ -105,7 +105,7 @@
/* ---------- Hero search (frosted glass pill) ---------- */
.hero-search { position: relative; width: 100%; max-width: 44rem; margin: 0 auto; text-align: left; }
@media (min-width: 768px) {
.hero-search { flex: 1 1 auto; margin: 0; }
.hero-search { flex: none; margin: 0 auto; }
}
/* 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
@@ -115,8 +115,17 @@
position: absolute; left: 1.4rem; top: 50%; transform: translateY(-50%);
width: 1.25rem; height: 1.25rem; color: var(--apple-gray); pointer-events: none;
}
.hero-search-kbd {
position: absolute; right: 0.95rem; top: 50%; transform: translateY(-50%);
font-family: inherit; font-size: 0.75rem; color: #86868b;
background: rgba(0, 0, 0, 0.06); border-radius: 7px;
padding: 0.2rem 0.55rem; pointer-events: none;
}
@media (max-width: 640px) {
.hero-search-kbd { display: none; }
}
.hero-search input {
width: 100%; height: 3.4rem; padding: 0 1.6rem 0 3.5rem;
width: 100%; height: 3.4rem; padding: 0 4.2rem 0 3.5rem;
font-size: 1.08rem; color: var(--apple-text);
background: rgba(255, 255, 255, 0.82);
-webkit-backdrop-filter: blur(20px) saturate(180%);
@@ -159,15 +168,15 @@
.search-result-item .chev { width: 0.85rem; height: 0.85rem; color: #c7c7cc; flex: none; }
.search-empty { padding: 1.1rem; color: var(--apple-gray); text-align: center; font-size: 0.95rem; }
/* ---------- Hero stats ---------- */
.hero-stats { display: flex; align-items: center; justify-content: center; gap: 1.4rem; margin-top: 1.1rem; }
/* ---------- Hero stats (inline, below the search pill) ---------- */
.hero-stats { display: flex; align-items: center; justify-content: center; gap: 1.1rem; margin-top: 0.55rem; }
@media (min-width: 768px) {
.hero-stats { margin-top: 0; flex: none; padding-right: 0.35rem; }
.hero-stats { margin-top: 0.35rem; }
}
.hero-stat { display: flex; flex-direction: column; align-items: center; gap: 0.1rem; }
.hero-stat-number { font-size: 1.35rem; font-weight: 700; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.hero-stat-label { font-size: 0.7rem; color: var(--apple-gray); text-transform: uppercase; letter-spacing: 0.09em; font-weight: 500; }
.hero-stat-divider { width: 1px; height: 2rem; background: rgba(0, 0, 0, 0.10); }
.hero-stat { display: flex; flex-direction: row; align-items: baseline; gap: 0.4rem; }
.hero-stat-number { font-size: 1.05rem; font-weight: 700; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.hero-stat-label { font-size: 0.68rem; color: var(--apple-gray); text-transform: uppercase; letter-spacing: 0.09em; font-weight: 500; }
.hero-stat-divider { width: 1px; height: 1.1rem; background: rgba(0, 0, 0, 0.10); }
/* ---------- Sections, cards, buttons ---------- */
.home-section { margin-top: clamp(1.5rem, 3.5vh, 2.5rem); }
@@ -589,10 +598,25 @@
/* Random image helpers — defined in <head> because inline onerror/onclick
handlers on the <img> elements can fire while the body is still parsing.
The random-image API picks from random subfolders and can 404 occasionally;
retry with a fresh cache-buster a couple of times before giving up. */
retry with a fresh cache-buster a couple of times, then fall back to an
elegant gradient placeholder instead of the browser's broken-image icon. */
const FALLBACK_IMG = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="600" height="800" viewBox="0 0 600 800">' +
'<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">' +
'<stop offset="0" stop-color="#f2f2f7"/><stop offset="1" stop-color="#e3e3e8"/></linearGradient></defs>' +
'<rect width="600" height="800" fill="url(#g)"/>' +
'<g stroke="#c7c7cc" stroke-width="5" fill="none" stroke-linejoin="round" transform="translate(300 380)">' +
'<circle cx="0" cy="8" r="42"/>' +
'<path d="M-24 -6 L-12 -6 L-6 -16 L20 -16 L26 -6 L30 -6 L30 22 L-24 22 Z"/>' +
'<circle cx="-8" cy="2" r="4" fill="#c7c7cc" stroke="none"/></g></svg>'
);
window.handleRandomImageError = function (img) {
const retries = parseInt(img.dataset.retries || '0', 10);
if (retries >= 2) return;
if (retries >= 2) {
img.src = FALLBACK_IMG;
return;
}
img.dataset.retries = String(retries + 1);
setTimeout(() => window.refreshRandomImage(img), 400 * (retries + 1));
};
@@ -654,6 +678,7 @@
aria-controls="main-search-results"
aria-autocomplete="list"
>
<kbd class="hero-search-kbd" aria-hidden="true">⌘K</kbd>
<div id="main-search-results" class="search-results" role="listbox" aria-label="{% trans 'Matching aliases' %}" hidden></div>
</div>
+314 -4
View File
@@ -87,6 +87,105 @@
-webkit-box-orient: vertical;
overflow: hidden;
}
/* ===================== ⌘K command palette ===================== */
.palette-overlay {
position: fixed; inset: 0; z-index: 1000;
display: flex; align-items: flex-start; justify-content: center;
padding: min(12vh, 6rem) 1rem 2rem;
background: rgba(0, 0, 0, 0.38);
-webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
animation: palette-fade 0.18s ease;
}
.palette-overlay[hidden] { display: none; }
.palette {
width: 100%; max-width: 36rem;
background: rgba(255, 255, 255, 0.96);
-webkit-backdrop-filter: blur(24px) saturate(180%); backdrop-filter: blur(24px) saturate(180%);
border-radius: 22px;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.28);
overflow: hidden;
animation: palette-pop 0.22s cubic-bezier(0.28, 0.11, 0.32, 1);
}
.palette-search {
display: flex; align-items: center; gap: 0.75rem;
padding: 1rem 1.25rem; border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.palette-search > svg { width: 1.15rem; height: 1.15rem; color: #86868b; flex: none; }
.palette-search input {
flex: 1; min-width: 0; border: none; outline: none;
font-size: 1.1rem; color: #1d1d1f; background: transparent;
}
.palette-search input::placeholder { color: #86868b; }
.palette-search kbd {
font-family: inherit; font-size: 0.72rem; color: #86868b;
background: rgba(0, 0, 0, 0.06); border-radius: 6px;
padding: 0.15rem 0.5rem; flex: none;
}
.palette-results { max-height: min(60vh, 30rem); overflow-y: auto; padding: 0.5rem; }
.palette-group-title {
font-size: 0.7rem; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.09em; color: #86868b;
padding: 0.7rem 0.9rem 0.3rem;
}
.palette-item {
display: flex; align-items: center; gap: 0.85rem;
padding: 0.7rem 0.9rem; border-radius: 14px; cursor: pointer;
}
.palette-item.active { background: rgba(0, 113, 227, 0.09); }
.palette-item-main { flex: 1; min-width: 0; }
.palette-item-title { display: flex; align-items: center; gap: 0.5rem; min-width: 0; }
.palette-item-title a {
font-weight: 600; font-size: 0.98rem; color: #1d1d1f; text-decoration: none;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.palette-item-title a:hover { color: #0071e3; }
.palette-item-sub {
font-size: 0.8rem; color: #86868b;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.palette-item mark {
background: rgba(0, 113, 227, 0.14); color: #0071e3;
border-radius: 4px; padding: 0 1px;
}
.palette-badge {
font-size: 0.68rem; font-weight: 600; padding: 0.15rem 0.55rem;
border-radius: 980px; flex: none; white-space: nowrap;
}
.palette-badge.link { background: rgba(0, 113, 227, 0.10); color: #0071e3; }
.palette-badge.action, .palette-badge.template { background: rgba(191, 90, 242, 0.12); color: #8944ab; }
.palette-badge.custom { background: rgba(48, 209, 88, 0.15); color: #1e7b34; }
.palette-item-actions { display: flex; align-items: center; gap: 0.15rem; flex: none; }
.palette-item-actions a {
display: inline-flex; align-items: center; justify-content: center;
width: 34px; height: 34px; border-radius: 50%;
color: #8e8e93; text-decoration: none;
transition: background 0.2s ease, color 0.2s ease;
}
.palette-item-actions a:hover { background: rgba(0, 0, 0, 0.05); }
.palette-item-actions a.blue:hover { color: #0071e3; }
.palette-item-actions a.green:hover { color: #1e7b34; }
.palette-item-actions svg { width: 1.05rem; height: 1.05rem; }
.palette-empty { padding: 2rem 1rem; text-align: center; color: #86868b; font-size: 0.92rem; }
.palette-footer {
display: flex; align-items: center; justify-content: center; gap: 1.1rem;
padding: 0.6rem 1rem; border-top: 1px solid rgba(0, 0, 0, 0.05);
font-size: 0.72rem; color: #86868b;
}
.palette-footer kbd {
font-family: inherit; font-size: 0.7rem;
background: rgba(0, 0, 0, 0.06); border-radius: 5px;
padding: 0.1rem 0.4rem;
}
@keyframes palette-fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes palette-pop {
from { opacity: 0; transform: translateY(-10px) scale(0.98); }
to { opacity: 1; transform: none; }
}
@media (max-width: 640px) {
.palette-overlay { padding-top: 4rem; align-items: flex-start; }
.palette-footer { display: none; }
}
</style>
{{ form.media }}
{% block extra_css %}{% endblock %}
@@ -94,13 +193,17 @@
<script src="{% static 'js/common.js' %}"></script>
</head>
<body class="bg-gray-100">
<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);">
<nav id="main-nav" class="bg-custom-blue text-white p-4 fixed w-full top-0 z-50 nav-visible">
<div class="container mx-auto flex justify-between items-center">
<a href="{% url 'link_list' %}" class="text-xl font-bold" style="color: #1d1d1f;">{% trans "GoLinks" %}</a>
<a href="{% url 'link_list' %}" class="text-xl font-bold">{% trans "GoLinks" %}</a>
<div class="flex items-center space-x-4">
<!-- ⌘K search trigger -->
<button id="palette-trigger" class="flex items-center text-white hover:text-gray-200" aria-label="{% trans 'Search' %} (⌘K)" title="{% trans 'Search' %} (⌘K)">
<i class="fas fa-search"></i>
</button>
<!-- "Menus" 下拉菜单 -->
<div class="relative group">
<button id="more-menu-button" class="flex items-center" style="color: #1d1d1f;">
<button id="more-menu-button" class="flex items-center text-white hover:text-gray-200">
<i class="fas fa-bars mr-2"></i>
{% trans "Menu" %}
</button>
@@ -260,7 +363,7 @@
</div>
<!-- 语言选择器 -->
<div class="relative">
<button id="language-selector-button" class="flex items-center" style="color: #1d1d1f;">
<button id="language-selector-button" class="flex items-center text-white hover:text-gray-200">
<i class="fas fa-globe mr-2"></i>
{% get_current_language as LANGUAGE_CODE %}
{% get_language_info for LANGUAGE_CODE as current_language %}
@@ -330,6 +433,213 @@
});
</script>
{% block extra_js %}{% endblock %}
<!-- ===================== ⌘K command palette ===================== -->
<div id="palette-overlay" class="palette-overlay" hidden>
<div class="palette" role="dialog" aria-modal="true" aria-label="{% trans 'Search' %}">
<div class="palette-search">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-4.35-4.35M11 19a8 8 0 100-16 8 8 0 000 16z"/></svg>
<input id="palette-input" type="search" placeholder="{% trans 'Search links, pages, posts…' %}" autocomplete="off" role="combobox" aria-expanded="false" aria-controls="palette-results">
<kbd>esc</kbd>
</div>
<div id="palette-results" class="palette-results" role="listbox"></div>
<div class="palette-footer">
<span><kbd>↑↓</kbd> navigate</span>
<span><kbd></kbd> open</span>
<span><kbd></kbd> details</span>
<span><kbd>e</kbd> edit</span>
</div>
</div>
</div>
<script>
(function () {
'use strict';
var overlay = document.getElementById('palette-overlay');
var input = document.getElementById('palette-input');
var results = document.getElementById('palette-results');
var trigger = document.getElementById('palette-trigger');
if (!overlay || !input || !results) return;
var items = []; // flat list of { el, primary, detail, edit, isLink }
var focus = -1;
var escapeHtml = function (s) {
return String(s || '').replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
};
var escapeRegExp = function (s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
};
function highlight(text, query) {
var safe = escapeHtml(text);
if (!query) return safe;
try {
return safe.replace(new RegExp('(' + escapeRegExp(escapeHtml(query)) + ')', 'gi'), '<mark>$1</mark>');
} catch (e) { return safe; }
}
function badgeFor(item) {
var cls = 'palette-badge link', label = 'Link';
if (item.original_url && item.original_url.indexOf('{') > -1 && item.original_url.indexOf('}') > -1) { cls = 'palette-badge template'; label = 'Template'; }
else if (item.link_type === 'ACTION') { cls = 'palette-badge action'; label = 'Action'; }
else if (item.link_type === 'CUSTOM') { cls = 'palette-badge custom'; label = 'Custom'; }
return '<span class="' + cls + '">' + label + '</span>';
}
var 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>';
var 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>';
function iconLink(href, cls, svg, label) {
var a = document.createElement('a');
a.href = href;
a.className = cls;
a.title = label;
a.setAttribute('aria-label', label);
a.innerHTML = svg;
return a;
}
function renderGroup(title, list, renderFn, q) {
if (!list || !list.length) return;
var gt = document.createElement('div');
gt.className = 'palette-group-title';
gt.textContent = title;
results.appendChild(gt);
list.forEach(function (item) { renderFn(item, q); });
}
function addRow(mainInnerHtml, subText, actions, entry) {
var row = document.createElement('div');
row.className = 'palette-item';
row.setAttribute('role', 'option');
var main = document.createElement('div');
main.className = 'palette-item-main';
main.innerHTML = mainInnerHtml;
if (subText) {
var sub = document.createElement('div');
sub.className = 'palette-item-sub';
sub.textContent = subText;
main.appendChild(sub);
}
row.appendChild(main);
var acts = document.createElement('div');
acts.className = 'palette-item-actions';
acts.appendChild(iconLink(entry.detail, 'blue', eyeSvg, 'Details'));
acts.appendChild(iconLink(entry.edit, 'green', pencilSvg, 'Edit'));
row.appendChild(acts);
row.addEventListener('click', function (e) {
if (e.target.closest('a')) return; // let links/actions handle it
window.location.href = entry.detail;
});
results.appendChild(row);
items.push({ el: row, primary: entry.primary, detail: entry.detail, edit: entry.edit, isLink: entry.isLink });
}
function render(data, q) {
results.innerHTML = '';
items = [];
focus = -1;
if (!data) return;
renderGroup('Links', data.links, function (item) {
var inner = '<div class="palette-item-title"><a href="' + escapeHtml(item.url) + '" target="_blank" rel="noopener noreferrer">' +
highlight(item.alias, q) + '</a>' + badgeFor(item) + '</div>';
addRow(inner, item.original_url || item.description, null,
{ primary: item.url, detail: item.detail_url, edit: item.edit_url, isLink: true });
}, q);
renderGroup('Pages', data.pages, function (item) {
var inner = '<div class="palette-item-title"><a href="' + escapeHtml(item.url) + '">' + highlight(item.title, q) + '</a></div>';
addRow(inner, item.original_url, null,
{ primary: item.url, detail: item.url, edit: item.edit_url, isLink: false });
}, q);
renderGroup('Posts', data.posts, function (item) {
var inner = '<div class="palette-item-title"><a href="' + escapeHtml(item.url) + '">' + highlight(item.title, q) + '</a></div>';
addRow(inner, null, null,
{ primary: item.url, detail: item.url, edit: item.edit_url, isLink: false });
}, q);
if (!items.length) {
var empty = document.createElement('div');
empty.className = 'palette-empty';
empty.textContent = '{% trans "No results found" %}';
results.appendChild(empty);
}
input.setAttribute('aria-expanded', items.length ? 'true' : 'false');
}
function fetchPalette(q) {
fetch('/search/palette/?q=' + encodeURIComponent(q))
.then(function (r) { return r.json(); })
.then(function (data) { render(data, q); })
.catch(function (err) { console.error('Palette search failed:', err); });
}
function setFocus(index) {
if (!items.length) return;
items.forEach(function (it, i) { it.el.classList.toggle('active', i === index); });
focus = index;
if (items[focus]) items[focus].el.scrollIntoView({ block: 'nearest' });
}
function move(delta) {
if (!items.length) return;
var next = ((focus + delta) % items.length + items.length) % items.length;
setFocus(next);
}
function openPalette() {
overlay.hidden = false;
input.value = '';
fetchPalette('');
setTimeout(function () { input.focus(); }, 60);
}
function closePalette() {
overlay.hidden = true;
}
// Triggers: nav button + ⌘K / Ctrl+K everywhere
if (trigger) trigger.addEventListener('click', function () { overlay.hidden ? openPalette() : closePalette(); });
document.addEventListener('keydown', function (e) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
overlay.hidden ? openPalette() : closePalette();
}
});
// Overlay interactions
overlay.addEventListener('click', function (e) { if (e.target === overlay) closePalette(); });
input.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { closePalette(); }
else if (e.key === 'ArrowDown') { e.preventDefault(); move(1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); move(-1); }
else if (e.key === 'Enter') {
if (focus > -1 && items[focus]) {
e.preventDefault();
var it = items[focus];
if (it.isLink) window.open(it.primary, '_blank');
else window.location.href = it.primary;
closePalette();
}
}
else if (e.key === 'ArrowRight') {
if (focus > -1 && items[focus]) { e.preventDefault(); window.location.href = items[focus].detail; closePalette(); }
}
else if ((e.key === 'e' || e.key === 'E') && focus > -1 && items[focus] && !e.metaKey && !e.ctrlKey && !e.altKey) {
e.preventDefault(); window.location.href = items[focus].edit; closePalette();
}
});
var debounceTimer = null;
input.addEventListener('input', function () {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(function () { fetchPalette(input.value.trim()); }, 200);
});
})();
</script>
{% if BUILD_TIME %}
<footer class="text-center text-gray-400 text-xs py-3 mt-4 border-t border-gray-100">
Built at: {{ BUILD_TIME }} with ver: {{ BUILD_VERSION }}
+53
View File
@@ -105,3 +105,56 @@ 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() == []
# ---------------- /search/palette/ (⌘K command palette) ----------------
@pytest.mark.django_db
def test_palette_groups_links_pages_posts(client, db_links):
from links.models import Page, Post
Page.objects.create(url="https://github.com/about", title="About GitHub", summary="dev")
Post.objects.create(title="Weekly digest", summary="dev roundup", content="body")
resp = client.get(reverse("search_palette"), {"q": "github"})
assert resp.status_code == 200
data = resp.json()
# links group matches alias/url; pages group matches title/url
assert any(i["alias"] == "github" for i in data["links"])
assert any(i["title"] == "About GitHub" for i in data["pages"])
# posts group does not match "github"
assert data["posts"] == []
@pytest.mark.django_db
def test_palette_matches_posts(client, db_links):
from links.models import Post
Post.objects.create(title="Weekly digest", summary="dev roundup", content="body")
resp = client.get(reverse("search_palette"), {"q": "digest"})
data = resp.json()
assert any(i["title"] == "Weekly digest" for i in data["posts"])
assert data["posts"][0]["edit_url"].startswith("/ui/posts/")
@pytest.mark.django_db
def test_palette_link_entry_has_management_fields(client, db_links):
resp = client.get(reverse("search_palette"), {"q": "notify"})
data = resp.json()
item = next(i for i in data["links"] if i["alias"] == "notify")
assert item["link_type"] == "ACTION"
assert item["detail_url"] == f"/link/{db_links[3].id}/"
assert item["edit_url"] == f"/link/{db_links[3].id}/edit/"
@pytest.mark.django_db
def test_palette_empty_query_returns_recent_links(client, db_links):
resp = client.get(reverse("search_palette"))
data = resp.json()
assert len(data["links"]) >= 4
assert data["pages"] == []
assert data["posts"] == []
@pytest.mark.django_db
def test_palette_no_results(client, db_links):
resp = client.get(reverse("search_palette"), {"q": "zzzznothing"})
data = resp.json()
assert data == {"links": [], "pages": [], "posts": []}