diff --git a/app/public/assets/css/layout.css b/app/public/assets/css/layout.css index 83fed04..6ca3d72 100644 --- a/app/public/assets/css/layout.css +++ b/app/public/assets/css/layout.css @@ -374,3 +374,127 @@ display: none; } } + +.guide-search-logo { + flex: 0 0 auto; + + width: 42px; + height: 42px; + + padding: 5px; + + object-fit: contain; + + background: rgba(255, 255, 255, 0.94); + border-radius: 9px; +} + +.guide-search-heading-row { + min-width: 0; + + display: flex; + align-items: center; + gap: 8px; +} + +.guide-search-heading-row strong { + min-width: 0; + flex: 1; +} + +.guide-search-live-badge { + flex: 0 0 auto; + + padding: 2px 6px; + + color: #fecaca; + background: rgba(239, 68, 68, 0.18); + + border: 1px solid rgba(239, 68, 68, 0.38); + border-radius: 999px; + + font-size: 0.65rem; + font-weight: 800; + line-height: 1.25; + letter-spacing: 0.05em; +} + +.guide-search-channel { + background: rgba(59, 130, 246, 0.04); +} + +.guide-search-channel:hover, +.guide-search-channel.is-active { + background: rgba(59, 130, 246, 0.18); +} + +.search-channel-highlight { + position: relative; + z-index: 2; + + animation: search-channel-highlight 1.8s ease; +} + +@keyframes search-channel-highlight { + 0%, + 100% { + background: inherit; + box-shadow: none; + } + + 20%, + 70% { + background: rgba(59, 130, 246, 0.2); + box-shadow: + inset 0 0 0 2px var(--accent), + 0 0 26px rgba(59, 130, 246, 0.35); + } +} + +.guide-search-group + .guide-search-group { + margin-top: 7px; + padding-top: 7px; + border-top: 1px solid rgba(51, 65, 85, 0.75); +} + +.guide-search-group-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + + padding: 7px 11px 6px; + + color: var(--muted); + + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.guide-search-group-count { + min-width: 22px; + padding: 2px 7px; + + color: #cbd5e1; + background: rgba(51, 65, 85, 0.7); + + border-radius: 999px; + + font-size: 0.68rem; + line-height: 1.3; + text-align: center; +} + +.guide-search-highlight { + padding: 0 2px; + + color: #fef3c7; + background: rgba(245, 158, 11, 0.28); + + border-radius: 4px; + + font: inherit; + font-weight: 800; +} diff --git a/app/public/assets/js/modules/search.js b/app/public/assets/js/modules/search.js index 348e248..14e19d8 100644 --- a/app/public/assets/js/modules/search.js +++ b/app/public/assets/js/modules/search.js @@ -11,9 +11,57 @@ export function initGuideSearch() { 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 = ""; @@ -90,168 +138,257 @@ export function initGuideSearch() { const openChannel = (item) => { const channelId = String(item.channel_id || ""); + + const channelElement = document.querySelector( + `.timeline-row[data-channel-id="${CSS.escape(channelId)}"]` + ); - /* - * 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(); + 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); + }); + }; - /* - * Falls der Sender auf der aktuellen Ansicht nicht im DOM liegt, - * Tagesansicht mit Senderfilter öffnen. - */ - window.location.href = - `/?view=day&channel=${encodeURIComponent(channelId)}`; + 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 icon = document.createElement("span"); - icon.className = "guide-search-type-icon"; - icon.textContent = "📺"; - + + const logo = createChannelLogo(item); + const content = document.createElement("span"); content.className = "guide-search-result-content"; - + const heading = document.createElement("strong"); - heading.textContent = - escapeText(item.channel_name || "Unbekannter Sender"); - + 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(icon, content); - + 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 icon = document.createElement("span"); - icon.className = "guide-search-type-icon"; - icon.textContent = item.is_current ? "🔴" : "🎬"; - + + 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"); - heading.textContent = - escapeText(item.title || "Ohne Titel"); - + 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}` : "" ]; - - if (item.is_current) { - parts.push("läuft jetzt"); - } - + details.textContent = parts.filter(Boolean).join(" · "); - - content.append(heading); - + + content.append(headingRow); + if (item.subtitle) { const subtitle = document.createElement("span"); subtitle.className = "guide-search-subtitle"; - subtitle.textContent = escapeText(item.subtitle); + appendHighlightedText( + subtitle, + item.subtitle, + currentQuery + ); + content.append(subtitle); } - + content.append(details); - button.append(icon, content); - + button.append(logo, content); + return button; }; const renderResults = (items) => { results.innerHTML = ""; - currentItems = items; + currentItems = []; 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); - }); - + + 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(); diff --git a/app/templates/day.php b/app/templates/day.php index 198cfb7..14b232d 100644 --- a/app/templates/day.php +++ b/app/templates/day.php @@ -100,14 +100,6 @@ foreach ($dayProgrammes as $channel) { -