Some checks failed
docker-images / resolve-build-targets (push) Successful in 6s
ui-regression / playwright-regression (push) Failing after 13m3s
docker-images / build-and-push (admin) (push) Successful in 4s
docker-images / submit-indexnow (push) Has been cancelled
docker-images / build-and-push (frontend) (push) Has been cancelled
docker-images / build-and-push (backend) (push) Has been cancelled
993 lines
40 KiB
Plaintext
993 lines
40 KiB
Plaintext
---
|
|
import { resolvePublicApiBaseUrl } from '../lib/api/client';
|
|
import { terminalConfig } from '../lib/config/terminal';
|
|
import { getI18n, SUPPORTED_LOCALES } from '../lib/i18n';
|
|
import ThemeToggle from './interactive/ThemeToggle.svelte';
|
|
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 musicEnabled = Astro.props.siteSettings?.musicEnabled ?? true;
|
|
const configuredMusicPlaylist = Astro.props.siteSettings?.musicPlaylist ?? [];
|
|
const musicPlaylist = (musicEnabled ? configuredMusicPlaylist : []).filter(
|
|
(item) => item?.title?.trim() && item?.url?.trim()
|
|
);
|
|
const musicPlaylistPayload = JSON.stringify(musicPlaylist);
|
|
const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
|
const hasMusicPlaylist = musicPlaylist.length > 0;
|
|
const currentMusicTrack = hasMusicPlaylist ? musicPlaylist[0] : null;
|
|
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 mobileDockItems = [
|
|
{ icon: 'fa-house', text: t('common.home'), href: '/' },
|
|
{ icon: 'fa-file-code', text: t('nav.articles'), href: '/articles' },
|
|
...(aiEnabled
|
|
? [{ icon: 'fa-robot', text: t('nav.ask'), href: '/ask' }]
|
|
: [{ icon: 'fa-folder', text: t('nav.categories'), href: '/categories' }]),
|
|
{ icon: 'fa-user-secret', text: t('nav.about'), href: '/about' },
|
|
];
|
|
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;
|
|
const currentNavLabel =
|
|
navItems.find((item) => currentPath === item.href || (item.href !== '/' && currentPath.startsWith(item.href)))
|
|
?.text || t('header.navigation');
|
|
---
|
|
|
|
<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-2.5">
|
|
<div class="terminal-toolbar-shell overflow-visible">
|
|
<div class="flex flex-col gap-2.5">
|
|
<div class="flex items-center gap-2 lg:flex-nowrap">
|
|
<a href="/" class="terminal-toolbar-module shrink-0 min-w-[9.5rem] px-2.5 py-1.5 hover:border-[var(--primary)] transition-all">
|
|
<span class="flex h-8 w-8 items-center justify-center rounded-xl border border-[var(--border-color)] bg-[var(--primary)]/8 text-[var(--primary)]">
|
|
<i class="fas fa-terminal text-base"></i>
|
|
</span>
|
|
<span>
|
|
<span class="terminal-toolbar-label block">{t('header.shellLabel')}</span>
|
|
<span class="mt-0.5 block text-[15px] font-bold text-[var(--title-color)]">{siteName}</span>
|
|
</span>
|
|
</a>
|
|
|
|
<div class="relative hidden min-w-[20rem] grow basis-[24rem] lg:block xl:min-w-[24rem] xl:basis-[30rem]">
|
|
<div class="terminal-toolbar-module min-w-0 gap-2 px-2.5 py-1.5">
|
|
<div class="terminal-toolbar-label shrink-0 whitespace-nowrap" id="search-label">{t('header.searchPromptKeyword')}</div>
|
|
{aiEnabled && (
|
|
<div id="search-mode-panel" class="hidden shrink-0 2xl: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-2.5 py-1.5 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-2.5 py-1.5 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"
|
|
/>
|
|
<button id="search-btn" class="terminal-toolbar-iconbtn h-8 w-8 shrink-0" aria-label="Search">
|
|
<i id="search-btn-icon" class="fas fa-search text-sm"></i>
|
|
</button>
|
|
</div>
|
|
<p id="search-hint" class="px-1 pt-0.5 text-[11px] font-mono text-[var(--text-tertiary)]">
|
|
{t('header.searchHintKeyword')}
|
|
</p>
|
|
<div
|
|
id="search-results"
|
|
class="hidden absolute left-0 top-[calc(100%+12px)] z-40 w-[min(34rem,calc(100vw-4rem))] overflow-hidden rounded-[24px] border border-[var(--border-color)]/80 bg-[color-mix(in_oklab,var(--terminal-bg)_94%,white)] shadow-[0_24px_60px_rgba(15,23,42,0.14)] backdrop-blur-xl"
|
|
></div>
|
|
</div>
|
|
|
|
<div class="ml-auto hidden shrink-0 items-center gap-2 lg:flex">
|
|
{musicEnabled && (
|
|
<div class="hidden 2xl:flex terminal-toolbar-module min-w-0 max-w-[13rem] gap-2 px-2.5 py-1.5">
|
|
<div class="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border-color)] bg-[var(--primary)]/8">
|
|
<img
|
|
id="desktop-music-cover"
|
|
src={currentMusicTrack?.coverImageUrl || ''}
|
|
alt={currentMusicTrack?.title || 'Music cover'}
|
|
class:list={[
|
|
'h-full w-full object-cover',
|
|
!currentMusicTrack?.coverImageUrl && 'hidden'
|
|
]}
|
|
/>
|
|
<i
|
|
id="desktop-music-cover-fallback"
|
|
class:list={[
|
|
'fas fa-compact-disc text-sm text-[var(--primary)]',
|
|
currentMusicTrack?.coverImageUrl && 'hidden'
|
|
]}
|
|
></i>
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<p class="truncate text-[13px] font-semibold text-[var(--title-color)]" id="desktop-music-title">
|
|
{currentMusicTrack?.title || '未配置曲目'}
|
|
</p>
|
|
<div class="mt-1 flex items-center gap-1">
|
|
<button id="desktop-music-prev" class="terminal-toolbar-iconbtn h-7 w-7" aria-label="Previous track" disabled={!hasMusicPlaylist}>
|
|
<i class="fas fa-step-backward text-[11px]"></i>
|
|
</button>
|
|
<button id="desktop-music-play" class="terminal-toolbar-iconbtn h-7 w-7 bg-[var(--primary)]/12 text-[var(--primary)]" style="border-color: color-mix(in oklab, var(--primary) 28%, var(--border-color));" aria-label="Play or pause" disabled={!hasMusicPlaylist}>
|
|
<i class="fas fa-play text-[11px]" id="desktop-music-play-icon"></i>
|
|
</button>
|
|
<button id="desktop-music-next" class="terminal-toolbar-iconbtn h-7 w-7" aria-label="Next track" disabled={!hasMusicPlaylist}>
|
|
<i class="fas fa-step-forward text-[11px]"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{aiEnabled && (
|
|
<a
|
|
href="/ask"
|
|
class="inline-flex shrink-0 items-center gap-2 rounded-xl border border-[color:color-mix(in_oklab,var(--primary)_28%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--primary)_14%,var(--terminal-bg)),color-mix(in_oklab,var(--primary)_8%,var(--terminal-bg)))] px-2.5 py-1.5 text-sm font-medium text-[var(--primary)] shadow-[0_10px_24px_rgba(var(--primary-rgb),0.10)] transition hover:border-[color:color-mix(in_oklab,var(--primary)_40%,var(--border-color))] hover:text-[var(--title-color)]"
|
|
>
|
|
<i class="fas fa-robot text-sm"></i>
|
|
<span class="hidden xl:inline">{t('nav.ask')}</span>
|
|
</a>
|
|
)}
|
|
|
|
<div class="flex shrink-0 items-center gap-1 rounded-xl border border-[var(--border-color)] bg-[var(--header-bg)]/80 p-0.5">
|
|
{localeLinks.map((item) => (
|
|
<a
|
|
href={item.href}
|
|
data-locale-switch={item.locale}
|
|
class:list={[
|
|
'rounded-lg px-2.5 py-1.5 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>
|
|
|
|
<div class="relative shrink-0">
|
|
<ThemeToggle
|
|
client:load
|
|
labels={{
|
|
toggle: t('header.themeToggle'),
|
|
system: t('header.themeSystem'),
|
|
light: t('header.themeLight'),
|
|
dark: t('header.themeDark'),
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="relative shrink-0 lg:hidden">
|
|
<ThemeToggle
|
|
client:load
|
|
labels={{
|
|
toggle: t('header.themeToggle'),
|
|
system: t('header.themeSystem'),
|
|
light: t('header.themeLight'),
|
|
dark: t('header.themeDark'),
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
id="mobile-menu-btn"
|
|
class="terminal-toolbar-iconbtn h-9 w-9 shrink-0 lg:hidden"
|
|
aria-label={t('header.toggleMenu')}
|
|
aria-expanded="false"
|
|
>
|
|
<span class="sr-only">
|
|
{currentNavLabel}
|
|
</span>
|
|
<i class="fas fa-bars text-[var(--text)]"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Mobile Menu -->
|
|
<div id="mobile-menu" class="hidden border-t border-[var(--border-color)] bg-[var(--bg)] lg:hidden">
|
|
<div class="px-4 py-4 space-y-4">
|
|
<div class="grid gap-3 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
|
<div class="space-y-3">
|
|
<div class="space-y-3 lg: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="terminal-toolbar-module">
|
|
<span class="terminal-toolbar-label" id="mobile-search-label">{t('header.searchPromptKeyword')}</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" aria-label="Search">
|
|
<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)]">{t('header.searchHintKeyword')}</p>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2 xl:hidden">
|
|
<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>
|
|
|
|
{musicEnabled && (
|
|
<div class="terminal-toolbar-module items-center gap-3">
|
|
<div class="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-[var(--border-color)] bg-[var(--primary)]/8">
|
|
<img
|
|
id="music-cover"
|
|
src={currentMusicTrack?.coverImageUrl || ''}
|
|
alt={currentMusicTrack?.title || 'Music cover'}
|
|
class:list={[
|
|
'h-full w-full object-cover',
|
|
!currentMusicTrack?.coverImageUrl && 'hidden'
|
|
]}
|
|
/>
|
|
<i
|
|
id="music-cover-fallback"
|
|
class:list={[
|
|
'fas fa-compact-disc text-base text-[var(--primary)]',
|
|
currentMusicTrack?.coverImageUrl && 'hidden'
|
|
]}
|
|
></i>
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="terminal-toolbar-label">{t('header.musicPanel')}</div>
|
|
<div class="mt-1 flex items-center gap-2">
|
|
<button id="music-prev" class="terminal-toolbar-iconbtn" aria-label="Previous track" disabled={!hasMusicPlaylist}>
|
|
<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));" aria-label="Play or pause" disabled={!hasMusicPlaylist}>
|
|
<i class="fas fa-play text-xs" id="music-play-icon"></i>
|
|
</button>
|
|
<button id="music-next" class="terminal-toolbar-iconbtn" aria-label="Next track" disabled={!hasMusicPlaylist}>
|
|
<i class="fas fa-step-forward text-xs"></i>
|
|
</button>
|
|
<button id="music-volume" class="terminal-toolbar-iconbtn" aria-label="Mute or unmute" disabled={!hasMusicPlaylist}>
|
|
<i class="fas fa-volume-up text-xs"></i>
|
|
</button>
|
|
</div>
|
|
<div class="mt-2 min-w-0">
|
|
<p class="truncate text-sm font-semibold text-[var(--title-color)]" id="music-title">
|
|
{currentMusicTrack?.title || '未配置曲目'}
|
|
</p>
|
|
<p class="truncate text-[11px] text-[var(--text-tertiary)]" id="music-artist">
|
|
{currentMusicTrack?.artist || currentMusicTrack?.album || '等待播放'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
|
{navItems.map(item => (
|
|
<a
|
|
href={item.href}
|
|
class:list={[
|
|
'terminal-nav-link',
|
|
currentPath === item.href || (item.href !== '/' && currentPath.startsWith(item.href))
|
|
? 'is-active'
|
|
: ''
|
|
]}
|
|
>
|
|
<span class="flex min-w-0 flex-1 items-center gap-3">
|
|
<span class="flex h-10 w-10 items-center justify-center rounded-xl border border-[var(--border-color)] bg-[var(--header-bg)]/82 text-[var(--primary)]">
|
|
<i class={`fas ${item.icon} text-sm`}></i>
|
|
</span>
|
|
<span class="min-w-0">
|
|
<span class="terminal-toolbar-label block">{t('header.navigation')}</span>
|
|
<span class="mt-1 block truncate text-sm font-semibold text-[var(--title-color)]">{item.text}</span>
|
|
</span>
|
|
</span>
|
|
<i class="fas fa-arrow-right text-[11px] text-[var(--text-tertiary)]"></i>
|
|
</a>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="fixed inset-x-0 bottom-0 z-40 px-3 pb-[calc(0.8rem+env(safe-area-inset-bottom))] lg:hidden">
|
|
<div class="mx-auto max-w-md">
|
|
<div class="grid grid-cols-5 gap-1 rounded-[24px] border border-[color:color-mix(in_oklab,var(--primary)_12%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_96%,transparent),color-mix(in_oklab,var(--header-bg)_92%,transparent))] p-1.5 shadow-[0_18px_36px_rgba(15,23,42,0.22)] backdrop-blur-xl">
|
|
{mobileDockItems.map((item) => {
|
|
const isActive = currentPath === item.href || (item.href !== '/' && currentPath.startsWith(item.href));
|
|
return (
|
|
<a
|
|
href={item.href}
|
|
class:list={[
|
|
'flex min-w-0 flex-col items-center gap-1 rounded-[18px] px-2 py-2 text-[11px] font-medium transition',
|
|
isActive
|
|
? 'border border-[color:color-mix(in_oklab,var(--primary)_30%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--primary)_14%,var(--terminal-bg)),color-mix(in_oklab,var(--primary)_8%,var(--terminal-bg)))] text-[var(--primary)]'
|
|
: 'border border-transparent text-[var(--text-secondary)] hover:bg-[color-mix(in_oklab,var(--primary)_6%,var(--terminal-bg))] hover:text-[var(--title-color)]',
|
|
]}
|
|
aria-current={isActive ? 'page' : undefined}
|
|
>
|
|
<i class={`fas ${item.icon} text-[13px]`}></i>
|
|
<span class="truncate">{item.text}</span>
|
|
</a>
|
|
);
|
|
})}
|
|
<button
|
|
type="button"
|
|
class="flex min-w-0 flex-col items-center gap-1 rounded-[18px] border border-transparent px-2 py-2 text-[11px] font-medium text-[var(--text-secondary)] transition hover:bg-[color-mix(in_oklab,var(--primary)_6%,var(--terminal-bg))] hover:text-[var(--title-color)]"
|
|
data-mobile-dock-menu
|
|
aria-label={t('header.toggleMenu')}
|
|
>
|
|
<i class="fas fa-bars text-[13px]"></i>
|
|
<span class="truncate">{t('header.navigation')}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script is:inline define:vars={{ apiBase: publicApiBaseUrl, musicPlaylistPayload }}>
|
|
const t = window.__termiTranslate;
|
|
|
|
// Site 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');
|
|
const mobileDockMenuBtn = document.querySelector('[data-mobile-dock-menu]');
|
|
|
|
function toggleMobileMenu() {
|
|
const nextExpanded = mobileMenu?.classList.contains('hidden');
|
|
mobileMenu?.classList.toggle('hidden');
|
|
mobileMenuBtn?.setAttribute('aria-expanded', String(nextExpanded));
|
|
}
|
|
|
|
mobileMenuBtn?.addEventListener('click', toggleMobileMenu);
|
|
mobileDockMenuBtn?.addEventListener('click', toggleMobileMenu);
|
|
|
|
document.querySelectorAll('#mobile-menu a[href]').forEach((link) => {
|
|
link.addEventListener('click', () => {
|
|
mobileMenu?.classList.add('hidden');
|
|
mobileMenuBtn?.setAttribute('aria-expanded', 'false');
|
|
});
|
|
});
|
|
|
|
// Music Player with actual audio
|
|
const musicPlay = document.getElementById('music-play');
|
|
const musicPlayIcon = document.getElementById('music-play-icon');
|
|
const musicCover = document.getElementById('music-cover');
|
|
const musicCoverFallback = document.getElementById('music-cover-fallback');
|
|
const musicTitle = document.getElementById('music-title');
|
|
const musicArtist = document.getElementById('music-artist');
|
|
const musicPrev = document.getElementById('music-prev');
|
|
const musicNext = document.getElementById('music-next');
|
|
const musicVolume = document.getElementById('music-volume');
|
|
const desktopMusicPlay = document.getElementById('desktop-music-play');
|
|
const desktopMusicPlayIcon = document.getElementById('desktop-music-play-icon');
|
|
const desktopMusicCover = document.getElementById('desktop-music-cover');
|
|
const desktopMusicCoverFallback = document.getElementById('desktop-music-cover-fallback');
|
|
const desktopMusicTitle = document.getElementById('desktop-music-title');
|
|
const desktopMusicPrev = document.getElementById('desktop-music-prev');
|
|
const desktopMusicNext = document.getElementById('desktop-music-next');
|
|
|
|
const playlist = JSON.parse(musicPlaylistPayload || '[]');
|
|
|
|
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() {
|
|
const currentTrack = playlist[currentSongIndex] || {};
|
|
if (musicTitle) {
|
|
musicTitle.textContent = currentTrack.title || '未配置曲目';
|
|
}
|
|
if (desktopMusicTitle) {
|
|
desktopMusicTitle.textContent = currentTrack.title || '未配置曲目';
|
|
}
|
|
if (musicArtist) {
|
|
musicArtist.textContent = currentTrack.artist || currentTrack.album || '等待播放';
|
|
}
|
|
if (musicCover && musicCoverFallback) {
|
|
if (currentTrack.coverImageUrl) {
|
|
musicCover.setAttribute('src', currentTrack.coverImageUrl);
|
|
musicCover.setAttribute('alt', currentTrack.title || 'Music cover');
|
|
musicCover.classList.remove('hidden');
|
|
musicCoverFallback.classList.add('hidden');
|
|
} else {
|
|
musicCover.setAttribute('src', '');
|
|
musicCover.classList.add('hidden');
|
|
musicCoverFallback.classList.remove('hidden');
|
|
}
|
|
}
|
|
if (desktopMusicCover && desktopMusicCoverFallback) {
|
|
if (currentTrack.coverImageUrl) {
|
|
desktopMusicCover.setAttribute('src', currentTrack.coverImageUrl);
|
|
desktopMusicCover.setAttribute('alt', currentTrack.title || 'Music cover');
|
|
desktopMusicCover.classList.remove('hidden');
|
|
desktopMusicCoverFallback.classList.add('hidden');
|
|
} else {
|
|
desktopMusicCover.setAttribute('src', '');
|
|
desktopMusicCover.classList.add('hidden');
|
|
desktopMusicCoverFallback.classList.remove('hidden');
|
|
}
|
|
}
|
|
}
|
|
|
|
function playSong() {
|
|
if (!playlist.length) {
|
|
return;
|
|
}
|
|
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 (desktopMusicPlayIcon) {
|
|
desktopMusicPlayIcon.className = 'fas fa-pause text-[11px]';
|
|
}
|
|
if (musicTitle) {
|
|
musicTitle.classList.add('text-[var(--primary)]');
|
|
musicTitle.classList.remove('text-[var(--title-color)]');
|
|
}
|
|
if (desktopMusicTitle) {
|
|
desktopMusicTitle.classList.add('text-[var(--primary)]');
|
|
desktopMusicTitle.classList.remove('text-[var(--title-color)]');
|
|
}
|
|
}
|
|
|
|
function pauseSong() {
|
|
if (audio) {
|
|
audio.pause();
|
|
}
|
|
isPlaying = false;
|
|
if (musicPlayIcon) {
|
|
musicPlayIcon.className = 'fas fa-play text-xs';
|
|
}
|
|
if (desktopMusicPlayIcon) {
|
|
desktopMusicPlayIcon.className = 'fas fa-play text-[11px]';
|
|
}
|
|
if (musicTitle) {
|
|
musicTitle.classList.remove('text-[var(--primary)]');
|
|
musicTitle.classList.add('text-[var(--title-color)]');
|
|
}
|
|
if (desktopMusicTitle) {
|
|
desktopMusicTitle.classList.remove('text-[var(--primary)]');
|
|
desktopMusicTitle.classList.add('text-[var(--title-color)]');
|
|
}
|
|
}
|
|
|
|
function togglePlay() {
|
|
if (isPlaying) {
|
|
pauseSong();
|
|
} else {
|
|
playSong();
|
|
}
|
|
}
|
|
|
|
function playNext() {
|
|
if (!playlist.length) {
|
|
return;
|
|
}
|
|
currentSongIndex = (currentSongIndex + 1) % playlist.length;
|
|
updateTitle();
|
|
if (isPlaying) {
|
|
playSong();
|
|
}
|
|
}
|
|
|
|
function playPrev() {
|
|
if (!playlist.length) {
|
|
return;
|
|
}
|
|
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);
|
|
desktopMusicPlay?.addEventListener('click', togglePlay);
|
|
musicNext?.addEventListener('click', playNext);
|
|
desktopMusicNext?.addEventListener('click', playNext);
|
|
musicPrev?.addEventListener('click', playPrev);
|
|
desktopMusicPrev?.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 = apiBase;
|
|
const searchInputs = [searchInput, mobileSearchInput].filter(Boolean);
|
|
const searchModeConfig = {
|
|
keyword: {
|
|
label: t('header.searchPromptKeyword'),
|
|
hint: t('header.searchHintKeyword'),
|
|
placeholder: t('header.searchPlaceholderKeyword'),
|
|
buttonIcon: 'fa-search'
|
|
},
|
|
ai: {
|
|
label: t('header.searchPromptAi'),
|
|
hint: t('header.searchHintAi'),
|
|
placeholder: t('header.searchPlaceholderAi'),
|
|
buttonIcon: 'fa-robot'
|
|
}
|
|
};
|
|
let searchTimer = null;
|
|
let currentSearchMode = 'keyword';
|
|
|
|
function escapeHtml(value) {
|
|
return value
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
}
|
|
|
|
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)}`
|
|
: `/search?q=${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(`/search?q=${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&preview=true`
|
|
);
|
|
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();
|
|
|
|
document.addEventListener('keydown', function(event) {
|
|
if (event.key === 'Escape') {
|
|
mobileMenu?.classList.add('hidden');
|
|
mobileMenuBtn?.setAttribute('aria-expanded', 'false');
|
|
hideSearchResults();
|
|
}
|
|
});
|
|
|
|
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();
|
|
}
|
|
|
|
if (
|
|
mobileMenu &&
|
|
!mobileMenu.classList.contains('hidden') &&
|
|
!mobileMenu.contains(target) &&
|
|
!mobileMenuBtn?.contains(target)
|
|
) {
|
|
mobileMenu.classList.add('hidden');
|
|
mobileMenuBtn?.setAttribute('aria-expanded', 'false');
|
|
}
|
|
});
|
|
</script>
|