Global Search
This commit is contained in:
@@ -52,6 +52,221 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-search-wrapper {
|
||||
position: relative;
|
||||
width: min(500px, 100%);
|
||||
}
|
||||
|
||||
.header-search {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
|
||||
background: #020617;
|
||||
color: var(--text);
|
||||
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
|
||||
padding: 10px 18px;
|
||||
|
||||
font: inherit;
|
||||
outline: none;
|
||||
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
background 0.18s ease;
|
||||
}
|
||||
|
||||
.header-search:hover {
|
||||
border-color: #475569;
|
||||
}
|
||||
|
||||
.header-search:focus {
|
||||
background: #07101f;
|
||||
border-color: var(--accent);
|
||||
box-shadow:
|
||||
0 0 0 3px var(--accent-soft),
|
||||
0 12px 30px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.header-search::placeholder {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.guide-search-results {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
max-height: min(520px, calc(100vh - 120px));
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
padding: 7px;
|
||||
|
||||
background: rgba(15, 23, 42, 0.98);
|
||||
backdrop-filter: blur(18px);
|
||||
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
|
||||
box-shadow:
|
||||
0 22px 60px rgba(0, 0, 0, 0.45),
|
||||
0 4px 14px rgba(0, 0, 0, 0.25);
|
||||
|
||||
z-index: 300;
|
||||
}
|
||||
|
||||
.guide-search-results[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.guide-search-result {
|
||||
width: 100%;
|
||||
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
|
||||
padding: 11px 12px;
|
||||
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
|
||||
border: 0;
|
||||
border-radius: 11px;
|
||||
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
transition:
|
||||
background 0.14s ease,
|
||||
transform 0.14s ease;
|
||||
}
|
||||
|
||||
.guide-search-result:hover,
|
||||
.guide-search-result.is-active {
|
||||
background: rgba(59, 130, 246, 0.16);
|
||||
}
|
||||
|
||||
.guide-search-result:active {
|
||||
transform: scale(0.992);
|
||||
}
|
||||
|
||||
.guide-search-result.is-current {
|
||||
background: rgba(34, 197, 94, 0.08);
|
||||
}
|
||||
|
||||
.guide-search-result.is-current:hover,
|
||||
.guide-search-result.is-current.is-active {
|
||||
background: rgba(34, 197, 94, 0.17);
|
||||
}
|
||||
|
||||
.guide-search-type-icon {
|
||||
flex: 0 0 auto;
|
||||
width: 24px;
|
||||
padding-top: 1px;
|
||||
|
||||
font-size: 1rem;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.guide-search-result-content {
|
||||
min-width: 0;
|
||||
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.guide-search-result-content strong {
|
||||
overflow: hidden;
|
||||
|
||||
color: var(--text);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.guide-search-result-content > span {
|
||||
overflow: hidden;
|
||||
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.guide-search-result-content .guide-search-subtitle {
|
||||
color: #cbd5e1;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.guide-search-channel {
|
||||
border-bottom: 1px solid rgba(51, 65, 85, 0.55);
|
||||
}
|
||||
|
||||
.guide-search-message {
|
||||
padding: 18px 14px;
|
||||
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.guide-search-message.is-loading::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
margin-right: 9px;
|
||||
|
||||
vertical-align: -2px;
|
||||
|
||||
border: 2px solid rgba(148, 163, 184, 0.35);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
|
||||
animation: guide-search-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
.guide-search-message.is-error {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.search-channel-highlight {
|
||||
animation: search-channel-highlight 1.6s ease;
|
||||
}
|
||||
|
||||
@keyframes guide-search-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes search-channel-highlight {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
25%,
|
||||
70% {
|
||||
box-shadow:
|
||||
inset 0 0 0 2px var(--accent),
|
||||
0 0 24px var(--accent-soft);
|
||||
}
|
||||
}
|
||||
|
||||
.header-search {
|
||||
width: min(500px, 100%);
|
||||
background: #020617;
|
||||
@@ -135,6 +350,14 @@
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.header-search-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.guide-search-results {
|
||||
max-height: min(430px, calc(100vh - 190px));
|
||||
}
|
||||
|
||||
.header-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -14,10 +14,7 @@ export function initProgrammeModal() {
|
||||
const logo = document.getElementById("modalLogo");
|
||||
const channel = document.getElementById("modalChannel");
|
||||
|
||||
const openModal = (programmeElement) => {
|
||||
const id = programmeElement.dataset.programmeId;
|
||||
const programme = window.TVGUIDE_PROGRAMMES?.[id];
|
||||
|
||||
const openProgramme = (programme) => {
|
||||
if (!programme) {
|
||||
return;
|
||||
}
|
||||
@@ -25,77 +22,129 @@ export function initProgrammeModal() {
|
||||
const metadata = programme.metadata || {};
|
||||
|
||||
title.textContent = programme.title || "";
|
||||
|
||||
subtitle.textContent =
|
||||
metadata.episode_title ||
|
||||
programme.subtitle ||
|
||||
"";
|
||||
description.textContent = programme.description || "Keine Beschreibung vorhanden.";
|
||||
metadata.episode_title ||
|
||||
programme.subtitle ||
|
||||
"";
|
||||
|
||||
subtitle.hidden = !subtitle.textContent;
|
||||
|
||||
description.textContent =
|
||||
programme.description ||
|
||||
"Keine Beschreibung vorhanden.";
|
||||
|
||||
category.textContent = programme.category || "";
|
||||
category.hidden = !category.textContent;
|
||||
|
||||
channel.textContent =
|
||||
`Kanal ${programme.channel_number} · ${programme.channel}`;
|
||||
const channelName =
|
||||
programme.channel ||
|
||||
programme.channel_name ||
|
||||
"";
|
||||
|
||||
logo.src = `/assets/logos/${encodeURIComponent(programme.channel_id)}.svg`;
|
||||
const channelNumber = programme.channel_number;
|
||||
|
||||
channel.textContent = channelNumber && channelNumber !== 999999
|
||||
? `Kanal ${channelNumber} · ${channelName}`
|
||||
: channelName;
|
||||
|
||||
logo.src =
|
||||
`/assets/logos/${encodeURIComponent(programme.channel_id || "")}.svg`;
|
||||
|
||||
logo.onerror = () => {
|
||||
logo.onerror = null;
|
||||
logo.src = "/assets/logos/default.svg";
|
||||
logo.onerror = null;
|
||||
logo.src = "/assets/logos/default.svg";
|
||||
};
|
||||
|
||||
const metaItems = [];
|
||||
|
||||
if (metadata.season && metadata.episode) {
|
||||
let text = `Staffel ${metadata.season} · Episode ${metadata.episode}`;
|
||||
let text =
|
||||
`Staffel ${metadata.season} · Episode ${metadata.episode}`;
|
||||
|
||||
if (metadata.episode_total) {
|
||||
text += `/${metadata.episode_total}`;
|
||||
}
|
||||
if (metadata.episode_total) {
|
||||
text += `/${metadata.episode_total}`;
|
||||
}
|
||||
|
||||
metaItems.push(text);
|
||||
metaItems.push(text);
|
||||
}
|
||||
|
||||
if (metadata.country) {
|
||||
metaItems.push(metadata.country);
|
||||
metaItems.push(metadata.country);
|
||||
} else if (programme.country) {
|
||||
metaItems.push(programme.country);
|
||||
}
|
||||
|
||||
if (metadata.year) {
|
||||
metaItems.push(metadata.year);
|
||||
metaItems.push(metadata.year);
|
||||
} else if (programme.year) {
|
||||
metaItems.push(programme.year);
|
||||
}
|
||||
|
||||
if (metadata.age_rating) {
|
||||
metaItems.push(`FSK ${metadata.age_rating}`);
|
||||
metaItems.push(`FSK ${metadata.age_rating}`);
|
||||
} else if (programme.rating) {
|
||||
metaItems.push(`FSK ${programme.rating}`);
|
||||
}
|
||||
|
||||
if (Array.isArray(metadata.genres) && metadata.genres.length) {
|
||||
metaItems.push(metadata.genres.join(", "));
|
||||
metaItems.push(metadata.genres.join(", "));
|
||||
}
|
||||
|
||||
meta.textContent = metaItems.join(" • ");
|
||||
meta.hidden = metaItems.length === 0;
|
||||
|
||||
time.textContent = `${programme.start} – ${programme.stop}`;
|
||||
const start =
|
||||
programme.start_time ||
|
||||
programme.start ||
|
||||
"";
|
||||
|
||||
const stop =
|
||||
programme.stop_time ||
|
||||
programme.stop ||
|
||||
"";
|
||||
|
||||
time.textContent = start && stop
|
||||
? `${start} – ${stop}`
|
||||
: "";
|
||||
|
||||
modal.hidden = false;
|
||||
document.body.classList.add("modal-open");
|
||||
};
|
||||
|
||||
const openProgrammeElement = (programmeElement) => {
|
||||
const id = programmeElement.dataset.programmeId;
|
||||
const programme = window.TVGUIDE_PROGRAMMES?.[id];
|
||||
|
||||
openProgramme(programme);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
modal.hidden = true;
|
||||
meta.textContent = "";
|
||||
document.body.classList.remove("modal-open");
|
||||
};
|
||||
|
||||
/*
|
||||
* Öffentliche Schnittstelle für die globale Suche.
|
||||
*/
|
||||
window.TVGuideProgrammeModal = {
|
||||
open: openProgramme,
|
||||
close: closeModal
|
||||
};
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const programme = event.target.closest(".timeline-programme, .dashboard-programme");
|
||||
const programme = event.target.closest(
|
||||
".timeline-programme, .dashboard-programme"
|
||||
);
|
||||
|
||||
if (!programme) {
|
||||
return;
|
||||
}
|
||||
|
||||
openModal(programme);
|
||||
openProgrammeElement(programme);
|
||||
});
|
||||
|
||||
|
||||
modal.querySelectorAll("[data-modal-close]").forEach((element) => {
|
||||
element.addEventListener("click", closeModal);
|
||||
});
|
||||
|
||||
@@ -1,72 +1,424 @@
|
||||
export function initGuideSearch() {
|
||||
const wrapper = document.querySelector("[data-global-search]");
|
||||
const search = document.getElementById("guideSearch");
|
||||
const results = document.getElementById("guideSearchResults");
|
||||
const programmes = window.TVGUIDE_PROGRAMMES || {};
|
||||
|
||||
if (!search || !results) {
|
||||
if (!wrapper || !search || !results) {
|
||||
return;
|
||||
}
|
||||
|
||||
const openProgrammeById = (id) => {
|
||||
const element = document.querySelector(`[data-programme-id="${CSS.escape(id)}"]`);
|
||||
let debounceTimer = null;
|
||||
let requestController = null;
|
||||
let activeIndex = -1;
|
||||
let currentItems = [];
|
||||
|
||||
if (element) {
|
||||
element.click();
|
||||
}
|
||||
const escapeText = (value) => String(value ?? "");
|
||||
|
||||
const closeResults = () => {
|
||||
results.hidden = true;
|
||||
results.innerHTML = "";
|
||||
search.setAttribute("aria-expanded", "false");
|
||||
|
||||
currentItems = [];
|
||||
activeIndex = -1;
|
||||
};
|
||||
|
||||
search.addEventListener("input", () => {
|
||||
const query = search.value.trim().toLowerCase();
|
||||
|
||||
if (query.length < 2) {
|
||||
results.hidden = true;
|
||||
results.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const matches = Object.values(programmes)
|
||||
.filter((programme) => {
|
||||
return [
|
||||
programme.title,
|
||||
programme.subtitle,
|
||||
programme.description,
|
||||
programme.category,
|
||||
programme.channel
|
||||
].some((value) => (value || "").toLowerCase().includes(query));
|
||||
})
|
||||
.slice(0, 12);
|
||||
|
||||
if (!matches.length) {
|
||||
results.innerHTML = `<div class="guide-search-result"><strong>Nichts gefunden</strong></div>`;
|
||||
results.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
results.innerHTML = matches.map((programme) => `
|
||||
<div class="guide-search-result" data-search-programme-id="${programme.id}">
|
||||
<strong>${programme.title || "Ohne Titel"}</strong>
|
||||
<span>${programme.channel || ""} · ${programme.start || ""} – ${programme.stop || ""}</span>
|
||||
</div>
|
||||
`).join("");
|
||||
|
||||
const openResults = () => {
|
||||
results.hidden = false;
|
||||
});
|
||||
search.setAttribute("aria-expanded", "true");
|
||||
};
|
||||
|
||||
results.addEventListener("click", (event) => {
|
||||
const item = event.target.closest("[data-search-programme-id]");
|
||||
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;
|
||||
}
|
||||
|
||||
openProgrammeById(item.dataset.searchProgrammeId);
|
||||
results.hidden = true;
|
||||
search.value = "";
|
||||
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 || "");
|
||||
|
||||
/*
|
||||
* Auf der Tagesansicht zuerst versuchen, den vorhandenen Sender
|
||||
* direkt anzuspringen.
|
||||
*/
|
||||
const channelElement =
|
||||
document.querySelector(
|
||||
`[data-channel-id="${CSS.escape(channelId)}"]`
|
||||
) ||
|
||||
document.querySelector(
|
||||
`[data-timeline-channel="${CSS.escape(channelId)}"]`
|
||||
);
|
||||
|
||||
if (channelElement) {
|
||||
channelElement.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
inline: "start"
|
||||
});
|
||||
|
||||
channelElement.classList.add("search-channel-highlight");
|
||||
|
||||
window.setTimeout(() => {
|
||||
channelElement.classList.remove("search-channel-highlight");
|
||||
}, 1600);
|
||||
|
||||
search.value = "";
|
||||
closeResults();
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* Falls der Sender auf der aktuellen Ansicht nicht im DOM liegt,
|
||||
* Tagesansicht mit Senderfilter öffnen.
|
||||
*/
|
||||
window.location.href =
|
||||
`/?view=day&channel=${encodeURIComponent(channelId)}`;
|
||||
};
|
||||
|
||||
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 icon = document.createElement("span");
|
||||
icon.className = "guide-search-type-icon";
|
||||
icon.textContent = "📺";
|
||||
|
||||
const content = document.createElement("span");
|
||||
content.className = "guide-search-result-content";
|
||||
|
||||
const heading = document.createElement("strong");
|
||||
heading.textContent =
|
||||
escapeText(item.channel_name || "Unbekannter Sender");
|
||||
|
||||
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(icon, 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 icon = document.createElement("span");
|
||||
icon.className = "guide-search-type-icon";
|
||||
icon.textContent = item.is_current ? "🔴" : "🎬";
|
||||
|
||||
const content = document.createElement("span");
|
||||
content.className = "guide-search-result-content";
|
||||
|
||||
const heading = document.createElement("strong");
|
||||
heading.textContent =
|
||||
escapeText(item.title || "Ohne Titel");
|
||||
|
||||
const details = document.createElement("span");
|
||||
|
||||
const parts = [
|
||||
item.channel_name || "",
|
||||
item.start_time && item.stop_time
|
||||
? `${item.start_time}–${item.stop_time}`
|
||||
: ""
|
||||
];
|
||||
|
||||
if (item.is_current) {
|
||||
parts.push("läuft jetzt");
|
||||
}
|
||||
|
||||
details.textContent = parts.filter(Boolean).join(" · ");
|
||||
|
||||
content.append(heading);
|
||||
|
||||
if (item.subtitle) {
|
||||
const subtitle = document.createElement("span");
|
||||
subtitle.className = "guide-search-subtitle";
|
||||
subtitle.textContent = escapeText(item.subtitle);
|
||||
content.append(subtitle);
|
||||
}
|
||||
|
||||
content.append(details);
|
||||
button.append(icon, content);
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
const renderResults = (items) => {
|
||||
results.innerHTML = "";
|
||||
currentItems = items;
|
||||
activeIndex = -1;
|
||||
|
||||
if (!items.length) {
|
||||
setMessage(
|
||||
"Keine passenden Sender oder Sendungen gefunden."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const element =
|
||||
item.type === "channel"
|
||||
? createChannelResult(item, index)
|
||||
: createProgrammeResult(item, index);
|
||||
|
||||
results.appendChild(element);
|
||||
});
|
||||
|
||||
openResults();
|
||||
};
|
||||
|
||||
const performSearch = async () => {
|
||||
const query = search.value.trim();
|
||||
|
||||
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 (!event.target.closest(".guide-search")) {
|
||||
results.hidden = true;
|
||||
if (!wrapper.contains(event.target)) {
|
||||
closeResults();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,6 +11,11 @@ class HomeController
|
||||
|
||||
public function handle(array $get, array $post, string $method): void
|
||||
{
|
||||
if (($get['api'] ?? '') === 'search') {
|
||||
$this->handleSearchApi($get, $method);
|
||||
}
|
||||
|
||||
|
||||
$this->xmltv->synchronizeChannels($this->config);
|
||||
|
||||
$view = $get['view'] ?? $this->config->get('default_view', 'today');
|
||||
@@ -80,4 +85,44 @@ class HomeController
|
||||
'nowMinute' => $nowMinute,
|
||||
]);
|
||||
}
|
||||
|
||||
private function handleSearchApi(array $get, string $method): void
|
||||
{
|
||||
if ($method !== 'GET') {
|
||||
ApiResponse::error(
|
||||
'Für die Programmsuche ist nur GET erlaubt.',
|
||||
405
|
||||
);
|
||||
}
|
||||
|
||||
$query = trim((string)($get['q'] ?? ''));
|
||||
|
||||
if (mb_strlen($query) < 2) {
|
||||
ApiResponse::json([
|
||||
'ok' => true,
|
||||
'query' => $query,
|
||||
'count' => 0,
|
||||
'results' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
$limit = isset($get['limit'])
|
||||
? (int)$get['limit']
|
||||
: 20;
|
||||
|
||||
$limit = max(1, min(50, $limit));
|
||||
|
||||
$results = $this->xmltv->search(
|
||||
$this->config,
|
||||
$query,
|
||||
$limit
|
||||
);
|
||||
|
||||
ApiResponse::json([
|
||||
'ok' => true,
|
||||
'query' => $query,
|
||||
'count' => count($results),
|
||||
'results' => $results,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
class ApiResponse
|
||||
{
|
||||
public static function json(array $payload, int $statusCode = 200): void
|
||||
{
|
||||
http_response_code($statusCode);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
echo json_encode(
|
||||
$payload,
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
|
||||
);
|
||||
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function error(string $message, int $statusCode = 500): void
|
||||
{
|
||||
self::json([
|
||||
'ok' => false,
|
||||
'error' => $message,
|
||||
], $statusCode);
|
||||
}
|
||||
}
|
||||
@@ -213,6 +213,146 @@ class ProgrammeService
|
||||
return $items;
|
||||
}
|
||||
|
||||
public function search(
|
||||
Config $config,
|
||||
array $data,
|
||||
string $query,
|
||||
int $limit = 20
|
||||
): array {
|
||||
$query = trim($query);
|
||||
$limit = max(1, min(50, $limit));
|
||||
|
||||
if (mb_strlen($query) < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$channelConfig = $config->getChannels();
|
||||
$tz = new DateTimeZone($this->timezone);
|
||||
|
||||
$dayStart = new DateTime('today', $tz);
|
||||
$dayEnd = clone $dayStart;
|
||||
$dayEnd->modify('+1 day');
|
||||
|
||||
$startTs = $dayStart->getTimestamp();
|
||||
$endTs = $dayEnd->getTimestamp();
|
||||
|
||||
$channelResults = [];
|
||||
$programmeResults = [];
|
||||
|
||||
foreach ($data['channels'] ?? [] as $channelId => $channel) {
|
||||
$cfg = $channelConfig[$channelId] ?? [];
|
||||
|
||||
if (($cfg['enabled'] ?? true) !== true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$channelName = $cfg['name'] ?? $channel['name'] ?? $channelId;
|
||||
$channelNumber = (int)($cfg['number'] ?? 999999);
|
||||
$channelIcon = $cfg['icon'] ?? $channel['icon'] ?? '';
|
||||
$favorite = (bool)($cfg['favorite'] ?? false);
|
||||
|
||||
if (mb_stripos($channelName, $query) !== false) {
|
||||
$channelResults[] = [
|
||||
'type' => 'channel',
|
||||
'channel_id' => $channelId,
|
||||
'channel_name' => $channelName,
|
||||
'channel_number' => $channelNumber,
|
||||
'channel_icon' => $channelIcon,
|
||||
'favorite' => $favorite,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($channel['programmes'] ?? [] as $programme) {
|
||||
$start = (int)($programme['start'] ?? 0);
|
||||
$stop = (int)($programme['stop'] ?? 0);
|
||||
|
||||
// Nur Sendungen, die den heutigen Tag berühren
|
||||
if ($start >= $endTs || $stop <= $startTs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$title = trim((string)($programme['title'] ?? ''));
|
||||
$subtitle = trim((string)($programme['subtitle'] ?? ''));
|
||||
|
||||
$matches =
|
||||
mb_stripos($title, $query) !== false ||
|
||||
mb_stripos($subtitle, $query) !== false;
|
||||
|
||||
if (!$matches) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$startDateTime = (new DateTime())
|
||||
->setTimestamp($start)
|
||||
->setTimezone($tz);
|
||||
|
||||
$stopDateTime = (new DateTime())
|
||||
->setTimestamp($stop)
|
||||
->setTimezone($tz);
|
||||
|
||||
$programmeResults[] = [
|
||||
'type' => 'programme',
|
||||
'id' => md5(
|
||||
$channelId . '|' .
|
||||
$start . '|' .
|
||||
$stop . '|' .
|
||||
$title
|
||||
),
|
||||
|
||||
'channel_id' => $channelId,
|
||||
'channel_name' => $channelName,
|
||||
'channel_number' => $channelNumber,
|
||||
'channel_icon' => $channelIcon,
|
||||
'favorite' => $favorite,
|
||||
|
||||
'title' => $title,
|
||||
'subtitle' => $subtitle,
|
||||
'description' => $programme['description'] ?? '',
|
||||
'category' => $programme['category'] ?? '',
|
||||
'metadata' => $programme['metadata'] ?? [],
|
||||
'year' => $programme['year'] ?? '',
|
||||
'rating' => $programme['rating'] ?? '',
|
||||
'episode' => $programme['episode'] ?? '',
|
||||
'icon' => $programme['icon'] ?? '',
|
||||
|
||||
'start' => $start,
|
||||
'stop' => $stop,
|
||||
'start_time' => $startDateTime->format('H:i'),
|
||||
'stop_time' => $stopDateTime->format('H:i'),
|
||||
'date' => $startDateTime->format('Y-m-d'),
|
||||
'is_current' => time() >= $start && time() < $stop,
|
||||
'is_past' => $stop <= time(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
usort($channelResults, function (array $a, array $b): int {
|
||||
return [
|
||||
$a['channel_number'],
|
||||
mb_strtolower($a['channel_name']),
|
||||
] <=> [
|
||||
$b['channel_number'],
|
||||
mb_strtolower($b['channel_name']),
|
||||
];
|
||||
});
|
||||
|
||||
usort($programmeResults, function (array $a, array $b): int {
|
||||
return [
|
||||
$a['start'],
|
||||
$a['channel_number'],
|
||||
] <=> [
|
||||
$b['start'],
|
||||
$b['channel_number'],
|
||||
];
|
||||
});
|
||||
|
||||
return array_slice(
|
||||
array_merge($channelResults, $programmeResults),
|
||||
0,
|
||||
$limit
|
||||
);
|
||||
}
|
||||
|
||||
private function getGenreIcon(string $category): string
|
||||
{
|
||||
$category = mb_strtolower($category);
|
||||
|
||||
+12
-2
@@ -50,11 +50,21 @@ class XmlTv
|
||||
return $this->programmeService->getCurrentProgrammes($this->getCachedData(), $config);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function getDayProgrammes(Config $config, string $date, ?string $channelId = null): array
|
||||
{
|
||||
return $this->programmeService->getDayProgrammes($config, $this->getCachedData(), $date, $channelId);
|
||||
}
|
||||
|
||||
public function search(
|
||||
Config $config,
|
||||
string $query,
|
||||
int $limit = 20
|
||||
): array {
|
||||
return $this->programmeService->search(
|
||||
$config,
|
||||
$this->getCachedData(),
|
||||
$query,
|
||||
$limit
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,24 @@
|
||||
</div>
|
||||
|
||||
<div class="header-center">
|
||||
<input
|
||||
class="header-search"
|
||||
type="search"
|
||||
placeholder="🔍 Sendung oder Sender suchen …"
|
||||
disabled>
|
||||
<div class="header-search-wrapper" data-global-search>
|
||||
<input
|
||||
id="guideSearch"
|
||||
class="header-search"
|
||||
type="search"
|
||||
placeholder="🔍 Sendung oder Sender suchen …"
|
||||
autocomplete="off"
|
||||
aria-label="Sender und Sendungen durchsuchen"
|
||||
aria-controls="guideSearchResults"
|
||||
aria-expanded="false">
|
||||
|
||||
<div
|
||||
id="guideSearchResults"
|
||||
class="guide-search-results"
|
||||
role="listbox"
|
||||
hidden>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-status">
|
||||
|
||||
Reference in New Issue
Block a user