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:
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user