562 lines
12 KiB
JavaScript
562 lines
12 KiB
JavaScript
export function initGuideSearch() {
|
||
const wrapper = document.querySelector("[data-global-search]");
|
||
const search = document.getElementById("guideSearch");
|
||
const results = document.getElementById("guideSearchResults");
|
||
|
||
if (!wrapper || !search || !results) {
|
||
return;
|
||
}
|
||
|
||
let debounceTimer = null;
|
||
let requestController = null;
|
||
let activeIndex = -1;
|
||
let currentItems = [];
|
||
let currentQuery = "";
|
||
|
||
const escapeText = (value) => String(value ?? "");
|
||
|
||
const appendHighlightedText = (element, text, query) => {
|
||
const value = String(text ?? "");
|
||
const searchQuery = String(query ?? "").trim();
|
||
|
||
if (!searchQuery) {
|
||
element.textContent = value;
|
||
return;
|
||
}
|
||
|
||
const lowerValue = value.toLocaleLowerCase("de");
|
||
const lowerQuery = searchQuery.toLocaleLowerCase("de");
|
||
|
||
let position = 0;
|
||
let matchIndex = lowerValue.indexOf(lowerQuery);
|
||
|
||
while (matchIndex !== -1) {
|
||
if (matchIndex > position) {
|
||
element.appendChild(
|
||
document.createTextNode(
|
||
value.slice(position, matchIndex)
|
||
)
|
||
);
|
||
}
|
||
|
||
const mark = document.createElement("mark");
|
||
mark.className = "guide-search-highlight";
|
||
mark.textContent = value.slice(
|
||
matchIndex,
|
||
matchIndex + searchQuery.length
|
||
);
|
||
|
||
element.appendChild(mark);
|
||
|
||
position = matchIndex + searchQuery.length;
|
||
matchIndex = lowerValue.indexOf(
|
||
lowerQuery,
|
||
position
|
||
);
|
||
}
|
||
|
||
if (position < value.length) {
|
||
element.appendChild(
|
||
document.createTextNode(value.slice(position))
|
||
);
|
||
}
|
||
};
|
||
|
||
const closeResults = () => {
|
||
results.hidden = true;
|
||
results.innerHTML = "";
|
||
search.setAttribute("aria-expanded", "false");
|
||
|
||
currentItems = [];
|
||
activeIndex = -1;
|
||
};
|
||
|
||
const openResults = () => {
|
||
results.hidden = false;
|
||
search.setAttribute("aria-expanded", "true");
|
||
};
|
||
|
||
const setMessage = (message, className = "") => {
|
||
results.innerHTML = "";
|
||
|
||
const element = document.createElement("div");
|
||
element.className = `guide-search-message ${className}`.trim();
|
||
element.textContent = message;
|
||
|
||
results.appendChild(element);
|
||
openResults();
|
||
|
||
currentItems = [];
|
||
activeIndex = -1;
|
||
};
|
||
|
||
const getResultElements = () => {
|
||
return Array.from(
|
||
results.querySelectorAll("[data-search-index]")
|
||
);
|
||
};
|
||
|
||
const updateActiveResult = () => {
|
||
const elements = getResultElements();
|
||
|
||
elements.forEach((element, index) => {
|
||
const active = index === activeIndex;
|
||
|
||
element.classList.toggle("is-active", active);
|
||
element.setAttribute(
|
||
"aria-selected",
|
||
active ? "true" : "false"
|
||
);
|
||
|
||
if (active) {
|
||
element.scrollIntoView({
|
||
block: "nearest"
|
||
});
|
||
}
|
||
});
|
||
};
|
||
|
||
const selectResult = (index) => {
|
||
const item = currentItems[index];
|
||
|
||
if (!item) {
|
||
return;
|
||
}
|
||
|
||
if (item.type === "programme") {
|
||
window.TVGuideProgrammeModal?.open(item);
|
||
|
||
search.value = "";
|
||
closeResults();
|
||
return;
|
||
}
|
||
|
||
if (item.type === "channel") {
|
||
openChannel(item);
|
||
}
|
||
};
|
||
|
||
const openChannel = (item) => {
|
||
const channelId = String(item.channel_id || "");
|
||
|
||
const channelElement = document.querySelector(
|
||
`.timeline-row[data-channel-id="${CSS.escape(channelId)}"]`
|
||
);
|
||
|
||
if (!channelElement) {
|
||
window.location.href =
|
||
`/?view=day&channel=${encodeURIComponent(channelId)}`;
|
||
|
||
return;
|
||
}
|
||
|
||
search.value = "";
|
||
closeResults();
|
||
|
||
requestAnimationFrame(() => {
|
||
const header =
|
||
document.querySelector(".app-header");
|
||
|
||
const headerHeight =
|
||
header?.getBoundingClientRect().height || 0;
|
||
|
||
const elementTop =
|
||
channelElement.getBoundingClientRect().top +
|
||
window.scrollY;
|
||
|
||
const targetTop = Math.max(
|
||
0,
|
||
elementTop - headerHeight - 24
|
||
);
|
||
|
||
window.scrollTo({
|
||
top: targetTop,
|
||
behavior: "smooth"
|
||
});
|
||
|
||
channelElement.classList.remove(
|
||
"search-channel-highlight"
|
||
);
|
||
|
||
void channelElement.offsetWidth;
|
||
|
||
channelElement.classList.add(
|
||
"search-channel-highlight"
|
||
);
|
||
|
||
window.setTimeout(() => {
|
||
channelElement.classList.remove(
|
||
"search-channel-highlight"
|
||
);
|
||
}, 1800);
|
||
});
|
||
};
|
||
|
||
const createChannelLogo = (item) => {
|
||
const logo = document.createElement("img");
|
||
|
||
logo.className = "guide-search-logo";
|
||
logo.src =
|
||
`/assets/logos/${encodeURIComponent(item.channel_id || "")}.svg`;
|
||
|
||
logo.alt = item.channel_name
|
||
? `${item.channel_name} Logo`
|
||
: "Senderlogo";
|
||
|
||
logo.onerror = () => {
|
||
logo.onerror = null;
|
||
logo.src = "/assets/logos/default.svg";
|
||
};
|
||
|
||
return logo;
|
||
};
|
||
|
||
const createChannelResult = (item, index) => {
|
||
const button = document.createElement("button");
|
||
|
||
button.type = "button";
|
||
button.className =
|
||
"guide-search-result guide-search-channel";
|
||
|
||
button.dataset.searchIndex = String(index);
|
||
button.setAttribute("role", "option");
|
||
button.setAttribute("aria-selected", "false");
|
||
|
||
const logo = createChannelLogo(item);
|
||
|
||
const content = document.createElement("span");
|
||
content.className = "guide-search-result-content";
|
||
|
||
const heading = document.createElement("strong");
|
||
appendHighlightedText(
|
||
heading,
|
||
item.channel_name || "Unbekannter Sender",
|
||
currentQuery
|
||
);
|
||
|
||
const details = document.createElement("span");
|
||
|
||
const number =
|
||
item.channel_number &&
|
||
item.channel_number !== 999999
|
||
? `Kanal ${item.channel_number}`
|
||
: "Sender";
|
||
|
||
details.textContent =
|
||
item.favorite
|
||
? `${number} · Favorit ⭐`
|
||
: number;
|
||
|
||
content.append(heading, details);
|
||
button.append(logo, content);
|
||
|
||
return button;
|
||
};
|
||
|
||
const createProgrammeResult = (item, index) => {
|
||
const button = document.createElement("button");
|
||
|
||
button.type = "button";
|
||
button.className =
|
||
"guide-search-result guide-search-programme";
|
||
|
||
if (item.is_current) {
|
||
button.classList.add("is-current");
|
||
}
|
||
|
||
button.dataset.searchIndex = String(index);
|
||
button.setAttribute("role", "option");
|
||
button.setAttribute("aria-selected", "false");
|
||
|
||
const logo = createChannelLogo(item);
|
||
|
||
const content = document.createElement("span");
|
||
content.className = "guide-search-result-content";
|
||
|
||
const headingRow = document.createElement("span");
|
||
headingRow.className = "guide-search-heading-row";
|
||
|
||
const heading = document.createElement("strong");
|
||
appendHighlightedText(
|
||
heading,
|
||
item.title || "Ohne Titel",
|
||
currentQuery
|
||
);
|
||
|
||
headingRow.appendChild(heading);
|
||
|
||
if (item.is_current) {
|
||
const liveBadge = document.createElement("span");
|
||
liveBadge.className = "guide-search-live-badge";
|
||
liveBadge.textContent = "LIVE";
|
||
|
||
headingRow.appendChild(liveBadge);
|
||
}
|
||
|
||
const details = document.createElement("span");
|
||
|
||
const parts = [
|
||
item.channel_name || "",
|
||
item.start_time && item.stop_time
|
||
? `${item.start_time}–${item.stop_time}`
|
||
: ""
|
||
];
|
||
|
||
details.textContent = parts.filter(Boolean).join(" · ");
|
||
|
||
content.append(headingRow);
|
||
|
||
if (item.subtitle) {
|
||
const subtitle = document.createElement("span");
|
||
subtitle.className = "guide-search-subtitle";
|
||
appendHighlightedText(
|
||
subtitle,
|
||
item.subtitle,
|
||
currentQuery
|
||
);
|
||
|
||
content.append(subtitle);
|
||
}
|
||
|
||
content.append(details);
|
||
button.append(logo, content);
|
||
|
||
return button;
|
||
};
|
||
|
||
const renderResults = (items) => {
|
||
results.innerHTML = "";
|
||
currentItems = [];
|
||
activeIndex = -1;
|
||
|
||
if (!items.length) {
|
||
setMessage(
|
||
"Keine passenden Sender oder Sendungen gefunden."
|
||
);
|
||
return;
|
||
}
|
||
|
||
const channels = items.filter(
|
||
(item) => item.type === "channel"
|
||
);
|
||
|
||
const programmes = items.filter(
|
||
(item) => item.type === "programme"
|
||
);
|
||
|
||
const appendGroup = (title, groupItems, type) => {
|
||
if (!groupItems.length) {
|
||
return;
|
||
}
|
||
|
||
const group = document.createElement("section");
|
||
group.className = "guide-search-group";
|
||
|
||
const heading = document.createElement("div");
|
||
heading.className = "guide-search-group-title";
|
||
|
||
const headingText = document.createElement("span");
|
||
headingText.textContent = title;
|
||
|
||
const count = document.createElement("span");
|
||
count.className = "guide-search-group-count";
|
||
count.textContent = String(groupItems.length);
|
||
|
||
heading.append(headingText, count);
|
||
group.appendChild(heading);
|
||
|
||
groupItems.forEach((item) => {
|
||
const index = currentItems.length;
|
||
currentItems.push(item);
|
||
|
||
const element =
|
||
type === "channel"
|
||
? createChannelResult(item, index)
|
||
: createProgrammeResult(item, index);
|
||
|
||
group.appendChild(element);
|
||
});
|
||
|
||
results.appendChild(group);
|
||
};
|
||
|
||
appendGroup("📺 Sender", channels, "channel");
|
||
appendGroup("🎬 Sendungen", programmes, "programme");
|
||
|
||
openResults();
|
||
};
|
||
|
||
const performSearch = async () => {
|
||
const query = search.value.trim();
|
||
currentQuery = query;
|
||
|
||
if (query.length < 2) {
|
||
closeResults();
|
||
return;
|
||
}
|
||
|
||
if (requestController) {
|
||
requestController.abort();
|
||
}
|
||
|
||
requestController = new AbortController();
|
||
|
||
setMessage("Suche läuft …", "is-loading");
|
||
|
||
try {
|
||
const response = await fetch(
|
||
`/?api=search&q=${encodeURIComponent(query)}&limit=20`,
|
||
{
|
||
method: "GET",
|
||
cache: "no-store",
|
||
signal: requestController.signal,
|
||
headers: {
|
||
Accept: "application/json"
|
||
}
|
||
}
|
||
);
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
/*
|
||
* Verhindert, dass ein älteres Ergebnis nach einer inzwischen
|
||
* geänderten Eingabe angezeigt wird.
|
||
*/
|
||
if (search.value.trim() !== query) {
|
||
return;
|
||
}
|
||
|
||
if (!data.ok) {
|
||
throw new Error(data.error || "Unbekannter Fehler");
|
||
}
|
||
|
||
renderResults(
|
||
Array.isArray(data.results)
|
||
? data.results
|
||
: []
|
||
);
|
||
} catch (error) {
|
||
if (error.name === "AbortError") {
|
||
return;
|
||
}
|
||
|
||
console.error("Programmsuche fehlgeschlagen:", error);
|
||
|
||
setMessage(
|
||
"Die Suche konnte gerade nicht geladen werden.",
|
||
"is-error"
|
||
);
|
||
}
|
||
};
|
||
|
||
search.addEventListener("input", () => {
|
||
window.clearTimeout(debounceTimer);
|
||
|
||
const query = search.value.trim();
|
||
|
||
if (query.length < 2) {
|
||
if (requestController) {
|
||
requestController.abort();
|
||
}
|
||
|
||
closeResults();
|
||
return;
|
||
}
|
||
|
||
debounceTimer = window.setTimeout(
|
||
performSearch,
|
||
300
|
||
);
|
||
});
|
||
|
||
search.addEventListener("focus", () => {
|
||
if (
|
||
search.value.trim().length >= 2 &&
|
||
results.children.length
|
||
) {
|
||
openResults();
|
||
}
|
||
});
|
||
|
||
search.addEventListener("keydown", (event) => {
|
||
if (event.key === "Escape") {
|
||
event.preventDefault();
|
||
closeResults();
|
||
search.blur();
|
||
return;
|
||
}
|
||
|
||
if (results.hidden || !currentItems.length) {
|
||
return;
|
||
}
|
||
|
||
if (event.key === "ArrowDown") {
|
||
event.preventDefault();
|
||
|
||
activeIndex =
|
||
activeIndex < currentItems.length - 1
|
||
? activeIndex + 1
|
||
: 0;
|
||
|
||
updateActiveResult();
|
||
return;
|
||
}
|
||
|
||
if (event.key === "ArrowUp") {
|
||
event.preventDefault();
|
||
|
||
activeIndex =
|
||
activeIndex > 0
|
||
? activeIndex - 1
|
||
: currentItems.length - 1;
|
||
|
||
updateActiveResult();
|
||
return;
|
||
}
|
||
|
||
if (event.key === "Enter") {
|
||
if (activeIndex < 0) {
|
||
activeIndex = 0;
|
||
}
|
||
|
||
event.preventDefault();
|
||
selectResult(activeIndex);
|
||
}
|
||
});
|
||
|
||
results.addEventListener("mousemove", (event) => {
|
||
const element =
|
||
event.target.closest("[data-search-index]");
|
||
|
||
if (!element) {
|
||
return;
|
||
}
|
||
|
||
activeIndex = Number(element.dataset.searchIndex);
|
||
updateActiveResult();
|
||
});
|
||
|
||
results.addEventListener("click", (event) => {
|
||
const element =
|
||
event.target.closest("[data-search-index]");
|
||
|
||
if (!element) {
|
||
return;
|
||
}
|
||
|
||
selectResult(
|
||
Number(element.dataset.searchIndex)
|
||
);
|
||
});
|
||
|
||
document.addEventListener("click", (event) => {
|
||
if (!wrapper.contains(event.target)) {
|
||
closeResults();
|
||
}
|
||
});
|
||
}
|