diff --git a/links/search_urls.py b/links/search_urls.py
index 8f6fde6..0fa14d4 100644
--- a/links/search_urls.py
+++ b/links/search_urls.py
@@ -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'),
]
diff --git a/links/search_views.py b/links/search_views.py
index 9533960..23e1ed7 100644
--- a/links/search_views.py
+++ b/links/search_views.py
@@ -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'
diff --git a/links/templates/links/link_list.html b/links/templates/links/link_list.html
index a021d2c..22b4a5a 100644
--- a/links/templates/links/link_list.html
+++ b/links/templates/links/link_list.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
because inline onerror/onclick
handlers on the
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(
+ ''
+ );
+
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"
>
+ ⌘K
diff --git a/templates/base.html b/templates/base.html
index ded2a91..a926266 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -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; }
+ }
{{ form.media }}
{% block extra_css %}{% endblock %}
@@ -94,13 +193,17 @@
-