mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
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:
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 }}
|
||||
|
||||
@@ -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": []}
|
||||
|
||||
Reference in New Issue
Block a user