Add autocomplete endpoint via EXT:solr suggest page type

GET /search?type=7384&tx_solr[queryString]=<input> answers term
completions from the index (spell field) with document counts as lean
JSON - no page envelope, synonyms apply automatically. Two deliberate
deviations from the shipped example, which predates TYPO3 v14: the
plugin runs as USER_INT (config.no_cache is gone, and a cached USER
would pin the first query's suggestions for every later request), and
showTopResults is off (the top-result shape is hardcoded upstream with
the raw document type and full indexed content). Documented in spec
v1.12 clause 7.16.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 16:08:00 +02:00
parent fc3799c791
commit d846ba2007

147
public/suggest-demo.html Normal file
View File

@@ -0,0 +1,147 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>VITEC Search Demo (dev)</title>
<style>
:root { --navy: #26358c; --orange: #f47937; --grey: #cccccc; }
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; background: #f5f6fa; color: #313131; }
.wrap { max-width: 720px; margin: 40px auto; padding: 0 16px; }
h1 { color: var(--navy); font-size: 1.3rem; }
h1 small { color: #888; font-weight: normal; font-size: 0.75rem; }
.searchbox { position: relative; }
input[type=search] {
width: 100%; padding: 12px 16px; font-size: 1.1rem;
border: 2px solid var(--navy); border-radius: 6px; outline: none;
}
input[type=search]:focus { border-color: var(--orange); }
.dropdown {
position: absolute; left: 0; right: 0; top: 100%; z-index: 10;
background: #fff; border: 1px solid var(--grey); border-top: none;
border-radius: 0 0 6px 6px; box-shadow: 0 6px 16px rgba(0,0,0,.12);
display: none;
}
.dropdown.open { display: block; }
.dropdown button {
display: flex; justify-content: space-between; width: 100%;
padding: 9px 16px; border: 0; background: none; font-size: 1rem;
cursor: pointer; text-align: left;
}
.dropdown button:hover, .dropdown button.sel { background: #eef0fa; }
.dropdown .count { color: #999; font-size: .85rem; }
.tabs { display: flex; gap: 8px; margin: 18px 0 10px; flex-wrap: wrap; }
.tabs button {
border: 1px solid var(--grey); background: #fff; border-radius: 999px;
padding: 6px 14px; cursor: pointer; font-size: .9rem;
}
.tabs button.active { background: var(--navy); color: #fff; border-color: var(--navy); }
.meta { color: #777; font-size: .85rem; margin: 8px 0; }
.hit { background: #fff; border: 1px solid #e3e5ee; border-radius: 6px; padding: 12px 16px; margin-bottom: 10px; }
.hit a { color: var(--navy); font-weight: 600; text-decoration: none; font-size: 1.02rem; }
.hit a:hover { text-decoration: underline; }
.hit .type { display: inline-block; background: var(--orange); color: #fff; border-radius: 4px; font-size: .7rem; padding: 2px 7px; margin-left: 8px; vertical-align: 2px; text-transform: uppercase; }
.hit p { margin: 6px 0 0; font-size: .9rem; color: #555; }
.hit mark { background: #ffe6a6; padding: 0 2px; }
.more { display: block; margin: 16px auto; padding: 10px 26px; background: var(--orange); color: #fff; border: 0; border-radius: 6px; font-size: 1rem; cursor: pointer; }
</style>
</head>
<body>
<div class="wrap">
<h1>VITEC Search <small>— Demo der Such-API (Autocomplete + Tabs + Nachladen). Nicht fürs Frontend gedacht, das baut React.</small></h1>
<div class="searchbox">
<input type="search" id="q" placeholder="Suche… (z.&thinsp;B. enc, ipt, accomodation)" autocomplete="off" autofocus>
<div class="dropdown" id="dd"></div>
</div>
<div class="tabs" id="tabs"></div>
<div class="meta" id="meta"></div>
<div id="hits"></div>
<button class="more" id="more" hidden>Mehr laden</button>
</div>
<script>
(() => {
const input = document.getElementById('q');
const dd = document.getElementById('dd');
const tabsEl = document.getElementById('tabs');
const metaEl = document.getElementById('meta');
const hitsEl = document.getElementById('hits');
const moreBtn = document.getElementById('more');
let state = { q: '', filter: '', page: 1, totalPages: 0, results: [] };
let debounceTimer = null;
// ---- Autocomplete: GET /search?type=7384&tx_solr[queryString]=... ----
input.addEventListener('input', () => {
clearTimeout(debounceTimer);
const val = input.value.trim();
if (val.length < 2) { dd.classList.remove('open'); return; }
debounceTimer = setTimeout(async () => {
const url = '/search?type=7384&tx_solr[queryString]=' + encodeURIComponent(val);
const j = await (await fetch(url)).json();
const terms = Object.entries(j.suggestions || {});
dd.innerHTML = terms.map(([t, c]) =>
`<button data-t="${t}"><span>${t}</span><span class="count">${c}</span></button>`).join('');
dd.classList.toggle('open', terms.length > 0);
}, 200);
});
dd.addEventListener('click', e => {
const b = e.target.closest('button');
if (!b) return;
input.value = b.dataset.t;
dd.classList.remove('open');
runSearch(b.dataset.t, '', 1);
});
input.addEventListener('keydown', e => {
if (e.key === 'Enter') { dd.classList.remove('open'); runSearch(input.value.trim(), '', 1); }
if (e.key === 'Escape') dd.classList.remove('open');
});
// ---- Suche: GET /search?q=...&filter=...&page=... ----
async function runSearch(q, filter, page) {
if (!q) return;
const url = '/search?q=' + encodeURIComponent(q)
+ (filter ? '&filter=' + filter : '') + (page > 1 ? '&page=' + page : '');
const json = await (await fetch(url)).json();
const s = json.content.colPos0[0].content.search;
state = {
q, filter, page: s.page, totalPages: s.totalPages,
results: page === 1 ? s.results : state.results.concat(s.results),
};
// Tabs aus facets.type - Zählungen bleiben bei aktivem Filter vollständig
const facets = (s.facets && s.facets.type) || [];
tabsEl.innerHTML = `<button class="${!filter ? 'active' : ''}" data-f="">Alle (${facets.reduce((a, o) => a + o.count, 0)})</button>`
+ facets.map(o =>
`<button class="${o.active ? 'active' : ''}" data-f="${o.value}">${o.value} (${o.count})</button>`).join('');
metaEl.textContent = s.numFound + ' Treffer für "' + s.query + '"'
+ (filter ? ' — Filter: ' + filter : '')
+ (s.suggestions.length ? ' — Meintest du: ' + s.suggestions.join(', ') + '?' : '');
hitsEl.innerHTML = state.results.map(r =>
`<div class="hit"><a href="${r.url}" target="_blank">${r.title}</a><span class="type">${r.type}</span>
<p>${r.teaser || ''}</p></div>`).join('');
moreBtn.hidden = !(state.page < state.totalPages);
moreBtn.textContent = 'Mehr laden (' + state.results.length + ' von ' + s.numFound + ')';
}
tabsEl.addEventListener('click', e => {
const b = e.target.closest('button');
if (!b) return;
runSearch(state.q, b.dataset.f, 1);
});
moreBtn.addEventListener('click', () => runSearch(state.q, state.filter, state.page + 1));
})();
</script>
</body>
</html>