Global Search - Highlighting
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
+7
-10
@@ -100,14 +100,6 @@ foreach ($dayProgrammes as $channel) {
|
||||
<button type="button" data-zoom="4">Extra</button>
|
||||
</div>
|
||||
|
||||
<div class="guide-search">
|
||||
<input
|
||||
type="search"
|
||||
id="guideSearch"
|
||||
placeholder="Sendung suchen …"
|
||||
autocomplete="off">
|
||||
<div class="guide-search-results" id="guideSearchResults" hidden></div>
|
||||
</div>
|
||||
|
||||
<?php if (!$dayProgrammes): ?>
|
||||
<div class="empty">Für diesen Tag wurden keine Sendungen gefunden.</div>
|
||||
@@ -130,7 +122,9 @@ foreach ($dayProgrammes as $channel) {
|
||||
|
||||
<?php foreach ($dayProgrammes as $channel): ?>
|
||||
<?php $channelLogoUrl = LogoService::getLogoUrl($channel['channel_id'] ?? $channel['id'] ?? ''); ?>
|
||||
<div class="timeline-row">
|
||||
<div
|
||||
class="timeline-row"
|
||||
data-channel-id="<?= htmlspecialchars($channel['channel_id']) ?>">
|
||||
|
||||
<div class="timeline-channel">
|
||||
<img
|
||||
@@ -186,7 +180,10 @@ foreach ($dayProgrammes as $channel) {
|
||||
<div class="day-list">
|
||||
<?php foreach ($dayProgrammes as $channel): ?>
|
||||
<?php $channelLogoUrl = LogoService::getLogoUrl($channel['channel_id'] ?? $channel['id'] ?? ''); ?>
|
||||
<section class="day-channel">
|
||||
<section
|
||||
class="day-channel"
|
||||
data-channel-id="<?= htmlspecialchars($channel['channel_id']) ?>">
|
||||
|
||||
<div class="day-channel-header">
|
||||
<div>
|
||||
<span class="channel-number"><?= (int)$channel['channel_number'] ?></span>
|
||||
|
||||
Reference in New Issue
Block a user