Global Search - Highlighting

This commit is contained in:
Gogs
2026-07-10 12:38:33 +02:00
parent d3973fbf9e
commit 8795d9b607
3 changed files with 354 additions and 96 deletions
+124
View File
@@ -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;
}
+223 -86
View File
@@ -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
View File
@@ -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>