ui: command palette (Cmd/Ctrl+K) m. build-time soegeindeks, vanilla JS

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Asger Geel Weirsøe
2026-06-11 21:55:40 +02:00
co-authored by Claude Opus 4.8
parent b0ffaa5973
commit 006da016cd
3 changed files with 165 additions and 0 deletions
+137
View File
@@ -0,0 +1,137 @@
---
// Command palette (⌘K / Ctrl+K): naviger, søg i blog, skift tema, kopiér email.
// Vanilla JS, ingen dependencies. Søgeindekset hentes lazy fra /search-index.json
// første gang paletten åbnes. Script i head er guarded (kører én gang), markup
// gen-renderes pr. side af ClientRouter — alt state læses on-open.
---
<div id="palette" class="palette-overlay" hidden>
<div class="palette-box" role="dialog" aria-modal="true" aria-label="Kommandopalette">
<input id="palette-input" class="palette-input" type="text" placeholder="Søg sider, indlæg eller kommandoer…"
autocomplete="off" spellcheck="false" />
<ul id="palette-list" class="palette-list" role="listbox"></ul>
<div class="palette-hint">↑↓ navigér · ↵ vælg · esc luk</div>
</div>
</div>
<style is:global>
.palette-overlay {
position: fixed; inset: 0; z-index: 100;
background: color-mix(in srgb, var(--color-bg) 60%, transparent);
backdrop-filter: blur(4px);
display: flex; align-items: flex-start; justify-content: center; padding-top: 15vh;
}
.palette-box {
width: min(36rem, calc(100vw - 2rem));
border: 1px solid var(--color-border); border-radius: 1rem;
background: var(--color-surface); box-shadow: 0 24px 64px rgb(0 0 0 / .45);
overflow: hidden;
}
.palette-input {
width: 100%; padding: .9rem 1.1rem; font-size: 1rem;
background: transparent; color: var(--color-text); border: 0; outline: none;
border-bottom: 1px solid var(--color-border);
}
.palette-list { max-height: 50vh; overflow-y: auto; padding: .35rem; }
.palette-item {
display: flex; justify-content: space-between; align-items: center; gap: 1rem;
padding: .55rem .8rem; border-radius: .6rem; cursor: pointer; font-size: .925rem;
}
.palette-item.is-selected, .palette-item:hover:not(.palette-empty) { background: var(--color-surface-2); }
.palette-empty { color: var(--color-muted); cursor: default; }
.palette-badge {
font-size: .7rem; color: var(--color-muted);
border: 1px solid var(--color-border); border-radius: 999px; padding: .05rem .5rem;
}
.palette-hint {
padding: .5rem 1.1rem; font-size: .72rem; color: var(--color-muted);
border-top: 1px solid var(--color-border);
}
</style>
<script is:inline>
(() => {
if (window.__palette) return; window.__palette = true;
let index = null, items = [], sel = 0;
const staticActions = [
{ type: 'action', title: 'Tema: mørkt', run: () => setTheme('dark') },
{ type: 'action', title: 'Tema: lyst', run: () => setTheme('light') },
{ type: 'action', title: 'Tema: terminal', run: () => setTheme('terminal') },
{ type: 'action', title: 'Kopiér email (asger@weircon.dk)', run: () => navigator.clipboard?.writeText('asger@weircon.dk') },
{ type: 'link', title: 'LinkedIn ↗', url: 'https://www.linkedin.com/in/ag-weirs%C3%B8e/', ext: true },
{ type: 'link', title: 'Mastodon ↗', url: 'https://mastodon.social/@asgerweirsoee', ext: true },
{ type: 'link', title: 'Gitea ↗', url: 'https://gitea.weircon.dk/agw', ext: true },
{ type: 'link', title: 'RSS-feed', url: '/rss.xml' },
];
function setTheme(t) {
document.documentElement.dataset.theme = t;
localStorage.setItem('theme', t);
}
const el = (id) => document.getElementById(id);
async function ensureIndex() {
if (index) return;
try { index = await (await fetch('/search-index.json')).json(); } catch { index = []; }
}
function score(q, it) {
const hay = (it.title + ' ' + (it.extra || '')).toLowerCase();
if (!q) return it.type === 'page' || it.type === 'action' ? 1 : 0.5;
if (hay.startsWith(q)) return 3;
if (hay.includes(q)) return 2;
// simpel subsekvens-match (fzf-agtig)
let i = 0; for (const c of hay) if (c === q[i]) i++;
return i === q.length ? 1 : 0;
}
function refresh() {
const q = el('palette-input').value.trim().toLowerCase();
items = [...(index || []), ...staticActions]
.map((it) => ({ it, s: score(q, it) }))
.filter((x) => x.s > 0)
.sort((a, b) => b.s - a.s)
.slice(0, 9)
.map((x) => x.it);
sel = 0;
el('palette-list').innerHTML = items.map((it, i) => {
const badge = it.type === 'post' ? 'blog' : it.type === 'action' ? 'kommando' : it.ext ? 'link' : 'side';
return `<li class="palette-item${i === sel ? ' is-selected' : ''}" data-i="${i}" role="option">
<span>${it.title.replace(/</g, '&lt;')}</span><span class="palette-badge">${badge}</span></li>`;
}).join('') || '<li class="palette-item palette-empty">Ingen resultater</li>';
}
function paint() {
el('palette-list').querySelectorAll('.palette-item').forEach((li, i) =>
li.classList.toggle('is-selected', i === sel));
}
function choose(i) {
const it = items[i]; if (!it) return;
close();
if (it.run) it.run();
else if (it.ext) window.open(it.url, '_blank', 'noopener');
else window.location.href = it.url;
}
async function open() {
await ensureIndex();
el('palette').hidden = false;
el('palette-input').value = '';
refresh();
el('palette-input').focus();
}
function close() { const p = el('palette'); if (p) p.hidden = true; }
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); el('palette')?.hidden ? open() : close(); return; }
const p = el('palette');
if (!p || p.hidden) return;
if (e.key === 'Escape') close();
else if (e.key === 'ArrowDown') { e.preventDefault(); sel = Math.min(sel + 1, items.length - 1); paint(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); sel = Math.max(sel - 1, 0); paint(); }
else if (e.key === 'Enter') { e.preventDefault(); choose(sel); }
});
document.addEventListener('input', (e) => { if (e.target?.id === 'palette-input') refresh(); });
document.addEventListener('click', (e) => {
if (e.target === el('palette')) close();
const li = e.target.closest?.('.palette-item[data-i]');
if (li) choose(+li.dataset.i);
});
document.addEventListener('astro:after-swap', close);
})();
</script>
+2
View File
@@ -3,6 +3,7 @@ import { ClientRouter } from 'astro:transitions';
import Head from '../components/Head.astro';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import Palette from '../components/Palette.astro';
import { SITE } from '../consts';
import '../styles/global.css';
interface Props {
@@ -50,5 +51,6 @@ const { title, description, image, type, publishedTime, modifiedTime, tags, json
<slot />
</main>
<Footer />
<Palette />
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
// Build-time søgeindeks til command paletten (⌘K): faste sider + alle blogindlæg.
import { getCollection } from 'astro:content';
export async function GET() {
const posts = await getCollection('blog', ({ data }) => !data.draft);
const items = [
{ type: 'page', title: 'Forside', url: '/' },
{ type: 'page', title: 'CV', url: '/cv' },
{ type: 'page', title: 'Projekter', url: '/projekter' },
{ type: 'page', title: 'Blog', url: '/blog' },
{ type: 'page', title: 'Tags', url: '/tags' },
{ type: 'page', title: 'Uses', url: '/uses' },
{ type: 'page', title: 'Kolofon', url: '/colophon' },
...posts
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime())
.map((p) => ({
type: 'post',
title: p.data.title,
url: `/blog/${p.id}`,
extra: (p.data.tags || []).join(' '),
})),
];
return new Response(JSON.stringify(items), {
headers: { 'Content-Type': 'application/json; charset=utf-8' },
});
}