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:
147
public/suggest-demo.html
Normal file
147
public/suggest-demo.html
Normal 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. 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>
|
||||||
Reference in New Issue
Block a user