CTAO Science Portal: static Astro site with git-based CMS (Sveltia + Gitea)
Portal code only; editorial content lives in the ctao/content repo on the demo machine's Gitea and is overlaid at build time (see README.md and deploy/README.md). Live demo: https://astro.isl-dev.grid.cyfronet.pl
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
// Shared client-side news search over /search.json (no dependencies, no modules).
|
||||
// Used by the header search (field + mobile reveal) and /search (full results).
|
||||
// Focused-empty state per Baymard autocomplete guidance: recent searches (local
|
||||
// only — localStorage, no network) plus the newest articles, never a void.
|
||||
(function () {
|
||||
const RKEY = 'ctao-recent-searches';
|
||||
function recents() {
|
||||
try { return JSON.parse(localStorage.getItem(RKEY) || '[]'); } catch { return []; }
|
||||
}
|
||||
function remember(q) {
|
||||
q = (q || '').trim();
|
||||
if (q.length < 2) return;
|
||||
try {
|
||||
const r = [q, ...recents().filter((x) => x.toLowerCase() !== q.toLowerCase())].slice(0, 4);
|
||||
localStorage.setItem(RKEY, JSON.stringify(r));
|
||||
} catch { /* private mode — recents are a nicety */ }
|
||||
}
|
||||
|
||||
window.newsSearch = function (opts) {
|
||||
const input = document.getElementById(opts.input);
|
||||
const list = document.getElementById(opts.list);
|
||||
const status = document.getElementById(opts.status);
|
||||
// Null-safe: a page may render only some search surfaces — bail silently
|
||||
// (returning a no-op) so one missing input can't kill later inits.
|
||||
if (!input || !list || !status) return function () {};
|
||||
let index = null;
|
||||
let timer;
|
||||
async function ensureIndex() {
|
||||
if (index) return index;
|
||||
try {
|
||||
index = await (await fetch('/search.json')).json();
|
||||
} catch {
|
||||
// Network hiccup: say so instead of failing silently; index stays
|
||||
// null so the next keystroke retries.
|
||||
status.textContent = 'Search is unavailable right now.';
|
||||
return null;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
// First title match wrapped in <mark> (Starlight search pattern) — built
|
||||
// from text nodes, never innerHTML.
|
||||
function highlight(text, q) {
|
||||
const i = text.toLowerCase().indexOf(q);
|
||||
if (i < 0) return [text];
|
||||
const mark = document.createElement('mark');
|
||||
mark.textContent = text.slice(i, i + q.length);
|
||||
return [text.slice(0, i), mark, text.slice(i + q.length)];
|
||||
}
|
||||
function label(text) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'suggest-label';
|
||||
li.setAttribute('aria-hidden', 'true'); // the status line announces it
|
||||
li.textContent = text;
|
||||
return li;
|
||||
}
|
||||
function row(children, href, q) {
|
||||
const li = document.createElement('li');
|
||||
const a = document.createElement('a');
|
||||
a.href = href;
|
||||
if (q) a.dataset.q = q;
|
||||
a.append(...children);
|
||||
li.append(a);
|
||||
return li;
|
||||
}
|
||||
// Dropdowns close with a "Press Enter for all results" row — a real list
|
||||
// element (was a CSS ::after), aria-hidden like the group labels so
|
||||
// screen-reader exposure stays consistent (the status line announces).
|
||||
function hint() {
|
||||
if (!opts.dismiss || !list.firstChild) return;
|
||||
const li = document.createElement('li');
|
||||
li.className = 'suggest-hint';
|
||||
li.setAttribute('aria-hidden', 'true');
|
||||
li.textContent = 'Press Enter for all results';
|
||||
list.append(li);
|
||||
}
|
||||
// Focused-empty state: up to 4 recent queries, topped up to 5 rows with
|
||||
// the newest articles from the same index.
|
||||
async function idle() {
|
||||
if (input.value.trim().length >= 2) return;
|
||||
// Build off-DOM, swap atomically — clearing before the await shrinks the
|
||||
// page mid-flight and the browser clamps the scroll position ("jumps up").
|
||||
const frag = document.createDocumentFragment();
|
||||
const r = recents();
|
||||
if (r.length) {
|
||||
frag.append(label('Recent'));
|
||||
for (const term of r) frag.append(row([term], '/search?q=' + encodeURIComponent(term), term));
|
||||
}
|
||||
const idx = await ensureIndex();
|
||||
if (!idx) return;
|
||||
const fresh = idx.slice(0, Math.max(1, 5 - r.length));
|
||||
if (input.value.trim().length >= 2) return; // typed meanwhile — run() owns the list
|
||||
frag.append(label('Latest news'));
|
||||
for (const p of fresh) {
|
||||
const li = row([p.title], '/news/' + encodeURIComponent(p.slug));
|
||||
const small = document.createElement('small');
|
||||
small.textContent = p.date;
|
||||
li.append(small);
|
||||
frag.append(li);
|
||||
}
|
||||
list.replaceChildren(frag);
|
||||
hint();
|
||||
status.textContent = opts.hint || (r.length ? 'Showing recent searches and latest news' : 'Showing latest news');
|
||||
}
|
||||
async function run() {
|
||||
const q = input.value.trim().toLowerCase();
|
||||
if (q.length < 2) { list.replaceChildren(); idle(); return; }
|
||||
const idx = await ensureIndex();
|
||||
if (!idx) { list.replaceChildren(); return; }
|
||||
const hits = idx
|
||||
.filter((p) => (p.title + ' ' + p.description + ' ' + p.category).toLowerCase().includes(q))
|
||||
.slice(0, opts.limit);
|
||||
const unit = opts.unit || 'result';
|
||||
status.textContent = hits.length
|
||||
? hits.length + ' ' + unit + (hits.length === 1 ? '' : 's') + ' for “' + input.value.trim() + '”'
|
||||
: 'No results for “' + input.value.trim() + '”. Try a shorter or different term.';
|
||||
// Off-DOM build + atomic swap (no transient page-height collapse)
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const p of hits) {
|
||||
const li = row(highlight(p.title, q), '/news/' + encodeURIComponent(p.slug));
|
||||
const small = document.createElement('small');
|
||||
small.textContent = p.date;
|
||||
li.append(small);
|
||||
frag.append(li);
|
||||
}
|
||||
// Dropdown only: an empty list would collapse (.suggest:empty) and the
|
||||
// panel would just vanish — show the no-results line sighted users too
|
||||
// (the status line above is sr-only in the header; /search shows a
|
||||
// visible notice instead).
|
||||
if (!hits.length && opts.dismiss) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'suggest-hint';
|
||||
li.setAttribute('aria-hidden', 'true'); // the status line announces it
|
||||
li.textContent = 'No results for “' + input.value.trim() + '”';
|
||||
frag.append(li);
|
||||
}
|
||||
list.replaceChildren(frag);
|
||||
hint();
|
||||
}
|
||||
input.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(run, 150); });
|
||||
input.addEventListener('focus', () => { if (input.value.trim().length < 2) idle(); });
|
||||
// Remember successful queries: Enter (form submit) and suggestion clicks
|
||||
if (input.form) input.form.addEventListener('submit', () => remember(input.value));
|
||||
list.addEventListener('click', (e) => {
|
||||
const a = e.target.closest && e.target.closest('a');
|
||||
remember((a && a.dataset.q) || input.value);
|
||||
});
|
||||
// Dropdown dismiss (opt-in — /search keeps its results list). The
|
||||
// suggestion list is a NON-MODAL popup (combobox-style; plain
|
||||
// Tab-reachable links, no roving arrow-key focus): interacting outside
|
||||
// the field+list closes it but the interaction is NOT swallowed —
|
||||
// unlike the modal header panels, which scrim-dismiss in Base.astro.
|
||||
if (opts.dismiss) {
|
||||
const box = input.closest('form') || input.parentElement;
|
||||
const hide = () => { clearTimeout(timer); list.replaceChildren(); status.textContent = ''; };
|
||||
document.addEventListener('pointerdown', (e) => { if (list.firstChild && !box.contains(e.target)) hide(); });
|
||||
// relatedTarget may be null mid-click on our own links — pointerdown covers that path
|
||||
box.addEventListener('focusout', (e) => { if (e.relatedTarget && !box.contains(e.relatedTarget)) hide(); });
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && list.firstChild) { hide(); e.stopPropagation(); } // panel Escape stays a second step
|
||||
});
|
||||
}
|
||||
return run; // callers may run() immediately (e.g. /search?q=…)
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user