Files
2026-07-10 12:38:33 +02:00

562 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
});
}