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:
co-authored by
Claude Opus 4.8
parent
b0ffaa5973
commit
006da016cd
@@ -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, '<')}</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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user