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" > + 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 @@ -