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

@@ -0,0 +1,24 @@
<svg width="640" height="960" viewBox="0 0 640 960" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="640" height="960" rx="36" fill="#14110F"/>
<rect x="34" y="34" width="572" height="892" rx="28" fill="url(#bg)"/>
<circle cx="322" cy="270" r="132" fill="url(#sun)"/>
<circle cx="322" cy="270" r="88" fill="#151312"/>
<path d="M322 106V432" stroke="#A57B2E" stroke-width="14" stroke-linecap="round"/>
<path d="M214 532C245.167 486 280.333 463 319.5 463C358.667 463 393.833 486 425 532" stroke="#A57B2E" stroke-width="10" stroke-linecap="round"/>
<path d="M180 640C225.333 594 272 571 320 571C368 571 414.667 594 460 640" stroke="#6C531E" stroke-width="8" stroke-linecap="round"/>
<path d="M136 740H504" stroke="#A57B2E" stroke-opacity=".65" stroke-width="2"/>
<text x="106" y="792" fill="#E7C779" font-family="'Noto Serif SC', 'Microsoft YaHei', serif" font-size="60" font-weight="700">黑神话:悟空</text>
<text x="108" y="852" fill="#B58A36" font-family="'IBM Plex Mono', monospace" font-size="18" letter-spacing="5">BLACK MYTH / WUKONG / GAME</text>
<defs>
<linearGradient id="bg" x1="92" y1="78" x2="512" y2="902" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1916"/>
<stop offset=".45" stop-color="#14110F"/>
<stop offset="1" stop-color="#201710"/>
</linearGradient>
<radialGradient id="sun" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(322 270) rotate(90) scale(132)">
<stop stop-color="#D6AE53"/>
<stop offset=".62" stop-color="#8A682A"/>
<stop offset="1" stop-color="#4D3918"/>
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,22 @@
<svg width="640" height="960" viewBox="0 0 640 960" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="640" height="960" rx="36" fill="#141C2C"/>
<rect x="34" y="34" width="572" height="892" rx="28" fill="url(#bg)"/>
<path d="M108 678L214 584L304 626L402 510L532 600" stroke="#C9D7F0" stroke-width="11" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M96 764H548" stroke="#6E86B2" stroke-width="3"/>
<path d="M96 800H500" stroke="#6E86B2" stroke-opacity=".7" stroke-width="3"/>
<path d="M96 836H452" stroke="#6E86B2" stroke-opacity=".45" stroke-width="3"/>
<circle cx="478" cy="208" r="76" fill="#F5BF79" fill-opacity=".92"/>
<circle cx="478" cy="208" r="44" fill="#23324C"/>
<path d="M128 220C200 168 264 142 320 142C376 142 444 168 524 220" stroke="#7F97C0" stroke-width="3"/>
<text x="96" y="186" fill="#C9D7F0" font-family="'IBM Plex Mono', monospace" font-size="18" letter-spacing="6">LATE NIGHT LOOP / INDIE POP</text>
<text x="96" y="690" fill="#F7FBFF" font-family="'Noto Serif SC', 'Microsoft YaHei', serif" font-size="54" font-weight="700">疲惫生活中的</text>
<text x="96" y="752" fill="#F7FBFF" font-family="'Noto Serif SC', 'Microsoft YaHei', serif" font-size="54" font-weight="700">英雄梦想</text>
<text x="98" y="884" fill="#9FB2D4" font-family="'IBM Plex Mono', monospace" font-size="18" letter-spacing="5">MUSIC REVIEW / MIDNIGHT LISTENING</text>
<defs>
<linearGradient id="bg" x1="88" y1="90" x2="510" y2="908" gradientUnits="userSpaceOnUse">
<stop stop-color="#24344F"/>
<stop offset=".56" stop-color="#162238"/>
<stop offset="1" stop-color="#111B2D"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,19 @@
<svg width="640" height="960" viewBox="0 0 640 960" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="640" height="960" rx="36" fill="#162322"/>
<rect x="34" y="34" width="572" height="892" rx="28" fill="url(#bg)"/>
<circle cx="198" cy="258" r="86" fill="#F3D7A2"/>
<circle cx="438" cy="258" r="64" fill="#B9D6D3" fill-opacity=".48"/>
<path d="M176 632C247.086 532.319 331.361 482.479 428.824 482.479C467.868 482.479 504.784 490.083 539.572 505.29" stroke="#E7E0CE" stroke-width="12" stroke-linecap="round"/>
<path d="M164 604C194.931 565.482 224.211 546.224 251.838 546.224C279.465 546.224 307.448 560.485 335.785 589.009" stroke="#F3D7A2" stroke-width="10" stroke-linecap="round"/>
<path d="M82 734H558" stroke="#C9D7D3" stroke-width="3"/>
<text x="96" y="160" fill="#F6E7C9" font-family="'IBM Plex Mono', monospace" font-size="18" letter-spacing="6">RETRO SCI-FI / FIELD NOTES</text>
<text x="96" y="818" fill="#F6E7C9" font-family="'Noto Serif SC', 'Microsoft YaHei', serif" font-size="58" font-weight="700">宇宙探索编辑部</text>
<text x="98" y="874" fill="#B9D6D3" font-family="'IBM Plex Mono', monospace" font-size="18" letter-spacing="5">JOURNEY TO THE WEST EDITORIAL</text>
<defs>
<linearGradient id="bg" x1="82" y1="74" x2="530" y2="910" gradientUnits="userSpaceOnUse">
<stop stop-color="#30514E"/>
<stop offset=".5" stop-color="#1E3332"/>
<stop offset="1" stop-color="#172726"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,20 @@
<svg width="640" height="960" viewBox="0 0 640 960" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="640" height="960" rx="36" fill="#2D1918"/>
<rect x="34" y="34" width="572" height="892" rx="28" fill="url(#bg)"/>
<rect x="96" y="130" width="448" height="278" rx="18" fill="#D1A15B"/>
<path d="M124 354H516" stroke="#5B2F2E" stroke-width="4" stroke-dasharray="8 10"/>
<path d="M140 642L220 582L286 612L370 514L494 438" stroke="#E8C690" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M140 676H500" stroke="#A56563" stroke-width="3"/>
<path d="M140 712H460" stroke="#A56563" stroke-opacity=".8" stroke-width="3"/>
<path d="M140 748H420" stroke="#A56563" stroke-opacity=".65" stroke-width="3"/>
<text x="126" y="226" fill="#5B2F2E" font-family="'IBM Plex Mono', monospace" font-size="20" letter-spacing="7">MACRO / CHINA / NOTES</text>
<text x="124" y="820" fill="#F3D7B1" font-family="'Noto Serif SC', 'Microsoft YaHei', serif" font-size="64" font-weight="700">置身事内</text>
<text x="126" y="876" fill="#D1A15B" font-family="'IBM Plex Mono', monospace" font-size="18" letter-spacing="5">ECONOMY / NONFICTION / BOOK</text>
<defs>
<linearGradient id="bg" x1="90" y1="84" x2="538" y2="906" gradientUnits="userSpaceOnUse">
<stop stop-color="#7C3A3A"/>
<stop offset=".58" stop-color="#5C2B2A"/>
<stop offset="1" stop-color="#442120"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,22 @@
<svg width="640" height="960" viewBox="0 0 640 960" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="640" height="960" rx="36" fill="#10232A"/>
<rect x="34" y="34" width="572" height="892" rx="28" fill="url(#bg)"/>
<path d="M72 786C166 706 231 612 308 612C385 612 442 686 568 770V888H72V786Z" fill="#142C33"/>
<path d="M106 718L218 603L275 648L361 542L491 698" stroke="#8FBCC0" stroke-opacity=".8" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M82 824H558" stroke="#5D8185" stroke-width="4" stroke-dasharray="10 12"/>
<circle cx="480" cy="204" r="92" fill="#D8E2DD" fill-opacity=".12"/>
<path d="M451 187H510" stroke="#D8E2DD" stroke-opacity=".8" stroke-width="6" stroke-linecap="round"/>
<path d="M421 219H516" stroke="#D8E2DD" stroke-opacity=".5" stroke-width="4" stroke-linecap="round"/>
<path d="M118 132L514 132" stroke="#ADC4C6" stroke-opacity=".22" stroke-width="2"/>
<path d="M118 160L466 160" stroke="#ADC4C6" stroke-opacity=".15" stroke-width="2"/>
<text x="100" y="220" fill="#DCE8E6" font-family="'Noto Serif SC', 'Microsoft YaHei', serif" font-size="58" font-weight="700">漫长的季节</text>
<text x="102" y="274" fill="#8FBCC0" font-family="'IBM Plex Mono', monospace" font-size="20" letter-spacing="6">THE LONG SEASON</text>
<text x="102" y="842" fill="#ADC4C6" font-family="'IBM Plex Mono', monospace" font-size="18" letter-spacing="4">FILM LOG / 2024 / NO.01</text>
<defs>
<linearGradient id="bg" x1="86" y1="80" x2="522" y2="902" gradientUnits="userSpaceOnUse">
<stop stop-color="#17343D"/>
<stop offset=".52" stop-color="#0E2027"/>
<stop offset="1" stop-color="#162B31"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,20 @@
<svg width="640" height="960" viewBox="0 0 640 960" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="640" height="960" rx="36" fill="#201A17"/>
<rect x="34" y="34" width="572" height="892" rx="28" fill="#EFE5D7"/>
<rect x="78" y="84" width="484" height="792" rx="18" fill="url(#paper)"/>
<path d="M164 694C164 598.492 241.492 521 337 521C432.508 521 510 598.492 510 694V744H164V694Z" fill="#1E1B18"/>
<circle cx="338" cy="416" r="100" fill="#2B2521"/>
<path d="M140 196H500" stroke="#7B6B5E" stroke-width="4"/>
<path d="M140 230H440" stroke="#7B6B5E" stroke-opacity=".6" stroke-width="2"/>
<path d="M140 778H500" stroke="#7B6B5E" stroke-width="4"/>
<text x="136" y="176" fill="#2B2521" font-family="'IBM Plex Mono', monospace" font-size="18" letter-spacing="7">INTERVIEW DOSSIER</text>
<text x="138" y="840" fill="#2B2521" font-family="'Noto Serif SC', 'Microsoft YaHei', serif" font-size="62" font-weight="700">十三邀</text>
<text x="140" y="892" fill="#7B6B5E" font-family="'IBM Plex Mono', monospace" font-size="18" letter-spacing="5">THIRTEEN INVITES / VOL.13</text>
<defs>
<linearGradient id="paper" x1="94" y1="106" x2="564" y2="846" gradientUnits="userSpaceOnUse">
<stop stop-color="#F5EBDD"/>
<stop offset=".54" stop-color="#E7D9C6"/>
<stop offset="1" stop-color="#D9C7B0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

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>

View File

@@ -1,5 +1,13 @@
/// <reference types="astro/client" />
interface ImportMetaEnv {
readonly PUBLIC_API_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
declare global {
interface Window {
__TERMI_I18N__?: {

View File

@@ -121,7 +121,7 @@ const i18nPayload = JSON.stringify({ locale, messages });
}
@media (prefers-color-scheme: dark) {
:root:not(.light) {
:root:not(.light):not(.dark) {
--primary: #00ff9d;
--primary-rgb: 0 255 157;
--primary-light: #00ff9d33;
@@ -193,12 +193,84 @@ const i18nPayload = JSON.stringify({ locale, messages });
<script is:inline>
(function() {
const theme = localStorage.getItem('theme');
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (theme === 'dark' || (!theme && systemDark)) {
document.documentElement.classList.add('dark');
} else if (theme === 'light') {
document.documentElement.classList.add('light');
const STORAGE_KEY = 'theme';
const VALID_THEMES = new Set(['light', 'dark', 'system']);
const root = document.documentElement;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
function readThemeMode() {
try {
const savedMode = localStorage.getItem(STORAGE_KEY);
return VALID_THEMES.has(savedMode) ? savedMode : 'system';
} catch {
return 'system';
}
}
function resolveTheme(mode) {
return mode === 'dark' || (mode === 'system' && mediaQuery.matches) ? 'dark' : 'light';
}
function applyTheme(mode, options = {}) {
const { persist = false, notify = true } = options;
const safeMode = VALID_THEMES.has(mode) ? mode : 'system';
const resolvedTheme = resolveTheme(safeMode);
root.dataset.themeMode = safeMode;
root.dataset.themeResolved = resolvedTheme;
root.classList.toggle('dark', resolvedTheme === 'dark');
root.classList.toggle('light', resolvedTheme === 'light');
if (persist) {
try {
localStorage.setItem(STORAGE_KEY, safeMode);
} catch {
// Ignore storage write failures and keep the UI responsive.
}
}
if (notify) {
window.dispatchEvent(
new CustomEvent('termi:theme-change', {
detail: { mode: safeMode, resolved: resolvedTheme },
})
);
}
return { mode: safeMode, resolved: resolvedTheme };
}
function syncThemeFromStorage(notify = false) {
return applyTheme(readThemeMode(), { notify });
}
window.__termiTheme = {
getMode: readThemeMode,
resolveTheme,
applyTheme(mode) {
return applyTheme(mode, { persist: true, notify: true });
},
syncTheme() {
return syncThemeFromStorage(true);
},
};
syncThemeFromStorage(false);
if (!window.__termiThemeMediaBound) {
const handleSystemThemeChange = () => {
if (readThemeMode() === 'system') {
syncThemeFromStorage(true);
}
};
if (typeof mediaQuery.addEventListener === 'function') {
mediaQuery.addEventListener('change', handleSystemThemeChange);
} else {
mediaQuery.onchange = handleSystemThemeChange;
}
window.__termiThemeMediaBound = true;
}
})();
</script>

View File

@@ -6,7 +6,14 @@ import type {
Tag as UiTag,
} from '../types';
export const API_BASE_URL = 'http://localhost:5150/api';
const envApiBaseUrl = import.meta.env.PUBLIC_API_BASE_URL?.trim();
export const API_BASE_URL =
envApiBaseUrl && envApiBaseUrl.length > 0
? envApiBaseUrl.replace(/\/$/, '')
: import.meta.env.DEV
? 'http://127.0.0.1:5150/api'
: 'https://init.cool/api';
export interface ApiPost {
id: number;
@@ -18,6 +25,7 @@ export interface ApiPost {
tags: string[];
post_type: 'article' | 'tweet';
image: string | null;
images: string[] | null;
pinned: boolean;
created_at: string;
updated_at: string;
@@ -111,11 +119,22 @@ export interface ApiSiteSettings {
social_email: string | null;
location: string | null;
tech_stack: string[] | null;
music_playlist: Array<{
title: string;
artist?: string | null;
album?: string | null;
url: string;
cover_image_url?: string | null;
accent_color?: string | null;
description?: string | null;
}> | null;
ai_enabled: boolean;
paragraph_comments_enabled: boolean;
}
export interface AiSource {
slug: string;
href: string;
title: string;
excerpt: string;
score: number;
@@ -152,10 +171,11 @@ export interface Review {
review_type: 'game' | 'anime' | 'music' | 'book' | 'movie';
rating: number;
review_date: string;
status: 'completed' | 'in-progress' | 'dropped';
status: 'published' | 'draft' | 'completed' | 'in-progress' | 'dropped';
description: string;
tags: string;
cover: string;
link_url: string | null;
created_at: string;
updated_at: string;
}
@@ -168,24 +188,59 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
id: '1',
siteName: 'InitCool',
siteShortName: 'Termi',
siteUrl: 'https://termi.dev',
siteUrl: 'https://init.cool',
siteTitle: 'InitCool - 终端风格的内容平台',
siteDescription: '一个基于终端美学的个人内容站,记录代码、设计和生活。',
heroTitle: '欢迎来到我的极客终端博客',
heroSubtitle: '这里记录技术、代码和生活点滴',
ownerName: 'InitCool',
ownerTitle: '前端开发者 / 技术博主',
ownerBio: '一名热爱技术的前端开发者,专注于构建高性能、优雅的用户界面。相信代码不仅是工具,更是一种艺术表达。',
ownerTitle: 'Rust / Go / Python Developer · Builder @ init.cool',
ownerBio: 'InitCoolGitHub 用户名 limitcool。坚持不要重复造轮子当前在维护 starter平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。',
location: 'Hong Kong',
social: {
github: 'https://github.com',
twitter: 'https://twitter.com',
email: 'mailto:hello@termi.dev',
github: 'https://github.com/limitcool',
twitter: '',
email: 'mailto:initcoool@gmail.com',
},
techStack: ['Astro', 'Svelte', 'Tailwind CSS', 'TypeScript'],
techStack: ['Rust', 'Go', 'Python', 'Svelte', 'Astro', 'Loco.rs'],
musicPlaylist: [
{
title: '山中来信',
artist: 'InitCool Radio',
album: '站点默认歌单',
url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
coverImageUrl:
'https://images.unsplash.com/photo-1510915228340-29c85a43dcfe?auto=format&fit=crop&w=600&q=80',
accentColor: '#2f6b5f',
description: '适合文章阅读时循环播放的轻氛围曲。',
},
{
title: '风吹松声',
artist: 'InitCool Radio',
album: '站点默认歌单',
url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3',
coverImageUrl:
'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=600&q=80',
accentColor: '#8a5b35',
description: '偏木质感的器乐氛围,适合深夜浏览。',
},
{
title: '夜航小记',
artist: 'InitCool Radio',
album: '站点默认歌单',
url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3',
coverImageUrl:
'https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?auto=format&fit=crop&w=600&q=80',
accentColor: '#375a7f',
description: '节奏更明显一点,适合切换阅读状态。',
},
],
ai: {
enabled: false,
},
comments: {
paragraphsEnabled: true,
},
};
const formatPostDate = (dateString: string) => dateString.slice(0, 10);
@@ -208,6 +263,7 @@ const normalizePost = (post: ApiPost): UiPost => ({
tags: post.tags ?? [],
category: post.category,
image: post.image ?? undefined,
images: post.images ?? undefined,
pinned: post.pinned,
});
@@ -277,9 +333,26 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => ({
email: settings.social_email || DEFAULT_SITE_SETTINGS.social.email,
},
techStack: settings.tech_stack?.length ? settings.tech_stack : DEFAULT_SITE_SETTINGS.techStack,
musicPlaylist:
settings.music_playlist?.filter((item) => item?.title?.trim() && item?.url?.trim())?.length
? settings.music_playlist
.filter((item) => item.title.trim() && item.url.trim())
.map((item) => ({
title: item.title,
artist: item.artist ?? undefined,
album: item.album ?? undefined,
url: item.url,
coverImageUrl: item.cover_image_url ?? undefined,
accentColor: item.accent_color ?? undefined,
description: item.description ?? undefined,
}))
: DEFAULT_SITE_SETTINGS.musicPlaylist,
ai: {
enabled: Boolean(settings.ai_enabled),
},
comments: {
paragraphsEnabled: settings.paragraph_comments_enabled ?? true,
},
});
class ApiClient {
@@ -450,6 +523,7 @@ class ApiClient {
tags: result.tags ?? [],
post_type: result.post_type || 'article',
image: result.image,
images: null,
pinned: result.pinned ?? false,
created_at: result.created_at,
updated_at: result.updated_at,

View File

@@ -140,11 +140,11 @@ I N N I T CCCC OOO OOO LLLLL`,
],
search: {
placeholders: {
default: "'关键词' articles/*.md",
default: "'关键词' 文章 / 标签 / 分类",
small: "搜索...",
medium: "搜索文章..."
},
promptText: "grep -i",
promptText: "搜索",
emptyResultText: "输入关键词搜索文章"
},
terminal: {

View File

@@ -66,12 +66,7 @@ export function resolveLocale(options: {
return fromCookie;
}
const acceptLanguages = String(options.acceptLanguage || '')
.split(',')
.map((part) => normalizeLocale(part.split(';')[0]))
.filter(Boolean) as Locale[];
return acceptLanguages[0] || DEFAULT_LOCALE;
return DEFAULT_LOCALE;
}
export function translate(locale: Locale, key: string, params?: TranslateParams): string {

View File

@@ -4,7 +4,7 @@ export const messages = {
language: '语言',
languages: {
'zh-CN': '简体中文',
en: 'English',
en: '英文',
},
all: '全部',
search: '搜索',
@@ -63,6 +63,7 @@ export const messages = {
featureOff: '功能未开启',
emptyState: '当前还没有内容。',
apiUnavailable: 'API 暂时不可用',
unknownError: '未知错误',
},
nav: {
articles: '文章',
@@ -77,19 +78,31 @@ export const messages = {
header: {
navigation: '导航',
themeToggle: '切换主题',
themePanelTitle: '外观模式',
themeLight: '浅色',
themeDark: '深色',
themeSystem: '跟随系统',
themeLightHint: '始终使用亮色界面',
themeDarkHint: '始终使用暗色界面',
themeSystemHint: '跟随设备当前主题',
themeResolvedAs: '当前生效:{mode}',
toggleMenu: '切换菜单',
searchModeKeyword: '搜索',
searchModeAi: 'AI',
searchModeKeywordMobile: '关键词搜索',
searchModeAiMobile: 'AI 搜索',
shellLabel: '站点终端',
musicPanel: '播放控制',
searchPromptKeyword: '站内搜索',
searchPromptAi: 'AI 问答',
searchPlaceholderKeyword: "'关键词'",
searchPlaceholderAi: '输入问题,交给站内 AI',
searchHintKeyword: 'articles/*.md',
searchHintAi: '手动确认',
searchHintKeyword: '文章 / 标签 / 分类',
searchHintAi: '前往问答页',
aiModeTitle: 'AI 问答模式',
aiModeHeading: '把这个问题交给站内 AI',
aiModeDescription: 'AI 会先检索站内知识库,再给出总结式回答,并附带相关文章来源。',
aiModeNotice: '进入问答页后不会自动调用模型,需要你手动确认发送。',
aiModeDescription: '在问答页输入问题后,系统会优先参考站内内容并给出整理后的回答。',
aiModeNotice: '回答会附带相关文章,方便继续阅读。',
aiModeCta: '前往 AI 问答页确认',
liveResults: '实时搜索结果',
searching: '正在搜索 {query} ...',
@@ -106,12 +119,22 @@ export const messages = {
copyright: '© {year} {site}. 保留所有权利。',
sitemap: '站点地图',
rss: 'RSS 订阅',
summary: '持续整理文章、记录与站内阅读入口。',
},
home: {
pinned: '置顶',
quickJump: '快速跳转',
about: '关于我',
techStack: '技术栈',
systemStatus: '系统状态',
promptWelcome: 'pwd',
promptDiscoverDefault: "find ./posts -type f | sort",
promptDiscoverFiltered: 'grep -Ril "{filters}" ./posts',
promptPinned: 'grep -Ril "^pinned: true$" ./posts',
promptPostsDefault: "find ./posts -type f | head -n {count}",
promptPostsFiltered: 'grep -Ril "{filters}" ./posts | head -n {count}',
promptFriends: "find ./links -maxdepth 1 -type f | sort",
promptAbout: "sed -n '1,80p' ~/profile.md",
},
articlesPage: {
title: '文章索引',
@@ -131,16 +154,20 @@ export const messages = {
filePath: '文件路径',
},
relatedPosts: {
kicker: '关联轨迹',
title: '相关文章',
description: '基于当前分类与标签关联出的相近内容,延续同一条阅读链路。',
linked: '{count} 条关联',
},
comments: {
title: '评论终端',
kicker: '讨论缓冲区',
description: '这里是整篇文章的讨论区,当前缓冲区共有 {count} 条已展示评论,新的留言提交后会进入审核队列。',
writeComment: '写评论',
nickname: '昵称',
nicknamePlaceholder: '山客',
email: '邮箱',
emailPlaceholder: 'name@example.com',
message: '内容',
messagePlaceholder: "$ echo '留下你的想法...'",
maxChars: '最多 500 字',
@@ -160,15 +187,19 @@ export const messages = {
},
paragraphComments: {
title: '段落评论已启用',
kicker: '段落批注',
intro: '正文里的自然段都会挂一个轻量讨论入口,适合只针对某一段补充上下文、指出问题或继续展开讨论。',
scanning: '正在扫描段落缓冲区...',
noParagraphs: '当前文章没有可挂载评论的自然段。',
summary: '已为 {paragraphCount} 个自然段挂载评论入口,其中 {discussedCount} 段已有讨论,当前共展示 {approvedCount} 条已审核段落评论。',
focusCurrent: '聚焦当前段落',
panelTitle: '段落讨论面板',
panelKicker: '段落讨论线程',
close: '关闭',
nickname: '昵称',
nicknamePlaceholder: '林泉',
email: '邮箱',
emailPlaceholder: 'name@example.com',
comment: '评论',
commentPlaceholder: "$ echo '只评论这一段...'",
maxChars: '最多 500 字',
@@ -192,22 +223,29 @@ export const messages = {
zeroNotes: '评论',
waitingReview: '等待审核',
locateParagraph: '定位段落',
showMarkers: '显示段落评论',
hideMarkers: '隐藏段落评论',
markersHidden: '段落评论入口已隐藏,你仍然可以随时重新打开。',
badgeLabel: '打开这一段的评论面板',
},
ask: {
pageTitle: 'AI 问答',
pageDescription: '基于 {siteName} 内容知识库的站内 AI 问答',
pageDescription: '{siteName} 的站内 AI 问答入口',
title: 'AI 站内问答',
subtitle: '基于博客 Markdown 内容建立索引,回答会优先引用站内真实资料。',
subtitle: '围绕本站内容回答问题,并附上可继续阅读的相关文章。',
terminalLabel: '问答助手',
assistantLabel: '回答输出',
disabledStateLabel: '功能已关闭',
disabledTitle: '后台暂未开启 AI 问答',
disabledDescription: '这个入口已经接好了真实后端,但当前站点设置里没有开启公开问答。管理员开启后,这里会自动变成可用状态,导航也会同步显示。',
textareaPlaceholder: '输入你想问的问题,比如:这个博客关于前端写过哪些内容?',
submit: '开始提问',
idleStatus: '知识库已接入,等待问题输入。',
idleStatus: '可以直接输入问题开始提问。',
examples: '示例问题',
workflow: '工作流',
workflow1: '1. 后台开启 AI 开关并配置聊天模型。',
workflow2: '2. 重建索引,把 Markdown 文章切块后由后端本地生成 embedding并写入 PostgreSQL pgvector。',
workflow3: '3. 前台提问时先在 pgvector 中做相似度检索,再交给聊天模型基于上下文回答。',
guide: '提问建议',
guide1: '1. 直接问主题、文章、观点或站内某类内容。',
guide2: '2. 回答会优先结合本站已有内容,并给出可继续阅读的文章。',
guide3: '3. 如果问题太宽泛,换成更具体的关键词通常会更准确。',
emptyAnswer: '暂无回答。',
requestFailed: '请求失败:{message}',
streamUnsupported: '当前浏览器无法读取流式响应。',
@@ -220,7 +258,15 @@ export const messages = {
streamInterrupted: '流式响应被提前中断。',
retryLater: '这次请求没有成功,可以稍后重试。',
prefixedQuestion: '已带入搜索词,确认后开始提问。',
promptIdle: 'cat > question.txt',
promptEditing: "sed -n '1,12p' question.txt",
promptSubmitting: 'tail -f answer.stream',
promptComplete: "printf 'sources=%s\\n' {count}",
promptFailed: "echo 'retry'",
sources: '来源',
sourceScore: '相关度 {score}',
metaSources: '{count} 篇相关文章',
metaSourcesWithTime: '{count} 篇相关文章 · 更新于 {time}',
},
about: {
pageTitle: '关于',
@@ -236,8 +282,11 @@ export const messages = {
title: '文章分类',
intro: '按内容主题浏览文章,分类页现在和其他列表页保持同一套终端面板语言。',
quickJump: '快速跳转分类文章',
allCategoriesDescription: '查看全部分类下的文章与更新记录。',
categoryPosts: '浏览 {name} 主题下的全部文章和更新记录。',
selectedSummary: '{name} 分类下找到 {count} 篇文章',
empty: '暂无分类数据',
emptyPosts: '当前分类下没有文章',
},
friends: {
pageTitle: '友情链接',
@@ -254,6 +303,9 @@ export const messages = {
name: '名称',
description: '描述',
link: '链接',
promptBrowse: "find ./links -maxdepth 1 -type f | sort",
promptApply: 'cat > friend-link.txt',
promptRules: "sed -n '1,120p' rules.md",
},
friendForm: {
title: '提交友链申请',
@@ -312,6 +364,9 @@ export const messages = {
emptyData: '暂无评价数据,请检查后端 API 连接',
emptyFiltered: '当前筛选下暂无评价',
currentFilter: '当前筛选: {type}',
statusCompleted: '已完成',
statusInProgress: '进行中',
statusDropped: '已弃置',
typeAll: '全部',
typeGame: '游戏',
typeAnime: '动画',
@@ -330,7 +385,7 @@ export const messages = {
time: '时间',
actions: '可执行操作',
actionsIntro: '像命令面板一样,优先给出直接可走的恢复路径。',
searchHint: '也可以直接使用顶部的搜索输入框,在 `articles/*.md` 里重新 grep 一次相关关键字。',
searchHint: '也可以直接使用顶部的搜索输入框,重新搜索相关文章。',
recommended: '推荐入口',
recommendedIntro: '使用真实文章数据,避免 404 页面再把人带进不存在的地址。',
cannotLoad: '暂时无法读取文章列表。',
@@ -409,6 +464,7 @@ export const messages = {
featureOff: 'Feature off',
emptyState: 'Nothing here yet.',
apiUnavailable: 'API temporarily unavailable',
unknownError: 'unknown error',
},
nav: {
articles: 'Articles',
@@ -423,19 +479,31 @@ export const messages = {
header: {
navigation: 'Navigation',
themeToggle: 'Toggle theme',
themePanelTitle: 'Appearance',
themeLight: 'Light',
themeDark: 'Dark',
themeSystem: 'System',
themeLightHint: 'Always use the light interface',
themeDarkHint: 'Always use the dark interface',
themeSystemHint: 'Follow the device appearance',
themeResolvedAs: 'Currently applied: {mode}',
toggleMenu: 'Toggle menu',
searchModeKeyword: 'Search',
searchModeAi: 'AI',
searchModeKeywordMobile: 'Keyword Search',
searchModeAiMobile: 'AI Search',
shellLabel: 'Site Terminal',
musicPanel: 'Playback',
searchPromptKeyword: 'Site Search',
searchPromptAi: 'Ask AI',
searchPlaceholderKeyword: "'keyword'",
searchPlaceholderAi: 'Type a question for the site AI',
searchHintKeyword: 'articles/*.md',
searchHintAi: 'manual confirm',
searchHintKeyword: 'posts / tags / categories',
searchHintAi: 'open AI Q&A',
aiModeTitle: 'AI Q&A mode',
aiModeHeading: 'Send this question to the site AI',
aiModeDescription: 'The AI will search the site knowledge base first, then answer with source-backed summaries.',
aiModeNotice: 'The model will not run automatically after navigation. You must confirm manually.',
aiModeDescription: 'Ask on the Q&A page and the system will answer with priority given to on-site content.',
aiModeNotice: 'Answers include related articles so visitors can keep reading.',
aiModeCta: 'Open AI Q&A to confirm',
liveResults: 'Live results',
searching: 'Searching {query} ...',
@@ -452,12 +520,22 @@ export const messages = {
copyright: '© {year} {site}. All rights reserved.',
sitemap: 'Sitemap',
rss: 'RSS feed',
summary: 'A place for posts, notes, and on-site reading paths.',
},
home: {
pinned: 'Pinned',
quickJump: 'Quick jump',
about: 'About',
techStack: 'Tech stack',
systemStatus: 'System status',
promptWelcome: 'pwd',
promptDiscoverDefault: "find ./posts -type f | sort",
promptDiscoverFiltered: 'grep -Ril "{filters}" ./posts',
promptPinned: 'grep -Ril "^pinned: true$" ./posts',
promptPostsDefault: "find ./posts -type f | head -n {count}",
promptPostsFiltered: 'grep -Ril "{filters}" ./posts | head -n {count}',
promptFriends: "find ./links -maxdepth 1 -type f | sort",
promptAbout: "sed -n '1,80p' ~/profile.md",
},
articlesPage: {
title: 'Article Index',
@@ -477,16 +555,20 @@ export const messages = {
filePath: 'File path',
},
relatedPosts: {
kicker: 'Related traces',
title: 'Related Posts',
description: 'More nearby reading paths based on the current category and shared tags.',
linked: '{count} linked',
},
comments: {
title: 'Comment Terminal',
kicker: 'Discussion Buffer',
description: 'This is the discussion thread for the whole article. {count} approved comments are shown right now, and new messages enter moderation first.',
writeComment: 'Write comment',
nickname: 'Nickname',
nicknamePlaceholder: 'trail_reader',
email: 'Email',
emailPlaceholder: 'you@example.com',
message: 'Message',
messagePlaceholder: "$ echo 'Leave your thoughts here...'",
maxChars: 'Max 500 chars',
@@ -506,15 +588,19 @@ export const messages = {
},
paragraphComments: {
title: 'Paragraph comments are enabled',
kicker: 'Paragraph Notes',
intro: 'Each natural paragraph in the article gets a lightweight discussion entry point, perfect for focused context, corrections, or follow-up questions.',
scanning: 'Scanning paragraph buffer...',
noParagraphs: 'No commentable paragraphs were found in this article.',
summary: '{paragraphCount} paragraphs have comment entries, {discussedCount} already have discussion, and {approvedCount} approved paragraph comments are currently visible.',
focusCurrent: 'Focus current paragraph',
panelTitle: 'Paragraph discussion panel',
panelKicker: 'Paragraph thread',
close: 'Close',
nickname: 'Nickname',
nicknamePlaceholder: 'inline_reader',
email: 'Email',
emailPlaceholder: 'you@example.com',
comment: 'Comment',
commentPlaceholder: "$ echo 'Comment on this paragraph only...'",
maxChars: 'Max 500 chars',
@@ -538,22 +624,29 @@ export const messages = {
zeroNotes: 'comment',
waitingReview: 'waiting review',
locateParagraph: 'Locate paragraph',
showMarkers: 'Show paragraph comments',
hideMarkers: 'Hide paragraph comments',
markersHidden: 'Paragraph comment markers are hidden. You can turn them back on anytime.',
badgeLabel: 'Open comments for this paragraph',
},
ask: {
pageTitle: 'Ask AI',
pageDescription: 'An on-site AI Q&A experience grounded in the {siteName} knowledge base',
pageDescription: 'An on-site AI Q&A entry for {siteName}',
title: 'On-site AI Q&A',
subtitle: 'Answers are grounded in indexed Markdown content from the blog and prioritize real on-site references.',
subtitle: 'Ask about the site and get answers with related articles attached for follow-up reading.',
terminalLabel: 'Q&A Assistant',
assistantLabel: 'Assistant Output',
disabledStateLabel: 'Feature Disabled',
disabledTitle: 'AI Q&A is not enabled yet',
disabledDescription: 'The real backend integration is already in place, but public Q&A is still disabled in site settings. Once it is enabled, this page and the navigation entry will become available automatically.',
textareaPlaceholder: 'Ask anything, for example: what has this blog written about frontend topics?',
submit: 'Ask now',
idleStatus: 'Knowledge base connected. Waiting for a question.',
idleStatus: 'Type a question to get started.',
examples: 'Example questions',
workflow: 'Workflow',
workflow1: '1. Enable the AI switch in the admin and configure the chat model.',
workflow2: '2. Rebuild the index so Markdown content is chunked, embedded locally by the backend, and written into PostgreSQL pgvector.',
workflow3: '3. Each user question retrieves similar chunks from pgvector first, then the chat model answers with that context.',
guide: 'Asking tips',
guide1: '1. Ask directly about topics, posts, viewpoints, or recurring themes on the site.',
guide2: '2. Answers prioritize on-site material and include related reading when available.',
guide3: '3. If the answer feels broad, try a more specific keyword or article topic.',
emptyAnswer: 'No answer yet.',
requestFailed: 'Request failed: {message}',
streamUnsupported: 'This browser cannot read streaming responses.',
@@ -566,7 +659,15 @@ export const messages = {
streamInterrupted: 'The streaming response ended early.',
retryLater: 'This request did not complete successfully. Please try again later.',
prefixedQuestion: 'The search query has been prefilled. Confirm manually to ask AI.',
promptIdle: 'cat > question.txt',
promptEditing: "sed -n '1,12p' question.txt",
promptSubmitting: 'tail -f answer.stream',
promptComplete: "printf 'sources=%s\\n' {count}",
promptFailed: "echo 'retry'",
sources: 'Sources',
sourceScore: 'Score {score}',
metaSources: '{count} related articles',
metaSourcesWithTime: '{count} related articles · updated {time}',
},
about: {
pageTitle: 'About',
@@ -582,8 +683,11 @@ export const messages = {
title: 'Categories',
intro: 'Browse posts by topic. This page now follows the same terminal language as the other list views.',
quickJump: 'Jump straight into category posts',
allCategoriesDescription: 'Browse posts and updates from every category.',
categoryPosts: 'Browse all posts and updates under {name}.',
selectedSummary: '{count} posts in {name}',
empty: 'No category data yet',
emptyPosts: 'No posts found in this category',
},
friends: {
pageTitle: 'Links',
@@ -600,6 +704,9 @@ export const messages = {
name: 'Name',
description: 'Description',
link: 'Link',
promptBrowse: "find ./links -maxdepth 1 -type f | sort",
promptApply: 'cat > friend-link.txt',
promptRules: "sed -n '1,120p' rules.md",
},
friendForm: {
title: 'Submit a link request',
@@ -658,6 +765,9 @@ export const messages = {
emptyData: 'No review data yet. Please check the backend API connection.',
emptyFiltered: 'No reviews match the current filter',
currentFilter: 'Current filter: {type}',
statusCompleted: 'Completed',
statusInProgress: 'In progress',
statusDropped: 'Dropped',
typeAll: 'All',
typeGame: 'Games',
typeAnime: 'Anime',
@@ -676,7 +786,7 @@ export const messages = {
time: 'time',
actions: 'Actions',
actionsIntro: 'Like a command palette, this page surfaces the most direct recovery paths first.',
searchHint: 'You can also use the search box in the header and grep through `articles/*.md` again.',
searchHint: 'You can also use the search box in the header to search related posts again.',
recommended: 'Recommended entries',
recommendedIntro: 'These use real article data so the 404 page does not send people into more dead ends.',
cannotLoad: 'Unable to load the article list right now.',

View File

@@ -61,9 +61,23 @@ export interface SiteSettings {
email?: string;
};
techStack: string[];
musicPlaylist: MusicTrack[];
ai: {
enabled: boolean;
};
comments: {
paragraphsEnabled: boolean;
};
}
export interface MusicTrack {
title: string;
artist?: string;
album?: string;
url: string;
coverImageUrl?: string;
accentColor?: string;
description?: string;
}
export interface SiteConfig {

View File

@@ -68,6 +68,147 @@ export function debounce<T extends (...args: unknown[]) => unknown>(
};
}
export interface AccentTheme {
color: string;
rgb: string;
}
const POST_TYPE_THEMES: Record<string, AccentTheme> = {
article: {
color: '#2563eb',
rgb: '37 99 235',
},
tweet: {
color: '#f97316',
rgb: '249 115 22',
},
};
const DEFAULT_THEME: AccentTheme = {
color: '#64748b',
rgb: '100 116 139',
};
function normalizeToken(value: string | null | undefined): string {
return value?.trim().toLowerCase() || '';
}
function hashToken(value: string): number {
let hash = 2166136261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
function hexToRgbTriplet(hex: string): string {
const normalized = hex.replace('#', '');
const safeHex = normalized.length === 3
? normalized.split('').map((char) => `${char}${char}`).join('')
: normalized;
const value = parseInt(safeHex, 16);
return `${(value >> 16) & 255} ${(value >> 8) & 255} ${value & 255}`;
}
function hslToHex(hue: number, saturation: number, lightness: number): string {
const normalizedHue = ((hue % 360) + 360) % 360;
const s = saturation / 100;
const l = lightness / 100;
const chroma = (1 - Math.abs(2 * l - 1)) * s;
const section = normalizedHue / 60;
const x = chroma * (1 - Math.abs((section % 2) - 1));
let red = 0;
let green = 0;
let blue = 0;
if (section >= 0 && section < 1) {
red = chroma;
green = x;
} else if (section < 2) {
red = x;
green = chroma;
} else if (section < 3) {
green = chroma;
blue = x;
} else if (section < 4) {
green = x;
blue = chroma;
} else if (section < 5) {
red = x;
blue = chroma;
} else {
red = chroma;
blue = x;
}
const match = l - chroma / 2;
const toHex = (value: number) => Math.round((value + match) * 255).toString(16).padStart(2, '0');
return `#${toHex(red)}${toHex(green)}${toHex(blue)}`;
}
function getGeneratedTheme(
value: string | null | undefined,
{
salt,
saturation,
lightness,
}: {
salt: string;
saturation: number;
lightness: number;
}
): AccentTheme {
const normalized = normalizeToken(value);
if (!normalized) {
return DEFAULT_THEME;
}
const hue = hashToken(`${salt}:${normalized}`) % 360;
const color = hslToHex(hue, saturation, lightness);
return {
color,
rgb: hexToRgbTriplet(color),
};
}
export function getAccentVars(theme: AccentTheme): string {
return [
`--accent-color:${theme.color}`,
`--accent-rgb:${theme.rgb}`,
`--pill-fg:${theme.color}`,
`--pill-rgb:${theme.rgb}`,
`--tile-rgb:${theme.rgb}`,
].join(';') + ';';
}
export function getPostTypeTheme(type: string | null | undefined): AccentTheme {
return POST_TYPE_THEMES[normalizeToken(type)] || DEFAULT_THEME;
}
export function getCategoryTheme(category: string | null | undefined): AccentTheme {
return getGeneratedTheme(category, {
salt: 'category',
saturation: 72,
lightness: 46,
});
}
export function getTagTheme(tag: string | null | undefined): AccentTheme {
return getGeneratedTheme(tag, {
salt: 'tag',
saturation: 68,
lightness: 50,
});
}
/**
* Filter posts by type and tag
*/
@@ -90,7 +231,7 @@ export function filterPosts(
* Get color for post type
*/
export function getPostTypeColor(type: string): string {
return type === 'article' ? 'var(--primary)' : 'var(--secondary)';
return getPostTypeTheme(type).color;
}
export {

View File

@@ -4,7 +4,6 @@ import TerminalWindow from '../../components/ui/TerminalWindow.astro';
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
import StatsList from '../../components/StatsList.astro';
import TechStackList from '../../components/TechStackList.astro';
import InfoTile from '../../components/ui/InfoTile.astro';
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n';
@@ -49,7 +48,7 @@ const ownerInitial = siteSettings.ownerName.charAt(0) || 'T';
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TerminalWindow title="~/about" class="w-full">
<div class="mb-6 px-4">
<CommandPrompt command="whoami" />
<CommandPrompt command="id -un" />
<div class="terminal-panel ml-4 mt-4">
<div class="terminal-kicker">identity profile</div>
<div class="terminal-section-title mt-4">
@@ -79,7 +78,7 @@ const ownerInitial = siteSettings.ownerName.charAt(0) || 'T';
<div class="px-4">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div>
<CommandPrompt command="cat profile.txt" />
<CommandPrompt command="sed -n '1,80p' ~/profile.md" />
<div class="ml-4 mt-4">
<div class="terminal-panel p-6">
<div class="flex items-center gap-4 mb-4">
@@ -104,7 +103,7 @@ const ownerInitial = siteSettings.ownerName.charAt(0) || 'T';
</div>
<div class="mt-6">
<CommandPrompt command="cat tech_stack.txt" />
<CommandPrompt command="ls ~/.local/share/stack" />
<div class="ml-4 mt-4">
<TechStackList items={techStack} />
</div>
@@ -112,59 +111,75 @@ const ownerInitial = siteSettings.ownerName.charAt(0) || 'T';
</div>
<div>
<CommandPrompt command="cat system_info.txt" />
<CommandPrompt command="uname -a" />
<div class="ml-4 mt-4">
<StatsList stats={systemStats} />
</div>
<div class="mt-6">
<CommandPrompt command="cat contact.txt" />
<CommandPrompt command="printenv | grep -E 'MAIL|WEB'" />
<div class="ml-4 mt-4">
<div class="flex flex-wrap gap-3">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
{siteSettings.social.github && (
<InfoTile
<a
href={siteSettings.social.github}
tone="neutral"
layout="grid"
target="_blank"
rel="noopener noreferrer"
class="terminal-interactive-card flex items-center gap-3 rounded-2xl border border-[var(--border-color)] bg-[linear-gradient(135deg,rgba(15,23,42,0.04),rgba(var(--primary-rgb),0.08))] px-4 py-3 shadow-[0_10px_28px_rgba(15,23,42,0.08)]"
>
<i class="fab fa-github text-[var(--text-secondary)]"></i>
<span class="text-sm">GitHub</span>
</InfoTile>
<span class="flex h-10 w-10 items-center justify-center rounded-xl bg-[#111827] text-white">
<i class="fab fa-github text-sm"></i>
</span>
<span class="min-w-0">
<span class="block text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">{t('about.contact')}</span>
<span class="mt-1 block text-sm font-semibold text-[var(--title-color)]">GitHub</span>
</span>
</a>
)}
{siteSettings.social.twitter && (
<InfoTile
{siteSettings.social.twitter && (
<a
href={siteSettings.social.twitter}
tone="neutral"
layout="grid"
target="_blank"
rel="noopener noreferrer"
class="terminal-interactive-card flex items-center gap-3 rounded-2xl border border-[var(--border-color)] bg-[linear-gradient(135deg,rgba(15,23,42,0.04),rgba(var(--secondary-rgb),0.1))] px-4 py-3 shadow-[0_10px_28px_rgba(15,23,42,0.08)]"
>
<i class="fab fa-twitter text-[var(--text-secondary)]"></i>
<span class="text-sm">Twitter</span>
</InfoTile>
<span class="flex h-10 w-10 items-center justify-center rounded-xl bg-[#1d9bf0] text-white">
<i class="fab fa-twitter text-sm"></i>
</span>
<span class="min-w-0">
<span class="block text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">{t('about.contact')}</span>
<span class="mt-1 block text-sm font-semibold text-[var(--title-color)]">Twitter</span>
</span>
</a>
)}
{siteSettings.social.email && (
<InfoTile
{siteSettings.social.email && (
<a
href={siteSettings.social.email}
tone="neutral"
layout="grid"
class="terminal-interactive-card flex items-center gap-3 rounded-2xl border border-[var(--border-color)] bg-[linear-gradient(135deg,rgba(15,23,42,0.04),rgba(59,130,246,0.08))] px-4 py-3 shadow-[0_10px_28px_rgba(15,23,42,0.08)]"
>
<i class="fas fa-envelope text-[var(--text-secondary)]"></i>
<span class="text-sm">{t('comments.email')}</span>
</InfoTile>
<span class="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--primary)] text-white">
<i class="fas fa-envelope text-sm"></i>
</span>
<span class="min-w-0">
<span class="block text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">{t('about.contact')}</span>
<span class="mt-1 block text-sm font-semibold text-[var(--title-color)]">{t('comments.email')}</span>
</span>
</a>
)}
<InfoTile
<a
href={siteSettings.siteUrl}
tone="neutral"
layout="grid"
target="_blank"
rel="noopener noreferrer"
class="terminal-interactive-card flex items-center gap-3 rounded-2xl border border-[var(--border-color)] bg-[linear-gradient(135deg,rgba(15,23,42,0.04),rgba(16,185,129,0.1))] px-4 py-3 shadow-[0_10px_28px_rgba(15,23,42,0.08)]"
>
<i class="fas fa-globe text-[var(--text-secondary)]"></i>
<span class="text-sm">{t('about.website')}</span>
</InfoTile>
<span class="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-500 text-white">
<i class="fas fa-globe text-sm"></i>
</span>
<span class="min-w-0">
<span class="block text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">{t('about.contact')}</span>
<span class="mt-1 block text-sm font-semibold text-[var(--title-color)]">{t('about.website')}</span>
</span>
</a>
</div>
</div>
</div>

View File

@@ -308,7 +308,7 @@ const recentReviews = [...reviews].sort((a, b) => b.review_date.localeCompare(a.
<div class="font-medium text-[var(--title-color)]">评价页</div>
</div>
</a>
<a href="http://localhost:5150/" class="terminal-toolbar-module hover:border-[var(--primary)]">
<a href="https://init.cool/" class="terminal-toolbar-module hover:border-[var(--primary)]">
<span class="terminal-section-icon h-10 w-10 rounded-xl">
<i class="fas fa-server"></i>
</span>
@@ -321,7 +321,7 @@ const recentReviews = [...reviews].sort((a, b) => b.review_date.localeCompare(a.
<div class="terminal-panel-muted">
<div class="terminal-toolbar-label">api endpoint</div>
<p class="mt-2 font-mono text-sm text-[var(--primary)]">http://localhost:5150/api</p>
<p class="mt-2 font-mono text-sm text-[var(--primary)]">https://init.cool/api</p>
</div>
</section>
</div>

View File

@@ -11,15 +11,23 @@ import Lightbox from '../../components/Lightbox.astro';
import CodeCopyButton from '../../components/CodeCopyButton.astro';
import Comments from '../../components/Comments.astro';
import ParagraphComments from '../../components/ParagraphComments.astro';
import { apiClient } from '../../lib/api/client';
import { apiClient, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
import { formatReadTime, getI18n } from '../../lib/i18n';
import { resolveFileRef, getPostTypeColor } from '../../lib/utils';
import {
getAccentVars,
getCategoryTheme,
getPostTypeColor,
getPostTypeTheme,
getTagTheme,
resolveFileRef,
} from '../../lib/utils';
export const prerender = false;
const { slug } = Astro.params;
let post = null;
let siteSettings = DEFAULT_SITE_SETTINGS;
try {
post = await apiClient.getPostBySlug(slug ?? '');
@@ -27,22 +35,31 @@ try {
console.error('API Error:', error);
}
try {
siteSettings = await apiClient.getSiteSettings();
} catch (error) {
console.error('Site settings API Error:', error);
}
if (!post) {
return new Response(null, { status: 404 });
}
const typeColor = getPostTypeColor(post.type || 'article');
const typeTheme = getPostTypeTheme(post.type || 'article');
const categoryTheme = getCategoryTheme(post.category);
const contentText = post.content || post.description || '';
const wordCount = contentText.length;
const readTimeMinutes = Math.ceil(wordCount / 300);
const { locale, t } = getI18n(Astro);
const articleMarkdown = contentText.replace(/^#\s+.+\r?\n+/, '');
const paragraphCommentsEnabled = siteSettings.comments.paragraphsEnabled;
const markdownProcessor = await createMarkdownProcessor();
const renderedContent = await markdownProcessor.render(articleMarkdown);
---
<BaseLayout title={`${post.title} - Termi`} description={post.description}>
<BaseLayout title={post.title} description={post.description}>
<ReadingProgress />
<BackToTop />
<Lightbox />
@@ -66,12 +83,12 @@ const renderedContent = await markdownProcessor.render(articleMarkdown);
<i class="fas fa-file-code"></i>
{t('article.documentSession')}
</span>
<span class="terminal-chip">
<span class="terminal-chip terminal-chip--accent" style={getAccentVars(typeTheme)}>
<span class="h-2.5 w-2.5 rounded-full" style={`background-color: ${typeColor}`}></span>
{post.type === 'article' ? t('common.article') : t('common.tweet')}
</span>
<span class="terminal-chip">
<i class="fas fa-folder-tree text-[var(--primary)]"></i>
<span class="terminal-chip terminal-chip--accent" style={getAccentVars(categoryTheme)}>
<i class="fas fa-folder-tree"></i>
{post.category}
</span>
</div>
@@ -101,7 +118,11 @@ const renderedContent = await markdownProcessor.render(articleMarkdown);
{post.tags?.length > 0 && (
<div class="flex flex-wrap gap-2">
{post.tags.map(tag => (
<a href={`/tags?tag=${encodeURIComponent(tag)}`} class="terminal-filter">
<a
href={`/tags?tag=${encodeURIComponent(tag)}`}
class="terminal-filter"
style={getAccentVars(getTagTheme(tag))}
>
<i class="fas fa-hashtag"></i>
<span>{tag}</span>
</a>
@@ -120,18 +141,30 @@ const renderedContent = await markdownProcessor.render(articleMarkdown);
<img
src={resolveFileRef(post.image)}
alt={post.title}
data-lightbox-image="true"
class="w-full h-auto rounded-xl border border-[var(--border-color)] cursor-zoom-in"
/>
</div>
)}
<div class="terminal-document article-content" set:html={renderedContent.code}></div>
</div>
</div>
{post.images && post.images.length > 0 && (
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{post.images.map((image, index) => (
<div class="terminal-panel-muted overflow-hidden">
<img
src={resolveFileRef(image)}
alt={`${post.title} 图片 ${index + 1}`}
data-lightbox-image="true"
class="h-full w-full rounded-xl border border-[var(--border-color)] object-cover cursor-zoom-in"
/>
</div>
))}
</div>
)}
<div class="px-4 pb-2">
<div class="ml-4 mt-4">
<ParagraphComments postSlug={post.slug} />
{paragraphCommentsEnabled && <ParagraphComments postSlug={post.slug} class="mb-4" />}
<div class="terminal-document article-content" set:html={renderedContent.code}></div>
</div>
</div>

View File

@@ -7,6 +7,7 @@ import PostCard from '../../components/PostCard.astro';
import { api } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n';
import type { Category, Post, Tag } from '../../lib/types';
import { getAccentVars, getCategoryTheme, getPostTypeTheme, getTagTheme } from '../../lib/utils';
export const prerender = false;
@@ -60,9 +61,22 @@ const postTypeFilters = [
{ id: 'tweet', name: t('common.tweet'), icon: 'fa-comment-dots' }
];
const typePromptCommand = `./filter --type ${selectedType || 'all'}`;
const categoryPromptCommand = `./filter --category ${selectedCategory ? `"${selectedCategory}"` : 'all'}`;
const tagPromptCommand = `./filter --tag ${selectedTag ? `"${selectedTag}"` : 'all'}`;
const typePromptCommand =
selectedType === 'all'
? `grep -E "^type: (article|tweet)$" ./posts/*.md`
: `grep -E "^type: ${selectedType}$" ./posts/*.md`;
const categoryPromptCommand = selectedCategory
? `grep -El "^category: ${selectedCategory}$" ./posts/*.md`
: `cut -d: -f2 ./categories.index | sort -u`;
const tagPromptCommand = selectedTag
? `grep -Ril "#${selectedTag}" ./posts`
: `cut -d: -f2 ./tags.index | sort -u`;
const categoryAccentMap = Object.fromEntries(
allCategories.map((category) => [category.name.toLowerCase(), getAccentVars(getCategoryTheme(category.name))])
);
const tagAccentMap = Object.fromEntries(
allTags.map((tag) => [String(tag.slug || tag.name).toLowerCase(), getAccentVars(getTagTheme(tag.name))])
);
const buildArticlesUrl = ({
type = selectedType,
@@ -94,7 +108,7 @@ const buildArticlesUrl = ({
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TerminalWindow title="~/articles/index" class="w-full">
<div class="px-4 pb-2">
<CommandPrompt command="fd . ./content/posts --full-path" />
<CommandPrompt command="find ./posts -type f -name '*.md' | sort" />
<div class="ml-4 mt-4 space-y-3">
<h1 class="text-3xl font-bold tracking-tight text-[var(--title-color)]">{t('articlesPage.title')}</h1>
@@ -104,7 +118,7 @@ const buildArticlesUrl = ({
<div class="flex flex-wrap gap-2">
<span class="terminal-stat-pill">
<i class="fas fa-file-lines text-[var(--primary)]"></i>
{t('articlesPage.totalPosts', { count: filteredPosts.length })}
<span id="articles-total-posts">{t('articlesPage.totalPosts', { count: filteredPosts.length })}</span>
</span>
{selectedSearch && (
<span class="terminal-stat-pill">
@@ -112,31 +126,43 @@ const buildArticlesUrl = ({
grep: {selectedSearch}
</span>
)}
{selectedCategory && (
<span class="terminal-stat-pill">
<i class="fas fa-folder-open text-[var(--primary)]"></i>
{selectedCategory}
</span>
)}
{selectedTag && (
<span class="terminal-stat-pill">
<i class="fas fa-hashtag text-[var(--primary)]"></i>
{selectedTag}
</span>
)}
<span
id="articles-current-category-pill"
class:list={[
'terminal-stat-pill terminal-stat-pill--accent',
!selectedCategory && 'hidden'
]}
style={selectedCategory ? getAccentVars(getCategoryTheme(selectedCategory)) : undefined}
>
<i class="fas fa-folder-open"></i>
<span id="articles-current-category">{selectedCategory}</span>
</span>
<span
id="articles-current-tag-pill"
class:list={[
'terminal-stat-pill terminal-stat-pill--accent',
!selectedTag && 'hidden'
]}
style={selectedTag ? getAccentVars(getTagTheme(selectedTag)) : undefined}
>
<i class="fas fa-hashtag"></i>
<span id="articles-current-tag">{selectedTag}</span>
</span>
</div>
</div>
</div>
<div class="px-4 pb-2 space-y-4">
<div class="ml-4">
<CommandPrompt command={typePromptCommand} typing={false} />
<CommandPrompt promptId="articles-type-prompt" command={typePromptCommand} typing={false} />
<div class="mt-3 flex flex-wrap gap-3">
{postTypeFilters.map(filter => (
<FilterPill
href={buildArticlesUrl({ type: filter.id, page: 1 })}
tone="blue"
data-articles-type={filter.id}
tone={filter.id === 'all' ? 'neutral' : 'accent'}
active={selectedType === filter.id}
style={filter.id === 'all' ? undefined : getAccentVars(getPostTypeTheme(filter.id))}
>
<i class={`fas ${filter.icon}`}></i>
<span class="font-medium">{filter.name}</span>
@@ -147,10 +173,11 @@ const buildArticlesUrl = ({
{allCategories.length > 0 && (
<div class="ml-4">
<CommandPrompt command={categoryPromptCommand} typing={false} />
<CommandPrompt promptId="articles-category-prompt" command={categoryPromptCommand} typing={false} />
<div class="mt-3 flex flex-wrap gap-3">
<FilterPill
href={buildArticlesUrl({ category: '', page: 1 })}
data-articles-category=""
tone="amber"
active={!selectedCategory}
>
@@ -160,8 +187,10 @@ const buildArticlesUrl = ({
{allCategories.map(category => (
<FilterPill
href={buildArticlesUrl({ category: category.name, page: 1 })}
tone="amber"
data-articles-category={category.name}
tone="accent"
active={selectedCategory.toLowerCase() === category.name.toLowerCase()}
style={getAccentVars(getCategoryTheme(category.name))}
>
<i class="fas fa-folder-open"></i>
<span class="font-medium">{category.name}</span>
@@ -174,10 +203,11 @@ const buildArticlesUrl = ({
{allTags.length > 0 && (
<div class="ml-4">
<CommandPrompt command={tagPromptCommand} typing={false} />
<CommandPrompt promptId="articles-tag-prompt" command={tagPromptCommand} typing={false} />
<div class="mt-3 flex flex-wrap gap-3">
<FilterPill
href={buildArticlesUrl({ tag: '', page: 1 })}
data-articles-tag=""
tone="teal"
active={!selectedTag}
>
@@ -187,8 +217,10 @@ const buildArticlesUrl = ({
{allTags.map(tag => (
<FilterPill
href={buildArticlesUrl({ tag: tag.slug || tag.name, page: 1 })}
tone="teal"
data-articles-tag={tag.slug || tag.name}
tone="accent"
active={isSelectedTag(tag)}
style={getAccentVars(getTagTheme(tag.name))}
>
<i class="fas fa-hashtag"></i>
<span class="font-medium">{tag.name}</span>
@@ -200,14 +232,34 @@ const buildArticlesUrl = ({
</div>
<div class="px-4">
{paginatedPosts.length > 0 ? (
{allPosts.length > 0 ? (
<div class="ml-4 mt-4 space-y-4">
{paginatedPosts.map(post => (
<PostCard post={post} selectedTag={selectedTag} highlightTerm={selectedSearch} />
))}
{allPosts.map((post, index) => {
const matchesCurrentFilter =
(selectedType === 'all' || post.type === selectedType) &&
(!selectedTag || post.tags?.some(isMatchingTag)) &&
(!selectedCategory || post.category?.toLowerCase() === selectedCategory.toLowerCase());
const filteredIndex = matchesCurrentFilter
? filteredPosts.findIndex((item) => item.slug === post.slug)
: -1;
const isVisible = matchesCurrentFilter && filteredIndex >= startIndex && filteredIndex < startIndex + postsPerPage;
return (
<div
data-article-card
data-article-type={post.type}
data-article-category={post.category?.toLowerCase() || ''}
data-article-tags={post.tags.map((tag) => tag.trim().toLowerCase()).join('|')}
data-article-index={index}
class:list={[!isVisible && 'hidden']}
>
<PostCard post={post} selectedTag={selectedTag} highlightTerm={selectedSearch} />
</div>
);
})}
</div>
) : (
<div class="terminal-empty ml-4 mt-4">
) : null}
<div id="articles-empty-state" class:list={['terminal-empty ml-4 mt-4', paginatedPosts.length > 0 && 'hidden']}>
<div class="mx-auto flex max-w-md flex-col items-center gap-3">
<span class="terminal-section-icon">
<i class="fas fa-folder-open"></i>
@@ -222,38 +274,237 @@ const buildArticlesUrl = ({
</a>
</div>
</div>
)}
</div>
{totalPages > 1 && (
<div class="px-4 py-6">
<div class="terminal-panel-muted ml-4 mt-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="px-4 py-6">
<div id="articles-pagination" class:list={['terminal-panel-muted ml-4 mt-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between', totalPages <= 1 && 'hidden']}>
<span class="text-sm text-[var(--text-secondary)]">
{t('articlesPage.pageSummary', { current: currentPage, total: totalPages, count: totalPosts })}
<span id="articles-page-summary">{t('articlesPage.pageSummary', { current: currentPage, total: totalPages, count: totalPosts })}</span>
</span>
<div class="flex flex-wrap gap-2">
{currentPage > 1 && (
<a
href={buildArticlesUrl({ page: currentPage - 1 })}
class="terminal-action-button"
<button
id="articles-prev-btn"
type="button"
class:list={['terminal-action-button', currentPage <= 1 && 'hidden']}
>
<i class="fas fa-chevron-left"></i>
<span>{t('articlesPage.previous')}</span>
</a>
)}
{currentPage < totalPages && (
<a
href={buildArticlesUrl({ page: currentPage + 1 })}
class="terminal-action-button terminal-action-button-primary"
</button>
<button
id="articles-next-btn"
type="button"
class:list={['terminal-action-button terminal-action-button-primary', currentPage >= totalPages && 'hidden']}
>
<span>{t('articlesPage.next')}</span>
<i class="fas fa-chevron-right"></i>
</a>
)}
</button>
</div>
</div>
</div>
)}
</TerminalWindow>
</div>
</BaseLayout>
<script
is:inline
define:vars={{
postsPerPage,
selectedSearch,
categoryAccentMap,
tagAccentMap,
initialArticlesState: {
type: selectedType,
category: selectedCategory,
tag: selectedTag,
page: currentPage,
},
}}
>
(function() {
/** @type {Window['__termiCommandPrompt']} */
let promptApi;
const articleCards = Array.from(document.querySelectorAll('[data-article-card]'));
const typeFilters = Array.from(document.querySelectorAll('[data-articles-type]'));
const categoryFilters = Array.from(document.querySelectorAll('[data-articles-category]'));
const tagFilters = Array.from(document.querySelectorAll('[data-articles-tag]'));
const totalPostsEl = document.getElementById('articles-total-posts');
const categoryPill = document.getElementById('articles-current-category-pill');
const categoryText = document.getElementById('articles-current-category');
const tagPill = document.getElementById('articles-current-tag-pill');
const tagText = document.getElementById('articles-current-tag');
const emptyState = document.getElementById('articles-empty-state');
const pagination = document.getElementById('articles-pagination');
const pageSummary = document.getElementById('articles-page-summary');
const prevBtn = document.getElementById('articles-prev-btn');
const nextBtn = document.getElementById('articles-next-btn');
const t = window.__termiTranslate;
promptApi = window.__termiCommandPrompt;
const state = {
type: initialArticlesState.type || 'all',
category: initialArticlesState.category || '',
tag: initialArticlesState.tag || '',
page: Number(initialArticlesState.page || 1),
};
function updateArticlePrompts() {
const typeCommand = state.type === 'all'
? 'grep -E "^type: (article|tweet)$" ./posts/*.md'
: `grep -E "^type: ${state.type}$" ./posts/*.md`;
const categoryCommand = state.category
? `grep -El "^category: ${state.category}$" ./posts/*.md`
: 'cut -d: -f2 ./categories.index | sort -u';
const tagCommand = state.tag
? `grep -Ril "#${state.tag}" ./posts`
: 'cut -d: -f2 ./tags.index | sort -u';
promptApi?.set?.('articles-type-prompt', typeCommand, { typing: false });
promptApi?.set?.('articles-category-prompt', categoryCommand, { typing: false });
promptApi?.set?.('articles-tag-prompt', tagCommand, { typing: false });
}
function syncActiveFilters(elements, key, emptyValue = '') {
elements.forEach((element) => {
const value = (element.getAttribute(key) || '').trim();
const activeValue =
key === 'data-articles-type'
? state.type
: key === 'data-articles-category'
? state.category
: state.tag;
element.classList.toggle('is-active', value === (activeValue || emptyValue));
});
}
function updateSummaryPills() {
if (categoryPill && categoryText) {
if (state.category) {
categoryPill.classList.remove('hidden');
categoryText.textContent = state.category;
categoryPill.setAttribute('style', categoryAccentMap[String(state.category).toLowerCase()] || '');
} else {
categoryPill.classList.add('hidden');
categoryPill.removeAttribute('style');
}
}
if (tagPill && tagText) {
if (state.tag) {
tagPill.classList.remove('hidden');
tagText.textContent = state.tag;
tagPill.setAttribute('style', tagAccentMap[String(state.tag).toLowerCase()] || '');
} else {
tagPill.classList.add('hidden');
tagPill.removeAttribute('style');
}
}
}
function updateUrl(totalPages) {
const params = new URLSearchParams();
if (state.type && state.type !== 'all') params.set('type', state.type);
if (selectedSearch) params.set('search', selectedSearch);
if (state.tag) params.set('tag', state.tag);
if (state.category) params.set('category', state.category);
if (state.page > 1 && totalPages > 1) params.set('page', String(state.page));
const nextUrl = params.toString() ? `/articles?${params.toString()}` : '/articles';
window.history.replaceState({}, '', nextUrl);
}
function applyArticleFilters(pushHistory = true) {
const filtered = articleCards.filter((card) => {
const type = card.getAttribute('data-article-type') || '';
const category = (card.getAttribute('data-article-category') || '').toLowerCase();
const tags = `|${(card.getAttribute('data-article-tags') || '').toLowerCase()}|`;
const typeMatch = state.type === 'all' || type === state.type;
const categoryMatch = !state.category || category === state.category.toLowerCase();
const tagMatch = !state.tag || tags.includes(`|${state.tag.toLowerCase()}|`);
return typeMatch && categoryMatch && tagMatch;
});
const total = filtered.length;
const totalPages = Math.max(Math.ceil(total / postsPerPage), 1);
if (state.page > totalPages) state.page = totalPages;
if (state.page < 1) state.page = 1;
const startIndex = (state.page - 1) * postsPerPage;
const endIndex = startIndex + postsPerPage;
articleCards.forEach((card) => card.classList.add('hidden'));
filtered.slice(startIndex, endIndex).forEach((card) => card.classList.remove('hidden'));
syncActiveFilters(typeFilters, 'data-articles-type', 'all');
syncActiveFilters(categoryFilters, 'data-articles-category', '');
syncActiveFilters(tagFilters, 'data-articles-tag', '');
updateSummaryPills();
updateArticlePrompts();
if (totalPostsEl) {
totalPostsEl.textContent = t('articlesPage.totalPosts', { count: total });
}
if (emptyState) {
emptyState.classList.toggle('hidden', total > 0);
}
if (pagination) {
pagination.classList.toggle('hidden', totalPages <= 1);
}
if (pageSummary) {
pageSummary.textContent = t('articlesPage.pageSummary', { current: state.page, total: totalPages, count: total });
}
if (prevBtn) {
prevBtn.classList.toggle('hidden', state.page <= 1 || totalPages <= 1);
}
if (nextBtn) {
nextBtn.classList.toggle('hidden', state.page >= totalPages || totalPages <= 1);
}
if (pushHistory) {
updateUrl(totalPages);
}
}
typeFilters.forEach((filter) => {
filter.addEventListener('click', (event) => {
event.preventDefault();
state.type = filter.getAttribute('data-articles-type') || 'all';
state.page = 1;
applyArticleFilters();
});
});
categoryFilters.forEach((filter) => {
filter.addEventListener('click', (event) => {
event.preventDefault();
state.category = filter.getAttribute('data-articles-category') || '';
state.page = 1;
applyArticleFilters();
});
});
tagFilters.forEach((filter) => {
filter.addEventListener('click', (event) => {
event.preventDefault();
state.tag = filter.getAttribute('data-articles-tag') || '';
state.page = 1;
applyArticleFilters();
});
});
prevBtn?.addEventListener('click', () => {
state.page -= 1;
applyArticleFilters();
});
nextBtn?.addEventListener('click', () => {
state.page += 1;
applyArticleFilters();
});
applyArticleFilters(false);
})();
</script>

View File

@@ -1,6 +1,7 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
import { API_BASE_URL, api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n';
export const prerender = false;
@@ -33,7 +34,7 @@ const sampleQuestions = [
<div class="rounded-3xl border border-[var(--border-color)] bg-[var(--terminal-bg)] shadow-[0_28px_90px_rgba(15,23,42,0.08)] overflow-hidden">
<div class="flex items-center justify-between gap-4 border-b border-[var(--border-color)] px-5 py-4">
<div>
<div class="text-xs uppercase tracking-[0.26em] text-[var(--text-tertiary)]">knowledge terminal</div>
<div class="text-xs uppercase tracking-[0.26em] text-[var(--text-tertiary)]">{t('ask.terminalLabel')}</div>
<h1 class="mt-2 text-2xl font-bold text-[var(--title-color)]">{t('ask.title')}</h1>
<p class="mt-2 text-sm text-[var(--text-secondary)]">{t('ask.subtitle')}</p>
</div>
@@ -52,14 +53,14 @@ const sampleQuestions = [
{aiEnabled ? (
<>
<form id="ai-form" class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-4">
<label class="mb-3 block text-xs uppercase tracking-[0.24em] text-[var(--text-tertiary)]">user@blog:~/ask$ ./answer</label>
<CommandPrompt promptId="ask-session-prompt" command={t('ask.promptIdle')} path="~/ask" />
<textarea
id="ai-question"
class="min-h-[140px] w-full resize-y rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)] px-4 py-3 font-mono text-sm text-[var(--text)] outline-none transition focus:border-[var(--primary)]"
placeholder={t('ask.textareaPlaceholder')}
></textarea>
<div class="mt-4 flex flex-wrap items-center gap-3">
<button type="submit" id="ai-submit" class="inline-flex items-center gap-2 rounded-full border border-[var(--primary)]/35 bg-[var(--primary)]/10 px-4 py-2 text-sm font-medium text-[var(--primary)] transition hover:bg-[var(--primary)]/16">
<button type="submit" id="ai-submit" class="terminal-action-button terminal-action-button-primary">
<i class="fas fa-terminal text-xs"></i>
<span>{t('ask.submit')}</span>
</button>
@@ -69,7 +70,7 @@ const sampleQuestions = [
<div id="ai-result" class="mt-6 hidden rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/65 p-5">
<div class="flex items-center justify-between gap-3">
<div class="text-xs uppercase tracking-[0.24em] text-[var(--text-tertiary)]">assistant@blog</div>
<div class="text-xs uppercase tracking-[0.24em] text-[var(--text-tertiary)]">{t('ask.assistantLabel')}</div>
<div id="ai-meta" class="text-xs text-[var(--text-tertiary)]"></div>
</div>
<div id="ai-answer" class="terminal-document mt-4"></div>
@@ -78,7 +79,7 @@ const sampleQuestions = [
</>
) : (
<div class="rounded-2xl border border-dashed border-[var(--border-color)] bg-[var(--bg)]/55 px-5 py-8">
<div class="text-xs uppercase tracking-[0.24em] text-[var(--text-tertiary)]">feature disabled</div>
<div class="text-xs uppercase tracking-[0.24em] text-[var(--text-tertiary)]">{t('ask.disabledStateLabel')}</div>
<h2 class="mt-3 text-xl font-semibold text-[var(--title-color)]">{t('ask.disabledTitle')}</h2>
<p class="mt-3 max-w-2xl text-sm leading-7 text-[var(--text-secondary)]">
{t('ask.disabledDescription')}
@@ -94,7 +95,7 @@ const sampleQuestions = [
{sampleQuestions.map((question) => (
<button
type="button"
class="sample-question w-full rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)] px-3 py-2 text-left text-sm text-[var(--text-secondary)] transition hover:border-[var(--primary)]/30 hover:text-[var(--text)]"
class="sample-question terminal-option-card"
data-question={question}
>
{question}
@@ -104,11 +105,11 @@ const sampleQuestions = [
</div>
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/60 p-4">
<div class="text-xs uppercase tracking-[0.22em] text-[var(--text-tertiary)]">{t('ask.workflow')}</div>
<div class="text-xs uppercase tracking-[0.22em] text-[var(--text-tertiary)]">{t('ask.guide')}</div>
<ol class="mt-4 space-y-2 text-sm leading-7 text-[var(--text-secondary)]">
<li>{t('ask.workflow1')}</li>
<li>{t('ask.workflow2')}</li>
<li>{t('ask.workflow3')}</li>
<li>{t('ask.guide1')}</li>
<li>{t('ask.guide2')}</li>
<li>{t('ask.guide3')}</li>
</ol>
</div>
</aside>
@@ -118,7 +119,7 @@ const sampleQuestions = [
</BaseLayout>
{aiEnabled && (
<script is:inline define:vars={{ apiBase: 'http://localhost:5150/api' }}>
<script is:inline define:vars={{ apiBase: API_BASE_URL }}>
const t = window.__termiTranslate;
const form = document.getElementById('ai-form');
const input = document.getElementById('ai-question');
@@ -131,6 +132,11 @@ const sampleQuestions = [
const sampleButtons = Array.from(document.querySelectorAll('.sample-question'));
const answerCache = new Map();
const prefilledQuestion = new URLSearchParams(window.location.search).get('q')?.trim() || '';
const promptApi = window.__termiCommandPrompt;
function updatePrompt(text, typing = true) {
promptApi?.set?.('ask-session-prompt', text, { typing });
}
function escapeHtml(value) {
return String(value || '')
@@ -226,15 +232,47 @@ const sampleQuestions = [
sources.innerHTML = `
<div class="text-xs uppercase tracking-[0.22em] text-[var(--text-tertiary)]">${escapeHtml(t('ask.sources'))}</div>
${items.map((item) => `
<a href="/articles/${encodeURIComponent(item.slug)}" class="block rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)] px-4 py-3 transition hover:border-[var(--primary)]/35">
<div class="flex items-center justify-between gap-3">
<div class="text-sm font-semibold text-[var(--title-color)]">${escapeHtml(item.title)}</div>
<div class="text-[11px] font-mono text-[var(--text-tertiary)]">score ${escapeHtml(item.score)}</div>
</div>
<div class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">${escapeHtml(item.excerpt)}</div>
</a>
`).join('')}
<div class="grid gap-3">
${items.map((item) => `
<a
href="${escapeHtml(item.href || `/articles/${encodeURIComponent(item.slug)}`)}"
class="post-card terminal-panel group relative block p-4 transition-all duration-300 hover:-translate-y-1 hover:border-[var(--primary)]"
style="--post-border-color: var(--primary);"
>
<div class="absolute bottom-4 left-0 top-4 w-1 rounded-full opacity-80" style="background-color: var(--primary);"></div>
<div class="relative z-10 mb-2 flex flex-col gap-2.5 pl-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 flex-1">
<div class="mb-2 flex items-center gap-2">
<span class="h-3 w-3 shrink-0 rounded-full bg-[var(--primary)]"></span>
<span class="terminal-chip terminal-chip--accent shrink-0 px-2 py-1 text-[10px]" style="--accent-rgb: var(--primary-rgb); --accent-color: var(--primary);">
${escapeHtml(t('common.article'))}
</span>
<h3 class="truncate text-base font-bold text-[var(--title-color)] transition group-hover:text-[var(--primary)]">
${escapeHtml(item.title)}
</h3>
</div>
<p class="text-sm text-[var(--text-secondary)]">
${escapeHtml(t('ask.sourceScore', { score: item.score }))}
</p>
</div>
<span class="terminal-chip shrink-0 px-2.5 py-1 text-xs">
#${escapeHtml(t('ask.sources'))}
</span>
</div>
<p class="relative z-10 mb-3 pl-3 text-sm leading-7 text-[var(--text-secondary)]">${escapeHtml(item.excerpt)}</p>
<div class="relative z-10 mt-3 pl-3">
<span class="terminal-action-button inline-flex">
<i class="fas fa-angle-right"></i>
<span>${escapeHtml(t('common.readMore'))}</span>
</span>
</div>
</a>
`).join('')}
</div>
`;
}
@@ -255,8 +293,12 @@ const sampleQuestions = [
function renderResult(data) {
result.classList.remove('hidden');
answer.innerHTML = renderMarkdown(data.answer);
meta.textContent = `chunks ${data.indexed_chunks}${data.last_indexed_at ? ` · indexed ${data.last_indexed_at}` : ''}`;
const relatedCount = Array.isArray(data.sources) ? data.sources.length : 0;
meta.textContent = data.last_indexed_at
? t('ask.metaSourcesWithTime', { count: relatedCount, time: data.last_indexed_at })
: t('ask.metaSources', { count: relatedCount });
renderSources(data.sources || []);
updatePrompt(t('ask.promptComplete', { count: relatedCount }), false);
}
function takeNextSseEvent(buffer) {
@@ -350,6 +392,7 @@ const sampleQuestions = [
const trimmed = String(question || '').trim();
if (!trimmed) {
status.textContent = t('ask.enterQuestion');
updatePrompt(t('ask.promptIdle'));
input?.focus();
return;
}
@@ -358,6 +401,7 @@ const sampleQuestions = [
if (cached) {
renderResult(cached);
status.textContent = t('ask.cacheRestored');
updatePrompt(t('ask.promptComplete', { count: Array.isArray(cached.sources) ? cached.sources.length : 0 }), false);
return;
}
@@ -366,6 +410,7 @@ const sampleQuestions = [
answer.innerHTML = `<p>${escapeHtml(t('ask.connecting'))}</p>`;
sources.innerHTML = '';
meta.textContent = '';
updatePrompt(t('ask.promptSubmitting'));
try {
const response = await fetch(`${apiBase}/ai/ask/stream`, {
@@ -386,6 +431,7 @@ const sampleQuestions = [
await readStreamResponse(response, {
status: () => {
status.textContent = t('ask.processing');
updatePrompt(t('ask.promptSubmitting'), false);
},
delta: (payload) => {
streamedAnswer += payload.delta || '';
@@ -407,10 +453,11 @@ const sampleQuestions = [
}
} catch (error) {
result.classList.remove('hidden');
answer.innerHTML = `<p>${escapeHtml(t('ask.requestFailed', { message: error?.message || 'unknown error' }))}</p>`;
answer.innerHTML = `<p>${escapeHtml(t('ask.requestFailed', { message: error?.message || t('common.unknownError') }))}</p>`;
meta.textContent = '';
sources.innerHTML = '';
status.textContent = t('ask.retryLater');
updatePrompt(t('ask.promptFailed'), false);
} finally {
setInteractiveState(false);
}
@@ -427,16 +474,30 @@ const sampleQuestions = [
if (input) {
input.value = question;
}
updatePrompt(t('ask.promptEditing'));
ask(question);
});
});
input?.addEventListener('input', () => {
const question = input.value.trim();
if (!question) {
updatePrompt(t('ask.promptIdle'), false);
return;
}
updatePrompt(t('ask.promptEditing'), false);
});
if (prefilledQuestion) {
if (input) {
input.value = prefilledQuestion;
input.focus();
}
status.textContent = t('ask.prefixedQuestion');
updatePrompt(t('ask.promptEditing'), false);
} else {
updatePrompt(t('ask.promptIdle'), false);
}
</script>
)}

View File

@@ -2,26 +2,49 @@
import BaseLayout from '../../layouts/BaseLayout.astro';
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
import PostCard from '../../components/PostCard.astro';
import { api } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n';
import type { Post } from '../../lib/types';
import { getAccentVars, getCategoryTheme } from '../../lib/utils';
export const prerender = false;
let categories: Awaited<ReturnType<typeof api.getCategories>> = [];
let allPosts: Post[] = [];
const url = new URL(Astro.request.url);
const selectedCategory = url.searchParams.get('category') || '';
const normalizedSelectedCategory = selectedCategory.trim().toLowerCase();
const { t } = getI18n(Astro);
try {
categories = await api.getCategories();
[categories, allPosts] = await Promise.all([
api.getCategories(),
api.getPosts(),
]);
} catch (error) {
console.error('Failed to fetch categories:', error);
}
const filteredPosts = selectedCategory
? allPosts.filter((post) => (post.category || '').trim().toLowerCase() === normalizedSelectedCategory)
: [];
const categoryPromptCommand = selectedCategory
? `grep -El "^category: ${selectedCategory}$" ./posts/*.md`
: 'cut -d: -f2 ./categories.index | sort -u';
const resultsPromptCommand = selectedCategory
? `find ./posts -type f | xargs grep -il "^category: ${selectedCategory}$"`
: 'find ./posts -type f | sort';
const categoryAccentMap = Object.fromEntries(
categories.map((category) => [category.name.trim().toLowerCase(), getAccentVars(getCategoryTheme(category.name))])
);
---
<BaseLayout title={`${t('categories.pageTitle')} - Termi`}>
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TerminalWindow title="~/categories" class="w-full">
<div class="mb-6 px-4">
<CommandPrompt command="ls -la ./categories" />
<CommandPrompt command="find ./categories -maxdepth 1 -type d | sort" />
<div class="terminal-panel ml-4 mt-4">
<div class="terminal-kicker">content taxonomy</div>
<div class="terminal-section-title mt-4">
@@ -44,55 +67,262 @@ try {
<i class="fas fa-terminal text-[var(--primary)]"></i>
<span>{t('categories.quickJump')}</span>
</span>
<span
id="categories-current-pill"
class:list={['terminal-stat-pill terminal-stat-pill--accent', !selectedCategory && 'hidden']}
style={selectedCategory ? getAccentVars(getCategoryTheme(selectedCategory)) : undefined}
>
<i class="fas fa-folder-open"></i>
<span id="categories-current-label">{selectedCategory}</span>
</span>
</div>
</div>
</div>
<div class="px-4">
{categories.length > 0 ? (
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{categories.map(category => (
<div class="px-4 space-y-6">
<div class="ml-4">
<CommandPrompt promptId="categories-filter-prompt" command={categoryPromptCommand} typing={false} />
{categories.length > 0 ? (
<div class="mt-4 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<a
href={`/articles?category=${encodeURIComponent(category.name)}`}
class="terminal-panel group p-5 transition-all duration-300 hover:-translate-y-1 hover:border-[var(--primary)]"
href="/categories"
data-category-filter=""
class:list={[
'terminal-panel terminal-interactive-card group p-5',
!selectedCategory && 'is-active'
]}
>
<div class="flex items-start gap-4">
<div class="shrink-0 flex h-12 w-12 items-center justify-center rounded-2xl border border-[var(--border-color)] bg-[var(--code-bg)]">
<i class={`fas fa-folder-open text-[var(--primary)] text-lg`}></i>
<div class="terminal-accent-icon shrink-0 flex h-12 w-12 items-center justify-center rounded-2xl border">
<i class="fas fa-layer-group text-lg"></i>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-3 mb-2">
<div>
<div class="text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)] mb-1">
{category.slug || category.name}
all
</div>
<h3 class="font-bold text-[var(--title-color)] group-hover:text-[var(--primary)] transition-colors text-lg">
{category.name}
<h3 class="font-bold text-[var(--title-color)] transition-colors text-lg">
{t('articlesPage.allCategories')}
</h3>
</div>
<span class="terminal-chip text-xs py-1 px-2.5">
<span>{t('common.postsCount', { count: category.count })}</span>
<span>{t('common.postsCount', { count: allPosts.length })}</span>
</span>
</div>
<p class="text-sm leading-6 text-[var(--text-secondary)]">
{t('categories.categoryPosts', { name: category.name })}
{t('categories.allCategoriesDescription')}
</p>
<div class="mt-4 terminal-link-arrow">
<span>{t('common.viewCategoryArticles')}</span>
<i class="fas fa-arrow-right text-xs"></i>
</div>
</div>
</div>
</a>
))}
{categories.map((category) => (
<a
href={`/categories?category=${encodeURIComponent(category.name)}`}
data-category-filter={category.name}
class:list={[
'terminal-panel terminal-panel-accent terminal-interactive-card group p-5',
normalizedSelectedCategory === category.name.trim().toLowerCase() && 'is-active'
]}
style={getAccentVars(getCategoryTheme(category.name))}
>
<div class="flex items-start gap-4">
<div class="terminal-accent-icon shrink-0 flex h-12 w-12 items-center justify-center rounded-2xl border">
<i class="fas fa-folder-open text-lg"></i>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-3 mb-2">
<div>
<div class="text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)] mb-1">
{category.slug || category.name}
</div>
<h3 class="font-bold text-[var(--title-color)] group-hover:text-[var(--primary)] transition-colors text-lg">
{category.name}
</h3>
</div>
<span class="terminal-chip terminal-chip--accent text-xs py-1 px-2.5" style={getAccentVars(getCategoryTheme(category.name))}>
<span>{t('common.postsCount', { count: category.count })}</span>
</span>
</div>
<p class="text-sm leading-6 text-[var(--text-secondary)]">
{t('categories.categoryPosts', { name: category.name })}
</p>
</div>
</div>
</a>
))}
</div>
) : (
<div class="terminal-empty mt-4">
<i class="fas fa-inbox text-4xl mb-4 opacity-50 text-[var(--primary)]"></i>
<p class="text-[var(--text-secondary)]">{t('categories.empty')}</p>
</div>
)}
</div>
<div id="categories-results-wrap" class:list={['ml-4', !selectedCategory && 'hidden']}>
<CommandPrompt promptId="categories-results-prompt" command={resultsPromptCommand} typing={false} />
<div class="mt-4 terminal-panel">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p id="categories-selected-summary" class="text-sm leading-6 text-[var(--text-secondary)]">
{selectedCategory ? t('categories.selectedSummary', { name: selectedCategory, count: filteredPosts.length }) : ''}
</p>
<a id="categories-clear-btn" href="/categories" class:list={['terminal-link-arrow', !selectedCategory && 'hidden']}>
<span>{t('common.clearFilters')}</span>
<i class="fas fa-rotate-left text-xs"></i>
</a>
</div>
</div>
) : (
<div class="terminal-empty">
<i class="fas fa-inbox text-4xl mb-4 opacity-50 text-[var(--primary)]"></i>
<p class="text-[var(--text-secondary)]">{t('categories.empty')}</p>
</div>
<div class="ml-4">
{allPosts.length > 0 ? (
<div id="categories-posts-list" class:list={['divide-y divide-[var(--border-color)]', !selectedCategory && 'hidden']}>
{allPosts.map((post) => {
const matchesInitial = selectedCategory
? (post.category || '').trim().toLowerCase() === normalizedSelectedCategory
: false;
return (
<div
data-category-post
data-category-name={(post.category || '').trim().toLowerCase()}
class:list={[!matchesInitial && 'hidden']}
>
<PostCard post={post} />
</div>
);
})}
</div>
) : null}
<div id="categories-empty-state" class:list={['terminal-empty', (!selectedCategory || filteredPosts.length > 0) && 'hidden']}>
<i class="fas fa-search text-4xl mb-4 opacity-50 text-[var(--primary)]"></i>
<p class="text-[var(--text-secondary)]">{t('categories.emptyPosts')}</p>
</div>
)}
</div>
</div>
</TerminalWindow>
</div>
</BaseLayout>
<script
is:inline
define:vars={{
categoryAccentMap,
initialSelectedCategory: selectedCategory,
}}
>
(function() {
/** @type {Window['__termiCommandPrompt']} */
let promptApi;
const categoryButtons = Array.from(document.querySelectorAll('[data-category-filter]'));
const postCards = Array.from(document.querySelectorAll('[data-category-post]'));
const currentPill = document.getElementById('categories-current-pill');
const currentLabel = document.getElementById('categories-current-label');
const resultsWrap = document.getElementById('categories-results-wrap');
const selectedSummary = document.getElementById('categories-selected-summary');
const postsList = document.getElementById('categories-posts-list');
const emptyState = document.getElementById('categories-empty-state');
const clearBtn = document.getElementById('categories-clear-btn');
const t = window.__termiTranslate;
promptApi = window.__termiCommandPrompt;
const state = {
category: initialSelectedCategory || '',
};
function updatePrompts() {
const filterCommand = state.category
? `grep -El "^category: ${state.category}$" ./posts/*.md`
: 'cut -d: -f2 ./categories.index | sort -u';
const resultsCommand = state.category
? `find ./posts -type f | xargs grep -il "^category: ${state.category}$"`
: 'find ./posts -type f | sort';
promptApi?.set?.('categories-filter-prompt', filterCommand, { typing: false });
promptApi?.set?.('categories-results-prompt', resultsCommand, { typing: false });
}
function updateUrl() {
const params = new URLSearchParams();
if (state.category) params.set('category', state.category);
const nextUrl = params.toString() ? `/categories?${params.toString()}` : '/categories';
window.history.replaceState({}, '', nextUrl);
}
function syncButtons() {
const activeValue = state.category.trim().toLowerCase();
categoryButtons.forEach((button) => {
const value = (button.getAttribute('data-category-filter') || '').trim().toLowerCase();
const isActive = activeValue ? value === activeValue : value === '';
button.classList.toggle('is-active', isActive);
});
}
function applyCategoryFilter(pushHistory = true) {
const activeValue = state.category.trim().toLowerCase();
let visibleCount = 0;
postCards.forEach((card) => {
const value = (card.getAttribute('data-category-name') || '').trim().toLowerCase();
const matches = Boolean(activeValue) && value === activeValue;
card.classList.toggle('hidden', !matches);
if (matches) {
visibleCount += 1;
}
});
syncButtons();
updatePrompts();
if (currentPill && currentLabel) {
currentPill.classList.toggle('hidden', !state.category);
if (state.category) {
currentLabel.textContent = state.category;
currentPill.setAttribute('style', categoryAccentMap[String(state.category).trim().toLowerCase()] || '');
} else {
currentLabel.textContent = '';
currentPill.removeAttribute('style');
}
}
resultsWrap?.classList.toggle('hidden', !state.category);
postsList?.classList.toggle('hidden', !state.category);
emptyState?.classList.toggle('hidden', !state.category || visibleCount > 0);
clearBtn?.classList.toggle('hidden', !state.category);
if (selectedSummary) {
selectedSummary.textContent = state.category
? t('categories.selectedSummary', { name: state.category, count: visibleCount })
: '';
}
if (pushHistory) {
updateUrl();
}
}
categoryButtons.forEach((button) => {
button.addEventListener('click', (event) => {
event.preventDefault();
const nextValue = button.getAttribute('data-category-filter') || '';
const normalizedCurrent = state.category.trim().toLowerCase();
state.category = normalizedCurrent === nextValue.trim().toLowerCase() ? '' : nextValue;
applyCategoryFilter();
});
});
clearBtn?.addEventListener('click', (event) => {
event.preventDefault();
state.category = '';
applyCategoryFilter();
});
applyCategoryFilter(false);
})();
</script>

View File

@@ -37,7 +37,7 @@ const groupedLinks = categories.map(category => ({
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TerminalWindow title="~/friends" class="w-full">
<div class="mb-6 px-4">
<CommandPrompt command="cat ./friends.txt" />
<CommandPrompt command={t('friends.promptBrowse')} />
<div class="terminal-panel ml-4 mt-4">
<div class="terminal-kicker">network map</div>
<div class="terminal-section-title mt-4">
@@ -97,14 +97,14 @@ const groupedLinks = categories.map(category => ({
</div>
<div class="px-4 mt-8 pt-8 border-t border-[var(--border-color)]">
<CommandPrompt command="./apply_friend_link.sh" />
<CommandPrompt command={t('friends.promptApply')} />
<div class="ml-4 mt-4">
<FriendLinkApplication siteSettings={siteSettings} />
</div>
</div>
<div class="px-4 mt-8 pt-8 border-t border-[var(--border-color)]">
<CommandPrompt command="cat ./exchange_info.txt" />
<CommandPrompt command={t('friends.promptRules')} />
<div class="terminal-panel ml-4 mt-4">
<div class="terminal-kicker">exchange rules</div>
<h3 class="mt-4 font-bold text-[var(--title-color)] text-lg">{t('friends.exchangeRules')}</h3>

View File

@@ -13,15 +13,22 @@ import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client';
import { formatReadTime, getI18n } from '../lib/i18n';
import type { AppFriendLink } from '../lib/api/client';
import type { Post } from '../lib/types';
import { getAccentVars, getCategoryTheme, getPostTypeTheme, getTagTheme } from '../lib/utils';
export const prerender = false;
const url = new URL(Astro.request.url);
const selectedType = url.searchParams.get('type') || 'all';
const selectedTag = url.searchParams.get('tag') || '';
const selectedCategory = url.searchParams.get('category') || '';
const normalizedSelectedTag = selectedTag.trim().toLowerCase();
const normalizedSelectedCategory = selectedCategory.trim().toLowerCase();
const previewLimit = 5;
let siteSettings = DEFAULT_SITE_SETTINGS;
let allPosts: Post[] = [];
let recentPosts: Post[] = [];
let filteredPostsCount = 0;
let pinnedPost: Post | null = null;
let tags: string[] = [];
let friendLinks: AppFriendLink[] = [];
@@ -32,12 +39,17 @@ const { locale, t } = getI18n(Astro);
try {
siteSettings = await api.getSiteSettings();
allPosts = await api.getPosts();
const filteredPosts = selectedType === 'all'
? allPosts
: allPosts.filter(post => post.type === selectedType);
const filteredPosts = allPosts.filter(post => {
const normalizedCategory = post.category?.trim().toLowerCase() || '';
if (selectedType !== 'all' && post.type !== selectedType) return false;
if (selectedCategory && normalizedCategory !== normalizedSelectedCategory) return false;
if (selectedTag && !post.tags.some(tag => tag.trim().toLowerCase() === normalizedSelectedTag)) return false;
return true;
});
recentPosts = filteredPosts.slice(0, 5);
pinnedPost = filteredPosts.find(post => post.pinned) || null;
recentPosts = filteredPosts.slice(0, previewLimit);
filteredPostsCount = filteredPosts.length;
pinnedPost = allPosts.find(post => post.pinned) || null;
tags = (await api.getTags()).map(tag => tag.name);
friendLinks = (await api.getFriendLinks()).filter(friend => friend.status === 'approved');
categories = await api.getCategories();
@@ -54,12 +66,74 @@ const systemStats = [
];
const techStack = siteSettings.techStack.map(name => ({ name }));
const tagFrequency = new Map<string, number>();
for (const post of allPosts) {
for (const tag of post.tags) {
tagFrequency.set(tag, (tagFrequency.get(tag) ?? 0) + 1);
}
}
const tagEntries = tags
.map(name => ({
name,
count: tagFrequency.get(name) ?? 0,
}))
.sort((left, right) => right.count - left.count || left.name.localeCompare(right.name));
const getTagCloudStyle = (name: string) => {
return getAccentVars(getTagTheme(name));
};
const matchesSelectedFilters = (post: Post) => {
const normalizedCategory = post.category?.trim().toLowerCase() || '';
if (selectedType !== 'all' && post.type !== selectedType) return false;
if (selectedCategory && normalizedCategory !== normalizedSelectedCategory) return false;
if (selectedTag && !post.tags.some(tag => tag.trim().toLowerCase() === normalizedSelectedTag)) return false;
return true;
};
const buildHomeUrl = ({
type = selectedType,
tag = selectedTag,
category = selectedCategory,
}: {
type?: string;
tag?: string;
category?: string;
}) => {
const params = new URLSearchParams();
if (type && type !== 'all') params.set('type', type);
if (category) params.set('category', category);
if (tag) params.set('tag', tag);
const query = params.toString();
return query ? `/?${query}` : '/';
};
const homeTagHrefPrefix = `${buildHomeUrl({ tag: '' })}${buildHomeUrl({ tag: '' }).includes('?') ? '&' : '?'}tag=`;
const hasActiveFilters = selectedType !== 'all' || Boolean(selectedCategory) || Boolean(selectedTag);
const previewCount = Math.min(filteredPostsCount, previewLimit);
const categoryAccentMap = Object.fromEntries(
categories.map((category) => [category.name.trim().toLowerCase(), getAccentVars(getCategoryTheme(category.name))])
);
const tagAccentMap = Object.fromEntries(
tagEntries.map((tag) => [tag.name.trim().toLowerCase(), getAccentVars(getTagTheme(tag.name))])
);
const initialPinnedVisible = pinnedPost ? matchesSelectedFilters(pinnedPost) : false;
const postTypeFilters = [
{ id: 'all', name: t('common.all'), icon: 'fa-stream' },
{ id: 'article', name: t('common.article'), icon: 'fa-file-alt' },
{ id: 'tweet', name: t('common.tweet'), icon: 'fa-comment-dots' }
];
const activeFilterLabels = [
selectedType !== 'all' ? `type=${selectedType}` : '',
selectedCategory ? `category=${selectedCategory}` : '',
selectedTag ? `tag=${selectedTag}` : '',
].filter(Boolean);
const discoverPrompt = hasActiveFilters
? t('home.promptDiscoverFiltered', { filters: activeFilterLabels.join(' · ') })
: t('home.promptDiscoverDefault');
const postsPrompt = hasActiveFilters
? t('home.promptPostsFiltered', { count: previewCount, filters: activeFilterLabels.join(' · ') })
: t('home.promptPostsDefault', { count: previewCount });
const navLinks = [
{ icon: 'fa-file-code', text: t('nav.articles'), href: '/articles' },
{ icon: 'fa-folder', text: t('nav.categories'), href: '/categories' },
@@ -68,33 +142,43 @@ const navLinks = [
{ icon: 'fa-star', text: t('nav.reviews'), href: '/reviews' },
{ icon: 'fa-link', text: t('nav.friends'), href: '/friends' },
{ icon: 'fa-user-secret', text: t('nav.about'), href: '/about' },
...(siteSettings.ai.enabled ? [{ icon: 'fa-robot', text: t('nav.ask'), href: '/ask' }] : []),
];
---
<BaseLayout title={siteSettings.siteTitle} description={siteSettings.siteDescription}>
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<TerminalWindow title={terminalConfig.title} class="w-full">
<div class="mb-6 px-4 overflow-x-auto">
<div class="mb-5 px-4 overflow-x-auto">
<pre class="font-mono text-xs sm:text-sm text-[var(--primary)] whitespace-pre">{terminalConfig.asciiArt}</pre>
</div>
<div class="mb-8 px-4">
<CommandPrompt command="cat welcome.txt" />
<div class="ml-4">
<p class="text-lg font-bold text-[var(--primary)] mb-1">{siteSettings.heroTitle}</p>
<p class="text-[var(--text-secondary)]">{siteSettings.heroSubtitle}</p>
</div>
</div>
<div class="mb-6 px-4">
<CommandPrompt command={t('home.promptWelcome')} />
<div class="ml-4 home-hero-shell">
<div class="min-w-0">
<p class="mb-1 text-lg font-bold text-[var(--primary)]">{siteSettings.heroTitle}</p>
<p class="text-sm leading-6 text-[var(--text-secondary)]">{siteSettings.heroSubtitle}</p>
</div>
<div class="mb-8 px-4">
<CommandPrompt command="ls -l" />
<div class="ml-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-4">
<div class="home-hero-meta">
<span class="terminal-stat-pill">
<i class="fas fa-file-alt text-[10px]"></i>
<span id="home-results-count">{t('common.resultsCount', { count: filteredPostsCount })}</span>
</span>
{siteSettings.ai.enabled && (
<a href="/ask" class="terminal-subtle-link">
<i class="fas fa-robot text-[11px]"></i>
<span>{t('nav.ask')}</span>
</a>
)}
</div>
</div>
<div class="ml-4 mt-4 home-nav-strip">
{navLinks.map(link => (
<a
href={link.href}
class="flex items-center gap-2 text-[var(--text)] hover:text-[var(--primary)] transition-colors py-2"
>
<i class={`fas ${link.icon}`}></i>
<a href={link.href} class="home-nav-pill">
<i class={`fas ${link.icon} text-[11px]`}></i>
<span>{link.text}</span>
</a>
))}
@@ -109,38 +193,146 @@ const navLinks = [
</div>
)}
<div id="categories" class="mb-8 px-4">
<CommandPrompt command="ls -l ./categories" />
<div class="ml-4 flex flex-wrap gap-2">
{categories.map(category => (
<FilterPill tone="amber" href={`/articles?category=${encodeURIComponent(category.name)}`}>
<i class="fas fa-folder"></i>
<span>{category.name}</span>
</FilterPill>
))}
</div>
</div>
<div id="discover" class="mb-6 px-4">
<CommandPrompt promptId="home-discover-prompt" command={discoverPrompt} />
<div class="ml-4 terminal-panel home-discovery-shell">
<div class="home-discovery-head">
<div class="home-taxonomy-copy">
<span class="terminal-kicker">
<i class="fas fa-sliders-h"></i>
<span>home filters</span>
</span>
<div>
<h2 class="text-xl font-bold text-[var(--title-color)]">{t('common.browsePosts')}</h2>
<p class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">
{t('common.categoriesCount', { count: categories.length })} · {t('common.tagsCount', { count: tags.length })} · <span id="home-discovery-results">{t('common.resultsCount', { count: filteredPostsCount })}</span>
</p>
</div>
</div>
<div class="mb-4 px-4">
<CommandPrompt command="./filter_posts.sh" />
<div class="ml-4 flex flex-wrap gap-2">
{postTypeFilters.map(filter => (
<FilterPill tone="blue" active={selectedType === filter.id} href={`/?type=${filter.id}`}>
<i class={`fas ${filter.icon}`}></i>
<span>{filter.name}</span>
</FilterPill>
))}
<div class="home-discovery-actions">
<a id="home-clear-filters" href="/" class:list={['terminal-link-arrow', !hasActiveFilters && 'hidden']}>
<span>{t('common.clearFilters')}</span>
<i class="fas fa-rotate-left text-xs"></i>
</a>
<ViewMoreLink href="/articles" text={t('common.viewAllArticles')} command="cd ~/articles" />
</div>
</div>
<div class="home-filter-toolbar">
{postTypeFilters.map(filter => (
<FilterPill
tone={filter.id === 'all' ? 'neutral' : 'accent'}
active={selectedType === filter.id}
href={buildHomeUrl({ type: filter.id, category: selectedCategory, tag: selectedTag })}
data-home-type-filter={filter.id}
style={filter.id === 'all' ? undefined : getAccentVars(getPostTypeTheme(filter.id))}
>
<i class={`fas ${filter.icon}`}></i>
<span>{filter.name}</span>
</FilterPill>
))}
</div>
<div id="home-active-filters" class:list={['home-active-filter-row', !hasActiveFilters && 'hidden']}>
<span id="home-active-type" class:list={['terminal-chip', selectedType === 'all' && 'hidden']}>
<i id="home-active-type-icon" class={`fas ${postTypeFilters.find((item) => item.id === selectedType)?.icon || 'fa-stream'} text-[10px]`}></i>
<span id="home-active-type-text">{postTypeFilters.find((item) => item.id === selectedType)?.name || selectedType}</span>
</span>
<span
id="home-active-category"
class:list={['terminal-chip terminal-chip--accent', !selectedCategory && 'hidden']}
style={selectedCategory ? getAccentVars(getCategoryTheme(selectedCategory)) : undefined}
>
<i class="fas fa-folder-open text-[10px]"></i>
<span id="home-active-category-text">{selectedCategory}</span>
</span>
<span
id="home-active-tag"
class:list={['terminal-chip terminal-chip--accent', !selectedTag && 'hidden']}
style={selectedTag ? getAccentVars(getTagTheme(selectedTag)) : undefined}
>
<i class="fas fa-hashtag text-[10px]"></i>
<span id="home-active-tag-text">{selectedTag}</span>
</span>
</div>
<div class="home-discovery-grid">
<section id="categories" class="home-discovery-panel">
<div class="home-discovery-panel__head">
<h3 class="text-sm font-semibold text-[var(--title-color)]">{t('nav.categories')}</h3>
<span class="terminal-stat-pill">{categories.length}</span>
</div>
<div class="home-category-grid home-category-grid--compact">
{categories.map(category => (
<a
href={buildHomeUrl({
category: normalizedSelectedCategory === category.name.trim().toLowerCase() ? '' : category.name,
tag: selectedTag,
})}
data-home-category-filter={category.name}
class:list={[
'home-category-card terminal-panel-accent terminal-interactive-card group',
normalizedSelectedCategory === category.name.trim().toLowerCase() && 'is-active'
]}
style={getAccentVars(getCategoryTheme(category.name))}
>
<div class="home-category-card__icon terminal-accent-icon">
<i class="fas fa-folder-open"></i>
</div>
<div class="min-w-0 flex flex-1 items-center justify-between gap-3">
<h3 class="truncate text-sm font-semibold text-[var(--title-color)] transition-colors group-hover:text-[var(--accent-color,var(--primary))]">
{category.name}
</h3>
<span class="terminal-stat-pill terminal-stat-pill--accent shrink-0" style={getAccentVars(getCategoryTheme(category.name))}>
<span>{category.count ?? 0}</span>
</span>
</div>
</a>
))}
</div>
</section>
<section id="tags" class="home-discovery-panel home-tag-shell">
<div class="home-discovery-panel__head">
<h3 class="text-sm font-semibold text-[var(--title-color)]">{t('nav.tags')}</h3>
<span class="terminal-stat-pill">{tags.length}</span>
</div>
<div class="home-tag-cloud">
{tagEntries.map((tag) => (
<a
href={buildHomeUrl({
tag: normalizedSelectedTag === tag.name.trim().toLowerCase() ? '' : tag.name,
category: selectedCategory,
})}
data-home-tag-filter={tag.name}
class:list={[
'home-tag-cloud__item',
normalizedSelectedTag === tag.name.trim().toLowerCase() && 'is-active'
]}
style={getTagCloudStyle(tag.name)}
>
<span class="home-tag-cloud__hash">#</span>
<span>{tag.name}</span>
<span class="home-tag-cloud__count">{tag.count}</span>
</a>
))}
</div>
</section>
</div>
</div>
</div>
{pinnedPost && (
<div class="mb-8 px-4">
<CommandPrompt command="cat ./pinned_post.md" />
<div class="ml-4">
<div class="p-4 rounded-lg border border-[var(--border-color)] bg-[var(--header-bg)] transition-colors hover:border-[var(--primary)]">
<div class="mb-6 px-4">
<CommandPrompt command={t('home.promptPinned')} />
<div id="home-pinned-wrap" class:list={['ml-4', !initialPinnedVisible && 'hidden']}>
<div class="terminal-panel terminal-panel-accent terminal-interactive-card p-4" style={getAccentVars(getPostTypeTheme(pinnedPost.type))}>
<div class="flex items-center gap-2 mb-2">
<span class="px-2 py-0.5 text-xs rounded bg-[var(--primary)] text-[var(--terminal-bg)] font-bold">{t('home.pinned')}</span>
<span class="w-3 h-3 rounded-full" style={`background-color: ${pinnedPost.type === 'article' ? 'var(--primary)' : 'var(--secondary)'}`}></span>
<span class="terminal-chip terminal-chip--accent text-[10px] py-1 px-2" style={getAccentVars(getPostTypeTheme(pinnedPost.type))}>
{pinnedPost.type === 'article' ? t('common.article') : t('common.tweet')}
</span>
<a href={`/articles/${pinnedPost.slug}`} class="text-lg font-bold text-[var(--title-color)] transition hover:text-[var(--primary)]">
{pinnedPost.title}
</a>
@@ -159,49 +351,88 @@ const navLinks = [
)}
<div id="posts" class="mb-10 px-4">
<CommandPrompt command="find ./posts -type f -name *.md | head -n 5" />
<CommandPrompt promptId="home-posts-prompt" command={postsPrompt} />
<div class="ml-4">
<div class="divide-y divide-[var(--border-color)]">
{recentPosts.map(post => (
<PostCard post={post} />
))}
</div>
{allPosts.length > 0 ? (
<div id="home-posts-list" class="divide-y divide-[var(--border-color)]">
{allPosts.map((post) => {
const matchesCurrentFilter = matchesSelectedFilters(post);
const filteredIndex = matchesCurrentFilter
? allPosts.filter(matchesSelectedFilters).findIndex((item) => item.slug === post.slug)
: -1;
const isVisible = matchesCurrentFilter && filteredIndex < previewLimit;
return (
<div
data-home-post-card
data-home-type={post.type}
data-home-category={post.category?.trim().toLowerCase() || ''}
data-home-tags={post.tags.map((tag) => tag.trim().toLowerCase()).join('|')}
data-home-pinned={post.pinned ? 'true' : 'false'}
data-home-slug={post.slug}
class:list={[!isVisible && 'hidden']}
>
<PostCard post={post} selectedTag={selectedTag} tagHrefPrefix={homeTagHrefPrefix} />
</div>
);
})}
</div>
) : (
<div id="home-posts-empty" class="terminal-empty">
<p class="text-base font-semibold text-[var(--title-color)]">{t('articlesPage.emptyTitle')}</p>
<p class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">{t('articlesPage.emptyDescription')}</p>
<div class="mt-4 flex flex-wrap justify-center gap-2">
<a href="/" class="terminal-subtle-link">
<i class="fas fa-rotate-left text-[11px]"></i>
<span>{t('common.clearFilters')}</span>
</a>
<a href="/articles" class="terminal-subtle-link">
<i class="fas fa-file-code text-[11px]"></i>
<span>{t('common.viewAllArticles')}</span>
</a>
</div>
</div>
)}
{allPosts.length > 0 && (
<div id="home-posts-empty" class:list={['terminal-empty', recentPosts.length > 0 && 'hidden']}>
<p class="text-base font-semibold text-[var(--title-color)]">{t('articlesPage.emptyTitle')}</p>
<p class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">{t('articlesPage.emptyDescription')}</p>
<div class="mt-4 flex flex-wrap justify-center gap-2">
<a href="/" class="terminal-subtle-link">
<i class="fas fa-rotate-left text-[11px]"></i>
<span>{t('common.clearFilters')}</span>
</a>
<a href="/articles" class="terminal-subtle-link">
<i class="fas fa-file-code text-[11px]"></i>
<span>{t('common.viewAllArticles')}</span>
</a>
</div>
</div>
)}
<div class="mt-4">
<ViewMoreLink href="/articles" text={t('common.viewAllArticles')} />
<ViewMoreLink href="/articles" text={t('common.viewAllArticles')} command="cd ~/articles" />
</div>
</div>
</div>
<div id="tags" class="mb-10 px-4">
<CommandPrompt command="cat ./tags.txt" />
<div class="ml-4 flex flex-wrap gap-2">
{tags.map(tag => (
<FilterPill tone="teal" href={`/tags?tag=${encodeURIComponent(tag)}`}>
<i class="fas fa-hashtag"></i>
<span>{tag}</span>
</FilterPill>
))}
</div>
</div>
<div class="border-t border-[var(--border-color)] my-8"></div>
<div class="border-t border-[var(--border-color)] my-10"></div>
<div id="friends" class="mb-10 px-4">
<CommandPrompt command="cat ./friends.txt" />
<div id="friends" class="mb-8 px-4">
<CommandPrompt command={t('home.promptFriends')} />
<div class="ml-4 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{friendLinks.map(friend => (
<FriendLinkCard friend={friend} />
))}
</div>
<div class="mt-6 ml-4">
<ViewMoreLink href="/friends" text={t('common.viewAllLinks')} />
<ViewMoreLink href="/friends" text={t('common.viewAllLinks')} command="cd ~/friends" />
</div>
</div>
<div class="border-t border-[var(--border-color)] my-10"></div>
<div class="border-t border-[var(--border-color)] my-8"></div>
<div id="about" class="px-4">
<CommandPrompt command="cat about_me.txt" />
<CommandPrompt command={t('home.promptAbout')} />
<div class="ml-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
@@ -222,3 +453,254 @@ const navLinks = [
</TerminalWindow>
</div>
</BaseLayout>
<script
is:inline
define:vars={{
previewLimit,
categoryAccentMap,
tagAccentMap,
initialHomeState: {
type: selectedType,
category: selectedCategory,
tag: selectedTag,
},
}}
>
(function() {
/** @type {Window['__termiCommandPrompt']} */
let promptApi;
const t = window.__termiTranslate;
const typeButtons = Array.from(document.querySelectorAll('[data-home-type-filter]'));
const categoryButtons = Array.from(document.querySelectorAll('[data-home-category-filter]'));
const tagButtons = Array.from(document.querySelectorAll('[data-home-tag-filter]'));
const postCards = Array.from(document.querySelectorAll('[data-home-post-card]'));
const postsRoot = document.getElementById('posts');
const resultsCount = document.getElementById('home-results-count');
const discoveryResults = document.getElementById('home-discovery-results');
const clearFilters = document.getElementById('home-clear-filters');
const activeFilters = document.getElementById('home-active-filters');
const activeType = document.getElementById('home-active-type');
const activeTypeIcon = document.getElementById('home-active-type-icon');
const activeTypeText = document.getElementById('home-active-type-text');
const activeCategory = document.getElementById('home-active-category');
const activeCategoryText = document.getElementById('home-active-category-text');
const activeTag = document.getElementById('home-active-tag');
const activeTagText = document.getElementById('home-active-tag-text');
const emptyState = document.getElementById('home-posts-empty');
const pinnedWrap = document.getElementById('home-pinned-wrap');
promptApi = window.__termiCommandPrompt;
const typeMeta = {
all: { icon: 'fa-stream', label: t('common.all') },
article: { icon: 'fa-file-alt', label: t('common.article') },
tweet: { icon: 'fa-comment-dots', label: t('common.tweet') },
};
const state = {
type: initialHomeState.type || 'all',
category: initialHomeState.category || '',
tag: initialHomeState.tag || '',
};
function getActiveTokens() {
return [
state.type !== 'all' ? `type=${state.type}` : '',
state.category ? `category=${state.category}` : '',
state.tag ? `tag=${state.tag}` : '',
].filter(Boolean);
}
function syncPromptText(total) {
const tokens = getActiveTokens();
const discoverCommand = tokens.length
? t('home.promptDiscoverFiltered', { filters: tokens.join(' · ') })
: t('home.promptDiscoverDefault');
const postsCommand = tokens.length
? t('home.promptPostsFiltered', { count: Math.min(total, previewLimit), filters: tokens.join(' · ') })
: t('home.promptPostsDefault', { count: Math.min(total, previewLimit) });
promptApi?.set?.('home-discover-prompt', discoverCommand, { typing: false });
promptApi?.set?.('home-posts-prompt', postsCommand, { typing: false });
}
function updateUrl() {
const params = new URLSearchParams();
if (state.type && state.type !== 'all') params.set('type', state.type);
if (state.category) params.set('category', state.category);
if (state.tag) params.set('tag', state.tag);
const nextUrl = params.toString() ? `/?${params.toString()}` : '/';
window.history.replaceState({}, '', nextUrl);
}
function syncActiveButtons() {
typeButtons.forEach((button) => {
button.classList.toggle('is-active', (button.getAttribute('data-home-type-filter') || 'all') === state.type);
});
categoryButtons.forEach((button) => {
const value = (button.getAttribute('data-home-category-filter') || '').trim().toLowerCase();
const activeValue = state.category.trim().toLowerCase();
button.classList.toggle('is-active', Boolean(activeValue) && value === activeValue);
});
tagButtons.forEach((button) => {
const value = (button.getAttribute('data-home-tag-filter') || '').trim().toLowerCase();
const activeValue = state.tag.trim().toLowerCase();
button.classList.toggle('is-active', Boolean(activeValue) && value === activeValue);
});
}
function syncActiveSummary() {
const hasActive = state.type !== 'all' || Boolean(state.category) || Boolean(state.tag);
activeFilters?.classList.toggle('hidden', !hasActive);
clearFilters?.classList.toggle('hidden', !hasActive);
if (activeType && activeTypeIcon && activeTypeText) {
const typeInfo = typeMeta[state.type] || typeMeta.all;
activeType.classList.toggle('hidden', state.type === 'all');
activeTypeIcon.className = `fas ${typeInfo.icon} text-[10px]`;
activeTypeText.textContent = typeInfo.label;
}
if (activeCategory && activeCategoryText) {
activeCategory.classList.toggle('hidden', !state.category);
activeCategoryText.textContent = state.category;
if (state.category) {
activeCategory.setAttribute('style', categoryAccentMap[String(state.category).trim().toLowerCase()] || '');
} else {
activeCategory.removeAttribute('style');
}
}
if (activeTag && activeTagText) {
activeTag.classList.toggle('hidden', !state.tag);
activeTagText.textContent = state.tag;
if (state.tag) {
activeTag.setAttribute('style', tagAccentMap[String(state.tag).trim().toLowerCase()] || '');
} else {
activeTag.removeAttribute('style');
}
}
}
function syncPostTagSelection() {
if (!postsRoot) return;
const activeValue = state.tag.trim().toLowerCase();
const tagLinks = postsRoot.querySelectorAll('a[href*="tag="]');
tagLinks.forEach((link) => {
const href = link.getAttribute('href') || '';
let tagValue = '';
try {
tagValue = new URL(href, window.location.origin).searchParams.get('tag')?.trim().toLowerCase() || '';
} catch {
tagValue = '';
}
link.classList.toggle('is-active', Boolean(activeValue) && tagValue === activeValue);
});
}
function applyHomeFilters(pushHistory = true) {
const filtered = postCards.filter((card) => {
const type = card.getAttribute('data-home-type') || '';
const category = (card.getAttribute('data-home-category') || '').trim().toLowerCase();
const tags = `|${(card.getAttribute('data-home-tags') || '').trim().toLowerCase()}|`;
const typeMatch = state.type === 'all' || type === state.type;
const categoryMatch = !state.category || category === state.category.trim().toLowerCase();
const tagMatch = !state.tag || tags.includes(`|${state.tag.trim().toLowerCase()}|`);
return typeMatch && categoryMatch && tagMatch;
});
postCards.forEach((card) => card.classList.add('hidden'));
filtered.slice(0, previewLimit).forEach((card) => card.classList.remove('hidden'));
const total = filtered.length;
const hasPinned = filtered.some((card) => card.getAttribute('data-home-pinned') === 'true');
if (resultsCount) {
resultsCount.textContent = t('common.resultsCount', { count: total });
}
if (discoveryResults) {
discoveryResults.textContent = t('common.resultsCount', { count: total });
}
if (emptyState) {
emptyState.classList.toggle('hidden', total > 0);
}
if (pinnedWrap) {
pinnedWrap.classList.toggle('hidden', !hasPinned);
}
syncActiveButtons();
syncActiveSummary();
syncPostTagSelection();
syncPromptText(total);
if (pushHistory) {
updateUrl();
}
}
typeButtons.forEach((button) => {
button.addEventListener('click', (event) => {
event.preventDefault();
state.type = button.getAttribute('data-home-type-filter') || 'all';
applyHomeFilters();
});
});
categoryButtons.forEach((button) => {
button.addEventListener('click', (event) => {
event.preventDefault();
const nextValue = button.getAttribute('data-home-category-filter') || '';
const normalizedCurrent = state.category.trim().toLowerCase();
state.category = normalizedCurrent === nextValue.trim().toLowerCase() ? '' : nextValue;
applyHomeFilters();
});
});
tagButtons.forEach((button) => {
button.addEventListener('click', (event) => {
event.preventDefault();
const nextValue = button.getAttribute('data-home-tag-filter') || '';
const normalizedCurrent = state.tag.trim().toLowerCase();
state.tag = normalizedCurrent === nextValue.trim().toLowerCase() ? '' : nextValue;
applyHomeFilters();
});
});
clearFilters?.addEventListener('click', (event) => {
event.preventDefault();
state.type = 'all';
state.category = '';
state.tag = '';
applyHomeFilters();
});
postsRoot?.addEventListener('click', (event) => {
const target = event.target instanceof Element ? event.target.closest('a[href*="tag="]') : null;
if (!target) return;
const href = target.getAttribute('href') || '';
try {
const nextTag = new URL(href, window.location.origin).searchParams.get('tag') || '';
if (!nextTag) return;
event.preventDefault();
const normalizedCurrent = state.tag.trim().toLowerCase();
state.tag = normalizedCurrent === nextTag.trim().toLowerCase() ? '' : nextTag;
applyHomeFilters();
} catch {
return;
}
});
applyHomeFilters(false);
})();
</script>

View File

@@ -3,15 +3,21 @@ import Layout from '../../layouts/BaseLayout.astro';
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
import FilterPill from '../../components/ui/FilterPill.astro';
import InfoTile from '../../components/ui/InfoTile.astro';
import { apiClient } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n';
import type { Review } from '../../lib/api/client';
export const prerender = false;
type ReviewStatus = 'completed' | 'in-progress' | 'dropped';
type ParsedReview = Omit<Review, 'tags'> & {
tags: string[];
normalizedStatus: ReviewStatus;
coverIsImage: boolean;
coverUrl: string | null;
linkUrl: string | null;
externalLink: boolean;
};
// Fetch reviews from backend API
@@ -25,10 +31,59 @@ try {
console.error('Failed to fetch reviews:', error);
}
const normalizeReviewStatus = (status: string | null | undefined): ReviewStatus => {
const normalized = String(status || '').trim().toLowerCase();
if (normalized === 'published' || normalized === 'completed' || normalized === 'done') {
return 'completed';
}
if (normalized === 'draft' || normalized === 'in-progress' || normalized === 'watching' || normalized === 'reading' || normalized === 'listening') {
return 'in-progress';
}
if (normalized === 'dropped' || normalized === 'abandoned') {
return 'dropped';
}
return 'completed';
};
const isImageCover = (cover: string | null | undefined) => /^(https?:)?\/\//.test(String(cover || '').trim()) || String(cover || '').trim().startsWith('/');
const reviewCoverCatalog: Record<string, string> = {
'《漫长的季节》': '/review-covers/the-long-season.svg',
'《十三邀》': '/review-covers/thirteen-invites.svg',
'《黑神话:悟空》': '/review-covers/black-myth-wukong.svg',
'《置身事内》': '/review-covers/placed-within.svg',
'《宇宙探索编辑部》': '/review-covers/journey-to-the-west-editorial.svg',
'《疲惫生活中的英雄梦想》': '/review-covers/hero-dreams-in-tired-life.svg',
};
const resolveReviewCover = (review: Review) => {
if (isImageCover(review.cover)) {
return String(review.cover).trim();
}
return reviewCoverCatalog[review.title] || null;
};
const normalizeLinkUrl = (value: string | null | undefined) => {
const trimmed = String(value || '').trim();
return trimmed ? trimmed : null;
};
const isExternalLink = (value: string | null | undefined) => /^(https?:)?\/\//.test(String(value || '').trim());
// Parse tags from JSON string
const parsedReviews: ParsedReview[] = reviews.map(r => ({
...r,
tags: r.tags ? JSON.parse(r.tags) as string[] : []
tags: r.tags ? JSON.parse(r.tags) as string[] : [],
normalizedStatus: normalizeReviewStatus(r.status),
coverIsImage: isImageCover(r.cover),
coverUrl: resolveReviewCover(r),
linkUrl: normalizeLinkUrl(r.link_url),
externalLink: isExternalLink(r.link_url),
}));
const filteredReviews = selectedType === 'all'
@@ -40,9 +95,12 @@ const stats = {
avgRating: filteredReviews.length > 0
? (filteredReviews.reduce((sum, r) => sum + (r.rating || 0), 0) / filteredReviews.length).toFixed(1)
: '0',
completed: filteredReviews.filter(r => r.status === 'completed').length,
inProgress: filteredReviews.filter(r => r.status === 'in-progress').length
completed: filteredReviews.filter(r => r.normalizedStatus === 'completed').length,
inProgress: filteredReviews.filter(r => r.normalizedStatus === 'in-progress').length
};
const highRatingCount = filteredReviews.filter(r => (r.rating || 0) >= 4).length;
const completedRatio = filteredReviews.length > 0 ? Math.round((stats.completed / filteredReviews.length) * 100) : 0;
const inProgressRatio = filteredReviews.length > 0 ? Math.round((stats.inProgress / filteredReviews.length) * 100) : 0;
const filters = [
{ id: 'all', name: t('reviews.typeAll'), icon: 'fa-list', count: parsedReviews.length },
@@ -68,6 +126,57 @@ const typeColors: Record<string, string> = {
book: '#f59e0b',
movie: '#9b59b6'
};
const statusLabels: Record<ReviewStatus, string> = {
completed: t('reviews.statusCompleted'),
'in-progress': t('reviews.statusInProgress'),
dropped: t('reviews.statusDropped'),
};
const statusColors: Record<ReviewStatus, string> = {
completed: 'var(--success)',
'in-progress': 'var(--warning)',
dropped: 'var(--text-tertiary)',
};
const statCards = [
{
id: 'total',
label: t('reviews.total'),
icon: 'fa-layer-group',
color: 'var(--primary)',
value: stats.total,
detail: `>=4 ★ · ${highRatingCount}`,
barWidth: `${stats.total ? Math.max((highRatingCount / stats.total) * 100, 12) : 12}%`,
},
{
id: 'average',
label: t('reviews.average'),
icon: 'fa-star',
color: '#e0a100',
value: stats.avgRating,
detail: '/ 5',
barWidth: `${Math.max((Number(stats.avgRating) / 5) * 100, 8)}%`,
},
{
id: 'completed',
label: t('reviews.completed'),
icon: 'fa-circle-check',
color: 'var(--success)',
value: stats.completed,
detail: `${completedRatio}%`,
barWidth: `${completedRatio}%`,
},
{
id: 'progress',
label: t('reviews.inProgress'),
icon: 'fa-hourglass-half',
color: 'var(--warning)',
value: stats.inProgress,
detail: `${inProgressRatio}%`,
barWidth: `${inProgressRatio}%`,
}
];
---
<Layout title={`${t('reviews.pageTitle')} | Termi`} description={t('reviews.pageDescription')}>
@@ -78,7 +187,7 @@ const typeColors: Record<string, string> = {
<!-- Header Section -->
<div>
<CommandPrompt command="cat README.md" path="~/reviews" />
<CommandPrompt command="less README.md" path="~/reviews" />
<div class="terminal-panel ml-4 mt-4">
<div class="terminal-kicker">review ledger</div>
<div class="terminal-section-title mt-4">
@@ -97,29 +206,57 @@ const typeColors: Record<string, string> = {
</div>
<div>
<CommandPrompt command="cat stats.json" path="~/reviews" />
<div class="ml-4 mt-2 grid grid-cols-2 sm:grid-cols-4 gap-4">
<InfoTile tone="blue" layout="stack">
<div id="reviews-total" class="text-2xl font-bold text-[var(--primary)]">{stats.total}</div>
<div class="mt-2 text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)]">{t('reviews.total')}</div>
</InfoTile>
<InfoTile tone="amber" layout="stack">
<div id="reviews-average" class="text-2xl font-bold text-yellow-500">{stats.avgRating}</div>
<div class="mt-2 text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)]">{t('reviews.average')}</div>
</InfoTile>
<InfoTile tone="teal" layout="stack">
<div id="reviews-completed" class="text-2xl font-bold text-[var(--success)]">{stats.completed}</div>
<div class="mt-2 text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)]">{t('reviews.completed')}</div>
</InfoTile>
<InfoTile tone="violet" layout="stack">
<div id="reviews-progress" class="text-2xl font-bold text-[var(--warning)]">{stats.inProgress}</div>
<div class="mt-2 text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)]">{t('reviews.inProgress')}</div>
</InfoTile>
<CommandPrompt promptId="reviews-stats-prompt" command="jq '.summary' stats.json" path="~/reviews" />
<div class="reviews-stats-grid ml-4 mt-2">
{statCards.map((card) => (
<div class="reviews-stat-card" style={`--review-stat-color: ${card.color};`}>
<div class="reviews-stat-card__head">
<span class="reviews-stat-card__icon">
<i class={`fas ${card.icon}`}></i>
</span>
<span class="terminal-kicker">{card.label}</span>
</div>
<div class="mt-3 flex items-end justify-between gap-4">
<div class="min-w-0">
<div
id={card.id === 'total' ? 'reviews-total' : card.id === 'average' ? 'reviews-average' : card.id === 'completed' ? 'reviews-completed' : 'reviews-progress'}
class="reviews-stat-card__value"
>
{card.value}
</div>
<div
id={card.id === 'total' ? 'reviews-total-detail' : card.id === 'completed' ? 'reviews-completed-detail' : card.id === 'progress' ? 'reviews-progress-detail' : undefined}
class:list={[
'reviews-stat-card__detail',
card.id === 'average' && 'hidden'
]}
>
{card.id === 'average' ? '' : card.detail}
</div>
{card.id === 'average' && <div class="reviews-stat-card__detail">/ 5</div>}
</div>
{card.id === 'average' && (
<div id="reviews-average-stars" class="reviews-average-stars">
{Array.from({ length: 5 }).map((_, index) => (
<i class={`fas fa-star ${index < Math.round(Number(stats.avgRating)) ? '' : 'opacity-25'}`}></i>
))}
</div>
)}
</div>
<div class="reviews-stat-card__bar">
<div
id={card.id === 'total' ? 'reviews-total-bar' : card.id === 'completed' ? 'reviews-completed-bar' : card.id === 'progress' ? 'reviews-progress-bar' : 'reviews-average-bar'}
class="reviews-stat-card__bar-fill"
style={`width: ${card.barWidth};`}
></div>
</div>
</div>
))}
</div>
</div>
<div>
<CommandPrompt command="filter --by-type" path="~/reviews" />
<CommandPrompt promptId="reviews-filter-prompt" command="printf '%s\n' all game anime music book movie" path="~/reviews" />
<div class="ml-4 mt-4 flex flex-wrap gap-2">
{filters.map(filter => (
<FilterPill
@@ -132,89 +269,118 @@ const typeColors: Record<string, string> = {
>
<i class={`fas ${filter.icon}`}></i>
{filter.name}
<span class="ml-1 text-[var(--text-tertiary)]">[{filter.count}]</span>
<span class="review-filter__count">{filter.count}</span>
</FilterPill>
))}
</div>
</div>
<div>
<CommandPrompt command="ls -la *.md" path="~/reviews" />
<div class="ml-4 mt-2 space-y-3">
{filteredReviews.length === 0 ? (
<div
id="reviews-empty-state"
class="terminal-empty"
>
<div class="text-3xl text-[var(--primary)] mb-3">
<i class="fas fa-inbox"></i>
</div>
<div class="text-[var(--text-secondary)]">
{parsedReviews.length === 0 ? t('reviews.emptyData') : t('reviews.emptyFiltered')}
</div>
</div>
) : (
<>
{filteredReviews.map(review => (
<div
class="review-card terminal-panel group cursor-pointer p-5 transition-all hover:-translate-y-1 hover:border-[var(--primary)]"
data-review-card
data-review-type={review.review_type}
data-review-status={review.status}
data-review-rating={review.rating || 0}
style={`border-left: 3px solid ${typeColors[review.review_type] || '#888'};`}
>
<div class="flex items-start gap-4">
<div class="w-16 h-16 rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)] flex items-center justify-center text-3xl shrink-0">
{review.cover}
<CommandPrompt promptId="reviews-list-prompt" command="find . -maxdepth 1 -name '*.md' | sort" path="~/reviews" />
<div class="ml-4 mt-2">
<div class="reviews-card-grid">
{parsedReviews.map(review => (
<article
class="review-card terminal-panel group"
data-review-card
data-review-type={review.review_type}
data-review-status={review.normalizedStatus}
data-review-rating={review.rating || 0}
style={`--review-accent: ${typeColors[review.review_type] || '#888'};`}
>
<div class="review-card__poster">
{review.coverUrl ? (
<img
src={review.coverUrl}
alt={`${review.title} cover`}
class="review-card__poster-image"
loading="lazy"
/>
) : (
<div class="review-card__poster-fallback">
<div class="review-card__poster-kicker">
<span>{typeLabels[review.review_type] || review.review_type}</span>
<span>{review.review_date.slice(0, 4)}</span>
</div>
<div class="review-card__poster-emoji">{review.cover || '★'}</div>
<div class="review-card__poster-title">{review.title}</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2 mb-2">
<span class="terminal-chip text-xs py-1 px-2.5" style={`border-color: ${typeColors[review.review_type] || '#888'}33; color: ${typeColors[review.review_type] || '#888'}; background: ${typeColors[review.review_type] || '#888'}12;`}>
)}
</div>
<div class="review-card__body">
<div class="review-card__head">
<div class="min-w-0">
<div class="review-card__badges">
<span class="terminal-chip terminal-chip--accent text-[10px] py-1 px-2" style={`--accent-color:${typeColors[review.review_type] || '#888'};--accent-rgb:${typeColors[review.review_type] === '#4285f4' ? '66 133 244' : review.review_type === 'anime' ? '255 107 107' : review.review_type === 'music' ? '0 255 157' : review.review_type === 'book' ? '245 158 11' : '155 89 182'};`}>
{typeLabels[review.review_type] || review.review_type}
</span>
<h3 class="font-bold text-[var(--title-color)] text-lg truncate">{review.title}</h3>
</div>
<p class="text-sm text-[var(--text-secondary)] mb-3 line-clamp-2 leading-6">{review.description}</p>
<div class="flex flex-wrap items-center gap-3 text-xs">
<span class="terminal-chip text-xs py-1 px-2.5">
<i class="fas fa-calendar mr-1"></i>{review.review_date}
</span>
<span class="terminal-chip text-xs py-1 px-2.5 flex items-center gap-1.5">
{Array.from({ length: 5 }).map((_, i) => (
<i class={`fas fa-star ${i < (review.rating || 0) ? 'text-yellow-500' : 'text-[var(--border-color)]'}`}></i>
))}
<span class="ml-1 text-[var(--text-tertiary)]">{review.rating || 0}/5</span>
<span class="terminal-chip text-[10px] py-1 px-2" style={`color:${statusColors[review.normalizedStatus]};`}>
{statusLabels[review.normalizedStatus]}
</span>
</div>
<div class="flex flex-wrap gap-2 mt-3">
{review.tags.map((tag: string) => (
<span class="terminal-chip text-xs py-1 px-2.5">
#{tag}
</span>
<h3 class="review-card__title">{review.title}</h3>
</div>
<div class="review-card__rating">
<div class="review-card__rating-value">{review.rating || 0}.0</div>
<div class="review-card__rating-stars">
{Array.from({ length: 5 }).map((_, i) => (
<i class={`fas fa-star ${i < (review.rating || 0) ? 'text-yellow-500' : 'text-[var(--border-color)]'}`}></i>
))}
</div>
</div>
</div>
<p class="review-card__description">{review.description}</p>
<div class="review-card__meta">
<span class="terminal-chip text-xs py-1 px-2.5">
<i class="fas fa-calendar text-[10px]"></i>
<span>{review.review_date}</span>
</span>
<span class="terminal-chip text-xs py-1 px-2.5">
<i class="fas fa-star text-[10px] text-yellow-500"></i>
<span>{review.rating || 0}/5</span>
</span>
{review.linkUrl && (
<a
href={review.linkUrl}
class="review-card__link"
target={review.externalLink ? '_blank' : undefined}
rel={review.externalLink ? 'noreferrer noopener' : undefined}
>
<i class={`fas ${review.externalLink ? 'fa-arrow-up-right-from-square' : 'fa-arrow-right'}`}></i>
<span class="font-mono">xdg-open</span>
</a>
)}
</div>
<div class="review-card__tags">
{review.tags.map((tag: string) => (
<span class="terminal-chip text-xs py-1 px-2.5">
#{tag}
</span>
))}
</div>
</div>
))}
<div
id="reviews-empty-state"
class="terminal-empty hidden"
>
<div class="text-3xl text-[var(--primary)] mb-3">
<i class="fas fa-inbox"></i>
</div>
<div class="text-[var(--text-secondary)]">{t('reviews.emptyFiltered')}</div>
</div>
</>
)}
</article>
))}
</div>
<div id="reviews-empty-state" class:list={['terminal-empty mt-4', filteredReviews.length > 0 && 'hidden']}>
<div class="text-3xl text-[var(--primary)] mb-3">
<i class="fas fa-inbox"></i>
</div>
<div class="text-[var(--text-secondary)]">
{parsedReviews.length === 0 ? t('reviews.emptyData') : t('reviews.emptyFiltered')}
</div>
</div>
</div>
</div>
<!-- Back to Home -->
<div class="pt-4 border-t border-[var(--border-color)]">
<a href="/" class="inline-flex items-center gap-2 text-[var(--text-secondary)] hover:text-[var(--primary)] transition-colors">
<a href="/" class="terminal-subtle-link">
<i class="fas fa-chevron-left"></i>
<span class="font-mono">cd ~</span>
</a>
@@ -237,6 +403,9 @@ const typeColors: Record<string, string> = {
}}
>
(function() {
/** @type {Window['__termiCommandPrompt']} */
let promptApi;
const typeLabels = reviewTypeLabels;
const cards = Array.from(document.querySelectorAll('[data-review-card]'));
@@ -246,9 +415,34 @@ const typeColors: Record<string, string> = {
const avgEl = document.getElementById('reviews-average');
const completedEl = document.getElementById('reviews-completed');
const progressEl = document.getElementById('reviews-progress');
const totalDetailEl = document.getElementById('reviews-total-detail');
const totalBarEl = document.getElementById('reviews-total-bar');
const averageStarsEl = document.getElementById('reviews-average-stars');
const completedDetailEl = document.getElementById('reviews-completed-detail');
const completedBarEl = document.getElementById('reviews-completed-bar');
const progressDetailEl = document.getElementById('reviews-progress-detail');
const progressBarEl = document.getElementById('reviews-progress-bar');
const emptyState = document.getElementById('reviews-empty-state');
const t = window.__termiTranslate;
const baseSubtitle = reviewsBaseSubtitle;
promptApi = window.__termiCommandPrompt;
function updateReviewPrompts(type) {
const selectedType = type || 'all';
const statsCommand = selectedType === 'all'
? "jq '.summary' stats.json"
: `jq '.summary.${selectedType}' stats.json`;
const filterCommand = selectedType === 'all'
? "printf '%s\\n' all game anime music book movie"
: `printf '%s\\n' ${selectedType}`;
const listCommand = selectedType === 'all'
? "find . -maxdepth 1 -name '*.md' | sort"
: `grep -El '^type: ${selectedType}$' ./*.md`;
promptApi?.set?.('reviews-stats-prompt', statsCommand, { typing: false });
promptApi?.set?.('reviews-filter-prompt', filterCommand, { typing: false });
promptApi?.set?.('reviews-list-prompt', listCommand, { typing: false });
}
function updateFilterUi(activeType) {
filters.forEach((filter) => {
@@ -264,11 +458,26 @@ const typeColors: Record<string, string> = {
: '0';
const completed = visibleCards.filter((card) => card.dataset.reviewStatus === 'completed').length;
const inProgress = visibleCards.filter((card) => card.dataset.reviewStatus === 'in-progress').length;
const highRatingCount = visibleCards.filter((card) => Number(card.dataset.reviewRating || 0) >= 4).length;
const completedRatio = total ? Math.round((completed / total) * 100) : 0;
const inProgressRatio = total ? Math.round((inProgress / total) * 100) : 0;
if (totalEl) totalEl.textContent = String(total);
if (avgEl) avgEl.textContent = average;
if (completedEl) completedEl.textContent = String(completed);
if (progressEl) progressEl.textContent = String(inProgress);
if (totalDetailEl) totalDetailEl.textContent = `≥4 ★ · ${highRatingCount}`;
if (totalBarEl) totalBarEl.style.width = `${total ? Math.max((highRatingCount / total) * 100, 12) : 12}%`;
if (completedDetailEl) completedDetailEl.textContent = `${completedRatio}%`;
if (completedBarEl) completedBarEl.style.width = `${completedRatio}%`;
if (progressDetailEl) progressDetailEl.textContent = `${inProgressRatio}%`;
if (progressBarEl) progressBarEl.style.width = `${inProgressRatio}%`;
if (averageStarsEl) {
const roundedAverage = Math.round(Number(average));
averageStarsEl.innerHTML = Array.from({ length: 5 }, (_, index) =>
`<i class="fas fa-star ${index < roundedAverage ? '' : 'opacity-25'}"></i>`
).join('');
}
}
function applyFilter(type, pushState = true) {
@@ -295,6 +504,8 @@ const typeColors: Record<string, string> = {
const nextUrl = type === 'all' ? '/reviews' : `/reviews?type=${encodeURIComponent(type)}`;
window.history.replaceState({}, '', nextUrl);
}
updateReviewPrompts(type);
}
filters.forEach((filter) => {
@@ -308,3 +519,308 @@ const typeColors: Record<string, string> = {
})();
</script>
</Layout>
<style>
.reviews-stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1rem;
}
.reviews-stat-card {
border: 1px solid color-mix(in oklab, var(--review-stat-color) 18%, var(--border-color));
background:
linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 98%, transparent), color-mix(in oklab, var(--review-stat-color) 5%, var(--header-bg))),
radial-gradient(circle at top right, color-mix(in oklab, var(--review-stat-color) 14%, transparent), transparent 52%);
border-radius: 1.1rem;
padding: 1rem 1rem 0.95rem;
box-shadow: 0 12px 28px rgba(var(--text-rgb), 0.05);
}
.reviews-stat-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.reviews-stat-card__icon {
display: inline-flex;
height: 2.75rem;
width: 2.75rem;
align-items: center;
justify-content: center;
border-radius: 1rem;
color: var(--review-stat-color);
background: color-mix(in oklab, var(--review-stat-color) 12%, var(--terminal-bg));
border: 1px solid color-mix(in oklab, var(--review-stat-color) 18%, var(--border-color));
}
.reviews-stat-card__value {
font-size: clamp(2rem, 3vw, 2.4rem);
line-height: 1;
font-weight: 800;
color: var(--review-stat-color);
}
.reviews-stat-card__detail {
margin-top: 0.35rem;
font-size: 0.8rem;
color: var(--text-secondary);
}
.reviews-stat-card__bar {
margin-top: 0.9rem;
height: 0.35rem;
border-radius: 999px;
background: color-mix(in oklab, var(--review-stat-color) 12%, transparent);
overflow: hidden;
}
.reviews-stat-card__bar-fill {
height: 100%;
border-radius: inherit;
background: var(--review-stat-color);
}
.reviews-average-stars {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
color: #e0a100;
}
.reviews-card-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
}
.review-card {
display: grid;
grid-template-columns: minmax(8.4rem, 9.6rem) minmax(0, 1fr);
gap: 0.9rem;
border-color: color-mix(in oklab, var(--review-accent) 18%, var(--border-color));
background:
linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 98%, transparent), color-mix(in oklab, var(--review-accent) 5%, var(--header-bg))),
radial-gradient(circle at top right, color-mix(in oklab, var(--review-accent) 12%, transparent), transparent 42%);
transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
padding: 0.8rem;
}
.review-card:hover {
transform: translateY(-4px);
border-color: color-mix(in oklab, var(--review-accent) 30%, var(--border-color));
box-shadow: 0 18px 36px rgba(var(--text-rgb), 0.08);
}
.review-card__poster {
position: relative;
min-height: 13rem;
border-radius: 0.95rem;
overflow: hidden;
border: 1px solid color-mix(in oklab, var(--review-accent) 18%, var(--border-color));
background: color-mix(in oklab, var(--review-accent) 8%, var(--terminal-bg));
box-shadow:
0 14px 30px rgba(var(--text-rgb), 0.14),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.review-card__poster-image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transform: scale(1.01);
}
.review-card__poster-fallback {
position: relative;
height: 100%;
min-height: 12rem;
padding: 0.8rem;
display: flex;
flex-direction: column;
justify-content: space-between;
background:
linear-gradient(160deg, color-mix(in oklab, var(--review-accent) 26%, var(--terminal-bg)), color-mix(in oklab, var(--review-accent) 10%, var(--header-bg)) 42%, color-mix(in oklab, var(--terminal-bg) 94%, transparent)),
radial-gradient(circle at top right, color-mix(in oklab, var(--review-accent) 26%, transparent), transparent 45%);
}
.review-card__poster-kicker {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.68rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: color-mix(in oklab, var(--title-color) 68%, var(--review-accent));
}
.review-card__poster-emoji {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 4rem;
opacity: 0.9;
filter: saturate(1.1);
}
.review-card__poster-title {
position: relative;
z-index: 1;
font-size: 1rem;
line-height: 1.35;
font-weight: 700;
color: var(--title-color);
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.18);
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
.review-card__body {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.72rem;
justify-content: space-between;
}
.review-card__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.9rem;
}
.review-card__badges {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
margin-bottom: 0.5rem;
}
.review-card__title {
font-size: 1.05rem;
line-height: 1.3;
font-weight: 800;
color: var(--title-color);
}
.review-card__rating {
text-align: right;
flex-shrink: 0;
}
.review-card__rating-value {
font-size: 1.25rem;
line-height: 1;
font-weight: 800;
color: var(--review-accent);
}
.review-card__rating-stars {
margin-top: 0.35rem;
display: flex;
justify-content: flex-end;
gap: 0.2rem;
font-size: 0.72rem;
}
.review-card__description {
color: var(--text-secondary);
font-size: 0.88rem;
line-height: 1.62;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.review-card__meta {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
}
.review-card__link {
display: inline-flex;
align-items: center;
gap: 0.42rem;
padding: 0.3rem 0.72rem;
border-radius: 999px;
border: 1px solid color-mix(in oklab, var(--review-accent) 20%, var(--border-color));
background: color-mix(in oklab, var(--review-accent) 10%, var(--terminal-bg));
color: color-mix(in oklab, var(--review-accent) 60%, var(--title-color));
font-size: 0.76rem;
line-height: 1;
text-decoration: none;
transition: border-color 180ms ease, transform 180ms ease, background 180ms ease;
}
.review-card__link:hover {
transform: translateY(-1px);
border-color: color-mix(in oklab, var(--review-accent) 34%, var(--border-color));
background: color-mix(in oklab, var(--review-accent) 16%, var(--terminal-bg));
}
.review-card__tags {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.review-filter__count {
display: inline-flex;
min-width: 1.4rem;
height: 1.4rem;
align-items: center;
justify-content: center;
padding: 0 0.38rem;
border-radius: 999px;
background: color-mix(in oklab, var(--terminal-bg) 82%, var(--border-color));
color: var(--text-tertiary);
font-size: 0.7rem;
line-height: 1;
font-variant-numeric: tabular-nums;
}
@media (max-width: 1024px) {
.reviews-stats-grid,
.reviews-card-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.reviews-stats-grid,
.reviews-card-grid {
grid-template-columns: minmax(0, 1fr);
}
.review-card {
grid-template-columns: minmax(0, 1fr);
}
.review-card__poster {
min-height: 14rem;
}
.review-card__head {
flex-direction: column;
}
.review-card__rating {
text-align: left;
}
.review-card__rating-stars {
justify-content: flex-start;
}
}
</style>

View File

@@ -6,16 +6,20 @@ import FilterPill from '../../components/ui/FilterPill.astro';
import { apiClient } from '../../lib/api/client';
import { getI18n, formatReadTime } from '../../lib/i18n';
import type { Post, Tag } from '../../lib/types';
import { getAccentVars, getCategoryTheme, getPostTypeTheme, getTagTheme } from '../../lib/utils';
export const prerender = false;
// Fetch tags from backend
let tags: Tag[] = [];
let filteredPosts: Post[] = [];
let allPosts: Post[] = [];
const { locale, t } = getI18n(Astro);
try {
tags = await apiClient.getTags();
[tags, allPosts] = await Promise.all([
apiClient.getTags(),
apiClient.getPosts(),
]);
} catch (error) {
console.error('Failed to fetch tags:', error);
}
@@ -24,24 +28,23 @@ try {
const url = new URL(Astro.request.url);
const selectedTag = url.searchParams.get('tag') || '';
const selectedTagToken = selectedTag.trim().toLowerCase();
const selectedTagTheme = getTagTheme(selectedTag);
const isSelectedTag = (tag: Tag) =>
tag.name.trim().toLowerCase() === selectedTagToken || tag.slug.trim().toLowerCase() === selectedTagToken;
// Fetch posts by tag from API if tag is selected
if (selectedTag) {
try {
filteredPosts = await apiClient.getPostsByTag(selectedTag);
} catch (error) {
console.error('Failed to fetch posts by tag:', error);
}
}
const filteredPosts = selectedTag
? allPosts.filter((post) => post.tags?.some((tag) => (tag || '').trim().toLowerCase() === selectedTagToken))
: [];
const tagAccentMap = Object.fromEntries(
tags.map((tag) => [String(tag.slug || tag.name).toLowerCase(), getAccentVars(getTagTheme(tag.name))])
);
---
<BaseLayout title={`${t('tags.pageTitle')} - Termi`}>
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TerminalWindow title="~/tags" class="w-full">
<div class="mb-6 px-4">
<CommandPrompt command="cat ./tags.txt" />
<CommandPrompt command="cut -d',' -f1 tags.index | sort -u" />
<div class="terminal-panel ml-4 mt-4">
<div class="terminal-kicker">tag index</div>
<div class="terminal-section-title mt-4">
@@ -60,32 +63,32 @@ if (selectedTag) {
<i class="fas fa-tags text-[var(--primary)]"></i>
<span>{t('common.tagsCount', { count: tags.length })}</span>
</span>
{selectedTag && (
<span class="terminal-stat-pill">
<i class="fas fa-filter text-[var(--primary)]"></i>
<span>{t('tags.currentTag', { tag: selectedTag })}</span>
</span>
)}
<span
id="tags-current-pill"
class:list={['terminal-stat-pill terminal-stat-pill--accent', !selectedTag && 'hidden']}
style={selectedTag ? getAccentVars(selectedTagTheme) : undefined}
>
<i class="fas fa-filter"></i>
<span id="tags-current-label">{selectedTag ? t('tags.currentTag', { tag: selectedTag }) : ''}</span>
</span>
</div>
</div>
</div>
{selectedTag && (
<div class="mb-6 px-4">
<CommandPrompt command={`grep -r "#${selectedTag}" ./posts/`} />
<div id="tags-summary-section" class:list={['mb-6 px-4', !selectedTag && 'hidden']}>
<CommandPrompt promptId="tags-match-prompt" command={selectedTag ? `grep -Ril "#${selectedTag}" ./posts` : 'grep -Ril "#tag" ./posts'} />
<div class="terminal-panel ml-4 mt-4">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<p class="text-[var(--text-secondary)] leading-6">
<p id="tags-selected-summary" class="text-[var(--text-secondary)] leading-6">
{t('tags.selectedSummary', { tag: selectedTag, count: filteredPosts.length })}
</p>
<FilterPill tone="teal" href="/tags">
<a id="tags-clear-btn" href="/tags" class="ui-filter-pill ui-filter-pill--teal">
<i class="fas fa-times"></i>
<span>{t('common.clearFilters')}</span>
</FilterPill>
</a>
</div>
</div>
</div>
)}
<div class="px-4 mb-8">
<div class="terminal-panel ml-4 mt-4">
@@ -100,9 +103,11 @@ if (selectedTag) {
) : (
tags.map(tag => (
<FilterPill
tone="teal"
tone="accent"
active={isSelectedTag(tag)}
href={`/tags?tag=${encodeURIComponent(tag.slug || tag.name || '')}`}
data-tag-filter={tag.slug || tag.name || ''}
style={getAccentVars(getTagTheme(tag.name))}
>
<i class="fas fa-hashtag"></i>
<span>{tag.name}</span>
@@ -113,46 +118,178 @@ if (selectedTag) {
</div>
</div>
{selectedTag && filteredPosts.length > 0 && (
<div class="px-4">
<div class="border-t border-[var(--border-color)] pt-6">
<CommandPrompt command={`ls -la ./posts/*#${selectedTag}*`} />
<div id="tags-results-section" class:list={['border-t border-[var(--border-color)] pt-6', !selectedTag && 'hidden']}>
<CommandPrompt promptId="tags-results-prompt" command={selectedTag ? `find ./posts -type f | xargs grep -il "#${selectedTag}"` : "find ./posts -type f | sort"} />
<div class="ml-4 mt-4 space-y-4">
{filteredPosts.map(post => (
<a
href={`/articles/${post.slug}`}
class="terminal-panel block p-5 transition-all hover:-translate-y-1 hover:border-[var(--primary)]"
>
<div class="flex flex-wrap items-center gap-2 mb-2">
<span class="w-2 h-2 rounded-full" style={`background-color: ${post.type === 'article' ? 'var(--primary)' : 'var(--secondary)'}`}></span>
<h3 class="font-bold text-[var(--title-color)] text-lg">{post.title}</h3>
<span class="terminal-chip text-xs py-1 px-2.5">
<span>{post.category}</span>
</span>
</div>
<p class="text-sm text-[var(--text-secondary)]">{post.date} | {formatReadTime(locale, post.readTime, t)}</p>
<p class="text-sm text-[var(--text-secondary)] mt-3 leading-6">{post.description}</p>
<div class="mt-4 terminal-link-arrow">
<span>{t('common.viewArticle')}</span>
<i class="fas fa-arrow-right text-xs"></i>
</div>
</a>
))}
{allPosts.map((post) => {
const matchesInitial = selectedTag
? post.tags?.some((tag) => (tag || '').trim().toLowerCase() === selectedTagToken)
: false;
return (
<a
href={`/articles/${post.slug}`}
data-tag-post
data-tags={post.tags.map((tag) => (tag || '').trim().toLowerCase()).join('|')}
class:list={[
'terminal-panel terminal-panel-accent terminal-interactive-card block p-5',
!matchesInitial && 'hidden'
]}
style={getAccentVars(getPostTypeTheme(post.type))}
>
<div class="flex flex-wrap items-center gap-2 mb-2">
<span class="terminal-chip terminal-chip--accent text-[10px] py-1 px-2" style={getAccentVars(getPostTypeTheme(post.type))}>
{post.type === 'article' ? t('common.article') : t('common.tweet')}
</span>
<h3 class="font-bold text-[var(--title-color)] text-lg">{post.title}</h3>
<span class="terminal-chip terminal-chip--accent text-xs py-1 px-2.5" style={getAccentVars(getCategoryTheme(post.category))}>
<span>{post.category}</span>
</span>
</div>
<p class="text-sm text-[var(--text-secondary)]">{post.date} | {formatReadTime(locale, post.readTime, t)}</p>
<p class="text-sm text-[var(--text-secondary)] mt-3 leading-6">{post.description}</p>
<div class="mt-4 terminal-link-arrow">
<span>{t('common.viewArticle')}</span>
<i class="fas fa-arrow-right text-xs"></i>
</div>
</a>
);
})}
</div>
</div>
</div>
)}
{selectedTag && filteredPosts.length === 0 && (
<div class="px-4">
<div class="border-t border-[var(--border-color)] pt-6">
<div class="terminal-empty ml-4 mt-4">
<div id="tags-empty-wrap" class:list={['border-t border-[var(--border-color)] pt-6', (!selectedTag || filteredPosts.length > 0) && 'hidden']}>
<div id="tags-empty-state" class="terminal-empty ml-4 mt-4">
<i class="fas fa-search text-4xl text-[var(--text-tertiary)] mb-4"></i>
<p class="text-[var(--text-secondary)]">{t('tags.emptyPosts')}</p>
</div>
</div>
</div>
)}
</TerminalWindow>
</div>
</BaseLayout>
<script
is:inline
define:vars={{
initialSelectedTag: selectedTag,
tagAccentMap,
}}
>
(function() {
/** @type {Window['__termiCommandPrompt']} */
let promptApi;
const tagButtons = Array.from(document.querySelectorAll('[data-tag-filter]'));
const tagPosts = Array.from(document.querySelectorAll('[data-tag-post]'));
const currentPill = document.getElementById('tags-current-pill');
const currentLabel = document.getElementById('tags-current-label');
const summarySection = document.getElementById('tags-summary-section');
const selectedSummary = document.getElementById('tags-selected-summary');
const resultsSection = document.getElementById('tags-results-section');
const emptyWrap = document.getElementById('tags-empty-wrap');
const clearBtn = document.getElementById('tags-clear-btn');
const t = window.__termiTranslate;
promptApi = window.__termiCommandPrompt;
const state = {
tag: initialSelectedTag || '',
};
function syncTagButtons() {
tagButtons.forEach((button) => {
const value = (button.getAttribute('data-tag-filter') || '').trim().toLowerCase();
button.classList.toggle('is-active', Boolean(state.tag) && value === state.tag.toLowerCase());
});
}
function updateTagPrompts() {
const matchCommand = state.tag
? `grep -Ril "#${state.tag}" ./posts`
: 'grep -Ril "#tag" ./posts';
const resultCommand = state.tag
? `find ./posts -type f | xargs grep -il "#${state.tag}"`
: "find ./posts -type f | sort";
promptApi?.set?.('tags-match-prompt', matchCommand, { typing: false });
promptApi?.set?.('tags-results-prompt', resultCommand, { typing: false });
}
function updateTagUrl() {
const params = new URLSearchParams();
if (state.tag) params.set('tag', state.tag);
const nextUrl = params.toString() ? `/tags?${params.toString()}` : '/tags';
window.history.replaceState({}, '', nextUrl);
}
function applyTagFilter(pushHistory = true) {
const normalizedTag = state.tag.trim().toLowerCase();
let visibleCount = 0;
tagPosts.forEach((post) => {
const tags = `|${(post.getAttribute('data-tags') || '').toLowerCase()}|`;
const matches = normalizedTag ? tags.includes(`|${normalizedTag}|`) : false;
post.classList.toggle('hidden', !matches);
if (matches) {
visibleCount += 1;
}
});
syncTagButtons();
updateTagPrompts();
if (currentPill && currentLabel) {
currentPill.classList.toggle('hidden', !state.tag);
if (state.tag) {
currentLabel.textContent = t('tags.currentTag', { tag: state.tag });
currentPill.setAttribute('style', tagAccentMap[String(state.tag).toLowerCase()] || '');
} else {
currentLabel.textContent = '';
currentPill.removeAttribute('style');
}
}
if (summarySection) {
summarySection.classList.toggle('hidden', !state.tag);
}
if (resultsSection) {
resultsSection.classList.toggle('hidden', !state.tag);
}
if (selectedSummary) {
selectedSummary.textContent = state.tag
? t('tags.selectedSummary', { tag: state.tag, count: visibleCount })
: '';
}
if (emptyWrap) {
emptyWrap.classList.toggle('hidden', !state.tag || visibleCount > 0);
}
if (clearBtn) {
clearBtn.classList.toggle('hidden', !state.tag);
}
if (pushHistory) {
updateTagUrl();
}
}
tagButtons.forEach((button) => {
button.addEventListener('click', (event) => {
event.preventDefault();
state.tag = button.getAttribute('data-tag-filter') || '';
applyTagFilter();
});
});
clearBtn?.addEventListener('click', (event) => {
event.preventDefault();
state.tag = '';
applyTagFilter();
});
applyTagFilter(false);
})();
</script>

View File

@@ -6,6 +6,7 @@ import FilterPill from '../../components/ui/FilterPill.astro';
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
import { getI18n, formatReadTime } from '../../lib/i18n';
import type { Post } from '../../lib/types';
import { getAccentVars, getCategoryTheme, getPostTypeTheme } from '../../lib/utils';
export const prerender = false;
@@ -38,7 +39,7 @@ const latestYear = years[0] || 'all';
<TerminalWindow title="~/timeline" class="w-full">
<div class="px-4 py-4 space-y-6">
<div>
<CommandPrompt command="cat timeline.log" path="~" />
<CommandPrompt command="tail -n +1 ~/timeline.log" path="~" />
<div class="terminal-panel ml-4 mt-4">
<div class="terminal-kicker">activity trace</div>
<div class="terminal-section-title mt-4">
@@ -56,7 +57,7 @@ const latestYear = years[0] || 'all';
</div>
<div>
<CommandPrompt command="filter --by-year" path="~/timeline" />
<CommandPrompt promptId="timeline-filter-prompt" command="cut -d'-' -f1 timeline.log | sort -ru" path="~/timeline" />
<div class="ml-4 mt-4 flex flex-wrap gap-2">
<FilterPill
class="timeline-filter"
@@ -82,7 +83,7 @@ const latestYear = years[0] || 'all';
</div>
<div>
<CommandPrompt command="git log --oneline --all" path="~/timeline" />
<CommandPrompt promptId="timeline-log-prompt" command="git log --oneline --date=short --all" path="~/timeline" />
<div class="ml-4 mt-2 space-y-8">
{years.map(year => (
<div class="timeline-year-group relative" data-year={year}>
@@ -102,7 +103,8 @@ const latestYear = years[0] || 'all';
<a
href={`/articles/${post.slug}`}
class="terminal-panel group flex items-start gap-4 p-4 hover:border-[var(--primary)] hover:translate-x-2 transition-all"
class="terminal-panel terminal-panel-accent group flex items-start gap-4 p-4 hover:translate-x-2 transition-all"
style={getAccentVars(getPostTypeTheme(post.type))}
>
<div class="terminal-panel-muted shrink-0 min-w-[72px] text-center py-3">
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
@@ -115,17 +117,16 @@ const latestYear = years[0] || 'all';
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2 mb-2">
<span
class="w-2 h-2 rounded-full"
style={`background-color: ${post.type === 'article' ? 'var(--primary)' : 'var(--secondary)'}`}
></span>
<span class="terminal-chip terminal-chip--accent text-[10px] py-1 px-2" style={getAccentVars(getPostTypeTheme(post.type))}>
{post.type === 'article' ? t('common.article') : t('common.tweet')}
</span>
<h3 class="font-bold text-[var(--title-color)] group-hover:text-[var(--primary)] transition-colors truncate text-lg">
{post.title}
</h3>
</div>
<p class="text-sm text-[var(--text-secondary)] line-clamp-2 leading-6">{post.description}</p>
<div class="flex items-center gap-2 mt-2 flex-wrap">
<span class="terminal-chip text-xs py-1 px-2.5">
<span class="terminal-chip terminal-chip--accent text-xs py-1 px-2.5" style={getAccentVars(getCategoryTheme(post.category))}>
{post.category}
</span>
<span class="terminal-chip text-xs py-1 px-2.5">
@@ -143,7 +144,7 @@ const latestYear = years[0] || 'all';
</div>
<div class="pt-4 border-t border-[var(--border-color)]">
<a href="/" class="inline-flex items-center gap-2 text-[var(--text-secondary)] hover:text-[var(--primary)] transition-colors">
<a href="/" class="terminal-subtle-link">
<i class="fas fa-chevron-left"></i>
<span class="font-mono">cd ~</span>
</a>
@@ -154,23 +155,52 @@ const latestYear = years[0] || 'all';
</Layout>
<script>
declare global {
interface Window {
__termiCommandPrompt?: {
set?: (promptId: string, command: string, options?: { typing?: boolean }) => void;
};
}
}
const filterButtons = document.querySelectorAll('.timeline-filter');
const yearGroups = document.querySelectorAll('.timeline-year-group');
const promptApi = window.__termiCommandPrompt;
function applyTimelineFilter(year: string | null, updateUi = true) {
const selectedYear = year || 'all';
const filterCommand = selectedYear === 'all'
? "cut -d'-' -f1 timeline.log | sort -ru"
: `grep '^${selectedYear}-' timeline.log`;
const logCommand = selectedYear === 'all'
? 'git log --oneline --date=short --all'
: `git log --oneline --date=short --since '${selectedYear}-01-01' --until '${selectedYear}-12-31'`;
if (updateUi) {
filterButtons.forEach((item) => {
item.classList.toggle('is-active', item.getAttribute('data-year') === selectedYear);
});
yearGroups.forEach((group) => {
const matches = selectedYear === 'all' || group.getAttribute('data-year') === selectedYear;
group.classList.toggle('hidden', !matches);
});
}
promptApi?.set?.('timeline-filter-prompt', filterCommand, { typing: false });
promptApi?.set?.('timeline-log-prompt', logCommand, { typing: false });
}
filterButtons.forEach(button => {
button.addEventListener('click', () => {
const year = button.getAttribute('data-year');
filterButtons.forEach(item => {
item.classList.remove('is-active');
});
button.classList.add('is-active');
yearGroups.forEach(group => {
const matches = year === 'all' || group.getAttribute('data-year') === year;
group.classList.toggle('hidden', !matches);
});
applyTimelineFilter(year);
});
});
const initialActiveYear =
document.querySelector('.timeline-filter.is-active')?.getAttribute('data-year') ||
new URL(window.location.href).searchParams.get('year') ||
'all';
applyTimelineFilter(initialActiveYear);
</script>

View File

@@ -125,7 +125,7 @@ html.dark {
/* System preference dark mode */
@media (prefers-color-scheme: dark) {
:root:not(.light) {
:root:not(.light):not(.dark) {
--primary: #00ff9d;
--primary-rgb: 0 255 157;
--primary-light: #00ff9d33;
@@ -289,19 +289,71 @@ html.dark {
color: var(--title-color);
}
.terminal-chip--accent {
border-color: color-mix(in oklab, rgb(var(--accent-rgb, var(--primary-rgb))) 16%, var(--border-color));
background:
linear-gradient(180deg, color-mix(in oklab, rgb(var(--accent-rgb, var(--primary-rgb))) 8%, var(--terminal-bg)), color-mix(in oklab, rgb(var(--accent-rgb, var(--primary-rgb))) 4%, var(--header-bg)));
color: var(--accent-color, var(--primary));
box-shadow: inset 0 0 0 1px rgba(var(--accent-rgb, var(--primary-rgb)), 0.08);
}
.terminal-chip--accent:hover {
border-color: color-mix(in oklab, rgb(var(--accent-rgb, var(--primary-rgb))) 26%, var(--border-color));
background:
linear-gradient(180deg, color-mix(in oklab, rgb(var(--accent-rgb, var(--primary-rgb))) 12%, var(--terminal-bg)), color-mix(in oklab, rgb(var(--accent-rgb, var(--primary-rgb))) 7%, var(--header-bg)));
color: var(--title-color);
}
.terminal-stat-pill--accent {
border-color: color-mix(in oklab, rgb(var(--accent-rgb, var(--primary-rgb))) 18%, var(--border-color));
background: color-mix(in oklab, rgb(var(--accent-rgb, var(--primary-rgb))) 8%, var(--terminal-bg));
color: var(--accent-color, var(--primary));
}
.terminal-panel-accent {
border-color: color-mix(in oklab, rgb(var(--accent-rgb, var(--primary-rgb))) 16%, var(--border-color));
background:
linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 98%, transparent), color-mix(in oklab, rgb(var(--accent-rgb, var(--primary-rgb))) 5%, var(--header-bg)));
box-shadow:
0 16px 34px rgba(var(--text-rgb), 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.34);
}
.terminal-panel-accent:hover {
border-color: color-mix(in oklab, rgb(var(--accent-rgb, var(--primary-rgb))) 28%, var(--border-color));
box-shadow:
0 18px 38px rgba(var(--text-rgb), 0.07),
inset 0 1px 0 rgba(255, 255, 255, 0.34);
}
.terminal-accent-icon {
color: var(--accent-color, var(--primary));
border-color: color-mix(in oklab, rgb(var(--accent-rgb, var(--primary-rgb))) 14%, var(--border-color));
background: color-mix(in oklab, rgb(var(--accent-rgb, var(--primary-rgb))) 10%, var(--terminal-bg));
}
.terminal-link-arrow {
@apply inline-flex items-center gap-2 text-sm font-medium;
@apply inline-flex items-center gap-2 rounded-full border px-3.5 py-2 text-[11px] font-mono uppercase tracking-[0.14em] transition-all duration-200;
border-color: color-mix(in oklab, var(--primary) 14%, var(--border-color));
background: color-mix(in oklab, var(--primary) 6%, var(--terminal-bg));
color: var(--primary);
}
.terminal-link-arrow:hover {
border-color: color-mix(in oklab, var(--primary) 24%, var(--border-color));
background: color-mix(in oklab, var(--primary) 10%, var(--terminal-bg));
color: var(--title-color);
transform: translateY(-1px);
}
.terminal-toolbar-shell {
@apply relative overflow-hidden rounded-[1.35rem] border p-3;
@apply relative overflow-hidden rounded-[1.35rem] border p-2.5;
border-color: color-mix(in oklab, var(--primary) 12%, var(--border-color));
background:
linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 97%, transparent), color-mix(in oklab, var(--header-bg) 90%, transparent)),
linear-gradient(90deg, rgba(var(--primary-rgb), 0.03), transparent 20%, rgba(var(--primary-rgb), 0.015) 78%, transparent);
box-shadow:
0 12px 30px rgba(var(--text-rgb), 0.05),
0 18px 40px rgba(var(--text-rgb), 0.055),
inset 0 1px 0 rgba(255, 255, 255, 0.42);
}
@@ -312,32 +364,91 @@ html.dark {
pointer-events: none;
background-image: linear-gradient(rgba(var(--primary-rgb), 0.04) 1px, transparent 1px);
background-size: 100% 14px;
opacity: 0.18;
opacity: 0.14;
}
.terminal-toolbar-shell > * {
position: relative;
z-index: 1;
}
.terminal-toolbar-module {
@apply relative flex items-center gap-3 rounded-lg border px-3 py-2;
@apply relative flex items-center gap-2.5 rounded-xl border px-3 py-2;
border-color: color-mix(in oklab, var(--primary) 8%, var(--border-color));
background: color-mix(in oklab, var(--terminal-bg) 97%, transparent);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.38);
}
.terminal-toolbar-module--compact {
@apply px-3 py-2;
}
.terminal-toolbar-label {
@apply text-[10px] font-mono uppercase tracking-[0.28em];
color: var(--text-tertiary);
}
.terminal-toolbar-iconbtn {
@apply inline-flex h-8 w-8 items-center justify-center rounded-md border transition-all;
@apply inline-flex h-9 w-9 items-center justify-center rounded-xl border transition-all duration-200;
border-color: color-mix(in oklab, var(--primary) 6%, var(--border-color));
color: var(--text-secondary);
background: color-mix(in oklab, var(--header-bg) 84%, transparent);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.38);
}
.terminal-toolbar-iconbtn:hover {
border-color: color-mix(in oklab, var(--primary) 18%, var(--border-color));
color: var(--primary);
background: color-mix(in oklab, var(--primary) 7%, var(--terminal-bg));
transform: translateY(-1px);
}
.terminal-toolbar-iconbtn:focus-visible {
outline: none;
border-color: color-mix(in oklab, var(--primary) 28%, var(--border-color));
box-shadow:
0 0 0 4px rgba(var(--primary-rgb), 0.09),
inset 0 1px 0 rgba(255, 255, 255, 0.38);
}
.theme-panel {
@apply overflow-hidden rounded-[1.4rem] border;
border-color: color-mix(in oklab, var(--primary) 10%, var(--border-color));
background:
linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 98%, transparent), color-mix(in oklab, var(--header-bg) 92%, transparent)),
linear-gradient(135deg, rgba(var(--primary-rgb), 0.045), transparent 52%);
box-shadow:
0 18px 40px rgba(var(--text-rgb), 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.38);
}
.theme-option-btn {
@apply flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left transition-all duration-200;
border-color: color-mix(in oklab, var(--primary) 7%, var(--border-color));
background: color-mix(in oklab, var(--terminal-bg) 96%, transparent);
color: var(--text-secondary);
}
.theme-option-btn:hover {
border-color: color-mix(in oklab, var(--primary) 18%, var(--border-color));
background: color-mix(in oklab, var(--primary) 5%, var(--terminal-bg));
transform: translateY(-1px);
}
.theme-option-btn.is-active {
border-color: color-mix(in oklab, var(--primary) 22%, var(--border-color));
background:
linear-gradient(180deg, color-mix(in oklab, var(--primary) 10%, var(--terminal-bg)), color-mix(in oklab, var(--primary) 6%, var(--header-bg)));
box-shadow:
0 10px 24px rgba(var(--text-rgb), 0.05),
inset 0 0 0 1px rgba(var(--primary-rgb), 0.08);
}
.theme-option-icon {
@apply flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border;
border-color: color-mix(in oklab, var(--primary) 10%, var(--border-color));
background: color-mix(in oklab, var(--primary) 7%, var(--terminal-bg));
color: var(--primary);
}
.terminal-console-input {
@@ -350,22 +461,28 @@ html.dark {
}
.terminal-nav-link {
@apply inline-flex shrink-0 items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm transition-all;
border-color: transparent;
@apply flex items-center justify-between gap-3 rounded-2xl border px-4 py-3 text-sm font-medium transition-all duration-200;
border-color: color-mix(in oklab, var(--primary) 8%, var(--border-color));
color: var(--text-secondary);
background: transparent;
background:
linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 97%, transparent), color-mix(in oklab, var(--header-bg) 92%, transparent));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.34);
}
.terminal-nav-link:hover {
border-color: color-mix(in oklab, var(--primary) 14%, var(--border-color));
border-color: color-mix(in oklab, var(--primary) 18%, var(--border-color));
background: color-mix(in oklab, var(--primary) 5%, var(--terminal-bg));
color: var(--title-color);
transform: translateY(-1px);
}
.terminal-nav-link.is-active {
border-color: color-mix(in oklab, var(--primary) 24%, var(--border-color));
background: color-mix(in oklab, var(--primary) 8%, var(--terminal-bg));
color: var(--primary);
box-shadow:
0 10px 24px rgba(var(--text-rgb), 0.05),
inset 0 0 0 1px rgba(var(--primary-rgb), 0.08);
}
.terminal-filter {
@@ -390,29 +507,280 @@ html.dark {
}
.terminal-action-button {
@apply inline-flex items-center gap-2 rounded-md border px-3 py-2 font-mono text-[12px] uppercase tracking-[0.18em] transition-all;
@apply inline-flex min-h-[2.35rem] items-center justify-center gap-1.5 rounded-xl border px-3.5 py-2 font-mono text-[11px] uppercase tracking-[0.12em] transition-all duration-200;
border-color: color-mix(in oklab, var(--primary) 12%, var(--border-color));
background: color-mix(in oklab, var(--terminal-bg) 97%, transparent);
background:
linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 98%, transparent), color-mix(in oklab, var(--header-bg) 90%, transparent));
color: var(--text-secondary);
box-shadow:
0 10px 24px rgba(var(--text-rgb), 0.035),
inset 0 1px 0 rgba(255, 255, 255, 0.38);
}
.terminal-action-button:hover {
border-color: color-mix(in oklab, var(--primary) 18%, var(--border-color));
color: var(--title-color);
transform: translateY(-1px);
}
.terminal-action-button:focus-visible {
outline: none;
border-color: color-mix(in oklab, var(--primary) 32%, var(--border-color));
box-shadow:
0 0 0 4px rgba(var(--primary-rgb), 0.08),
0 10px 24px rgba(var(--text-rgb), 0.04),
inset 0 1px 0 rgba(255, 255, 255, 0.38);
}
.terminal-action-button-primary {
border-color: color-mix(in oklab, var(--primary) 24%, var(--border-color));
background:
linear-gradient(180deg, color-mix(in oklab, var(--primary) 10%, var(--terminal-bg)), color-mix(in oklab, var(--primary) 6%, var(--terminal-bg)));
linear-gradient(180deg, color-mix(in oklab, var(--primary) 12%, var(--terminal-bg)), color-mix(in oklab, var(--primary) 7%, var(--terminal-bg)));
color: var(--primary);
box-shadow: 0 8px 18px rgba(var(--primary-rgb), 0.08);
box-shadow:
0 14px 32px rgba(var(--primary-rgb), 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.3);
}
.terminal-action-button-primary:hover {
color: var(--title-color);
background:
linear-gradient(180deg, color-mix(in oklab, var(--primary) 14%, var(--terminal-bg)), color-mix(in oklab, var(--primary) 8%, var(--terminal-bg)));
linear-gradient(180deg, color-mix(in oklab, var(--primary) 16%, var(--terminal-bg)), color-mix(in oklab, var(--primary) 9%, var(--terminal-bg)));
}
.terminal-action-button-secondary {
border-color: color-mix(in oklab, var(--secondary) 22%, var(--border-color));
background:
linear-gradient(180deg, color-mix(in oklab, var(--secondary) 10%, var(--terminal-bg)), color-mix(in oklab, var(--secondary) 5%, var(--terminal-bg)));
color: var(--secondary);
}
.terminal-action-button-secondary:hover {
color: var(--title-color);
}
.terminal-option-card {
@apply w-full rounded-2xl border px-4 py-3 text-left text-sm transition-all duration-200;
border-color: color-mix(in oklab, var(--primary) 10%, var(--border-color));
background:
linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 98%, transparent), color-mix(in oklab, var(--header-bg) 92%, transparent));
color: var(--text-secondary);
box-shadow:
0 10px 24px rgba(var(--text-rgb), 0.035),
inset 0 1px 0 rgba(255, 255, 255, 0.34);
}
.terminal-option-card:hover {
border-color: color-mix(in oklab, var(--primary) 20%, var(--border-color));
color: var(--title-color);
transform: translateY(-1px);
}
.terminal-copy-button {
@apply inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-[11px] font-mono transition-all duration-200;
border-color: color-mix(in oklab, var(--primary) 16%, var(--border-color));
background: color-mix(in oklab, var(--primary) 6%, var(--terminal-bg));
color: var(--primary);
}
.terminal-copy-button:hover {
border-color: color-mix(in oklab, var(--primary) 28%, var(--border-color));
background: color-mix(in oklab, var(--primary) 10%, var(--terminal-bg));
}
.terminal-interactive-card {
@apply transition-all duration-300;
}
.terminal-interactive-card:hover {
transform: translateY(-2px);
border-color: color-mix(in oklab, var(--primary) 22%, var(--border-color));
box-shadow: 0 18px 40px rgba(var(--text-rgb), 0.06);
}
.terminal-quick-link {
@apply flex items-center gap-2.5 rounded-xl border px-3 py-2 transition-all duration-200;
border-color: color-mix(in oklab, var(--primary) 10%, var(--border-color));
background:
linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 98%, transparent), color-mix(in oklab, var(--header-bg) 92%, transparent));
color: var(--text-secondary);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.34);
}
.terminal-quick-link:hover {
border-color: color-mix(in oklab, var(--primary) 18%, var(--border-color));
color: var(--title-color);
transform: translateY(-1px);
}
.terminal-quick-link i:last-child {
opacity: 0.75;
}
.home-hero-shell {
@apply flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between;
}
.home-hero-meta {
@apply flex flex-wrap items-center gap-2;
}
.home-nav-strip {
@apply flex flex-wrap gap-2;
}
.home-nav-pill {
@apply inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-mono transition-all duration-200;
border-color: color-mix(in oklab, var(--primary) 10%, var(--border-color));
background: color-mix(in oklab, var(--terminal-bg) 97%, transparent);
color: var(--text-secondary);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.3);
}
.home-nav-pill:hover {
border-color: color-mix(in oklab, var(--primary) 18%, var(--border-color));
background: color-mix(in oklab, var(--primary) 5%, var(--terminal-bg));
color: var(--title-color);
transform: translateY(-1px);
}
.home-taxonomy-shell,
.home-tag-shell {
@apply space-y-5;
}
.home-discovery-shell {
@apply space-y-4;
}
.home-discovery-head {
@apply flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between;
}
.home-discovery-actions {
@apply flex flex-wrap items-center gap-2;
}
.home-filter-toolbar {
@apply flex flex-wrap gap-2;
}
.home-active-filter-row {
@apply flex flex-wrap gap-2;
}
.home-discovery-grid {
@apply grid gap-4 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)];
}
.home-discovery-panel {
@apply rounded-2xl border p-4;
border-color: color-mix(in oklab, var(--primary) 8%, var(--border-color));
background:
linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 98%, transparent), color-mix(in oklab, var(--header-bg) 92%, transparent));
}
.home-discovery-panel__head {
@apply mb-3 flex items-center justify-between gap-3;
}
.home-taxonomy-head {
@apply flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between;
}
.home-taxonomy-head--airy {
@apply lg:items-center;
}
.home-taxonomy-copy {
@apply min-w-0 space-y-3;
}
.home-category-grid {
@apply grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3;
}
.home-category-grid--compact {
@apply grid-cols-1 sm:grid-cols-2 xl:grid-cols-2;
}
.home-category-card {
@apply flex items-center gap-3 rounded-xl border px-3.5 py-3;
border-color: color-mix(in oklab, rgb(var(--accent-rgb, var(--primary-rgb))) 15%, var(--border-color));
background:
linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 98%, transparent), color-mix(in oklab, rgb(var(--accent-rgb, var(--primary-rgb))) 5%, var(--header-bg)));
}
.home-category-card.is-active {
border-color: color-mix(in oklab, rgb(var(--accent-rgb, var(--primary-rgb))) 30%, var(--border-color));
box-shadow:
inset 0 0 0 1px rgba(var(--accent-rgb, var(--primary-rgb)), 0.08),
0 12px 28px rgba(var(--text-rgb), 0.05);
}
.home-category-card__icon {
@apply flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border text-sm;
}
.home-category-card__slug {
@apply truncate text-[10px] uppercase tracking-[0.24em];
color: var(--text-tertiary);
}
.home-tag-shell {
background:
radial-gradient(circle at top right, rgba(var(--primary-rgb), 0.08), transparent 26%),
linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 97%, transparent), color-mix(in oklab, var(--header-bg) 90%, transparent));
}
.home-tag-cloud {
@apply flex flex-wrap items-center gap-2;
align-items: stretch;
}
.home-tag-cloud__item {
@apply inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-all duration-200;
border-color: color-mix(in oklab, rgb(var(--accent-rgb, var(--primary-rgb))) 16%, var(--border-color));
background:
linear-gradient(180deg, color-mix(in oklab, rgb(var(--accent-rgb, var(--primary-rgb))) 6%, var(--terminal-bg)), color-mix(in oklab, rgb(var(--accent-rgb, var(--primary-rgb))) 2%, var(--header-bg)));
color: var(--accent-color, var(--primary));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.28),
0 10px 24px rgba(var(--text-rgb), 0.035);
font-family: var(--font-mono);
}
.home-tag-cloud__item:hover {
border-color: color-mix(in oklab, rgb(var(--accent-rgb, var(--primary-rgb))) 24%, var(--border-color));
color: var(--title-color);
transform: translateY(-1px);
}
.home-tag-cloud__item.is-active {
border-color: color-mix(in oklab, rgb(var(--accent-rgb, var(--primary-rgb))) 28%, var(--border-color));
background:
linear-gradient(180deg, color-mix(in oklab, rgb(var(--accent-rgb, var(--primary-rgb))) 12%, var(--terminal-bg)), color-mix(in oklab, rgb(var(--accent-rgb, var(--primary-rgb))) 6%, var(--header-bg)));
box-shadow:
inset 0 0 0 1px rgba(var(--accent-rgb, var(--primary-rgb)), 0.08),
0 10px 24px rgba(var(--text-rgb), 0.04);
color: var(--title-color);
}
.home-tag-cloud__hash {
@apply text-xs;
color: color-mix(in oklab, var(--accent-color, var(--primary)) 72%, var(--title-color));
}
.home-tag-cloud__count {
@apply rounded-full px-1.5 py-0.5 text-[10px] font-semibold;
background: rgba(var(--accent-rgb, var(--primary-rgb)), 0.12);
color: color-mix(in oklab, var(--accent-color, var(--primary)) 78%, var(--title-color));
}
@media (max-width: 640px) {
.home-tag-cloud__item:hover {
transform: translateY(-1px);
}
}
.terminal-form-label {
@@ -574,6 +942,29 @@ html.dark {
background: color-mix(in oklab, var(--terminal-bg) 97%, transparent);
}
.terminal-subtle-link {
@apply inline-flex items-center gap-2 rounded-full border px-3.5 py-2 text-sm font-mono transition-all duration-200;
border-color: color-mix(in oklab, var(--primary) 12%, var(--border-color));
background: color-mix(in oklab, var(--terminal-bg) 96%, transparent);
color: var(--primary);
}
.terminal-subtle-link:hover {
border-color: color-mix(in oklab, var(--primary) 22%, var(--border-color));
background: color-mix(in oklab, var(--primary) 7%, var(--terminal-bg));
transform: translateY(-1px);
}
@media (max-width: 1023px) {
.terminal-toolbar-shell {
@apply rounded-[1.2rem] p-2;
}
.terminal-quick-link {
@apply items-start;
}
}
.ui-filter-pill {
--pill-rgb: var(--primary-rgb);
--pill-fg: var(--text-secondary);
@@ -630,17 +1021,33 @@ html.dark {
--pill-fg: var(--text-secondary);
}
.ui-filter-pill--accent {
--pill-rgb: var(--accent-rgb, var(--primary-rgb));
--pill-fg: var(--accent-color, var(--primary));
}
.ui-info-tile {
--tile-rgb: var(--primary-rgb);
@apply rounded-xl border transition-all;
@apply relative overflow-hidden rounded-2xl border transition-all duration-200;
border-color: color-mix(in oklab, rgb(var(--tile-rgb)) 10%, var(--border-color));
background:
linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 98%, transparent), color-mix(in oklab, rgb(var(--tile-rgb)) 1%, var(--header-bg)));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.3);
box-shadow:
0 14px 30px rgba(var(--text-rgb), 0.04),
inset 0 1px 0 rgba(255, 255, 255, 0.3);
}
.ui-info-tile::before {
content: '';
position: absolute;
inset: 0 auto 0 0;
width: 3px;
background: linear-gradient(180deg, rgba(var(--tile-rgb), 0.78), rgba(var(--tile-rgb), 0.18));
}
.ui-info-tile:hover {
border-color: color-mix(in oklab, rgb(var(--tile-rgb)) 18%, var(--border-color));
transform: translateY(-1px);
}
.ui-info-tile--row {
@@ -652,7 +1059,7 @@ html.dark {
}
.ui-info-tile--stack {
@apply px-3.5 py-3 text-left;
@apply px-4 py-4 text-left;
}
.ui-info-tile--blue {
@@ -675,20 +1082,26 @@ html.dark {
--tile-rgb: 100 116 139;
}
.paragraph-comments-intro {
@apply flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between;
}
.paragraph-comments-shell {
@apply space-y-4;
@apply relative;
}
.paragraph-comments-summary {
@apply shrink-0 self-start;
.paragraph-comments-toolbar {
@apply flex flex-col gap-3 rounded-2xl px-4 py-3 sm:flex-row sm:items-center sm:justify-between;
border: 1px solid color-mix(in oklab, var(--primary) 9%, var(--border-color));
}
.paragraph-comments-toolbar-copy {
@apply min-w-0 space-y-1;
}
.paragraph-comments-visibility {
@apply shrink-0;
}
.paragraph-comment-paragraph {
position: relative;
padding-right: clamp(3rem, 7vw, 4.25rem);
scroll-margin-top: 8rem;
transition:
color 0.2s ease,
@@ -707,47 +1120,76 @@ html.dark {
margin-right: -0.6rem;
}
.paragraph-comment-row {
@apply mt-3 flex flex-col gap-2 rounded-lg border px-3 py-2 sm:flex-row sm:items-center sm:justify-between;
border-color: color-mix(in oklab, var(--primary) 9%, var(--border-color));
background:
linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 97%, transparent), color-mix(in oklab, var(--header-bg) 90%, transparent));
}
.paragraph-comment-row:hover {
border-color: color-mix(in oklab, var(--primary) 18%, var(--border-color));
}
.paragraph-comment-row.is-active {
border-color: color-mix(in oklab, var(--primary) 24%, var(--border-color));
box-shadow:
inset 0 0 0 1px rgba(var(--primary-rgb), 0.08),
0 10px 24px rgba(var(--text-rgb), 0.04);
}
.paragraph-comment-command {
@apply min-w-0 font-mono text-[11px] leading-6 sm:text-[12px];
.paragraph-comment-marker {
position: absolute;
top: 0.25rem;
right: 0.25rem;
z-index: 3;
display: inline-flex;
align-items: center;
gap: 0.3rem;
min-width: 2rem;
border-radius: 999px;
border: 1px solid color-mix(in oklab, var(--primary) 16%, var(--border-color));
background: color-mix(in oklab, var(--terminal-bg) 94%, var(--header-bg));
color: var(--text-secondary);
box-shadow: 0 10px 24px rgba(var(--text-rgb), 0.08);
padding: 0.3rem 0.5rem;
opacity: 0.34;
pointer-events: auto;
transform: translate3d(0, 0, 0);
transition:
opacity 0.2s ease,
transform 0.2s ease,
border-color 0.2s ease,
color 0.2s ease,
background-color 0.2s ease;
}
.paragraph-comment-prompt {
color: var(--primary);
.paragraph-comment-marker-icon {
@apply text-[11px];
}
.paragraph-comment-command-text {
.paragraph-comment-marker-count {
@apply text-[10px] font-semibold leading-none;
}
[data-paragraph-comments-visible='true'] .paragraph-comment-paragraph:hover .paragraph-comment-marker,
[data-paragraph-comments-visible='true'] .paragraph-comment-paragraph:focus-within .paragraph-comment-marker,
[data-paragraph-comments-visible='true'] .paragraph-comment-marker.has-comments,
[data-paragraph-comments-visible='true'] .paragraph-comment-marker.is-active {
opacity: 1;
transform: translate3d(0, 0, 0);
}
.paragraph-comment-marker.has-comments,
.paragraph-comment-marker.is-active {
border-color: color-mix(in oklab, var(--primary) 24%, var(--border-color));
color: var(--title-color);
word-break: break-all;
}
.paragraph-comment-actions {
@apply flex flex-wrap items-center gap-2;
.paragraph-comment-marker.is-active {
background: color-mix(in oklab, var(--primary) 8%, var(--terminal-bg));
}
.paragraph-comment-hint {
@apply inline-flex items-center rounded-md border px-2 py-1 text-[10px] font-mono uppercase tracking-[0.18em];
border-color: color-mix(in oklab, var(--primary) 8%, var(--border-color));
color: var(--text-tertiary);
background: color-mix(in oklab, var(--header-bg) 84%, transparent);
.paragraph-comment-marker:hover {
border-color: color-mix(in oklab, var(--primary) 28%, var(--border-color));
color: var(--title-color);
}
@media (max-width: 768px) {
.paragraph-comment-marker {
right: 0.2rem;
top: -0.8rem;
}
}
@media (hover: none) {
[data-paragraph-comments-visible='true'] .paragraph-comment-marker {
opacity: 0.92;
pointer-events: auto;
transform: translate3d(0, 0, 0);
}
}
.paragraph-comment-panel {