Files
termi-blog/frontend/src/components/Header.astro

801 lines
29 KiB
Plaintext

---
import { terminalConfig } from '../lib/config/terminal';
import { getI18n, SUPPORTED_LOCALES } from '../lib/i18n';
import type { SiteSettings } from '../lib/types';
interface Props {
siteName?: string;
siteSettings?: SiteSettings;
}
const {
siteName = Astro.props.siteSettings?.siteShortName || terminalConfig.branding?.shortName || 'Termi'
} = Astro.props;
const { locale, t, buildLocaleUrl } = getI18n(Astro);
const aiEnabled = Boolean(Astro.props.siteSettings?.ai?.enabled);
const navItems = [
{ icon: 'fa-file-code', text: t('nav.articles'), href: '/articles' },
{ icon: 'fa-folder', text: t('nav.categories'), href: '/categories' },
{ icon: 'fa-tags', text: t('nav.tags'), href: '/tags' },
{ icon: 'fa-stream', text: t('nav.timeline'), href: '/timeline' },
{ icon: 'fa-star', text: t('nav.reviews'), href: '/reviews' },
{ icon: 'fa-link', text: t('nav.friends'), href: '/friends' },
{ icon: 'fa-user-secret', text: t('nav.about'), href: '/about' },
...(aiEnabled ? [{ icon: 'fa-robot', text: t('nav.ask'), href: '/ask' }] : []),
];
const localeLinks = SUPPORTED_LOCALES.map((item) => ({
locale: item,
href: buildLocaleUrl(item),
label: t(`common.languages.${item}`),
shortLabel: item === 'zh-CN' ? '中' : 'EN',
}));
const currentPath = Astro.url.pathname;
---
<header data-ai-search-enabled={aiEnabled ? 'true' : 'false'} class="sticky top-0 z-50 border-b border-[var(--border-color)] backdrop-blur-xl" style="background-color: color-mix(in oklab, var(--bg) 88%, transparent);">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
<div class="terminal-toolbar-shell">
<div class="flex flex-col gap-3">
<div class="flex items-center gap-3">
<a href="/" class="terminal-toolbar-module shrink-0 min-w-[11.5rem] hover:border-[var(--primary)] transition-all">
<span class="flex h-10 w-10 items-center justify-center rounded-xl border border-[var(--border-color)] bg-[var(--primary)]/8 text-[var(--primary)]">
<i class="fas fa-terminal text-lg"></i>
</span>
<span>
<span class="terminal-toolbar-label block">root@termi</span>
<span class="mt-1 block text-lg font-bold text-[var(--title-color)]">{siteName}</span>
</span>
</a>
<div class="hidden xl:flex terminal-toolbar-module min-w-[15rem]">
<div class="min-w-0 flex-1">
<div class="terminal-toolbar-label">playerctl</div>
<div class="mt-1 flex items-center gap-2">
<button id="music-prev" class="terminal-toolbar-iconbtn">
<i class="fas fa-step-backward text-xs"></i>
</button>
<button id="music-play" class="terminal-toolbar-iconbtn bg-[var(--primary)]/12 text-[var(--primary)]" style="border-color: color-mix(in oklab, var(--primary) 28%, var(--border-color));">
<i class="fas fa-play text-xs" id="music-play-icon"></i>
</button>
<button id="music-next" class="terminal-toolbar-iconbtn">
<i class="fas fa-step-forward text-xs"></i>
</button>
<span class="min-w-0 flex-1 truncate text-xs font-mono text-[var(--text-secondary)]" id="music-title">
ギターと孤独と蒼い惑星
</span>
<button id="music-volume" class="terminal-toolbar-iconbtn">
<i class="fas fa-volume-up text-xs"></i>
</button>
</div>
</div>
</div>
<div class="relative hidden md:block flex-1 min-w-0">
<div class="terminal-toolbar-module gap-3">
<div class="terminal-toolbar-label" id="search-label">grep -i</div>
{aiEnabled && (
<div id="search-mode-panel" class="flex items-center gap-1 rounded-xl border border-[var(--border-color)] bg-[var(--header-bg)]/80 p-1">
<button
type="button"
class="search-mode-btn rounded-lg px-3 py-2 text-xs font-medium text-[var(--text-secondary)] transition hover:bg-[var(--header-bg)]"
data-search-mode="keyword"
aria-pressed="true"
>
<i class="fas fa-search mr-1 text-[11px]"></i>
<span>{t('header.searchModeKeyword')}</span>
</button>
<button
type="button"
class="search-mode-btn rounded-lg px-3 py-2 text-xs font-medium text-[var(--text-secondary)] transition hover:bg-[var(--header-bg)]"
data-search-mode="ai"
aria-pressed="false"
>
<i class="fas fa-robot mr-1 text-[11px]"></i>
<span>{t('header.searchModeAi')}</span>
</button>
</div>
)}
<input
type="text"
id="search-input"
placeholder={t('header.searchPlaceholderKeyword')}
class="terminal-console-input"
/>
<span id="search-hint" class="hidden xl:inline text-xs font-mono text-[var(--secondary)]">articles/*.md</span>
<button id="search-btn" class="terminal-toolbar-iconbtn">
<i id="search-btn-icon" class="fas fa-search text-sm"></i>
</button>
</div>
<div
id="search-results"
class="hidden absolute right-0 top-[calc(100%+12px)] w-[26rem] overflow-hidden rounded-xl border border-[var(--border-color)] bg-[var(--terminal-bg)] shadow-[0_20px_40px_rgba(15,23,42,0.08)]"
></div>
</div>
<div class="hidden sm:flex items-center gap-1 rounded-xl border border-[var(--border-color)] bg-[var(--header-bg)]/80 p-1">
{localeLinks.map((item) => (
<a
href={item.href}
data-locale-switch={item.locale}
class:list={[
'rounded-lg px-3 py-2 text-xs font-semibold transition',
item.locale === locale
? 'bg-[var(--primary)] text-white shadow-sm'
: 'text-[var(--text-secondary)] hover:bg-[var(--header-bg)]'
]}
aria-current={item.locale === locale ? 'true' : undefined}
title={item.label}
>
{item.shortLabel}
</a>
))}
</div>
<button
id="theme-toggle"
class="theme-toggle terminal-toolbar-iconbtn h-11 w-11 shrink-0"
aria-label={t('header.themeToggle')}
title={t('header.themeToggle')}
>
<i id="theme-icon" class="fas fa-moon text-[var(--primary)]"></i>
</button>
<button
id="mobile-menu-btn"
class="lg:hidden terminal-toolbar-iconbtn h-11 w-11 shrink-0"
aria-label={t('header.toggleMenu')}
>
<i class="fas fa-bars text-[var(--text)]"></i>
</button>
</div>
<div class="hidden lg:flex items-center gap-3 border-t border-[var(--border-color)]/70 pt-3">
<div class="terminal-toolbar-label">{t('header.navigation')}</div>
<nav class="min-w-0 flex-1 flex items-center gap-1.5 overflow-x-auto pb-1">
{navItems.map((item) => {
const isActive = currentPath === item.href || (item.href !== '/' && currentPath.startsWith(item.href));
return (
<a
href={item.href}
class:list={[
'terminal-nav-link',
isActive && 'is-active'
]}
>
<i class={`fas ${item.icon} text-xs`}></i>
<span>{item.text}</span>
</a>
);
})}
</nav>
</div>
</div>
</div>
<!-- Mobile Menu -->
<div id="mobile-menu" class="hidden lg:hidden border-t border-[var(--border-color)] bg-[var(--bg)]">
<div class="px-4 py-3 space-y-3">
<div class="space-y-3 md:hidden">
{aiEnabled && (
<div class="flex items-center gap-2 rounded-2xl border border-[var(--border-color)] bg-[var(--header-bg)]/80 p-1">
<button
type="button"
class="search-mode-btn flex-1 rounded-xl px-3 py-2 text-sm font-medium text-[var(--text-secondary)] transition hover:bg-[var(--header-bg)]"
data-search-mode="keyword"
aria-pressed="true"
>
<i class="fas fa-search mr-2 text-xs"></i>
<span>{t('header.searchModeKeywordMobile')}</span>
</button>
<button
type="button"
class="search-mode-btn flex-1 rounded-xl px-3 py-2 text-sm font-medium text-[var(--text-secondary)] transition hover:bg-[var(--header-bg)]"
data-search-mode="ai"
aria-pressed="false"
>
<i class="fas fa-robot mr-2 text-xs"></i>
<span>{t('header.searchModeAiMobile')}</span>
</button>
</div>
)}
<div class="flex items-center gap-2">
<span class="terminal-toolbar-label">{t('common.language')}</span>
<div class="flex flex-1 items-center gap-2">
{localeLinks.map((item) => (
<a
href={item.href}
data-locale-switch={item.locale}
class:list={[
'flex-1 rounded-xl border px-3 py-2 text-center text-sm font-medium transition',
item.locale === locale
? 'border-[var(--primary)] bg-[var(--primary)]/10 text-[var(--primary)]'
: 'border-[var(--border-color)] bg-[var(--header-bg)] text-[var(--text-secondary)]'
]}
aria-current={item.locale === locale ? 'true' : undefined}
>
{item.label}
</a>
))}
</div>
</div>
<div class="terminal-toolbar-module">
<span class="terminal-toolbar-label" id="mobile-search-label">grep -i</span>
<input
type="text"
id="mobile-search-input"
placeholder={t('header.searchPlaceholderKeyword')}
class="terminal-console-input"
/>
<button id="mobile-search-btn" class="terminal-toolbar-iconbtn">
<i id="mobile-search-btn-icon" class="fas fa-search text-sm"></i>
</button>
</div>
<p id="mobile-search-hint" class="px-1 text-xs font-mono text-[var(--text-tertiary)]">articles/*.md</p>
</div>
{navItems.map(item => (
<a
href={item.href}
class:list={[
'terminal-nav-link flex',
currentPath === item.href || (item.href !== '/' && currentPath.startsWith(item.href))
? 'is-active'
: ''
]}
>
<i class={`fas ${item.icon} w-5`}></i>
<span>{item.text}</span>
</a>
))}
</div>
</div>
</header>
<script is:inline>
// Theme Toggle - simplified vanilla JS
function initThemeToggle() {
const themeToggle = document.getElementById('theme-toggle');
const themeIcon = document.getElementById('theme-icon');
if (!themeToggle || !themeIcon) {
console.error('[Theme] Elements not found');
return;
}
console.log('[Theme] Initializing toggle button');
function updateThemeIcon(isDark) {
console.log('[Theme] Updating icon, isDark:', isDark);
if (isDark) {
themeIcon.className = 'fas fa-sun text-[var(--secondary)]';
} else {
themeIcon.className = 'fas fa-moon text-[var(--primary)]';
}
}
themeToggle.addEventListener('click', function() {
console.log('[Theme] Button clicked');
const root = document.documentElement;
const hasDark = root.classList.contains('dark');
console.log('[Theme] Current hasDark:', hasDark);
if (hasDark) {
root.classList.remove('dark');
root.classList.add('light');
localStorage.setItem('theme', 'light');
updateThemeIcon(false);
} else {
root.classList.remove('light');
root.classList.add('dark');
localStorage.setItem('theme', 'dark');
updateThemeIcon(true);
}
});
// Initialize icon based on current theme
const isDark = document.documentElement.classList.contains('dark');
updateThemeIcon(isDark);
}
// Run immediately if DOM is ready, otherwise wait
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initThemeToggle);
} else {
initThemeToggle();
}
// Mobile Menu
const mobileMenuBtn = document.getElementById('mobile-menu-btn');
const mobileMenu = document.getElementById('mobile-menu');
const mobileSearchInput = document.getElementById('mobile-search-input');
const mobileSearchBtn = document.getElementById('mobile-search-btn');
mobileMenuBtn?.addEventListener('click', () => {
mobileMenu?.classList.toggle('hidden');
});
// Music Player with actual audio
const musicPlay = document.getElementById('music-play');
const musicPlayIcon = document.getElementById('music-play-icon');
const musicTitle = document.getElementById('music-title');
const musicPrev = document.getElementById('music-prev');
const musicNext = document.getElementById('music-next');
const musicVolume = document.getElementById('music-volume');
// Playlist - Using placeholder audio URLs (replace with actual music URLs)
const playlist = [
{ title: 'ギターと孤独と蒼い惑星', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3' },
{ title: '星座になれたら', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3' },
{ title: 'あのバンド', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3' }
];
let currentSongIndex = 0;
let isPlaying = false;
let audio = null;
let volume = 0.5;
function initAudio() {
if (!audio) {
audio = new Audio();
audio.volume = volume;
audio.addEventListener('ended', () => {
playNext();
});
}
}
function updateTitle() {
if (musicTitle) {
musicTitle.textContent = playlist[currentSongIndex].title;
}
}
function playSong() {
initAudio();
if (audio.src !== playlist[currentSongIndex].url) {
audio.src = playlist[currentSongIndex].url;
}
audio.play().catch(err => console.log('Audio play failed:', err));
isPlaying = true;
if (musicPlayIcon) {
musicPlayIcon.className = 'fas fa-pause text-xs';
}
if (musicTitle) {
musicTitle.classList.add('text-[var(--primary)]');
musicTitle.classList.remove('text-[var(--text-secondary)]');
}
}
function pauseSong() {
if (audio) {
audio.pause();
}
isPlaying = false;
if (musicPlayIcon) {
musicPlayIcon.className = 'fas fa-play text-xs';
}
if (musicTitle) {
musicTitle.classList.remove('text-[var(--primary)]');
musicTitle.classList.add('text-[var(--text-secondary)]');
}
}
function togglePlay() {
if (isPlaying) {
pauseSong();
} else {
playSong();
}
}
function playNext() {
currentSongIndex = (currentSongIndex + 1) % playlist.length;
updateTitle();
if (isPlaying) {
playSong();
}
}
function playPrev() {
currentSongIndex = (currentSongIndex - 1 + playlist.length) % playlist.length;
updateTitle();
if (isPlaying) {
playSong();
}
}
function toggleMute() {
if (audio) {
audio.muted = !audio.muted;
if (musicVolume) {
musicVolume.innerHTML = audio.muted ?
'<i class="fas fa-volume-mute text-xs"></i>' :
'<i class="fas fa-volume-up text-xs"></i>';
}
}
}
musicPlay?.addEventListener('click', togglePlay);
musicNext?.addEventListener('click', playNext);
musicPrev?.addEventListener('click', playPrev);
musicVolume?.addEventListener('click', toggleMute);
// Initialize title
updateTitle();
// Search functionality
const headerRoot = document.querySelector('header[data-ai-search-enabled]');
const aiSearchEnabled = headerRoot?.getAttribute('data-ai-search-enabled') === 'true';
const searchInput = document.getElementById('search-input');
const searchBtn = document.getElementById('search-btn');
const searchBtnIcon = document.getElementById('search-btn-icon');
const searchResults = document.getElementById('search-results');
const searchLabel = document.getElementById('search-label');
const searchHint = document.getElementById('search-hint');
const mobileSearchLabel = document.getElementById('mobile-search-label');
const mobileSearchHint = document.getElementById('mobile-search-hint');
const mobileSearchBtnIcon = document.getElementById('mobile-search-btn-icon');
const searchModePanel = document.getElementById('search-mode-panel');
const searchModeButtons = Array.from(document.querySelectorAll('.search-mode-btn'));
const localeSwitchLinks = Array.from(document.querySelectorAll('[data-locale-switch]'));
const searchApiBase = 'http://localhost:5150/api';
const searchInputs = [searchInput, mobileSearchInput].filter(Boolean);
const t = window.__termiTranslate;
const searchModeConfig = {
keyword: {
label: 'grep -i',
hint: t('header.searchHintKeyword'),
placeholder: t('header.searchPlaceholderKeyword'),
buttonIcon: 'fa-search'
},
ai: {
label: 'ask ai',
hint: t('header.searchHintAi'),
placeholder: t('header.searchPlaceholderAi'),
buttonIcon: 'fa-robot'
}
};
let searchTimer = null;
let currentSearchMode = 'keyword';
function escapeHtml(value) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function highlightText(value, query) {
const escapedValue = escapeHtml(value || '');
const normalizedQuery = query.trim();
if (!normalizedQuery) {
return escapedValue;
}
const escapedQuery = normalizedQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return escapedValue.replace(
new RegExp(`(${escapedQuery})`, 'ig'),
'<mark class="rounded-sm border border-[var(--border-color)] bg-[var(--primary-light)] px-1 text-[var(--title-color)]">$1</mark>'
);
}
function syncSearchInputs(sourceInput) {
const nextValue = sourceInput && 'value' in sourceInput ? sourceInput.value : '';
searchInputs.forEach((input) => {
if (input !== sourceInput) {
input.value = nextValue;
}
});
}
function getQueryFromInput(input) {
return input && 'value' in input ? input.value.trim() : '';
}
function buildLocalizedUrl(path) {
const nextUrl = new URL(path, window.location.origin);
nextUrl.searchParams.set('lang', document.documentElement.lang || 'zh-CN');
return `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`;
}
function buildSearchTarget(query) {
return buildLocalizedUrl(
currentSearchMode === 'ai'
? `/ask?q=${encodeURIComponent(query)}`
: `/articles?search=${encodeURIComponent(query)}`
);
}
function syncSearchModeUI() {
const config = searchModeConfig[currentSearchMode] || searchModeConfig.keyword;
if (searchLabel) {
searchLabel.textContent = config.label;
}
if (mobileSearchLabel) {
mobileSearchLabel.textContent = config.label;
}
if (searchHint) {
searchHint.textContent = config.hint;
}
if (mobileSearchHint) {
mobileSearchHint.textContent = config.hint;
}
searchInputs.forEach((input) => {
input.setAttribute('placeholder', config.placeholder);
});
if (searchBtnIcon) {
searchBtnIcon.className = `fas ${config.buttonIcon} text-sm`;
}
if (mobileSearchBtnIcon) {
mobileSearchBtnIcon.className = `fas ${config.buttonIcon} text-sm`;
}
searchModeButtons.forEach((button) => {
const isActive = button.getAttribute('data-search-mode') === currentSearchMode;
button.setAttribute('aria-pressed', String(isActive));
button.classList.toggle('bg-[var(--primary)]', isActive);
button.classList.toggle('text-white', isActive);
button.classList.toggle('shadow-sm', isActive);
button.classList.toggle('text-[var(--text-secondary)]', !isActive);
});
}
function renderAiSearchResults(query) {
if (!searchResults) return;
searchResults.innerHTML = `
<div class="overflow-hidden">
<div class="border-b border-[var(--border-color)] px-4 py-2 text-xs uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
${escapeHtml(t('header.aiModeTitle'))}
</div>
<div class="space-y-4 px-4 py-4">
<div class="space-y-2">
<div class="text-sm font-semibold text-[var(--title-color)]">${escapeHtml(t('header.aiModeHeading'))}</div>
<p class="text-sm leading-6 text-[var(--text-secondary)]">
${escapeHtml(t('header.aiModeDescription'))}
</p>
<p class="text-xs leading-5 text-[var(--text-tertiary)]">
${escapeHtml(t('header.aiModeNotice'))}
</p>
</div>
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-3">
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">${escapeHtml(t('common.search'))}</div>
<div class="mt-2 font-mono text-sm text-[var(--title-color)]">${escapeHtml(query)}</div>
</div>
<a href="${buildSearchTarget(query)}" class="flex items-center justify-between rounded-2xl border border-[var(--primary)]/30 bg-[var(--primary)]/10 px-4 py-3 text-sm font-medium text-[var(--primary)] transition hover:bg-[var(--primary)]/16">
<span><i class="fas fa-robot mr-2 text-xs"></i>${escapeHtml(t('header.aiModeCta'))}</span>
<i class="fas fa-arrow-right text-xs"></i>
</a>
</div>
</div>
`;
searchResults.classList.remove('hidden');
}
function hideSearchResults() {
if (!searchResults) return;
searchResults.classList.add('hidden');
searchResults.innerHTML = '';
}
function renderSearchResults(query, results, state = 'ready') {
if (!searchResults) return;
if (state === 'loading') {
searchResults.innerHTML = `
<div class="px-4 py-4 text-sm text-[var(--text-secondary)]">
${escapeHtml(t('header.searching', { query }))}
</div>
`;
searchResults.classList.remove('hidden');
return;
}
if (state === 'error') {
searchResults.innerHTML = `
<div class="px-4 py-4 text-sm text-[var(--danger)]">
${escapeHtml(t('header.searchFailed'))}
</div>
`;
searchResults.classList.remove('hidden');
return;
}
if (!results.length) {
const aiRetry = aiSearchEnabled
? `
<a href="${buildLocalizedUrl(`/ask?q=${encodeURIComponent(query)}`)}" class="mt-3 inline-flex items-center gap-2 rounded-full border border-[var(--primary)]/30 bg-[var(--primary)]/10 px-3 py-1.5 text-xs font-medium text-[var(--primary)] transition hover:bg-[var(--primary)]/16">
<i class="fas fa-robot text-[11px]"></i>
<span>${escapeHtml(t('header.searchEmptyCta'))}</span>
</a>
`
: '';
searchResults.innerHTML = `
<div class="px-4 py-4 text-sm text-[var(--text-secondary)]">
${escapeHtml(t('header.searchEmpty', { query }))}
${aiRetry}
</div>
`;
searchResults.classList.remove('hidden');
return;
}
const itemsHtml = results.map((item) => {
const tags = Array.isArray(item.tags) ? item.tags.slice(0, 4) : [];
const tagHtml = tags.length
? `<div class="mt-2 flex flex-wrap gap-2">${tags
.map((tag) => `<span class="rounded-full border border-[var(--border-color)] px-2 py-0.5 text-xs text-[var(--text-secondary)]">#${highlightText(tag, query)}</span>`)
.join('')}</div>`
: '';
return `
<a href="/articles/${encodeURIComponent(item.slug)}" class="block border-b border-[var(--border-color)] px-4 py-3 transition-colors hover:bg-[var(--header-bg)] last:border-b-0">
<div class="flex items-center justify-between gap-3">
<div class="text-sm font-semibold text-[var(--title-color)]">${highlightText(item.title || t('header.untitled'), query)}</div>
<div class="text-[11px] text-[var(--text-tertiary)]">${escapeHtml(item.category || '')}</div>
</div>
<div class="mt-1 text-xs leading-5 text-[var(--text-secondary)]">${highlightText(item.description || item.content || '', query)}</div>
${tagHtml}
</a>
`;
}).join('');
const aiFooter = aiSearchEnabled
? `
<a href="${buildLocalizedUrl(`/ask?q=${encodeURIComponent(query)}`)}" class="block border-t border-[var(--border-color)] px-4 py-3 text-sm font-medium text-[var(--primary)] transition-colors hover:bg-[var(--header-bg)]">
<i class="fas fa-robot mr-2 text-xs"></i>
${escapeHtml(t('header.searchAiFooter'))}
</a>
`
: '';
searchResults.innerHTML = `
<div class="max-h-[26rem] overflow-auto">
<div class="border-b border-[var(--border-color)] px-4 py-2 text-xs uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
${escapeHtml(t('header.liveResults'))}
</div>
${itemsHtml}
<a href="${buildLocalizedUrl(`/articles?search=${encodeURIComponent(query)}`)}" class="block px-4 py-3 text-sm font-medium text-[var(--primary)] transition-colors hover:bg-[var(--header-bg)]">
${escapeHtml(t('header.searchAllResults'))}
</a>
${aiFooter}
</div>
`;
searchResults.classList.remove('hidden');
}
async function runLiveSearch(query) {
if (!query) {
hideSearchResults();
return;
}
if (currentSearchMode === 'ai') {
renderAiSearchResults(query);
return;
}
renderSearchResults(query, [], 'loading');
try {
const response = await fetch(`${searchApiBase}/search?q=${encodeURIComponent(query)}&limit=6`);
if (!response.ok) {
throw new Error(`Search failed: ${response.status}`);
}
const results = await response.json();
renderSearchResults(query, Array.isArray(results) ? results : []);
} catch (error) {
console.error('Live search failed:', error);
renderSearchResults(query, [], 'error');
}
}
function setSearchMode(mode) {
if (!aiSearchEnabled && mode === 'ai') {
currentSearchMode = 'keyword';
} else {
currentSearchMode = mode;
}
syncSearchModeUI();
const query = getQueryFromInput(searchInput);
if (query && document.activeElement === searchInput) {
void runLiveSearch(query);
} else if (!query) {
hideSearchResults();
}
}
function submitSearch(preferredInput) {
const query = getQueryFromInput(preferredInput) || getQueryFromInput(searchInput) || getQueryFromInput(mobileSearchInput);
if (query) {
window.location.href = buildSearchTarget(query);
}
}
searchModeButtons.forEach((button) => {
button.addEventListener('click', () => {
const nextMode = button.getAttribute('data-search-mode') || 'keyword';
setSearchMode(nextMode);
});
});
searchBtn?.addEventListener('click', function() {
submitSearch(searchInput);
});
mobileSearchBtn?.addEventListener('click', function() {
submitSearch(mobileSearchInput);
});
searchInput?.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
submitSearch(searchInput);
}
});
mobileSearchInput?.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
submitSearch(mobileSearchInput);
}
});
searchInput?.addEventListener('input', function() {
syncSearchInputs(searchInput);
const query = this.value.trim();
if (searchTimer) {
clearTimeout(searchTimer);
}
searchTimer = setTimeout(() => {
runLiveSearch(query);
}, 180);
});
mobileSearchInput?.addEventListener('input', function() {
syncSearchInputs(mobileSearchInput);
});
searchInput?.addEventListener('focus', function() {
const query = this.value.trim();
if (query) {
runLiveSearch(query);
}
});
syncSearchModeUI();
localeSwitchLinks.forEach((link) => {
link.addEventListener('click', () => {
const nextLocale = link.getAttribute('data-locale-switch');
if (!nextLocale) {
return;
}
localStorage.setItem('locale', nextLocale);
document.cookie = `${'termi_locale'}=${encodeURIComponent(nextLocale)};path=/;max-age=31536000;samesite=lax`;
});
});
document.addEventListener('click', function(event) {
const target = event.target;
if (
searchResults &&
!searchResults.contains(target) &&
!searchModePanel?.contains(target) &&
!target?.closest?.('.search-mode-btn') &&
target !== searchInput &&
target !== searchBtn &&
!searchBtn?.contains(target)
) {
hideSearchResults();
}
});
</script>