Global Search

This commit is contained in:
Gogs
2026-07-10 11:56:37 +02:00
parent 4567e77343
commit d3973fbf9e
8 changed files with 914 additions and 107 deletions
+223
View File
@@ -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%;
}
+75 -26
View File
@@ -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);
});
+401 -49
View File
@@ -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();
}
});
}
+45
View File
@@ -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,
]);
}
}
-25
View File
@@ -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);
}
}
+140
View File
@@ -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
View File
@@ -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
);
}
}
+18 -5
View File
@@ -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">