feat: Refactor service management scripts to use a unified dev script

- Added package.json to manage development scripts.
- Updated restart-services.ps1 to call the new dev script for starting services.
- Refactored start-admin.ps1, start-backend.ps1, start-frontend.ps1, and start-mcp.ps1 to utilize the dev script for starting respective services.
- Enhanced stop-services.ps1 to improve process termination logic by matching command patterns.
This commit is contained in:
2026-03-29 21:36:13 +08:00
parent 84f82c2a7e
commit 92a85eef20
137 changed files with 14181 additions and 2691 deletions

View File

@@ -40,7 +40,7 @@ function formatCommentDate(dateStr: string): string {
<div class="space-y-3">
<span class="terminal-kicker">
<i class="fas fa-message"></i>
discussion buffer
{t('comments.kicker')}
</span>
<div class="terminal-section-title">
<span class="terminal-section-icon">
@@ -72,7 +72,7 @@ function formatCommentDate(dateStr: string): string {
type="text"
name="nickname"
required
placeholder="anonymous_operator"
placeholder={t('comments.nicknamePlaceholder')}
class="terminal-form-input"
/>
</div>
@@ -83,7 +83,7 @@ function formatCommentDate(dateStr: string): string {
<input
type="email"
name="email"
placeholder="you@example.com"
placeholder={t('comments.emailPlaceholder')}
class="terminal-form-input"
/>
</div>
@@ -210,7 +210,7 @@ function formatCommentDate(dateStr: string): string {
const replyBtns = document.querySelectorAll('.reply-btn');
const messageBox = document.getElementById('comment-message');
const postSlug = wrapper?.getAttribute('data-post-slug') || '';
const apiBase = wrapper?.getAttribute('data-api-base') || 'http://localhost:5150/api';
const apiBase = wrapper?.getAttribute('data-api-base') || '/api';
function showMessage(message: string, type: 'success' | 'error' | 'info') {
if (!messageBox) return;
@@ -319,7 +319,7 @@ function formatCommentDate(dateStr: string): string {
formContainer?.classList.add('hidden');
showMessage(t('comments.submitSuccess'), 'success');
} catch (error) {
showMessage(t('comments.submitFailed', { message: error instanceof Error ? error.message : 'unknown error' }), 'error');
showMessage(t('comments.submitFailed', { message: error instanceof Error ? error.message : t('common.unknownError') }), 'error');
}
});

View File

@@ -75,8 +75,8 @@ const tools = [
</div>
</div>
<div class="mt-4 border-t border-[var(--border-color)]/70 pt-4">
<p class="text-xs text-[var(--text-tertiary)] font-mono">
<span class="text-[var(--primary)]">user@{siteSettings.siteShortName.toLowerCase()}</span>:<span class="text-[var(--secondary)]">~</span>$ echo "{siteSettings.siteDescription}"
<p class="text-xs leading-6 text-[var(--text-tertiary)]">
{t('footer.summary')}
</p>
</div>
</div>

View File

@@ -130,21 +130,21 @@ const { t } = getI18n(Astro);
<p class="flex items-center gap-2">
<span class="text-[var(--text-tertiary)]">{t('friends.name')}:</span>
<span class="text-[var(--text)] font-medium">{siteSettings.siteName}</span>
<button type="button" class="copy-btn text-[var(--primary)] hover:underline text-xs" data-text={siteSettings.siteName}>
<button type="button" class="copy-btn terminal-copy-button" data-text={siteSettings.siteName}>
<i class="fas fa-copy"></i>{t('friendForm.copy')}
</button>
</p>
<p class="flex items-center gap-2">
<span class="text-[var(--text-tertiary)]">{t('friends.link')}:</span>
<span class="text-[var(--text)]">{siteSettings.siteUrl}</span>
<button type="button" class="copy-btn text-[var(--primary)] hover:underline text-xs" data-text={siteSettings.siteUrl}>
<button type="button" class="copy-btn terminal-copy-button" data-text={siteSettings.siteUrl}>
<i class="fas fa-copy"></i>{t('friendForm.copy')}
</button>
</p>
<p class="flex items-center gap-2">
<span class="text-[var(--text-tertiary)]">{t('friends.description')}:</span>
<span class="text-[var(--text)]">{siteSettings.siteDescription}</span>
<button type="button" class="copy-btn text-[var(--primary)] hover:underline text-xs" data-text={siteSettings.siteDescription}>
<button type="button" class="copy-btn terminal-copy-button" data-text={siteSettings.siteDescription}>
<i class="fas fa-copy"></i>{t('friendForm.copy')}
</button>
</p>
@@ -178,7 +178,7 @@ const { t } = getI18n(Astro);
const reciprocalInfo = document.getElementById('reciprocal-info') as HTMLDivElement | null;
const messageDiv = document.getElementById('form-message') as HTMLDivElement | null;
const copyBtns = document.querySelectorAll('.copy-btn');
const apiBase = wrapper?.getAttribute('data-api-base') || 'http://localhost:5150/api';
const apiBase = wrapper?.getAttribute('data-api-base') || '/api';
reciprocalCheckbox?.addEventListener('change', () => {
reciprocalInfo?.classList.toggle('hidden', !reciprocalCheckbox.checked);
@@ -248,7 +248,7 @@ const { t } = getI18n(Astro);
reciprocalInfo?.classList.add('hidden');
showMessage(t('friendForm.submitSuccess'), 'success');
} catch (error) {
showMessage(t('friendForm.submitFailed', { message: error instanceof Error ? error.message : 'unknown error' }), 'error');
showMessage(t('friendForm.submitFailed', { message: error instanceof Error ? error.message : t('common.unknownError') }), 'error');
}
});
</script>

View File

@@ -14,7 +14,7 @@ const { t } = getI18n(Astro);
href={friend.url}
target="_blank"
rel="noopener noreferrer"
class="terminal-panel group flex h-full items-start gap-4 p-4 transition-all duration-300 hover:-translate-y-1 hover:border-[var(--primary)]"
class="terminal-panel terminal-interactive-card group flex h-full items-start gap-4 p-4"
>
<div class="shrink-0">
{friend.avatar ? (

View File

@@ -1,4 +1,5 @@
---
import { API_BASE_URL } from '../lib/api/client';
import { terminalConfig } from '../lib/config/terminal';
import { getI18n, SUPPORTED_LOCALES } from '../lib/i18n';
import type { SiteSettings } from '../lib/types';
@@ -14,6 +15,12 @@ const {
const { locale, t, buildLocaleUrl } = getI18n(Astro);
const aiEnabled = Boolean(Astro.props.siteSettings?.ai?.enabled);
const musicPlaylist = (Astro.props.siteSettings?.musicPlaylist || []).filter(
(item) => item?.title?.trim() && item?.url?.trim()
);
const musicPlaylistPayload = JSON.stringify(musicPlaylist);
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' },
@@ -31,54 +38,34 @@ const localeLinks = SUPPORTED_LOCALES.map((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-3">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-2.5">
<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>
<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">root@termi</span>
<span class="mt-1 block text-lg font-bold text-[var(--title-color)]">{siteName}</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="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>
<div class="relative hidden lg:block flex-1 min-w-0 max-w-[16rem] xl:max-w-[18rem]">
<div class="terminal-toolbar-module gap-2 px-2.5 py-1.5">
<div class="terminal-toolbar-label" id="search-label">{t('header.searchPromptKeyword')}</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">
<div id="search-mode-panel" class="hidden 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-3 py-2 text-xs font-medium text-[var(--text-secondary)] transition hover:bg-[var(--header-bg)]"
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"
>
@@ -87,7 +74,7 @@ const currentPath = Astro.url.pathname;
</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)]"
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"
>
@@ -102,24 +89,73 @@ const currentPath = Astro.url.pathname;
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">
<button id="search-btn" class="terminal-toolbar-iconbtn h-8 w-8">
<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 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)]"
class="hidden absolute right-0 top-[calc(100%+12px)] z-20 w-[26rem] overflow-hidden rounded-2xl 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">
<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" 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));" 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" disabled={!hasMusicPlaylist}>
<i class="fas fa-step-forward text-[11px]"></i>
</button>
</div>
</div>
</div>
{aiEnabled && (
<a
href="/ask"
class="hidden lg:inline-flex items-center gap-2 rounded-xl border border-[var(--primary)]/18 bg-[var(--primary)]/8 px-2.5 py-1.5 text-sm font-medium text-[var(--primary)] transition hover:border-[var(--primary)]/32 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="hidden lg:flex 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-3 py-2 text-xs font-semibold transition',
'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)]'
@@ -132,169 +168,248 @@ const currentPath = Astro.url.pathname;
))}
</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>
<div class="relative shrink-0">
<button
id="theme-toggle"
class="theme-toggle terminal-toolbar-iconbtn h-8 w-8 shrink-0"
aria-label={t('header.themeToggle')}
title={t('header.themeToggle')}
>
<i id="theme-icon" class="fas fa-desktop text-sm text-[var(--text-secondary)]"></i>
<span id="theme-toggle-label" class="sr-only">
{t('header.themeSystem')}
</span>
</button>
</div>
<button
id="mobile-menu-btn"
class="lg:hidden terminal-toolbar-iconbtn h-11 w-11 shrink-0"
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 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 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">
<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">
<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 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>
<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 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" 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));" disabled={!hasMusicPlaylist}>
<i class="fas fa-play text-xs" id="music-play-icon"></i>
</button>
<button id="music-next" class="terminal-toolbar-iconbtn" disabled={!hasMusicPlaylist}>
<i class="fas fa-step-forward text-xs"></i>
</button>
<button id="music-volume" class="terminal-toolbar-iconbtn" 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>
<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 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 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 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>
<script is:inline>
// Theme Toggle - simplified vanilla JS
<script is:inline define:vars={{ apiBase: API_BASE_URL, musicPlaylistPayload }}>
const t = window.__termiTranslate;
// Theme selection
function initThemeToggle() {
const themeToggle = document.getElementById('theme-toggle');
const themeIcon = document.getElementById('theme-icon');
const themeToggleLabel = document.getElementById('theme-toggle-label');
const themeApi = window.__termiTheme;
if (!themeToggle || !themeIcon) {
console.error('[Theme] Elements not found');
if (!themeToggle || !themeIcon || !themeApi) {
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)]';
}
if (themeToggle.dataset.bound === 'true') {
return;
}
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);
themeToggle.dataset.bound = 'true';
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);
const themeMeta = {
light: {
iconClass: 'fas fa-sun text-sm',
color: 'var(--secondary)',
label: t('header.themeLight'),
},
dark: {
iconClass: 'fas fa-moon text-sm',
color: 'var(--primary)',
label: t('header.themeDark'),
},
system: {
iconClass: 'fas fa-desktop text-sm',
color: 'var(--text-secondary)',
label: t('header.themeSystem'),
},
};
function updateThemeUI(detail = null) {
const mode = detail?.mode || themeApi.getMode();
const resolved = detail?.resolved || themeApi.resolveTheme(mode);
const modeMeta = themeMeta[mode] || themeMeta.system;
const modeLabel = themeMeta[mode]?.label || themeMeta.system.label;
const resolvedLabel = resolved === 'dark' ? t('header.themeDark') : t('header.themeLight');
themeIcon.className = modeMeta.iconClass;
themeIcon.style.color = modeMeta.color;
if (themeToggleLabel) {
themeToggleLabel.textContent = `${modeLabel} / ${resolvedLabel}`;
}
const toggleTitle = `${t('header.themeToggle')} · ${modeLabel} / ${resolvedLabel}`;
themeToggle.setAttribute('aria-label', toggleTitle);
themeToggle.setAttribute('title', toggleTitle);
}
themeToggle.addEventListener('click', function(event) {
event.preventDefault();
const currentMode = themeApi.getMode();
const nextMode =
currentMode === 'system' ? 'light' :
currentMode === 'light' ? 'dark' :
'system';
themeApi.applyTheme(nextMode);
});
// Initialize icon based on current theme
const isDark = document.documentElement.classList.contains('dark');
updateThemeIcon(isDark);
window.addEventListener('termi:theme-change', function(event) {
updateThemeUI(event.detail);
});
updateThemeUI(themeApi.syncTheme());
}
// Run immediately if DOM is ready, otherwise wait
@@ -304,30 +419,44 @@ const currentPath = Astro.url.pathname;
initThemeToggle();
}
// Mobile Menu
// 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');
mobileMenuBtn?.addEventListener('click', () => {
const nextExpanded = mobileMenu?.classList.contains('hidden');
mobileMenu?.classList.toggle('hidden');
mobileMenuBtn.setAttribute('aria-expanded', String(nextExpanded));
});
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');
// 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' }
];
const playlist = JSON.parse(musicPlaylistPayload || '[]');
let currentSongIndex = 0;
let isPlaying = false;
@@ -345,12 +474,46 @@ const currentPath = Astro.url.pathname;
}
function updateTitle() {
const currentTrack = playlist[currentSongIndex] || {};
if (musicTitle) {
musicTitle.textContent = playlist[currentSongIndex].title;
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;
@@ -360,9 +523,16 @@ const currentPath = Astro.url.pathname;
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(--text-secondary)]');
musicTitle.classList.remove('text-[var(--title-color)]');
}
if (desktopMusicTitle) {
desktopMusicTitle.classList.add('text-[var(--primary)]');
desktopMusicTitle.classList.remove('text-[var(--title-color)]');
}
}
@@ -374,9 +544,16 @@ const currentPath = Astro.url.pathname;
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(--text-secondary)]');
musicTitle.classList.add('text-[var(--title-color)]');
}
if (desktopMusicTitle) {
desktopMusicTitle.classList.remove('text-[var(--primary)]');
desktopMusicTitle.classList.add('text-[var(--title-color)]');
}
}
@@ -389,6 +566,9 @@ const currentPath = Astro.url.pathname;
}
function playNext() {
if (!playlist.length) {
return;
}
currentSongIndex = (currentSongIndex + 1) % playlist.length;
updateTitle();
if (isPlaying) {
@@ -397,6 +577,9 @@ const currentPath = Astro.url.pathname;
}
function playPrev() {
if (!playlist.length) {
return;
}
currentSongIndex = (currentSongIndex - 1 + playlist.length) % playlist.length;
updateTitle();
if (isPlaying) {
@@ -416,8 +599,11 @@ const currentPath = Astro.url.pathname;
}
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
@@ -438,18 +624,17 @@ const currentPath = Astro.url.pathname;
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 searchApiBase = apiBase;
const searchInputs = [searchInput, mobileSearchInput].filter(Boolean);
const t = window.__termiTranslate;
const searchModeConfig = {
keyword: {
label: 'grep -i',
label: t('header.searchPromptKeyword'),
hint: t('header.searchHintKeyword'),
placeholder: t('header.searchPlaceholderKeyword'),
buttonIcon: 'fa-search'
},
ai: {
label: 'ask ai',
label: t('header.searchPromptAi'),
hint: t('header.searchHintAi'),
placeholder: t('header.searchPlaceholderAi'),
buttonIcon: 'fa-robot'
@@ -771,6 +956,14 @@ const currentPath = Astro.url.pathname;
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');
@@ -796,5 +989,15 @@ const currentPath = Astro.url.pathname;
) {
hideSearchResults();
}
if (
mobileMenu &&
!mobileMenu.classList.contains('hidden') &&
!mobileMenu.contains(target) &&
!mobileMenuBtn?.contains(target)
) {
mobileMenu.classList.add('hidden');
mobileMenuBtn?.setAttribute('aria-expanded', 'false');
}
});
</script>

View File

@@ -49,10 +49,10 @@
// Initialize lightbox for all article images
function initLightbox() {
const content = document.querySelector('.article-content');
const content = document.querySelector('[data-article-slug]');
if (!content) return;
images = Array.from(content.querySelectorAll('img'));
images = Array.from(content.querySelectorAll('.article-content img, [data-lightbox-image="true"]'));
images.forEach((img, index) => {
img.style.cursor = 'zoom-in';

View File

@@ -11,24 +11,32 @@ const { postSlug, class: className = '' } = Astro.props;
const { t } = getI18n(Astro);
---
<div class={`paragraph-comments-shell ${className}`} data-post-slug={postSlug} data-api-base={API_BASE_URL}>
<div class="terminal-panel-muted paragraph-comments-intro">
<div class="space-y-3">
<div
class={`paragraph-comments-shell ${className}`}
data-post-slug={postSlug}
data-api-base={API_BASE_URL}
data-storage-key={`termi:paragraph-comments:${postSlug}`}
>
<div class="paragraph-comments-toolbar terminal-panel-muted">
<div class="paragraph-comments-toolbar-copy">
<span class="terminal-kicker">
<i class="fas fa-paragraph"></i>
paragraph annotations
{t('paragraphComments.kicker')}
</span>
<div class="space-y-2">
<h3 class="text-lg font-semibold text-[var(--title-color)]">{t('paragraphComments.title')}</h3>
<p class="text-sm leading-7 text-[var(--text-secondary)]">
{t('paragraphComments.intro')}
</p>
</div>
</div>
<div class="paragraph-comments-summary terminal-chip">
<i class="fas fa-terminal text-[var(--primary)]"></i>
<span data-summary-text>{t('paragraphComments.scanning')}</span>
<p class="text-sm leading-6 text-[var(--text-secondary)]" data-summary-text>
{t('paragraphComments.scanning')}
</p>
</div>
<button
type="button"
class="terminal-action-button paragraph-comments-visibility"
data-display-toggle
aria-pressed="true"
>
<i class="fas fa-comment-dots"></i>
<span data-toggle-label>{t('paragraphComments.hideMarkers')}</span>
</button>
</div>
</div>
@@ -62,13 +70,17 @@ const { t } = getI18n(Astro);
const wrappers = document.querySelectorAll('.paragraph-comments-shell');
const wrapper = wrappers.item(wrappers.length - 1) as HTMLElement | null;
const postSlug = wrapper?.dataset.postSlug || '';
const apiBase = wrapper?.dataset.apiBase || 'http://localhost:5150/api';
const articleRoot = wrapper?.closest('[data-article-slug]') || document;
const articleContent = articleRoot.querySelector('.article-content') as HTMLElement | null;
const apiBase = wrapper?.dataset.apiBase || '/api';
const storageKey = wrapper?.dataset.storageKey || 'termi:paragraph-comments';
const articleRoot = wrapper?.closest('[data-article-slug]') as HTMLElement | null;
const articleContent = articleRoot?.querySelector('.article-content') as HTMLElement | null;
const summaryText = wrapper?.querySelector('[data-summary-text]') as HTMLElement | null;
const toggleButton = wrapper?.querySelector('[data-display-toggle]') as HTMLButtonElement | null;
const toggleLabel = wrapper?.querySelector('[data-toggle-label]') as HTMLElement | null;
if (wrapper && articleRoot && articleContent && postSlug) {
const paragraphCounts = new Map<string, number>();
const paragraphRows = new Map<string, HTMLElement>();
const paragraphMarkers = new Map<string, HTMLButtonElement>();
const paragraphDescriptors = new Map<
string,
ReturnType<typeof buildParagraphDescriptors>[number]
@@ -79,6 +91,7 @@ const { t } = getI18n(Astro);
let activeParagraphKey: string | null = null;
let activeReplyToCommentId: number | null = null;
let pendingCounter = 0;
let markersVisible = true;
function escapeHtml(value: string): string {
return value
@@ -119,6 +132,14 @@ const { t } = getI18n(Astro);
return t('paragraphComments.manyNotes', { count });
}
function markerCountText(count: number): string {
if (count <= 0) {
return '';
}
return count > 99 ? '99+' : String(count);
}
function previewReplyText(value: string | null | undefined, limit = 88) {
const normalized = (value || '').replace(/\s+/g, ' ').trim();
if (normalized.length <= limit) {
@@ -128,10 +149,6 @@ const { t } = getI18n(Astro);
return `${normalized.slice(0, limit).trimEnd()}...`;
}
function promptLabel(key: string, active: boolean) {
return active ? `./comment --paragraph ${key} --open` : `./comment --paragraph ${key}`;
}
function anchorForParagraph(key: string) {
return `#paragraph-${key}`;
}
@@ -146,23 +163,6 @@ const { t } = getI18n(Astro);
return key || null;
}
function updateRowState() {
paragraphRows.forEach((row, rowKey) => {
const trigger = row.querySelector('[data-trigger-label]') as HTMLElement | null;
const prompt = row.querySelector('[data-command-text]') as HTMLElement | null;
const count = paragraphCounts.get(rowKey) || 0;
const isActive = rowKey === activeParagraphKey;
row.classList.toggle('is-active', isActive);
if (trigger) {
trigger.textContent = countLabel(count);
}
if (prompt) {
prompt.textContent = promptLabel(rowKey, isActive);
}
});
}
function updateSummaryFromCounts() {
const paragraphCount = paragraphDescriptors.size;
const discussedParagraphs = Array.from(paragraphCounts.values()).filter(count => count > 0).length;
@@ -173,6 +173,11 @@ const { t } = getI18n(Astro);
return;
}
if (!markersVisible) {
setSummaryMessage(t('paragraphComments.markersHidden'));
return;
}
setSummaryMessage(
t('paragraphComments.summary', {
paragraphCount,
@@ -182,30 +187,74 @@ const { t } = getI18n(Astro);
);
}
function createParagraphRow(key: string, excerpt: string) {
const row = document.createElement('div');
row.className = 'paragraph-comment-row';
row.dataset.paragraphKey = key;
row.innerHTML = `
<div class="paragraph-comment-command">
<span class="paragraph-comment-prompt">user@blog:~/articles$</span>
<span class="paragraph-comment-command-text" data-command-text>${escapeHtml(promptLabel(key, false))}</span>
</div>
<div class="paragraph-comment-actions">
<span class="paragraph-comment-hint" title="${escapeHtml(excerpt)}">${escapeHtml(t('paragraphComments.focusCurrent'))}</span>
<button type="button" class="terminal-action-button paragraph-comment-trigger">
<i class="fas fa-message"></i>
<span data-trigger-label>${countLabel(0)}</span>
</button>
</div>
function updateMarkerState() {
paragraphMarkers.forEach((marker, key) => {
const count = paragraphCounts.get(key) || 0;
const countNode = marker.querySelector('[data-marker-count]') as HTMLElement | null;
const isActive = key === activeParagraphKey;
marker.classList.toggle('has-comments', count > 0);
marker.classList.toggle('is-active', isActive);
marker.setAttribute(
'aria-label',
count > 0
? `${t('paragraphComments.badgeLabel')} (${countLabel(count)})`
: t('paragraphComments.badgeLabel')
);
if (countNode) {
countNode.textContent = markerCountText(count);
countNode.classList.toggle('hidden', count <= 0);
}
});
}
function applyMarkerVisibility(visible: boolean, options?: { persist?: boolean }) {
markersVisible = visible;
if (articleRoot) {
articleRoot.dataset.paragraphCommentsVisible = visible ? 'true' : 'false';
}
if (toggleButton) {
toggleButton.setAttribute('aria-pressed', visible ? 'true' : 'false');
}
if (toggleLabel) {
toggleLabel.textContent = visible
? t('paragraphComments.hideMarkers')
: t('paragraphComments.showMarkers');
}
if (options?.persist !== false) {
localStorage.setItem(storageKey, visible ? 'true' : 'false');
}
if (!visible) {
closePanel(true);
}
updateSummaryFromCounts();
}
function createParagraphMarker(key: string, excerpt: string) {
const marker = document.createElement('button');
marker.type = 'button';
marker.className = 'paragraph-comment-marker';
marker.dataset.paragraphKey = key;
marker.title = excerpt;
marker.innerHTML = `
<span class="paragraph-comment-marker-icon" aria-hidden="true">
<i class="fas fa-message"></i>
</span>
<span class="paragraph-comment-marker-count hidden" data-marker-count></span>
`;
const button = row.querySelector('.paragraph-comment-trigger') as HTMLButtonElement | null;
button?.addEventListener('click', () => {
marker.addEventListener('click', () => {
void openPanelForParagraph(key, { focusForm: true, syncHash: true });
});
return row;
return marker;
}
const panel = document.createElement('section');
@@ -215,7 +264,7 @@ const { t } = getI18n(Astro);
<div class="space-y-2">
<span class="terminal-kicker">
<i class="fas fa-terminal"></i>
paragraph thread
${escapeHtml(t('paragraphComments.panelKicker'))}
</span>
<div>
<h4 class="text-lg font-semibold text-[var(--title-color)]">${escapeHtml(t('paragraphComments.panelTitle'))}</h4>
@@ -244,7 +293,7 @@ const { t } = getI18n(Astro);
type="text"
name="nickname"
required
placeholder="inline_operator"
placeholder="${escapeHtml(t('paragraphComments.nicknamePlaceholder'))}"
class="terminal-form-input"
/>
</div>
@@ -255,7 +304,7 @@ const { t } = getI18n(Astro);
<input
type="email"
name="email"
placeholder="you@example.com"
placeholder="${escapeHtml(t('paragraphComments.emailPlaceholder'))}"
class="terminal-form-input"
/>
</div>
@@ -413,7 +462,7 @@ const { t } = getI18n(Astro);
paragraphCounts.set(paragraphKey, comments.length);
pendingCountChip.textContent = `${pending.length} ${t('common.pending')}`;
pendingCountChip.classList.toggle('hidden', pending.length === 0);
updateRowState();
updateMarkerState();
updateSummaryFromCounts();
const approvedMarkup =
@@ -514,17 +563,20 @@ const { t } = getI18n(Astro);
}
) {
const descriptor = paragraphDescriptors.get(paragraphKey);
const row = paragraphRows.get(paragraphKey);
if (!descriptor || !row) {
if (!descriptor) {
return;
}
if (!markersVisible) {
applyMarkerVisibility(true, { persist: false });
}
activeParagraphKey = paragraphKey;
clearStatus();
resetReplyState();
panelExcerpt.textContent = descriptor.excerpt;
row.insertAdjacentElement('afterend', panel);
descriptor.element.insertAdjacentElement('afterend', panel);
panel.classList.remove('hidden');
panel.dataset.paragraphKey = paragraphKey;
@@ -540,7 +592,7 @@ const { t } = getI18n(Astro);
descriptor.element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
updateRowState();
updateMarkerState();
threadContainer.innerHTML = `
<div class="terminal-panel-muted text-sm text-[var(--text-secondary)]">
${escapeHtml(t('paragraphComments.loadingThread'))}
@@ -558,7 +610,7 @@ const { t } = getI18n(Astro);
pendingCountChip.classList.add('hidden');
threadContainer.innerHTML = `
<div class="paragraph-comment-status paragraph-comment-status-error">
${escapeHtml(t('paragraphComments.loadFailed', { message: error instanceof Error ? error.message : 'unknown error' }))}
${escapeHtml(t('paragraphComments.loadFailed', { message: error instanceof Error ? error.message : t('common.unknownError') }))}
</div>
`;
}
@@ -570,7 +622,7 @@ const { t } = getI18n(Astro);
resetReplyState();
clearStatus();
paragraphDescriptors.forEach(item => item.element.classList.remove('is-comment-focused'));
updateRowState();
updateMarkerState();
if (clearHash) {
syncHashForParagraph(null);
@@ -690,15 +742,22 @@ const { t } = getI18n(Astro);
renderThread(descriptor.key, approvedComments);
setStatus(t('paragraphComments.submitSuccess'), 'success');
} catch (error) {
setStatus(t('paragraphComments.submitFailed', { message: error instanceof Error ? error.message : 'unknown error' }), 'error');
setStatus(t('paragraphComments.submitFailed', { message: error instanceof Error ? error.message : t('common.unknownError') }), 'error');
}
});
toggleButton?.addEventListener('click', () => {
applyMarkerVisibility(!markersVisible);
});
async function init() {
if (!wrapper || !articleContent || !postSlug) {
if (!wrapper || !articleRoot || !articleContent || !postSlug) {
return;
}
const storedVisibility = localStorage.getItem(storageKey);
markersVisible = storedVisibility !== 'false';
const descriptors = buildParagraphDescriptors(articleContent);
if (descriptors.length === 0) {
setSummaryMessage(t('paragraphComments.noParagraphs'));
@@ -710,6 +769,10 @@ const { t } = getI18n(Astro);
descriptor.element.id = `paragraph-${descriptor.key}`;
descriptor.element.dataset.paragraphKey = descriptor.key;
descriptor.element.classList.add('paragraph-comment-paragraph');
const marker = createParagraphMarker(descriptor.key, descriptor.excerpt);
paragraphMarkers.set(descriptor.key, marker);
descriptor.element.appendChild(marker);
});
try {
@@ -730,14 +793,11 @@ const { t } = getI18n(Astro);
}
descriptors.forEach(descriptor => {
const row = createParagraphRow(descriptor.key, descriptor.excerpt);
paragraphRows.set(descriptor.key, row);
paragraphCounts.set(descriptor.key, paragraphCounts.get(descriptor.key) || 0);
descriptor.element.insertAdjacentElement('afterend', row);
});
updateRowState();
updateSummaryFromCounts();
updateMarkerState();
applyMarkerVisibility(markersVisible, { persist: false });
await openFromHash();
window.addEventListener('hashchange', () => {
void openFromHash();
@@ -745,4 +805,5 @@ const { t } = getI18n(Astro);
}
void init();
}
</script>

View File

@@ -1,20 +1,29 @@
---
import type { Post } from '../lib/types';
import TerminalButton from './ui/TerminalButton.astro';
import CodeBlock from './CodeBlock.astro';
import { formatReadTime, getI18n } from '../lib/i18n';
import { resolveFileRef, getPostTypeColor } from '../lib/utils';
import {
getAccentVars,
getCategoryTheme,
getPostTypeColor,
getPostTypeTheme,
getTagTheme,
resolveFileRef,
} from '../lib/utils';
interface Props {
post: Post;
selectedTag?: string;
highlightTerm?: string;
tagHrefPrefix?: string;
}
const { post, selectedTag = '', highlightTerm = '' } = Astro.props;
const { post, selectedTag = '', highlightTerm = '', tagHrefPrefix = '/tags?tag=' } = Astro.props;
const { locale, t } = getI18n(Astro);
const typeColor = getPostTypeColor(post.type);
const typeTheme = getPostTypeTheme(post.type);
const categoryTheme = getCategoryTheme(post.category);
const escapeHtml = (value: string) =>
value
@@ -42,15 +51,23 @@ const normalizedSelectedTag = selectedTag.trim().toLowerCase();
---
<article
class="post-card terminal-panel group relative my-3 p-5 transition-all duration-300 hover:-translate-y-1 hover:border-[var(--primary)]"
class="post-card terminal-panel group relative my-2.5 cursor-pointer p-4 transition-all duration-300 hover:-translate-y-1 hover:border-[var(--primary)] focus-within:border-[var(--primary)]"
style={`--post-border-color: ${typeColor}`}
data-post-card-link
data-post-url={`/articles/${post.slug}`}
tabindex="0"
role="link"
aria-label={`Open ${post.title}`}
>
<div class="absolute left-0 top-4 bottom-4 w-1 rounded-full opacity-80" style={`background-color: ${typeColor}`}></div>
<div class="relative z-10 flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between mb-2 pl-3">
<div class="relative z-10 mb-2 flex flex-col gap-2.5 pl-3 lg:flex-row lg:items-start lg:justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-2">
<div class="mb-2 flex items-center gap-2">
<span class="w-3 h-3 rounded-full shrink-0" style={`background-color: ${typeColor}`}></span>
<span class="terminal-chip terminal-chip--accent shrink-0 text-[10px] py-1 px-2" style={getAccentVars(typeTheme)}>
{post.type === 'article' ? t('common.article') : t('common.tweet')}
</span>
<a
href={`/articles/${post.slug}`}
class={`inline-flex min-w-0 items-center text-[var(--title-color)] transition hover:text-[var(--primary)] ${post.type === 'article' ? 'text-lg font-bold' : 'text-base font-bold'}`}
@@ -62,12 +79,12 @@ const normalizedSelectedTag = selectedTag.trim().toLowerCase();
{post.date} | {t('common.readTime')}: {formatReadTime(locale, post.readTime, t)}
</p>
</div>
<span class="terminal-chip shrink-0 text-xs py-1 px-2.5">
<span class="terminal-chip terminal-chip--accent shrink-0 text-xs py-1 px-2.5" style={getAccentVars(categoryTheme)}>
#{post.category}
</span>
</div>
<p class="relative z-10 pl-3 text-[var(--text-secondary)] mb-4 leading-7" set:html={highlightText(post.description, highlightTerm)} />
<p class="relative z-10 mb-3 pl-3 text-sm leading-7 text-[var(--text-secondary)]" set:html={highlightText(post.description, highlightTerm)} />
{post.code && (
<div class="relative z-10 mb-3">
@@ -102,18 +119,22 @@ const normalizedSelectedTag = selectedTag.trim().toLowerCase();
<!-- Tags -->
<div class="relative z-10 pl-3 flex flex-wrap gap-2">
{post.tags?.map(tag => (
<TerminalButton
variant={normalizedSelectedTag === tag.trim().toLowerCase() ? 'primary' : 'neutral'}
size="xs"
href={`/tags?tag=${encodeURIComponent(tag)}`}
<a
href={`${tagHrefPrefix}${encodeURIComponent(tag)}`}
class:list={[
'terminal-chip text-xs py-1 px-2.5',
'terminal-chip--accent',
normalizedSelectedTag === tag.trim().toLowerCase() && 'is-active'
]}
style={getAccentVars(getTagTheme(tag))}
>
<i class="fas fa-hashtag text-xs"></i>
<span set:html={highlightText(tag, highlightTerm)} />
</TerminalButton>
</a>
))}
</div>
<div class="relative z-10 mt-4 pl-3">
<div class="relative z-10 mt-3 pl-3">
<a
href={`/articles/${post.slug}`}
class="terminal-action-button inline-flex"
@@ -123,3 +144,38 @@ const normalizedSelectedTag = selectedTag.trim().toLowerCase();
</a>
</div>
</article>
<script>
const interactiveSelector = 'a, button, input, textarea, select, summary, [role="button"]';
const hasTextSelection = () => {
const selection = window.getSelection?.();
return Boolean(selection && selection.toString().trim());
};
const navigateFromCard = (card) => {
const href = card.dataset.postUrl;
if (!href) return;
window.location.href = href;
};
document.querySelectorAll('[data-post-card-link]').forEach((card) => {
if (card.dataset.postCardBound === 'true') return;
card.dataset.postCardBound = 'true';
card.addEventListener('click', (event) => {
if (event.defaultPrevented) return;
if (hasTextSelection()) return;
if (event.target instanceof Element && event.target.closest(interactiveSelector)) return;
navigateFromCard(card);
});
card.addEventListener('keydown', (event) => {
if (event.defaultPrevented) return;
if (event.key !== 'Enter' && event.key !== ' ') return;
if (event.target instanceof Element && event.target.closest(interactiveSelector)) return;
event.preventDefault();
navigateFromCard(card);
});
});
</script>

View File

@@ -1,6 +1,7 @@
---
import { apiClient } from '../lib/api/client';
import { getI18n } from '../lib/i18n';
import { getAccentVars, getCategoryTheme, getPostTypeTheme, getTagTheme } from '../lib/utils';
interface Props {
currentSlug: string;
@@ -38,7 +39,7 @@ const relatedPosts = allPosts
<div class="space-y-3">
<span class="terminal-kicker">
<i class="fas fa-diagram-project"></i>
related traces
{t('relatedPosts.kicker')}
</span>
<div class="terminal-section-title">
<span class="terminal-section-icon">
@@ -63,13 +64,13 @@ const relatedPosts = allPosts
{relatedPosts.map(post => (
<a
href={`/articles/${post.slug}`}
class="terminal-panel-muted group flex h-full flex-col gap-3 p-4 transition-all hover:-translate-y-1 hover:border-[var(--primary)]"
class="terminal-panel-muted terminal-panel-accent terminal-interactive-card group flex h-full flex-col gap-3 p-4"
style={getAccentVars(getPostTypeTheme(post.type))}
>
<div class="flex items-start justify-between gap-3">
<div class="space-y-2">
<span class="terminal-chip px-2.5 py-1 text-xs">
<span class={`h-2.5 w-2.5 rounded-full ${post.type === 'article' ? 'bg-[var(--primary)]' : 'bg-[var(--secondary)]'}`}></span>
{post.type}
<span class="terminal-chip terminal-chip--accent px-2.5 py-1 text-xs" style={getAccentVars(getPostTypeTheme(post.type))}>
{post.type === 'article' ? t('common.article') : t('common.tweet')}
</span>
<h4 class="text-base font-semibold text-[var(--title-color)] group-hover:text-[var(--primary)]">
{post.title}
@@ -85,11 +86,17 @@ const relatedPosts = allPosts
<i class="far fa-calendar text-[var(--primary)]"></i>
{post.date}
</span>
<span class="terminal-chip terminal-chip--accent px-2.5 py-1 text-xs" style={getAccentVars(getCategoryTheme(post.category))}>
<i class="fas fa-folder-tree text-[11px]"></i>
{post.category}
</span>
{post.sharedTags.length > 0 && (
<span class="terminal-chip px-2.5 py-1 text-xs">
<i class="fas fa-hashtag text-[var(--primary)]"></i>
{post.sharedTags.map(tag => `#${tag}`).join(' ')}
</span>
post.sharedTags.map(tag => (
<span class="terminal-chip terminal-chip--accent px-2.5 py-1 text-xs" style={getAccentVars(getTagTheme(tag))}>
<i class="fas fa-hashtag text-[11px]"></i>
{tag}
</span>
))
)}
</div>
</a>

View File

@@ -1,6 +1,5 @@
---
import type { SystemStat } from '../lib/types';
import InfoTile from './ui/InfoTile.astro';
interface Props {
stats: SystemStat[];
@@ -9,13 +8,21 @@ interface Props {
const { stats } = Astro.props;
---
<ul class="space-y-3 font-mono text-sm">
{stats.map(stat => (
<li>
<InfoTile layout="row" tone="neutral">
<span class="text-[var(--text-secondary)] uppercase tracking-[0.18em] text-[11px]">{stat.label}</span>
<span class="text-[var(--title-color)] font-bold text-base">{stat.value}</span>
</InfoTile>
<ul class="grid gap-3">
{stats.map((stat, index) => (
<li class="rounded-2xl border border-[var(--border-color)] bg-[linear-gradient(135deg,rgba(var(--primary-rgb),0.12),rgba(255,255,255,0.55))] px-4 py-4 shadow-[0_12px_32px_rgba(37,99,235,0.08)]">
<div class="flex items-center justify-between gap-4">
<div class="flex min-w-0 items-center gap-3">
<span class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-[var(--border-color)] bg-white/75 text-[var(--primary)] shadow-sm">
<span class="font-mono text-xs">{String(index + 1).padStart(2, '0')}</span>
</span>
<div class="min-w-0">
<div class="text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)]">{stat.label}</div>
<div class="mt-1 text-lg font-semibold text-[var(--title-color)]">{stat.value}</div>
</div>
</div>
<span class="h-10 w-px bg-[linear-gradient(180deg,transparent,rgba(var(--primary-rgb),0.3),transparent)]"></span>
</div>
</li>
))}
</ul>

View File

@@ -1,6 +1,5 @@
---
import type { TechStackItem } from '../lib/types';
import InfoTile from './ui/InfoTile.astro';
interface Props {
items: TechStackItem[];
@@ -9,20 +8,23 @@ interface Props {
const { items } = Astro.props;
---
<ul class="grid grid-cols-1 sm:grid-cols-2 gap-3">
{items.map(item => (
<li>
<InfoTile layout="grid" tone="blue">
<span class="flex h-8 w-8 items-center justify-center rounded-xl bg-[var(--primary)]/10 text-[var(--primary)]">
<ul class="grid grid-cols-1 gap-3 sm:grid-cols-2">
{items.map((item) => (
<li class="group overflow-hidden rounded-2xl border border-[var(--border-color)] bg-[linear-gradient(180deg,rgba(255,255,255,0.88),rgba(var(--primary-rgb),0.08))] shadow-[0_12px_30px_rgba(37,99,235,0.08)] transition-transform duration-200 hover:-translate-y-0.5">
<div class="flex items-start gap-3 px-4 py-4">
<span class="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[var(--primary)] text-white shadow-[0_10px_24px_rgba(37,99,235,0.24)]">
<i class="fas fa-code text-xs"></i>
</span>
<span class="min-w-0 flex-1">
<span class="block text-[var(--text)] text-sm font-medium">{item.name}</span>
{item.level && (
<span class="block text-xs text-[var(--text-tertiary)] mt-0.5">{item.level}</span>
)}
<span class="block text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
stack://module
</span>
<span class="mt-1 block text-base font-semibold text-[var(--title-color)]">{item.name}</span>
<span class="mt-2 block font-mono text-xs text-[var(--primary)]">
{item.level || 'active'}
</span>
</span>
</InfoTile>
</div>
</li>
))}
</ul>

View File

@@ -5,13 +5,20 @@ interface Props {
clickable?: boolean;
href?: string;
typing?: boolean;
promptId?: string;
}
const { command, path = '~/', clickable = false, href = '/', typing = true } = Astro.props;
const { command, path = '~/', clickable = false, href = '/', typing = true, promptId = '' } = Astro.props;
const uniqueId = Math.random().toString(36).slice(2, 11);
---
<div class:list={['command-prompt', { clickable }]} data-command={command} data-typing={typing} data-id={uniqueId}>
<div
class:list={['command-prompt', { clickable }]}
data-command={command}
data-typing={typing}
data-id={uniqueId}
data-prompt-id={promptId || undefined}
>
{clickable ? (
<a href={href} class="prompt-link">
<span class="prompt">user@blog</span>
@@ -35,44 +42,64 @@ const uniqueId = Math.random().toString(36).slice(2, 11);
<script is:inline>
(function() {
const prompts = document.querySelectorAll('[data-command]:not([data-typed])');
prompts.forEach(function(el) {
// Mark as processed immediately
el.setAttribute('data-typed', 'true');
const command = el.getAttribute('data-command');
const typing = el.getAttribute('data-typing') === 'true';
function renderPrompt(el, nextCommand, typingMode) {
const id = el.getAttribute('data-id');
const cmdEl = document.getElementById('cmd-' + id);
const cursorEl = document.getElementById('cursor-' + id);
if (!cmdEl || !command) return;
if (!cmdEl || !cursorEl) return;
if (typing) {
// Typewriter effect - characters appear one by one
let i = 0;
cmdEl.textContent = '';
cursorEl.style.animation = 'none';
cursorEl.style.opacity = '1';
const command = String(nextCommand || '');
const typing = typingMode === true || typingMode === 'true';
const renderSeq = String((Number(el.getAttribute('data-render-seq') || '0') || 0) + 1);
function typeChar() {
if (i < command.length) {
cmdEl.textContent += command.charAt(i);
i++;
setTimeout(typeChar, 80 + Math.random() * 40); // Random delay for realistic effect
} else {
// Start cursor blinking after typing completes
cursorEl.style.animation = 'blink 1s infinite';
}
el.setAttribute('data-command', command);
el.setAttribute('data-render-seq', renderSeq);
cmdEl.textContent = '';
cursorEl.style.animation = 'none';
cursorEl.style.opacity = '1';
if (!typing) {
cmdEl.textContent = command;
cursorEl.style.animation = 'blink 1s infinite';
return;
}
let index = 0;
function typeChar() {
if (el.getAttribute('data-render-seq') !== renderSeq) {
return;
}
// Start typing after a small delay
setTimeout(typeChar, 300);
} else {
// Show all at once
cmdEl.textContent = command;
if (index < command.length) {
cmdEl.textContent += command.charAt(index);
index += 1;
setTimeout(typeChar, 42 + Math.random() * 22);
} else {
cursorEl.style.animation = 'blink 1s infinite';
}
}
setTimeout(typeChar, 120);
}
if (!window.__termiCommandPrompt) {
window.__termiCommandPrompt = {
set(promptId, command, options = {}) {
if (!promptId) return;
const el = document.querySelector(`[data-prompt-id="${promptId}"]`);
if (!el) return;
renderPrompt(el, command, options.typing ?? true);
}
};
}
const prompts = document.querySelectorAll('[data-command]:not([data-command-mounted])');
prompts.forEach(function(el) {
el.setAttribute('data-command-mounted', 'true');
renderPrompt(el, el.getAttribute('data-command') || '', el.getAttribute('data-typing') === 'true');
});
})();
</script>

View File

@@ -2,8 +2,9 @@
interface Props {
href?: string;
active?: boolean;
tone?: 'blue' | 'amber' | 'teal' | 'violet' | 'neutral';
tone?: 'blue' | 'amber' | 'teal' | 'violet' | 'neutral' | 'accent';
class?: string;
style?: string;
}
const {

View File

@@ -2,15 +2,84 @@
interface Props {
href: string;
text: string;
command?: string;
}
const { href, text } = Astro.props;
const { href, text, command = 'cd' } = Astro.props;
---
<a
href={href}
class="inline-flex items-center gap-1.5 text-sm font-mono text-[var(--primary)] hover:underline transition-all"
class="terminal-view-more-link"
>
<span>{text}</span>
<i class="fas fa-arrow-right text-xs"></i>
<span class="terminal-view-more-link__prompt">{command}</span>
<span class="terminal-view-more-link__label">{text}</span>
<span class="terminal-view-more-link__icon" aria-hidden="true">
<i class="fas fa-arrow-right text-[10px]"></i>
</span>
</a>
<style>
.terminal-view-more-link {
display: inline-grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.7rem;
min-width: min(100%, 20rem);
padding: 0.65rem 0.8rem;
border-radius: 1rem;
border: 1px solid color-mix(in oklab, var(--primary) 14%, var(--border-color));
background:
linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 97%, transparent), color-mix(in oklab, var(--header-bg) 88%, transparent)),
linear-gradient(90deg, rgba(var(--primary-rgb), 0.04), transparent 24%, rgba(var(--primary-rgb), 0.02) 76%, transparent);
color: var(--title-color);
text-decoration: none;
box-shadow:
0 14px 28px rgba(var(--text-rgb), 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.36);
transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease, background 180ms ease;
}
.terminal-view-more-link:hover {
transform: translateY(-1px);
border-color: color-mix(in oklab, var(--primary) 24%, var(--border-color));
background:
linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 95%, transparent), color-mix(in oklab, var(--primary) 5%, var(--header-bg))),
linear-gradient(90deg, rgba(var(--primary-rgb), 0.06), transparent 22%, rgba(var(--primary-rgb), 0.025) 78%, transparent);
box-shadow:
0 16px 30px rgba(var(--text-rgb), 0.07),
inset 0 1px 0 rgba(255, 255, 255, 0.42);
}
.terminal-view-more-link__prompt {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.28rem 0.55rem;
border-radius: 999px;
background: color-mix(in oklab, var(--primary) 10%, var(--terminal-bg));
color: var(--primary);
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
font-size: 0.72rem;
line-height: 1;
white-space: nowrap;
}
.terminal-view-more-link__label {
min-width: 0;
font-size: 0.92rem;
font-weight: 600;
line-height: 1.3;
}
.terminal-view-more-link__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.6rem;
height: 1.6rem;
border-radius: 999px;
color: var(--primary);
background: color-mix(in oklab, var(--primary) 10%, transparent);
}
</style>