diff --git a/app/public/assets/css/layout.css b/app/public/assets/css/layout.css index b04df36..83fed04 100644 --- a/app/public/assets/css/layout.css +++ b/app/public/assets/css/layout.css @@ -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%; } diff --git a/app/public/assets/js/modules/modal.js b/app/public/assets/js/modules/modal.js index c0c62d6..204ba25 100644 --- a/app/public/assets/js/modules/modal.js +++ b/app/public/assets/js/modules/modal.js @@ -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); }); diff --git a/app/public/assets/js/modules/search.js b/app/public/assets/js/modules/search.js index a69b5e7..348e248 100644 --- a/app/public/assets/js/modules/search.js +++ b/app/public/assets/js/modules/search.js @@ -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 = `
Nichts gefunden
`; - results.hidden = false; - return; - } - - results.innerHTML = matches.map((programme) => ` -
- ${programme.title || "Ohne Titel"} - ${programme.channel || ""} · ${programme.start || ""} – ${programme.stop || ""} -
- `).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(); } }); } diff --git a/app/src/Controllers/HomeController.php b/app/src/Controllers/HomeController.php index a516b37..5bfcc1f 100644 --- a/app/src/Controllers/HomeController.php +++ b/app/src/Controllers/HomeController.php @@ -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, + ]); + } } diff --git a/app/src/Http/ApiResponse.php b/app/src/Http/ApiResponse.php deleted file mode 100644 index 4ecee58..0000000 --- a/app/src/Http/ApiResponse.php +++ /dev/null @@ -1,25 +0,0 @@ - false, - 'error' => $message, - ], $statusCode); - } -} diff --git a/app/src/Services/ProgrammeService.php b/app/src/Services/ProgrammeService.php index 3bf252c..542cc49 100644 --- a/app/src/Services/ProgrammeService.php +++ b/app/src/Services/ProgrammeService.php @@ -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); diff --git a/app/src/XmlTv.php b/app/src/XmlTv.php index 668c02d..acb30f3 100644 --- a/app/src/XmlTv.php +++ b/app/src/XmlTv.php @@ -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 + ); + } } diff --git a/app/templates/header.php b/app/templates/header.php index 47ff8bd..7456efc 100644 --- a/app/templates/header.php +++ b/app/templates/header.php @@ -6,11 +6,24 @@
- +
+ + + +