508 lines
17 KiB
Plaintext
508 lines
17 KiB
Plaintext
---
|
|
import { terminalConfig } from '../lib/config/terminal';
|
|
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 navItems = terminalConfig.navLinks;
|
|
const currentPath = Astro.url.pathname;
|
|
---
|
|
|
|
<header 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">
|
|
<div class="terminal-toolbar-label">grep -i</div>
|
|
<input
|
|
type="text"
|
|
id="search-input"
|
|
placeholder="'关键词'"
|
|
class="terminal-console-input"
|
|
/>
|
|
<span class="hidden xl:inline text-xs font-mono text-[var(--secondary)]">articles/*.md</span>
|
|
<button id="search-btn" class="terminal-toolbar-iconbtn">
|
|
<i 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>
|
|
|
|
<button
|
|
id="theme-toggle"
|
|
class="theme-toggle terminal-toolbar-iconbtn h-11 w-11 shrink-0"
|
|
aria-label="切换主题"
|
|
title="切换主题"
|
|
>
|
|
<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="Toggle menu"
|
|
>
|
|
<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">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="terminal-toolbar-module md:hidden">
|
|
<span class="terminal-toolbar-label">grep -i</span>
|
|
<input
|
|
type="text"
|
|
id="mobile-search-input"
|
|
placeholder="'关键词'"
|
|
class="terminal-console-input"
|
|
/>
|
|
<button id="mobile-search-btn" class="terminal-toolbar-iconbtn">
|
|
<i class="fas fa-search text-sm"></i>
|
|
</button>
|
|
</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 searchInput = document.getElementById('search-input');
|
|
const searchBtn = document.getElementById('search-btn');
|
|
const searchResults = document.getElementById('search-results');
|
|
const searchApiBase = 'http://localhost:5150/api';
|
|
let searchTimer = null;
|
|
|
|
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 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)]">
|
|
正在搜索 <span class="text-[var(--primary)] font-mono">${escapeHtml(query)}</span> ...
|
|
</div>
|
|
`;
|
|
searchResults.classList.remove('hidden');
|
|
return;
|
|
}
|
|
|
|
if (state === 'error') {
|
|
searchResults.innerHTML = `
|
|
<div class="px-4 py-4 text-sm text-[var(--danger)]">
|
|
搜索失败,请稍后再试。
|
|
</div>
|
|
`;
|
|
searchResults.classList.remove('hidden');
|
|
return;
|
|
}
|
|
|
|
if (!results.length) {
|
|
searchResults.innerHTML = `
|
|
<div class="px-4 py-4 text-sm text-[var(--text-secondary)]">
|
|
没有找到和 <span class="text-[var(--primary)] font-mono">${escapeHtml(query)}</span> 相关的内容。
|
|
</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 || '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('');
|
|
|
|
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)]">
|
|
实时搜索结果
|
|
</div>
|
|
${itemsHtml}
|
|
<a href="/articles?search=${encodeURIComponent(query)}" class="block px-4 py-3 text-sm font-medium text-[var(--primary)] transition-colors hover:bg-[var(--header-bg)]">
|
|
查看全部结果
|
|
</a>
|
|
</div>
|
|
`;
|
|
searchResults.classList.remove('hidden');
|
|
}
|
|
|
|
async function runLiveSearch(query) {
|
|
if (!query) {
|
|
hideSearchResults();
|
|
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 submitSearch() {
|
|
const query = searchInput && 'value' in searchInput ? searchInput.value.trim() : '';
|
|
if (query) {
|
|
window.location.href = `/articles?search=${encodeURIComponent(query)}`;
|
|
}
|
|
}
|
|
|
|
searchBtn?.addEventListener('click', function() {
|
|
submitSearch();
|
|
});
|
|
mobileSearchBtn?.addEventListener('click', function() {
|
|
const query = mobileSearchInput && 'value' in mobileSearchInput ? mobileSearchInput.value.trim() : '';
|
|
if (query) {
|
|
window.location.href = `/articles?search=${encodeURIComponent(query)}`;
|
|
}
|
|
});
|
|
|
|
searchInput?.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Enter') {
|
|
submitSearch();
|
|
}
|
|
});
|
|
mobileSearchInput?.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Enter') {
|
|
const query = this.value.trim();
|
|
if (query) {
|
|
window.location.href = `/articles?search=${encodeURIComponent(query)}`;
|
|
}
|
|
}
|
|
});
|
|
|
|
searchInput?.addEventListener('input', function() {
|
|
const query = this.value.trim();
|
|
if (searchTimer) {
|
|
clearTimeout(searchTimer);
|
|
}
|
|
searchTimer = setTimeout(() => {
|
|
runLiveSearch(query);
|
|
}, 180);
|
|
});
|
|
|
|
searchInput?.addEventListener('focus', function() {
|
|
const query = this.value.trim();
|
|
if (query) {
|
|
runLiveSearch(query);
|
|
}
|
|
});
|
|
|
|
document.addEventListener('click', function(event) {
|
|
const target = event.target;
|
|
if (
|
|
searchResults &&
|
|
!searchResults.contains(target) &&
|
|
target !== searchInput &&
|
|
target !== searchBtn &&
|
|
!searchBtn?.contains(target)
|
|
) {
|
|
hideSearchResults();
|
|
}
|
|
});
|
|
</script>
|