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:
24
frontend/public/review-covers/black-myth-wukong.svg
Normal file
24
frontend/public/review-covers/black-myth-wukong.svg
Normal 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 |
22
frontend/public/review-covers/hero-dreams-in-tired-life.svg
Normal file
22
frontend/public/review-covers/hero-dreams-in-tired-life.svg
Normal 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 |
@@ -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 |
20
frontend/public/review-covers/placed-within.svg
Normal file
20
frontend/public/review-covers/placed-within.svg
Normal 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 |
22
frontend/public/review-covers/the-long-season.svg
Normal file
22
frontend/public/review-covers/the-long-season.svg
Normal 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 |
20
frontend/public/review-covers/thirteen-invites.svg
Normal file
20
frontend/public/review-covers/thirteen-invites.svg
Normal 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 |
@@ -40,7 +40,7 @@ function formatCommentDate(dateStr: string): string {
|
||||
<div class="space-y-3">
|
||||
<span class="terminal-kicker">
|
||||
<i class="fas fa-message"></i>
|
||||
discussion buffer
|
||||
{t('comments.kicker')}
|
||||
</span>
|
||||
<div class="terminal-section-title">
|
||||
<span class="terminal-section-icon">
|
||||
@@ -72,7 +72,7 @@ function formatCommentDate(dateStr: string): string {
|
||||
type="text"
|
||||
name="nickname"
|
||||
required
|
||||
placeholder="anonymous_operator"
|
||||
placeholder={t('comments.nicknamePlaceholder')}
|
||||
class="terminal-form-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -83,7 +83,7 @@ function formatCommentDate(dateStr: string): string {
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="you@example.com"
|
||||
placeholder={t('comments.emailPlaceholder')}
|
||||
class="terminal-form-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -210,7 +210,7 @@ function formatCommentDate(dateStr: string): string {
|
||||
const replyBtns = document.querySelectorAll('.reply-btn');
|
||||
const messageBox = document.getElementById('comment-message');
|
||||
const postSlug = wrapper?.getAttribute('data-post-slug') || '';
|
||||
const apiBase = wrapper?.getAttribute('data-api-base') || 'http://localhost:5150/api';
|
||||
const apiBase = wrapper?.getAttribute('data-api-base') || '/api';
|
||||
|
||||
function showMessage(message: string, type: 'success' | 'error' | 'info') {
|
||||
if (!messageBox) return;
|
||||
@@ -319,7 +319,7 @@ function formatCommentDate(dateStr: string): string {
|
||||
formContainer?.classList.add('hidden');
|
||||
showMessage(t('comments.submitSuccess'), 'success');
|
||||
} catch (error) {
|
||||
showMessage(t('comments.submitFailed', { message: error instanceof Error ? error.message : 'unknown error' }), 'error');
|
||||
showMessage(t('comments.submitFailed', { message: error instanceof Error ? error.message : t('common.unknownError') }), 'error');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -75,8 +75,8 @@ const tools = [
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 border-t border-[var(--border-color)]/70 pt-4">
|
||||
<p class="text-xs text-[var(--text-tertiary)] font-mono">
|
||||
<span class="text-[var(--primary)]">user@{siteSettings.siteShortName.toLowerCase()}</span>:<span class="text-[var(--secondary)]">~</span>$ echo "{siteSettings.siteDescription}"
|
||||
<p class="text-xs leading-6 text-[var(--text-tertiary)]">
|
||||
{t('footer.summary')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -130,21 +130,21 @@ const { t } = getI18n(Astro);
|
||||
<p class="flex items-center gap-2">
|
||||
<span class="text-[var(--text-tertiary)]">{t('friends.name')}:</span>
|
||||
<span class="text-[var(--text)] font-medium">{siteSettings.siteName}</span>
|
||||
<button type="button" class="copy-btn text-[var(--primary)] hover:underline text-xs" data-text={siteSettings.siteName}>
|
||||
<button type="button" class="copy-btn terminal-copy-button" data-text={siteSettings.siteName}>
|
||||
<i class="fas fa-copy"></i>{t('friendForm.copy')}
|
||||
</button>
|
||||
</p>
|
||||
<p class="flex items-center gap-2">
|
||||
<span class="text-[var(--text-tertiary)]">{t('friends.link')}:</span>
|
||||
<span class="text-[var(--text)]">{siteSettings.siteUrl}</span>
|
||||
<button type="button" class="copy-btn text-[var(--primary)] hover:underline text-xs" data-text={siteSettings.siteUrl}>
|
||||
<button type="button" class="copy-btn terminal-copy-button" data-text={siteSettings.siteUrl}>
|
||||
<i class="fas fa-copy"></i>{t('friendForm.copy')}
|
||||
</button>
|
||||
</p>
|
||||
<p class="flex items-center gap-2">
|
||||
<span class="text-[var(--text-tertiary)]">{t('friends.description')}:</span>
|
||||
<span class="text-[var(--text)]">{siteSettings.siteDescription}</span>
|
||||
<button type="button" class="copy-btn text-[var(--primary)] hover:underline text-xs" data-text={siteSettings.siteDescription}>
|
||||
<button type="button" class="copy-btn terminal-copy-button" data-text={siteSettings.siteDescription}>
|
||||
<i class="fas fa-copy"></i>{t('friendForm.copy')}
|
||||
</button>
|
||||
</p>
|
||||
@@ -178,7 +178,7 @@ const { t } = getI18n(Astro);
|
||||
const reciprocalInfo = document.getElementById('reciprocal-info') as HTMLDivElement | null;
|
||||
const messageDiv = document.getElementById('form-message') as HTMLDivElement | null;
|
||||
const copyBtns = document.querySelectorAll('.copy-btn');
|
||||
const apiBase = wrapper?.getAttribute('data-api-base') || 'http://localhost:5150/api';
|
||||
const apiBase = wrapper?.getAttribute('data-api-base') || '/api';
|
||||
|
||||
reciprocalCheckbox?.addEventListener('change', () => {
|
||||
reciprocalInfo?.classList.toggle('hidden', !reciprocalCheckbox.checked);
|
||||
@@ -248,7 +248,7 @@ const { t } = getI18n(Astro);
|
||||
reciprocalInfo?.classList.add('hidden');
|
||||
showMessage(t('friendForm.submitSuccess'), 'success');
|
||||
} catch (error) {
|
||||
showMessage(t('friendForm.submitFailed', { message: error instanceof Error ? error.message : 'unknown error' }), 'error');
|
||||
showMessage(t('friendForm.submitFailed', { message: error instanceof Error ? error.message : t('common.unknownError') }), 'error');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -14,7 +14,7 @@ const { t } = getI18n(Astro);
|
||||
href={friend.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="terminal-panel group flex h-full items-start gap-4 p-4 transition-all duration-300 hover:-translate-y-1 hover:border-[var(--primary)]"
|
||||
class="terminal-panel terminal-interactive-card group flex h-full items-start gap-4 p-4"
|
||||
>
|
||||
<div class="shrink-0">
|
||||
{friend.avatar ? (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
import { API_BASE_URL } from '../lib/api/client';
|
||||
import { terminalConfig } from '../lib/config/terminal';
|
||||
import { getI18n, SUPPORTED_LOCALES } from '../lib/i18n';
|
||||
import type { SiteSettings } from '../lib/types';
|
||||
@@ -14,6 +15,12 @@ const {
|
||||
|
||||
const { locale, t, buildLocaleUrl } = getI18n(Astro);
|
||||
const aiEnabled = Boolean(Astro.props.siteSettings?.ai?.enabled);
|
||||
const musicPlaylist = (Astro.props.siteSettings?.musicPlaylist || []).filter(
|
||||
(item) => item?.title?.trim() && item?.url?.trim()
|
||||
);
|
||||
const musicPlaylistPayload = JSON.stringify(musicPlaylist);
|
||||
const hasMusicPlaylist = musicPlaylist.length > 0;
|
||||
const currentMusicTrack = hasMusicPlaylist ? musicPlaylist[0] : null;
|
||||
const navItems = [
|
||||
{ icon: 'fa-file-code', text: t('nav.articles'), href: '/articles' },
|
||||
{ icon: 'fa-folder', text: t('nav.categories'), href: '/categories' },
|
||||
@@ -31,54 +38,34 @@ const localeLinks = SUPPORTED_LOCALES.map((item) => ({
|
||||
shortLabel: item === 'zh-CN' ? '中' : 'EN',
|
||||
}));
|
||||
const currentPath = Astro.url.pathname;
|
||||
const currentNavLabel =
|
||||
navItems.find((item) => currentPath === item.href || (item.href !== '/' && currentPath.startsWith(item.href)))
|
||||
?.text || t('header.navigation');
|
||||
---
|
||||
|
||||
<header data-ai-search-enabled={aiEnabled ? 'true' : 'false'} class="sticky top-0 z-50 border-b border-[var(--border-color)] backdrop-blur-xl" style="background-color: color-mix(in oklab, var(--bg) 88%, transparent);">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-2.5">
|
||||
<div class="terminal-toolbar-shell">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/" class="terminal-toolbar-module shrink-0 min-w-[11.5rem] hover:border-[var(--primary)] transition-all">
|
||||
<span class="flex h-10 w-10 items-center justify-center rounded-xl border border-[var(--border-color)] bg-[var(--primary)]/8 text-[var(--primary)]">
|
||||
<i class="fas fa-terminal text-lg"></i>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div class="flex items-center gap-2 lg:flex-nowrap">
|
||||
<a href="/" class="terminal-toolbar-module shrink-0 min-w-[9.5rem] px-2.5 py-1.5 hover:border-[var(--primary)] transition-all">
|
||||
<span class="flex h-8 w-8 items-center justify-center rounded-xl border border-[var(--border-color)] bg-[var(--primary)]/8 text-[var(--primary)]">
|
||||
<i class="fas fa-terminal text-base"></i>
|
||||
</span>
|
||||
<span>
|
||||
<span class="terminal-toolbar-label block">root@termi</span>
|
||||
<span class="mt-1 block text-lg font-bold text-[var(--title-color)]">{siteName}</span>
|
||||
<span class="terminal-toolbar-label block">{t('header.shellLabel')}</span>
|
||||
<span class="mt-0.5 block text-[15px] font-bold text-[var(--title-color)]">{siteName}</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<div class="hidden xl:flex terminal-toolbar-module min-w-[15rem]">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="terminal-toolbar-label">playerctl</div>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<button id="music-prev" class="terminal-toolbar-iconbtn">
|
||||
<i class="fas fa-step-backward text-xs"></i>
|
||||
</button>
|
||||
<button id="music-play" class="terminal-toolbar-iconbtn bg-[var(--primary)]/12 text-[var(--primary)]" style="border-color: color-mix(in oklab, var(--primary) 28%, var(--border-color));">
|
||||
<i class="fas fa-play text-xs" id="music-play-icon"></i>
|
||||
</button>
|
||||
<button id="music-next" class="terminal-toolbar-iconbtn">
|
||||
<i class="fas fa-step-forward text-xs"></i>
|
||||
</button>
|
||||
<span class="min-w-0 flex-1 truncate text-xs font-mono text-[var(--text-secondary)]" id="music-title">
|
||||
ギターと孤独と蒼い惑星
|
||||
</span>
|
||||
<button id="music-volume" class="terminal-toolbar-iconbtn">
|
||||
<i class="fas fa-volume-up text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative hidden md:block flex-1 min-w-0">
|
||||
<div class="terminal-toolbar-module gap-3">
|
||||
<div class="terminal-toolbar-label" id="search-label">grep -i</div>
|
||||
<div class="relative hidden lg:block flex-1 min-w-0 max-w-[16rem] xl:max-w-[18rem]">
|
||||
<div class="terminal-toolbar-module gap-2 px-2.5 py-1.5">
|
||||
<div class="terminal-toolbar-label" id="search-label">{t('header.searchPromptKeyword')}</div>
|
||||
{aiEnabled && (
|
||||
<div id="search-mode-panel" class="flex items-center gap-1 rounded-xl border border-[var(--border-color)] bg-[var(--header-bg)]/80 p-1">
|
||||
<div id="search-mode-panel" class="hidden 2xl:flex items-center gap-1 rounded-xl border border-[var(--border-color)] bg-[var(--header-bg)]/80 p-1">
|
||||
<button
|
||||
type="button"
|
||||
class="search-mode-btn rounded-lg px-3 py-2 text-xs font-medium text-[var(--text-secondary)] transition hover:bg-[var(--header-bg)]"
|
||||
class="search-mode-btn rounded-lg px-2.5 py-1.5 text-xs font-medium text-[var(--text-secondary)] transition hover:bg-[var(--header-bg)]"
|
||||
data-search-mode="keyword"
|
||||
aria-pressed="true"
|
||||
>
|
||||
@@ -87,7 +74,7 @@ const currentPath = Astro.url.pathname;
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="search-mode-btn rounded-lg px-3 py-2 text-xs font-medium text-[var(--text-secondary)] transition hover:bg-[var(--header-bg)]"
|
||||
class="search-mode-btn rounded-lg px-2.5 py-1.5 text-xs font-medium text-[var(--text-secondary)] transition hover:bg-[var(--header-bg)]"
|
||||
data-search-mode="ai"
|
||||
aria-pressed="false"
|
||||
>
|
||||
@@ -102,24 +89,73 @@ const currentPath = Astro.url.pathname;
|
||||
placeholder={t('header.searchPlaceholderKeyword')}
|
||||
class="terminal-console-input"
|
||||
/>
|
||||
<span id="search-hint" class="hidden xl:inline text-xs font-mono text-[var(--secondary)]">articles/*.md</span>
|
||||
<button id="search-btn" class="terminal-toolbar-iconbtn">
|
||||
<button id="search-btn" class="terminal-toolbar-iconbtn h-8 w-8">
|
||||
<i id="search-btn-icon" class="fas fa-search text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p id="search-hint" class="px-1 pt-0.5 text-[11px] font-mono text-[var(--text-tertiary)]">
|
||||
{t('header.searchHintKeyword')}
|
||||
</p>
|
||||
<div
|
||||
id="search-results"
|
||||
class="hidden absolute right-0 top-[calc(100%+12px)] w-[26rem] overflow-hidden rounded-xl border border-[var(--border-color)] bg-[var(--terminal-bg)] shadow-[0_20px_40px_rgba(15,23,42,0.08)]"
|
||||
class="hidden absolute right-0 top-[calc(100%+12px)] z-20 w-[26rem] overflow-hidden rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)] shadow-[0_20px_40px_rgba(15,23,42,0.08)]"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="hidden sm:flex items-center gap-1 rounded-xl border border-[var(--border-color)] bg-[var(--header-bg)]/80 p-1">
|
||||
<div class="hidden 2xl:flex terminal-toolbar-module min-w-0 max-w-[13rem] gap-2 px-2.5 py-1.5">
|
||||
<div class="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border-color)] bg-[var(--primary)]/8">
|
||||
<img
|
||||
id="desktop-music-cover"
|
||||
src={currentMusicTrack?.coverImageUrl || ''}
|
||||
alt={currentMusicTrack?.title || 'Music cover'}
|
||||
class:list={[
|
||||
'h-full w-full object-cover',
|
||||
!currentMusicTrack?.coverImageUrl && 'hidden'
|
||||
]}
|
||||
/>
|
||||
<i
|
||||
id="desktop-music-cover-fallback"
|
||||
class:list={[
|
||||
'fas fa-compact-disc text-sm text-[var(--primary)]',
|
||||
currentMusicTrack?.coverImageUrl && 'hidden'
|
||||
]}
|
||||
></i>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-[13px] font-semibold text-[var(--title-color)]" id="desktop-music-title">
|
||||
{currentMusicTrack?.title || '未配置曲目'}
|
||||
</p>
|
||||
<div class="mt-1 flex items-center gap-1">
|
||||
<button id="desktop-music-prev" class="terminal-toolbar-iconbtn h-7 w-7" disabled={!hasMusicPlaylist}>
|
||||
<i class="fas fa-step-backward text-[11px]"></i>
|
||||
</button>
|
||||
<button id="desktop-music-play" class="terminal-toolbar-iconbtn h-7 w-7 bg-[var(--primary)]/12 text-[var(--primary)]" style="border-color: color-mix(in oklab, var(--primary) 28%, var(--border-color));" disabled={!hasMusicPlaylist}>
|
||||
<i class="fas fa-play text-[11px]" id="desktop-music-play-icon"></i>
|
||||
</button>
|
||||
<button id="desktop-music-next" class="terminal-toolbar-iconbtn h-7 w-7" disabled={!hasMusicPlaylist}>
|
||||
<i class="fas fa-step-forward text-[11px]"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{aiEnabled && (
|
||||
<a
|
||||
href="/ask"
|
||||
class="hidden lg:inline-flex items-center gap-2 rounded-xl border border-[var(--primary)]/18 bg-[var(--primary)]/8 px-2.5 py-1.5 text-sm font-medium text-[var(--primary)] transition hover:border-[var(--primary)]/32 hover:text-[var(--title-color)]"
|
||||
>
|
||||
<i class="fas fa-robot text-sm"></i>
|
||||
<span class="hidden xl:inline">{t('nav.ask')}</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
<div class="hidden lg:flex items-center gap-1 rounded-xl border border-[var(--border-color)] bg-[var(--header-bg)]/80 p-0.5">
|
||||
{localeLinks.map((item) => (
|
||||
<a
|
||||
href={item.href}
|
||||
data-locale-switch={item.locale}
|
||||
class:list={[
|
||||
'rounded-lg px-3 py-2 text-xs font-semibold transition',
|
||||
'rounded-lg px-2.5 py-1.5 text-xs font-semibold transition',
|
||||
item.locale === locale
|
||||
? 'bg-[var(--primary)] text-white shadow-sm'
|
||||
: 'text-[var(--text-secondary)] hover:bg-[var(--header-bg)]'
|
||||
@@ -132,169 +168,248 @@ const currentPath = Astro.url.pathname;
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
id="theme-toggle"
|
||||
class="theme-toggle terminal-toolbar-iconbtn h-11 w-11 shrink-0"
|
||||
aria-label={t('header.themeToggle')}
|
||||
title={t('header.themeToggle')}
|
||||
>
|
||||
<i id="theme-icon" class="fas fa-moon text-[var(--primary)]"></i>
|
||||
</button>
|
||||
<div class="relative shrink-0">
|
||||
<button
|
||||
id="theme-toggle"
|
||||
class="theme-toggle terminal-toolbar-iconbtn h-8 w-8 shrink-0"
|
||||
aria-label={t('header.themeToggle')}
|
||||
title={t('header.themeToggle')}
|
||||
>
|
||||
<i id="theme-icon" class="fas fa-desktop text-sm text-[var(--text-secondary)]"></i>
|
||||
<span id="theme-toggle-label" class="sr-only">
|
||||
{t('header.themeSystem')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
id="mobile-menu-btn"
|
||||
class="lg:hidden terminal-toolbar-iconbtn h-11 w-11 shrink-0"
|
||||
class="terminal-toolbar-iconbtn h-9 w-9 shrink-0 lg:hidden"
|
||||
aria-label={t('header.toggleMenu')}
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span class="sr-only">
|
||||
{currentNavLabel}
|
||||
</span>
|
||||
<i class="fas fa-bars text-[var(--text)]"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="hidden lg:flex items-center gap-3 border-t border-[var(--border-color)]/70 pt-3">
|
||||
<div class="terminal-toolbar-label">{t('header.navigation')}</div>
|
||||
<nav class="min-w-0 flex-1 flex items-center gap-1.5 overflow-x-auto pb-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive = currentPath === item.href || (item.href !== '/' && currentPath.startsWith(item.href));
|
||||
return (
|
||||
<a
|
||||
href={item.href}
|
||||
class:list={[
|
||||
'terminal-nav-link',
|
||||
isActive && 'is-active'
|
||||
]}
|
||||
>
|
||||
<i class={`fas ${item.icon} text-xs`}></i>
|
||||
<span>{item.text}</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
<div id="mobile-menu" class="hidden lg:hidden border-t border-[var(--border-color)] bg-[var(--bg)]">
|
||||
<div class="px-4 py-3 space-y-3">
|
||||
<div class="space-y-3 md:hidden">
|
||||
{aiEnabled && (
|
||||
<div class="flex items-center gap-2 rounded-2xl border border-[var(--border-color)] bg-[var(--header-bg)]/80 p-1">
|
||||
<button
|
||||
type="button"
|
||||
class="search-mode-btn flex-1 rounded-xl px-3 py-2 text-sm font-medium text-[var(--text-secondary)] transition hover:bg-[var(--header-bg)]"
|
||||
data-search-mode="keyword"
|
||||
aria-pressed="true"
|
||||
>
|
||||
<i class="fas fa-search mr-2 text-xs"></i>
|
||||
<span>{t('header.searchModeKeywordMobile')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="search-mode-btn flex-1 rounded-xl px-3 py-2 text-sm font-medium text-[var(--text-secondary)] transition hover:bg-[var(--header-bg)]"
|
||||
data-search-mode="ai"
|
||||
aria-pressed="false"
|
||||
>
|
||||
<i class="fas fa-robot mr-2 text-xs"></i>
|
||||
<span>{t('header.searchModeAiMobile')}</span>
|
||||
</button>
|
||||
<div id="mobile-menu" class="hidden border-t border-[var(--border-color)] bg-[var(--bg)] lg:hidden">
|
||||
<div class="px-4 py-4 space-y-4">
|
||||
<div class="grid gap-3 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-3 lg:hidden">
|
||||
{aiEnabled && (
|
||||
<div class="flex items-center gap-2 rounded-2xl border border-[var(--border-color)] bg-[var(--header-bg)]/80 p-1">
|
||||
<button
|
||||
type="button"
|
||||
class="search-mode-btn flex-1 rounded-xl px-3 py-2 text-sm font-medium text-[var(--text-secondary)] transition hover:bg-[var(--header-bg)]"
|
||||
data-search-mode="keyword"
|
||||
aria-pressed="true"
|
||||
>
|
||||
<i class="fas fa-search mr-2 text-xs"></i>
|
||||
<span>{t('header.searchModeKeywordMobile')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="search-mode-btn flex-1 rounded-xl px-3 py-2 text-sm font-medium text-[var(--text-secondary)] transition hover:bg-[var(--header-bg)]"
|
||||
data-search-mode="ai"
|
||||
aria-pressed="false"
|
||||
>
|
||||
<i class="fas fa-robot mr-2 text-xs"></i>
|
||||
<span>{t('header.searchModeAiMobile')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div class="terminal-toolbar-module">
|
||||
<span class="terminal-toolbar-label" id="mobile-search-label">{t('header.searchPromptKeyword')}</span>
|
||||
<input
|
||||
type="text"
|
||||
id="mobile-search-input"
|
||||
placeholder={t('header.searchPlaceholderKeyword')}
|
||||
class="terminal-console-input"
|
||||
/>
|
||||
<button id="mobile-search-btn" class="terminal-toolbar-iconbtn">
|
||||
<i id="mobile-search-btn-icon" class="fas fa-search text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p id="mobile-search-hint" class="px-1 text-xs font-mono text-[var(--text-tertiary)]">{t('header.searchHintKeyword')}</p>
|
||||
</div>
|
||||
)}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="terminal-toolbar-label">{t('common.language')}</span>
|
||||
<div class="flex flex-1 items-center gap-2">
|
||||
{localeLinks.map((item) => (
|
||||
<a
|
||||
href={item.href}
|
||||
data-locale-switch={item.locale}
|
||||
class:list={[
|
||||
'flex-1 rounded-xl border px-3 py-2 text-center text-sm font-medium transition',
|
||||
item.locale === locale
|
||||
? 'border-[var(--primary)] bg-[var(--primary)]/10 text-[var(--primary)]'
|
||||
: 'border-[var(--border-color)] bg-[var(--header-bg)] text-[var(--text-secondary)]'
|
||||
]}
|
||||
aria-current={item.locale === locale ? 'true' : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
|
||||
<div class="flex items-center gap-2 xl:hidden">
|
||||
<span class="terminal-toolbar-label">{t('common.language')}</span>
|
||||
<div class="flex flex-1 items-center gap-2">
|
||||
{localeLinks.map((item) => (
|
||||
<a
|
||||
href={item.href}
|
||||
data-locale-switch={item.locale}
|
||||
class:list={[
|
||||
'flex-1 rounded-xl border px-3 py-2 text-center text-sm font-medium transition',
|
||||
item.locale === locale
|
||||
? 'border-[var(--primary)] bg-[var(--primary)]/10 text-[var(--primary)]'
|
||||
: 'border-[var(--border-color)] bg-[var(--header-bg)] text-[var(--text-secondary)]'
|
||||
]}
|
||||
aria-current={item.locale === locale ? 'true' : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="terminal-toolbar-module">
|
||||
<span class="terminal-toolbar-label" id="mobile-search-label">grep -i</span>
|
||||
<input
|
||||
type="text"
|
||||
id="mobile-search-input"
|
||||
placeholder={t('header.searchPlaceholderKeyword')}
|
||||
class="terminal-console-input"
|
||||
/>
|
||||
<button id="mobile-search-btn" class="terminal-toolbar-iconbtn">
|
||||
<i id="mobile-search-btn-icon" class="fas fa-search text-sm"></i>
|
||||
</button>
|
||||
|
||||
<div class="terminal-toolbar-module items-center gap-3">
|
||||
<div class="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-[var(--border-color)] bg-[var(--primary)]/8">
|
||||
<img
|
||||
id="music-cover"
|
||||
src={currentMusicTrack?.coverImageUrl || ''}
|
||||
alt={currentMusicTrack?.title || 'Music cover'}
|
||||
class:list={[
|
||||
'h-full w-full object-cover',
|
||||
!currentMusicTrack?.coverImageUrl && 'hidden'
|
||||
]}
|
||||
/>
|
||||
<i
|
||||
id="music-cover-fallback"
|
||||
class:list={[
|
||||
'fas fa-compact-disc text-base text-[var(--primary)]',
|
||||
currentMusicTrack?.coverImageUrl && 'hidden'
|
||||
]}
|
||||
></i>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="terminal-toolbar-label">{t('header.musicPanel')}</div>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<button id="music-prev" class="terminal-toolbar-iconbtn" disabled={!hasMusicPlaylist}>
|
||||
<i class="fas fa-step-backward text-xs"></i>
|
||||
</button>
|
||||
<button id="music-play" class="terminal-toolbar-iconbtn bg-[var(--primary)]/12 text-[var(--primary)]" style="border-color: color-mix(in oklab, var(--primary) 28%, var(--border-color));" disabled={!hasMusicPlaylist}>
|
||||
<i class="fas fa-play text-xs" id="music-play-icon"></i>
|
||||
</button>
|
||||
<button id="music-next" class="terminal-toolbar-iconbtn" disabled={!hasMusicPlaylist}>
|
||||
<i class="fas fa-step-forward text-xs"></i>
|
||||
</button>
|
||||
<button id="music-volume" class="terminal-toolbar-iconbtn" disabled={!hasMusicPlaylist}>
|
||||
<i class="fas fa-volume-up text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2 min-w-0">
|
||||
<p class="truncate text-sm font-semibold text-[var(--title-color)]" id="music-title">
|
||||
{currentMusicTrack?.title || '未配置曲目'}
|
||||
</p>
|
||||
<p class="truncate text-[11px] text-[var(--text-tertiary)]" id="music-artist">
|
||||
{currentMusicTrack?.artist || currentMusicTrack?.album || '等待播放'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p id="mobile-search-hint" class="px-1 text-xs font-mono text-[var(--text-tertiary)]">articles/*.md</p>
|
||||
</div>
|
||||
{navItems.map(item => (
|
||||
<a
|
||||
href={item.href}
|
||||
class:list={[
|
||||
'terminal-nav-link flex',
|
||||
currentPath === item.href || (item.href !== '/' && currentPath.startsWith(item.href))
|
||||
? 'is-active'
|
||||
: ''
|
||||
]}
|
||||
>
|
||||
<i class={`fas ${item.icon} w-5`}></i>
|
||||
<span>{item.text}</span>
|
||||
</a>
|
||||
))}
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{navItems.map(item => (
|
||||
<a
|
||||
href={item.href}
|
||||
class:list={[
|
||||
'terminal-nav-link',
|
||||
currentPath === item.href || (item.href !== '/' && currentPath.startsWith(item.href))
|
||||
? 'is-active'
|
||||
: ''
|
||||
]}
|
||||
>
|
||||
<span class="flex items-center gap-3">
|
||||
<span class="flex h-10 w-10 items-center justify-center rounded-xl border border-[var(--border-color)] bg-[var(--header-bg)]/82 text-[var(--primary)]">
|
||||
<i class={`fas ${item.icon} text-sm`}></i>
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="terminal-toolbar-label block">{t('header.navigation')}</span>
|
||||
<span class="mt-1 block text-sm font-semibold text-[var(--title-color)]">{item.text}</span>
|
||||
</span>
|
||||
</span>
|
||||
<i class="fas fa-arrow-right text-[11px] text-[var(--text-tertiary)]"></i>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<script is:inline>
|
||||
// Theme Toggle - simplified vanilla JS
|
||||
<script is:inline define:vars={{ apiBase: API_BASE_URL, musicPlaylistPayload }}>
|
||||
const t = window.__termiTranslate;
|
||||
|
||||
// Theme selection
|
||||
function initThemeToggle() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
const themeIcon = document.getElementById('theme-icon');
|
||||
const themeToggleLabel = document.getElementById('theme-toggle-label');
|
||||
const themeApi = window.__termiTheme;
|
||||
|
||||
if (!themeToggle || !themeIcon) {
|
||||
console.error('[Theme] Elements not found');
|
||||
if (!themeToggle || !themeIcon || !themeApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Theme] Initializing toggle button');
|
||||
|
||||
function updateThemeIcon(isDark) {
|
||||
console.log('[Theme] Updating icon, isDark:', isDark);
|
||||
if (isDark) {
|
||||
themeIcon.className = 'fas fa-sun text-[var(--secondary)]';
|
||||
} else {
|
||||
themeIcon.className = 'fas fa-moon text-[var(--primary)]';
|
||||
}
|
||||
if (themeToggle.dataset.bound === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
themeToggle.addEventListener('click', function() {
|
||||
console.log('[Theme] Button clicked');
|
||||
const root = document.documentElement;
|
||||
const hasDark = root.classList.contains('dark');
|
||||
console.log('[Theme] Current hasDark:', hasDark);
|
||||
themeToggle.dataset.bound = 'true';
|
||||
|
||||
if (hasDark) {
|
||||
root.classList.remove('dark');
|
||||
root.classList.add('light');
|
||||
localStorage.setItem('theme', 'light');
|
||||
updateThemeIcon(false);
|
||||
} else {
|
||||
root.classList.remove('light');
|
||||
root.classList.add('dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
updateThemeIcon(true);
|
||||
const themeMeta = {
|
||||
light: {
|
||||
iconClass: 'fas fa-sun text-sm',
|
||||
color: 'var(--secondary)',
|
||||
label: t('header.themeLight'),
|
||||
},
|
||||
dark: {
|
||||
iconClass: 'fas fa-moon text-sm',
|
||||
color: 'var(--primary)',
|
||||
label: t('header.themeDark'),
|
||||
},
|
||||
system: {
|
||||
iconClass: 'fas fa-desktop text-sm',
|
||||
color: 'var(--text-secondary)',
|
||||
label: t('header.themeSystem'),
|
||||
},
|
||||
};
|
||||
|
||||
function updateThemeUI(detail = null) {
|
||||
const mode = detail?.mode || themeApi.getMode();
|
||||
const resolved = detail?.resolved || themeApi.resolveTheme(mode);
|
||||
const modeMeta = themeMeta[mode] || themeMeta.system;
|
||||
const modeLabel = themeMeta[mode]?.label || themeMeta.system.label;
|
||||
const resolvedLabel = resolved === 'dark' ? t('header.themeDark') : t('header.themeLight');
|
||||
|
||||
themeIcon.className = modeMeta.iconClass;
|
||||
themeIcon.style.color = modeMeta.color;
|
||||
|
||||
if (themeToggleLabel) {
|
||||
themeToggleLabel.textContent = `${modeLabel} / ${resolvedLabel}`;
|
||||
}
|
||||
|
||||
const toggleTitle = `${t('header.themeToggle')} · ${modeLabel} / ${resolvedLabel}`;
|
||||
themeToggle.setAttribute('aria-label', toggleTitle);
|
||||
themeToggle.setAttribute('title', toggleTitle);
|
||||
}
|
||||
|
||||
themeToggle.addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const currentMode = themeApi.getMode();
|
||||
const nextMode =
|
||||
currentMode === 'system' ? 'light' :
|
||||
currentMode === 'light' ? 'dark' :
|
||||
'system';
|
||||
themeApi.applyTheme(nextMode);
|
||||
});
|
||||
|
||||
// Initialize icon based on current theme
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
updateThemeIcon(isDark);
|
||||
window.addEventListener('termi:theme-change', function(event) {
|
||||
updateThemeUI(event.detail);
|
||||
});
|
||||
|
||||
updateThemeUI(themeApi.syncTheme());
|
||||
}
|
||||
|
||||
// Run immediately if DOM is ready, otherwise wait
|
||||
@@ -304,30 +419,44 @@ const currentPath = Astro.url.pathname;
|
||||
initThemeToggle();
|
||||
}
|
||||
|
||||
// Mobile Menu
|
||||
// Site Menu
|
||||
const mobileMenuBtn = document.getElementById('mobile-menu-btn');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
const mobileSearchInput = document.getElementById('mobile-search-input');
|
||||
const mobileSearchBtn = document.getElementById('mobile-search-btn');
|
||||
|
||||
mobileMenuBtn?.addEventListener('click', () => {
|
||||
const nextExpanded = mobileMenu?.classList.contains('hidden');
|
||||
mobileMenu?.classList.toggle('hidden');
|
||||
mobileMenuBtn.setAttribute('aria-expanded', String(nextExpanded));
|
||||
});
|
||||
|
||||
document.querySelectorAll('#mobile-menu a[href]').forEach((link) => {
|
||||
link.addEventListener('click', () => {
|
||||
mobileMenu?.classList.add('hidden');
|
||||
mobileMenuBtn?.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
});
|
||||
|
||||
// Music Player with actual audio
|
||||
const musicPlay = document.getElementById('music-play');
|
||||
const musicPlayIcon = document.getElementById('music-play-icon');
|
||||
const musicCover = document.getElementById('music-cover');
|
||||
const musicCoverFallback = document.getElementById('music-cover-fallback');
|
||||
const musicTitle = document.getElementById('music-title');
|
||||
const musicArtist = document.getElementById('music-artist');
|
||||
const musicPrev = document.getElementById('music-prev');
|
||||
const musicNext = document.getElementById('music-next');
|
||||
const musicVolume = document.getElementById('music-volume');
|
||||
const desktopMusicPlay = document.getElementById('desktop-music-play');
|
||||
const desktopMusicPlayIcon = document.getElementById('desktop-music-play-icon');
|
||||
const desktopMusicCover = document.getElementById('desktop-music-cover');
|
||||
const desktopMusicCoverFallback = document.getElementById('desktop-music-cover-fallback');
|
||||
const desktopMusicTitle = document.getElementById('desktop-music-title');
|
||||
const desktopMusicPrev = document.getElementById('desktop-music-prev');
|
||||
const desktopMusicNext = document.getElementById('desktop-music-next');
|
||||
|
||||
// Playlist - Using placeholder audio URLs (replace with actual music URLs)
|
||||
const playlist = [
|
||||
{ title: 'ギターと孤独と蒼い惑星', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3' },
|
||||
{ title: '星座になれたら', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3' },
|
||||
{ title: 'あのバンド', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3' }
|
||||
];
|
||||
const playlist = JSON.parse(musicPlaylistPayload || '[]');
|
||||
|
||||
let currentSongIndex = 0;
|
||||
let isPlaying = false;
|
||||
@@ -345,12 +474,46 @@ const currentPath = Astro.url.pathname;
|
||||
}
|
||||
|
||||
function updateTitle() {
|
||||
const currentTrack = playlist[currentSongIndex] || {};
|
||||
if (musicTitle) {
|
||||
musicTitle.textContent = playlist[currentSongIndex].title;
|
||||
musicTitle.textContent = currentTrack.title || '未配置曲目';
|
||||
}
|
||||
if (desktopMusicTitle) {
|
||||
desktopMusicTitle.textContent = currentTrack.title || '未配置曲目';
|
||||
}
|
||||
if (musicArtist) {
|
||||
musicArtist.textContent = currentTrack.artist || currentTrack.album || '等待播放';
|
||||
}
|
||||
if (musicCover && musicCoverFallback) {
|
||||
if (currentTrack.coverImageUrl) {
|
||||
musicCover.setAttribute('src', currentTrack.coverImageUrl);
|
||||
musicCover.setAttribute('alt', currentTrack.title || 'Music cover');
|
||||
musicCover.classList.remove('hidden');
|
||||
musicCoverFallback.classList.add('hidden');
|
||||
} else {
|
||||
musicCover.setAttribute('src', '');
|
||||
musicCover.classList.add('hidden');
|
||||
musicCoverFallback.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
if (desktopMusicCover && desktopMusicCoverFallback) {
|
||||
if (currentTrack.coverImageUrl) {
|
||||
desktopMusicCover.setAttribute('src', currentTrack.coverImageUrl);
|
||||
desktopMusicCover.setAttribute('alt', currentTrack.title || 'Music cover');
|
||||
desktopMusicCover.classList.remove('hidden');
|
||||
desktopMusicCoverFallback.classList.add('hidden');
|
||||
} else {
|
||||
desktopMusicCover.setAttribute('src', '');
|
||||
desktopMusicCover.classList.add('hidden');
|
||||
desktopMusicCoverFallback.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function playSong() {
|
||||
if (!playlist.length) {
|
||||
return;
|
||||
}
|
||||
initAudio();
|
||||
if (audio.src !== playlist[currentSongIndex].url) {
|
||||
audio.src = playlist[currentSongIndex].url;
|
||||
@@ -360,9 +523,16 @@ const currentPath = Astro.url.pathname;
|
||||
if (musicPlayIcon) {
|
||||
musicPlayIcon.className = 'fas fa-pause text-xs';
|
||||
}
|
||||
if (desktopMusicPlayIcon) {
|
||||
desktopMusicPlayIcon.className = 'fas fa-pause text-[11px]';
|
||||
}
|
||||
if (musicTitle) {
|
||||
musicTitle.classList.add('text-[var(--primary)]');
|
||||
musicTitle.classList.remove('text-[var(--text-secondary)]');
|
||||
musicTitle.classList.remove('text-[var(--title-color)]');
|
||||
}
|
||||
if (desktopMusicTitle) {
|
||||
desktopMusicTitle.classList.add('text-[var(--primary)]');
|
||||
desktopMusicTitle.classList.remove('text-[var(--title-color)]');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,9 +544,16 @@ const currentPath = Astro.url.pathname;
|
||||
if (musicPlayIcon) {
|
||||
musicPlayIcon.className = 'fas fa-play text-xs';
|
||||
}
|
||||
if (desktopMusicPlayIcon) {
|
||||
desktopMusicPlayIcon.className = 'fas fa-play text-[11px]';
|
||||
}
|
||||
if (musicTitle) {
|
||||
musicTitle.classList.remove('text-[var(--primary)]');
|
||||
musicTitle.classList.add('text-[var(--text-secondary)]');
|
||||
musicTitle.classList.add('text-[var(--title-color)]');
|
||||
}
|
||||
if (desktopMusicTitle) {
|
||||
desktopMusicTitle.classList.remove('text-[var(--primary)]');
|
||||
desktopMusicTitle.classList.add('text-[var(--title-color)]');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,6 +566,9 @@ const currentPath = Astro.url.pathname;
|
||||
}
|
||||
|
||||
function playNext() {
|
||||
if (!playlist.length) {
|
||||
return;
|
||||
}
|
||||
currentSongIndex = (currentSongIndex + 1) % playlist.length;
|
||||
updateTitle();
|
||||
if (isPlaying) {
|
||||
@@ -397,6 +577,9 @@ const currentPath = Astro.url.pathname;
|
||||
}
|
||||
|
||||
function playPrev() {
|
||||
if (!playlist.length) {
|
||||
return;
|
||||
}
|
||||
currentSongIndex = (currentSongIndex - 1 + playlist.length) % playlist.length;
|
||||
updateTitle();
|
||||
if (isPlaying) {
|
||||
@@ -416,8 +599,11 @@ const currentPath = Astro.url.pathname;
|
||||
}
|
||||
|
||||
musicPlay?.addEventListener('click', togglePlay);
|
||||
desktopMusicPlay?.addEventListener('click', togglePlay);
|
||||
musicNext?.addEventListener('click', playNext);
|
||||
desktopMusicNext?.addEventListener('click', playNext);
|
||||
musicPrev?.addEventListener('click', playPrev);
|
||||
desktopMusicPrev?.addEventListener('click', playPrev);
|
||||
musicVolume?.addEventListener('click', toggleMute);
|
||||
|
||||
// Initialize title
|
||||
@@ -438,18 +624,17 @@ const currentPath = Astro.url.pathname;
|
||||
const searchModePanel = document.getElementById('search-mode-panel');
|
||||
const searchModeButtons = Array.from(document.querySelectorAll('.search-mode-btn'));
|
||||
const localeSwitchLinks = Array.from(document.querySelectorAll('[data-locale-switch]'));
|
||||
const searchApiBase = 'http://localhost:5150/api';
|
||||
const searchApiBase = apiBase;
|
||||
const searchInputs = [searchInput, mobileSearchInput].filter(Boolean);
|
||||
const t = window.__termiTranslate;
|
||||
const searchModeConfig = {
|
||||
keyword: {
|
||||
label: 'grep -i',
|
||||
label: t('header.searchPromptKeyword'),
|
||||
hint: t('header.searchHintKeyword'),
|
||||
placeholder: t('header.searchPlaceholderKeyword'),
|
||||
buttonIcon: 'fa-search'
|
||||
},
|
||||
ai: {
|
||||
label: 'ask ai',
|
||||
label: t('header.searchPromptAi'),
|
||||
hint: t('header.searchHintAi'),
|
||||
placeholder: t('header.searchPlaceholderAi'),
|
||||
buttonIcon: 'fa-robot'
|
||||
@@ -771,6 +956,14 @@ const currentPath = Astro.url.pathname;
|
||||
|
||||
syncSearchModeUI();
|
||||
|
||||
document.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Escape') {
|
||||
mobileMenu?.classList.add('hidden');
|
||||
mobileMenuBtn?.setAttribute('aria-expanded', 'false');
|
||||
hideSearchResults();
|
||||
}
|
||||
});
|
||||
|
||||
localeSwitchLinks.forEach((link) => {
|
||||
link.addEventListener('click', () => {
|
||||
const nextLocale = link.getAttribute('data-locale-switch');
|
||||
@@ -796,5 +989,15 @@ const currentPath = Astro.url.pathname;
|
||||
) {
|
||||
hideSearchResults();
|
||||
}
|
||||
|
||||
if (
|
||||
mobileMenu &&
|
||||
!mobileMenu.classList.contains('hidden') &&
|
||||
!mobileMenu.contains(target) &&
|
||||
!mobileMenuBtn?.contains(target)
|
||||
) {
|
||||
mobileMenu.classList.add('hidden');
|
||||
mobileMenuBtn?.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -49,10 +49,10 @@
|
||||
|
||||
// Initialize lightbox for all article images
|
||||
function initLightbox() {
|
||||
const content = document.querySelector('.article-content');
|
||||
const content = document.querySelector('[data-article-slug]');
|
||||
if (!content) return;
|
||||
|
||||
images = Array.from(content.querySelectorAll('img'));
|
||||
images = Array.from(content.querySelectorAll('.article-content img, [data-lightbox-image="true"]'));
|
||||
|
||||
images.forEach((img, index) => {
|
||||
img.style.cursor = 'zoom-in';
|
||||
|
||||
@@ -11,24 +11,32 @@ const { postSlug, class: className = '' } = Astro.props;
|
||||
const { t } = getI18n(Astro);
|
||||
---
|
||||
|
||||
<div class={`paragraph-comments-shell ${className}`} data-post-slug={postSlug} data-api-base={API_BASE_URL}>
|
||||
<div class="terminal-panel-muted paragraph-comments-intro">
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
class={`paragraph-comments-shell ${className}`}
|
||||
data-post-slug={postSlug}
|
||||
data-api-base={API_BASE_URL}
|
||||
data-storage-key={`termi:paragraph-comments:${postSlug}`}
|
||||
>
|
||||
<div class="paragraph-comments-toolbar terminal-panel-muted">
|
||||
<div class="paragraph-comments-toolbar-copy">
|
||||
<span class="terminal-kicker">
|
||||
<i class="fas fa-paragraph"></i>
|
||||
paragraph annotations
|
||||
{t('paragraphComments.kicker')}
|
||||
</span>
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-lg font-semibold text-[var(--title-color)]">{t('paragraphComments.title')}</h3>
|
||||
<p class="text-sm leading-7 text-[var(--text-secondary)]">
|
||||
{t('paragraphComments.intro')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="paragraph-comments-summary terminal-chip">
|
||||
<i class="fas fa-terminal text-[var(--primary)]"></i>
|
||||
<span data-summary-text>{t('paragraphComments.scanning')}</span>
|
||||
<p class="text-sm leading-6 text-[var(--text-secondary)]" data-summary-text>
|
||||
{t('paragraphComments.scanning')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="terminal-action-button paragraph-comments-visibility"
|
||||
data-display-toggle
|
||||
aria-pressed="true"
|
||||
>
|
||||
<i class="fas fa-comment-dots"></i>
|
||||
<span data-toggle-label>{t('paragraphComments.hideMarkers')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -62,13 +70,17 @@ const { t } = getI18n(Astro);
|
||||
const wrappers = document.querySelectorAll('.paragraph-comments-shell');
|
||||
const wrapper = wrappers.item(wrappers.length - 1) as HTMLElement | null;
|
||||
const postSlug = wrapper?.dataset.postSlug || '';
|
||||
const apiBase = wrapper?.dataset.apiBase || 'http://localhost:5150/api';
|
||||
const articleRoot = wrapper?.closest('[data-article-slug]') || document;
|
||||
const articleContent = articleRoot.querySelector('.article-content') as HTMLElement | null;
|
||||
const apiBase = wrapper?.dataset.apiBase || '/api';
|
||||
const storageKey = wrapper?.dataset.storageKey || 'termi:paragraph-comments';
|
||||
const articleRoot = wrapper?.closest('[data-article-slug]') as HTMLElement | null;
|
||||
const articleContent = articleRoot?.querySelector('.article-content') as HTMLElement | null;
|
||||
const summaryText = wrapper?.querySelector('[data-summary-text]') as HTMLElement | null;
|
||||
const toggleButton = wrapper?.querySelector('[data-display-toggle]') as HTMLButtonElement | null;
|
||||
const toggleLabel = wrapper?.querySelector('[data-toggle-label]') as HTMLElement | null;
|
||||
|
||||
if (wrapper && articleRoot && articleContent && postSlug) {
|
||||
const paragraphCounts = new Map<string, number>();
|
||||
const paragraphRows = new Map<string, HTMLElement>();
|
||||
const paragraphMarkers = new Map<string, HTMLButtonElement>();
|
||||
const paragraphDescriptors = new Map<
|
||||
string,
|
||||
ReturnType<typeof buildParagraphDescriptors>[number]
|
||||
@@ -79,6 +91,7 @@ const { t } = getI18n(Astro);
|
||||
let activeParagraphKey: string | null = null;
|
||||
let activeReplyToCommentId: number | null = null;
|
||||
let pendingCounter = 0;
|
||||
let markersVisible = true;
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
@@ -119,6 +132,14 @@ const { t } = getI18n(Astro);
|
||||
return t('paragraphComments.manyNotes', { count });
|
||||
}
|
||||
|
||||
function markerCountText(count: number): string {
|
||||
if (count <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return count > 99 ? '99+' : String(count);
|
||||
}
|
||||
|
||||
function previewReplyText(value: string | null | undefined, limit = 88) {
|
||||
const normalized = (value || '').replace(/\s+/g, ' ').trim();
|
||||
if (normalized.length <= limit) {
|
||||
@@ -128,10 +149,6 @@ const { t } = getI18n(Astro);
|
||||
return `${normalized.slice(0, limit).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function promptLabel(key: string, active: boolean) {
|
||||
return active ? `./comment --paragraph ${key} --open` : `./comment --paragraph ${key}`;
|
||||
}
|
||||
|
||||
function anchorForParagraph(key: string) {
|
||||
return `#paragraph-${key}`;
|
||||
}
|
||||
@@ -146,23 +163,6 @@ const { t } = getI18n(Astro);
|
||||
return key || null;
|
||||
}
|
||||
|
||||
function updateRowState() {
|
||||
paragraphRows.forEach((row, rowKey) => {
|
||||
const trigger = row.querySelector('[data-trigger-label]') as HTMLElement | null;
|
||||
const prompt = row.querySelector('[data-command-text]') as HTMLElement | null;
|
||||
const count = paragraphCounts.get(rowKey) || 0;
|
||||
const isActive = rowKey === activeParagraphKey;
|
||||
|
||||
row.classList.toggle('is-active', isActive);
|
||||
if (trigger) {
|
||||
trigger.textContent = countLabel(count);
|
||||
}
|
||||
if (prompt) {
|
||||
prompt.textContent = promptLabel(rowKey, isActive);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateSummaryFromCounts() {
|
||||
const paragraphCount = paragraphDescriptors.size;
|
||||
const discussedParagraphs = Array.from(paragraphCounts.values()).filter(count => count > 0).length;
|
||||
@@ -173,6 +173,11 @@ const { t } = getI18n(Astro);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!markersVisible) {
|
||||
setSummaryMessage(t('paragraphComments.markersHidden'));
|
||||
return;
|
||||
}
|
||||
|
||||
setSummaryMessage(
|
||||
t('paragraphComments.summary', {
|
||||
paragraphCount,
|
||||
@@ -182,30 +187,74 @@ const { t } = getI18n(Astro);
|
||||
);
|
||||
}
|
||||
|
||||
function createParagraphRow(key: string, excerpt: string) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'paragraph-comment-row';
|
||||
row.dataset.paragraphKey = key;
|
||||
row.innerHTML = `
|
||||
<div class="paragraph-comment-command">
|
||||
<span class="paragraph-comment-prompt">user@blog:~/articles$</span>
|
||||
<span class="paragraph-comment-command-text" data-command-text>${escapeHtml(promptLabel(key, false))}</span>
|
||||
</div>
|
||||
<div class="paragraph-comment-actions">
|
||||
<span class="paragraph-comment-hint" title="${escapeHtml(excerpt)}">${escapeHtml(t('paragraphComments.focusCurrent'))}</span>
|
||||
<button type="button" class="terminal-action-button paragraph-comment-trigger">
|
||||
<i class="fas fa-message"></i>
|
||||
<span data-trigger-label>${countLabel(0)}</span>
|
||||
</button>
|
||||
</div>
|
||||
function updateMarkerState() {
|
||||
paragraphMarkers.forEach((marker, key) => {
|
||||
const count = paragraphCounts.get(key) || 0;
|
||||
const countNode = marker.querySelector('[data-marker-count]') as HTMLElement | null;
|
||||
const isActive = key === activeParagraphKey;
|
||||
|
||||
marker.classList.toggle('has-comments', count > 0);
|
||||
marker.classList.toggle('is-active', isActive);
|
||||
marker.setAttribute(
|
||||
'aria-label',
|
||||
count > 0
|
||||
? `${t('paragraphComments.badgeLabel')} (${countLabel(count)})`
|
||||
: t('paragraphComments.badgeLabel')
|
||||
);
|
||||
|
||||
if (countNode) {
|
||||
countNode.textContent = markerCountText(count);
|
||||
countNode.classList.toggle('hidden', count <= 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applyMarkerVisibility(visible: boolean, options?: { persist?: boolean }) {
|
||||
markersVisible = visible;
|
||||
|
||||
if (articleRoot) {
|
||||
articleRoot.dataset.paragraphCommentsVisible = visible ? 'true' : 'false';
|
||||
}
|
||||
|
||||
if (toggleButton) {
|
||||
toggleButton.setAttribute('aria-pressed', visible ? 'true' : 'false');
|
||||
}
|
||||
|
||||
if (toggleLabel) {
|
||||
toggleLabel.textContent = visible
|
||||
? t('paragraphComments.hideMarkers')
|
||||
: t('paragraphComments.showMarkers');
|
||||
}
|
||||
|
||||
if (options?.persist !== false) {
|
||||
localStorage.setItem(storageKey, visible ? 'true' : 'false');
|
||||
}
|
||||
|
||||
if (!visible) {
|
||||
closePanel(true);
|
||||
}
|
||||
|
||||
updateSummaryFromCounts();
|
||||
}
|
||||
|
||||
function createParagraphMarker(key: string, excerpt: string) {
|
||||
const marker = document.createElement('button');
|
||||
marker.type = 'button';
|
||||
marker.className = 'paragraph-comment-marker';
|
||||
marker.dataset.paragraphKey = key;
|
||||
marker.title = excerpt;
|
||||
marker.innerHTML = `
|
||||
<span class="paragraph-comment-marker-icon" aria-hidden="true">
|
||||
<i class="fas fa-message"></i>
|
||||
</span>
|
||||
<span class="paragraph-comment-marker-count hidden" data-marker-count></span>
|
||||
`;
|
||||
|
||||
const button = row.querySelector('.paragraph-comment-trigger') as HTMLButtonElement | null;
|
||||
button?.addEventListener('click', () => {
|
||||
marker.addEventListener('click', () => {
|
||||
void openPanelForParagraph(key, { focusForm: true, syncHash: true });
|
||||
});
|
||||
|
||||
return row;
|
||||
return marker;
|
||||
}
|
||||
|
||||
const panel = document.createElement('section');
|
||||
@@ -215,7 +264,7 @@ const { t } = getI18n(Astro);
|
||||
<div class="space-y-2">
|
||||
<span class="terminal-kicker">
|
||||
<i class="fas fa-terminal"></i>
|
||||
paragraph thread
|
||||
${escapeHtml(t('paragraphComments.panelKicker'))}
|
||||
</span>
|
||||
<div>
|
||||
<h4 class="text-lg font-semibold text-[var(--title-color)]">${escapeHtml(t('paragraphComments.panelTitle'))}</h4>
|
||||
@@ -244,7 +293,7 @@ const { t } = getI18n(Astro);
|
||||
type="text"
|
||||
name="nickname"
|
||||
required
|
||||
placeholder="inline_operator"
|
||||
placeholder="${escapeHtml(t('paragraphComments.nicknamePlaceholder'))}"
|
||||
class="terminal-form-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -255,7 +304,7 @@ const { t } = getI18n(Astro);
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="you@example.com"
|
||||
placeholder="${escapeHtml(t('paragraphComments.emailPlaceholder'))}"
|
||||
class="terminal-form-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -413,7 +462,7 @@ const { t } = getI18n(Astro);
|
||||
paragraphCounts.set(paragraphKey, comments.length);
|
||||
pendingCountChip.textContent = `${pending.length} ${t('common.pending')}`;
|
||||
pendingCountChip.classList.toggle('hidden', pending.length === 0);
|
||||
updateRowState();
|
||||
updateMarkerState();
|
||||
updateSummaryFromCounts();
|
||||
|
||||
const approvedMarkup =
|
||||
@@ -514,17 +563,20 @@ const { t } = getI18n(Astro);
|
||||
}
|
||||
) {
|
||||
const descriptor = paragraphDescriptors.get(paragraphKey);
|
||||
const row = paragraphRows.get(paragraphKey);
|
||||
|
||||
if (!descriptor || !row) {
|
||||
if (!descriptor) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!markersVisible) {
|
||||
applyMarkerVisibility(true, { persist: false });
|
||||
}
|
||||
|
||||
activeParagraphKey = paragraphKey;
|
||||
clearStatus();
|
||||
resetReplyState();
|
||||
panelExcerpt.textContent = descriptor.excerpt;
|
||||
row.insertAdjacentElement('afterend', panel);
|
||||
descriptor.element.insertAdjacentElement('afterend', panel);
|
||||
panel.classList.remove('hidden');
|
||||
panel.dataset.paragraphKey = paragraphKey;
|
||||
|
||||
@@ -540,7 +592,7 @@ const { t } = getI18n(Astro);
|
||||
descriptor.element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
updateRowState();
|
||||
updateMarkerState();
|
||||
threadContainer.innerHTML = `
|
||||
<div class="terminal-panel-muted text-sm text-[var(--text-secondary)]">
|
||||
${escapeHtml(t('paragraphComments.loadingThread'))}
|
||||
@@ -558,7 +610,7 @@ const { t } = getI18n(Astro);
|
||||
pendingCountChip.classList.add('hidden');
|
||||
threadContainer.innerHTML = `
|
||||
<div class="paragraph-comment-status paragraph-comment-status-error">
|
||||
${escapeHtml(t('paragraphComments.loadFailed', { message: error instanceof Error ? error.message : 'unknown error' }))}
|
||||
${escapeHtml(t('paragraphComments.loadFailed', { message: error instanceof Error ? error.message : t('common.unknownError') }))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -570,7 +622,7 @@ const { t } = getI18n(Astro);
|
||||
resetReplyState();
|
||||
clearStatus();
|
||||
paragraphDescriptors.forEach(item => item.element.classList.remove('is-comment-focused'));
|
||||
updateRowState();
|
||||
updateMarkerState();
|
||||
|
||||
if (clearHash) {
|
||||
syncHashForParagraph(null);
|
||||
@@ -690,15 +742,22 @@ const { t } = getI18n(Astro);
|
||||
renderThread(descriptor.key, approvedComments);
|
||||
setStatus(t('paragraphComments.submitSuccess'), 'success');
|
||||
} catch (error) {
|
||||
setStatus(t('paragraphComments.submitFailed', { message: error instanceof Error ? error.message : 'unknown error' }), 'error');
|
||||
setStatus(t('paragraphComments.submitFailed', { message: error instanceof Error ? error.message : t('common.unknownError') }), 'error');
|
||||
}
|
||||
});
|
||||
|
||||
toggleButton?.addEventListener('click', () => {
|
||||
applyMarkerVisibility(!markersVisible);
|
||||
});
|
||||
|
||||
async function init() {
|
||||
if (!wrapper || !articleContent || !postSlug) {
|
||||
if (!wrapper || !articleRoot || !articleContent || !postSlug) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storedVisibility = localStorage.getItem(storageKey);
|
||||
markersVisible = storedVisibility !== 'false';
|
||||
|
||||
const descriptors = buildParagraphDescriptors(articleContent);
|
||||
if (descriptors.length === 0) {
|
||||
setSummaryMessage(t('paragraphComments.noParagraphs'));
|
||||
@@ -710,6 +769,10 @@ const { t } = getI18n(Astro);
|
||||
descriptor.element.id = `paragraph-${descriptor.key}`;
|
||||
descriptor.element.dataset.paragraphKey = descriptor.key;
|
||||
descriptor.element.classList.add('paragraph-comment-paragraph');
|
||||
|
||||
const marker = createParagraphMarker(descriptor.key, descriptor.excerpt);
|
||||
paragraphMarkers.set(descriptor.key, marker);
|
||||
descriptor.element.appendChild(marker);
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -730,14 +793,11 @@ const { t } = getI18n(Astro);
|
||||
}
|
||||
|
||||
descriptors.forEach(descriptor => {
|
||||
const row = createParagraphRow(descriptor.key, descriptor.excerpt);
|
||||
paragraphRows.set(descriptor.key, row);
|
||||
paragraphCounts.set(descriptor.key, paragraphCounts.get(descriptor.key) || 0);
|
||||
descriptor.element.insertAdjacentElement('afterend', row);
|
||||
});
|
||||
|
||||
updateRowState();
|
||||
updateSummaryFromCounts();
|
||||
updateMarkerState();
|
||||
applyMarkerVisibility(markersVisible, { persist: false });
|
||||
await openFromHash();
|
||||
window.addEventListener('hashchange', () => {
|
||||
void openFromHash();
|
||||
@@ -745,4 +805,5 @@ const { t } = getI18n(Astro);
|
||||
}
|
||||
|
||||
void init();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
---
|
||||
import type { Post } from '../lib/types';
|
||||
import TerminalButton from './ui/TerminalButton.astro';
|
||||
import CodeBlock from './CodeBlock.astro';
|
||||
import { formatReadTime, getI18n } from '../lib/i18n';
|
||||
import { resolveFileRef, getPostTypeColor } from '../lib/utils';
|
||||
import {
|
||||
getAccentVars,
|
||||
getCategoryTheme,
|
||||
getPostTypeColor,
|
||||
getPostTypeTheme,
|
||||
getTagTheme,
|
||||
resolveFileRef,
|
||||
} from '../lib/utils';
|
||||
|
||||
interface Props {
|
||||
post: Post;
|
||||
selectedTag?: string;
|
||||
highlightTerm?: string;
|
||||
tagHrefPrefix?: string;
|
||||
}
|
||||
|
||||
const { post, selectedTag = '', highlightTerm = '' } = Astro.props;
|
||||
const { post, selectedTag = '', highlightTerm = '', tagHrefPrefix = '/tags?tag=' } = Astro.props;
|
||||
const { locale, t } = getI18n(Astro);
|
||||
|
||||
const typeColor = getPostTypeColor(post.type);
|
||||
const typeTheme = getPostTypeTheme(post.type);
|
||||
const categoryTheme = getCategoryTheme(post.category);
|
||||
|
||||
const escapeHtml = (value: string) =>
|
||||
value
|
||||
@@ -42,15 +51,23 @@ const normalizedSelectedTag = selectedTag.trim().toLowerCase();
|
||||
---
|
||||
|
||||
<article
|
||||
class="post-card terminal-panel group relative my-3 p-5 transition-all duration-300 hover:-translate-y-1 hover:border-[var(--primary)]"
|
||||
class="post-card terminal-panel group relative my-2.5 cursor-pointer p-4 transition-all duration-300 hover:-translate-y-1 hover:border-[var(--primary)] focus-within:border-[var(--primary)]"
|
||||
style={`--post-border-color: ${typeColor}`}
|
||||
data-post-card-link
|
||||
data-post-url={`/articles/${post.slug}`}
|
||||
tabindex="0"
|
||||
role="link"
|
||||
aria-label={`Open ${post.title}`}
|
||||
>
|
||||
<div class="absolute left-0 top-4 bottom-4 w-1 rounded-full opacity-80" style={`background-color: ${typeColor}`}></div>
|
||||
|
||||
<div class="relative z-10 flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between mb-2 pl-3">
|
||||
<div class="relative z-10 mb-2 flex flex-col gap-2.5 pl-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<span class="w-3 h-3 rounded-full shrink-0" style={`background-color: ${typeColor}`}></span>
|
||||
<span class="terminal-chip terminal-chip--accent shrink-0 text-[10px] py-1 px-2" style={getAccentVars(typeTheme)}>
|
||||
{post.type === 'article' ? t('common.article') : t('common.tweet')}
|
||||
</span>
|
||||
<a
|
||||
href={`/articles/${post.slug}`}
|
||||
class={`inline-flex min-w-0 items-center text-[var(--title-color)] transition hover:text-[var(--primary)] ${post.type === 'article' ? 'text-lg font-bold' : 'text-base font-bold'}`}
|
||||
@@ -62,12 +79,12 @@ const normalizedSelectedTag = selectedTag.trim().toLowerCase();
|
||||
{post.date} | {t('common.readTime')}: {formatReadTime(locale, post.readTime, t)}
|
||||
</p>
|
||||
</div>
|
||||
<span class="terminal-chip shrink-0 text-xs py-1 px-2.5">
|
||||
<span class="terminal-chip terminal-chip--accent shrink-0 text-xs py-1 px-2.5" style={getAccentVars(categoryTheme)}>
|
||||
#{post.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="relative z-10 pl-3 text-[var(--text-secondary)] mb-4 leading-7" set:html={highlightText(post.description, highlightTerm)} />
|
||||
<p class="relative z-10 mb-3 pl-3 text-sm leading-7 text-[var(--text-secondary)]" set:html={highlightText(post.description, highlightTerm)} />
|
||||
|
||||
{post.code && (
|
||||
<div class="relative z-10 mb-3">
|
||||
@@ -102,18 +119,22 @@ const normalizedSelectedTag = selectedTag.trim().toLowerCase();
|
||||
<!-- Tags -->
|
||||
<div class="relative z-10 pl-3 flex flex-wrap gap-2">
|
||||
{post.tags?.map(tag => (
|
||||
<TerminalButton
|
||||
variant={normalizedSelectedTag === tag.trim().toLowerCase() ? 'primary' : 'neutral'}
|
||||
size="xs"
|
||||
href={`/tags?tag=${encodeURIComponent(tag)}`}
|
||||
<a
|
||||
href={`${tagHrefPrefix}${encodeURIComponent(tag)}`}
|
||||
class:list={[
|
||||
'terminal-chip text-xs py-1 px-2.5',
|
||||
'terminal-chip--accent',
|
||||
normalizedSelectedTag === tag.trim().toLowerCase() && 'is-active'
|
||||
]}
|
||||
style={getAccentVars(getTagTheme(tag))}
|
||||
>
|
||||
<i class="fas fa-hashtag text-xs"></i>
|
||||
<span set:html={highlightText(tag, highlightTerm)} />
|
||||
</TerminalButton>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 mt-4 pl-3">
|
||||
<div class="relative z-10 mt-3 pl-3">
|
||||
<a
|
||||
href={`/articles/${post.slug}`}
|
||||
class="terminal-action-button inline-flex"
|
||||
@@ -123,3 +144,38 @@ const normalizedSelectedTag = selectedTag.trim().toLowerCase();
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<script>
|
||||
const interactiveSelector = 'a, button, input, textarea, select, summary, [role="button"]';
|
||||
|
||||
const hasTextSelection = () => {
|
||||
const selection = window.getSelection?.();
|
||||
return Boolean(selection && selection.toString().trim());
|
||||
};
|
||||
|
||||
const navigateFromCard = (card) => {
|
||||
const href = card.dataset.postUrl;
|
||||
if (!href) return;
|
||||
window.location.href = href;
|
||||
};
|
||||
|
||||
document.querySelectorAll('[data-post-card-link]').forEach((card) => {
|
||||
if (card.dataset.postCardBound === 'true') return;
|
||||
card.dataset.postCardBound = 'true';
|
||||
|
||||
card.addEventListener('click', (event) => {
|
||||
if (event.defaultPrevented) return;
|
||||
if (hasTextSelection()) return;
|
||||
if (event.target instanceof Element && event.target.closest(interactiveSelector)) return;
|
||||
navigateFromCard(card);
|
||||
});
|
||||
|
||||
card.addEventListener('keydown', (event) => {
|
||||
if (event.defaultPrevented) return;
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
if (event.target instanceof Element && event.target.closest(interactiveSelector)) return;
|
||||
event.preventDefault();
|
||||
navigateFromCard(card);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import { apiClient } from '../lib/api/client';
|
||||
import { getI18n } from '../lib/i18n';
|
||||
import { getAccentVars, getCategoryTheme, getPostTypeTheme, getTagTheme } from '../lib/utils';
|
||||
|
||||
interface Props {
|
||||
currentSlug: string;
|
||||
@@ -38,7 +39,7 @@ const relatedPosts = allPosts
|
||||
<div class="space-y-3">
|
||||
<span class="terminal-kicker">
|
||||
<i class="fas fa-diagram-project"></i>
|
||||
related traces
|
||||
{t('relatedPosts.kicker')}
|
||||
</span>
|
||||
<div class="terminal-section-title">
|
||||
<span class="terminal-section-icon">
|
||||
@@ -63,13 +64,13 @@ const relatedPosts = allPosts
|
||||
{relatedPosts.map(post => (
|
||||
<a
|
||||
href={`/articles/${post.slug}`}
|
||||
class="terminal-panel-muted group flex h-full flex-col gap-3 p-4 transition-all hover:-translate-y-1 hover:border-[var(--primary)]"
|
||||
class="terminal-panel-muted terminal-panel-accent terminal-interactive-card group flex h-full flex-col gap-3 p-4"
|
||||
style={getAccentVars(getPostTypeTheme(post.type))}
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="space-y-2">
|
||||
<span class="terminal-chip px-2.5 py-1 text-xs">
|
||||
<span class={`h-2.5 w-2.5 rounded-full ${post.type === 'article' ? 'bg-[var(--primary)]' : 'bg-[var(--secondary)]'}`}></span>
|
||||
{post.type}
|
||||
<span class="terminal-chip terminal-chip--accent px-2.5 py-1 text-xs" style={getAccentVars(getPostTypeTheme(post.type))}>
|
||||
{post.type === 'article' ? t('common.article') : t('common.tweet')}
|
||||
</span>
|
||||
<h4 class="text-base font-semibold text-[var(--title-color)] group-hover:text-[var(--primary)]">
|
||||
{post.title}
|
||||
@@ -85,11 +86,17 @@ const relatedPosts = allPosts
|
||||
<i class="far fa-calendar text-[var(--primary)]"></i>
|
||||
{post.date}
|
||||
</span>
|
||||
<span class="terminal-chip terminal-chip--accent px-2.5 py-1 text-xs" style={getAccentVars(getCategoryTheme(post.category))}>
|
||||
<i class="fas fa-folder-tree text-[11px]"></i>
|
||||
{post.category}
|
||||
</span>
|
||||
{post.sharedTags.length > 0 && (
|
||||
<span class="terminal-chip px-2.5 py-1 text-xs">
|
||||
<i class="fas fa-hashtag text-[var(--primary)]"></i>
|
||||
{post.sharedTags.map(tag => `#${tag}`).join(' ')}
|
||||
</span>
|
||||
post.sharedTags.map(tag => (
|
||||
<span class="terminal-chip terminal-chip--accent px-2.5 py-1 text-xs" style={getAccentVars(getTagTheme(tag))}>
|
||||
<i class="fas fa-hashtag text-[11px]"></i>
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
import type { SystemStat } from '../lib/types';
|
||||
import InfoTile from './ui/InfoTile.astro';
|
||||
|
||||
interface Props {
|
||||
stats: SystemStat[];
|
||||
@@ -9,13 +8,21 @@ interface Props {
|
||||
const { stats } = Astro.props;
|
||||
---
|
||||
|
||||
<ul class="space-y-3 font-mono text-sm">
|
||||
{stats.map(stat => (
|
||||
<li>
|
||||
<InfoTile layout="row" tone="neutral">
|
||||
<span class="text-[var(--text-secondary)] uppercase tracking-[0.18em] text-[11px]">{stat.label}</span>
|
||||
<span class="text-[var(--title-color)] font-bold text-base">{stat.value}</span>
|
||||
</InfoTile>
|
||||
<ul class="grid gap-3">
|
||||
{stats.map((stat, index) => (
|
||||
<li class="rounded-2xl border border-[var(--border-color)] bg-[linear-gradient(135deg,rgba(var(--primary-rgb),0.12),rgba(255,255,255,0.55))] px-4 py-4 shadow-[0_12px_32px_rgba(37,99,235,0.08)]">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-[var(--border-color)] bg-white/75 text-[var(--primary)] shadow-sm">
|
||||
<span class="font-mono text-xs">{String(index + 1).padStart(2, '0')}</span>
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)]">{stat.label}</div>
|
||||
<div class="mt-1 text-lg font-semibold text-[var(--title-color)]">{stat.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="h-10 w-px bg-[linear-gradient(180deg,transparent,rgba(var(--primary-rgb),0.3),transparent)]"></span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
import type { TechStackItem } from '../lib/types';
|
||||
import InfoTile from './ui/InfoTile.astro';
|
||||
|
||||
interface Props {
|
||||
items: TechStackItem[];
|
||||
@@ -9,20 +8,23 @@ interface Props {
|
||||
const { items } = Astro.props;
|
||||
---
|
||||
|
||||
<ul class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{items.map(item => (
|
||||
<li>
|
||||
<InfoTile layout="grid" tone="blue">
|
||||
<span class="flex h-8 w-8 items-center justify-center rounded-xl bg-[var(--primary)]/10 text-[var(--primary)]">
|
||||
<ul class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<li class="group overflow-hidden rounded-2xl border border-[var(--border-color)] bg-[linear-gradient(180deg,rgba(255,255,255,0.88),rgba(var(--primary-rgb),0.08))] shadow-[0_12px_30px_rgba(37,99,235,0.08)] transition-transform duration-200 hover:-translate-y-0.5">
|
||||
<div class="flex items-start gap-3 px-4 py-4">
|
||||
<span class="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[var(--primary)] text-white shadow-[0_10px_24px_rgba(37,99,235,0.24)]">
|
||||
<i class="fas fa-code text-xs"></i>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block text-[var(--text)] text-sm font-medium">{item.name}</span>
|
||||
{item.level && (
|
||||
<span class="block text-xs text-[var(--text-tertiary)] mt-0.5">{item.level}</span>
|
||||
)}
|
||||
<span class="block text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
|
||||
stack://module
|
||||
</span>
|
||||
<span class="mt-1 block text-base font-semibold text-[var(--title-color)]">{item.name}</span>
|
||||
<span class="mt-2 block font-mono text-xs text-[var(--primary)]">
|
||||
{item.level || 'active'}
|
||||
</span>
|
||||
</span>
|
||||
</InfoTile>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -5,13 +5,20 @@ interface Props {
|
||||
clickable?: boolean;
|
||||
href?: string;
|
||||
typing?: boolean;
|
||||
promptId?: string;
|
||||
}
|
||||
|
||||
const { command, path = '~/', clickable = false, href = '/', typing = true } = Astro.props;
|
||||
const { command, path = '~/', clickable = false, href = '/', typing = true, promptId = '' } = Astro.props;
|
||||
const uniqueId = Math.random().toString(36).slice(2, 11);
|
||||
---
|
||||
|
||||
<div class:list={['command-prompt', { clickable }]} data-command={command} data-typing={typing} data-id={uniqueId}>
|
||||
<div
|
||||
class:list={['command-prompt', { clickable }]}
|
||||
data-command={command}
|
||||
data-typing={typing}
|
||||
data-id={uniqueId}
|
||||
data-prompt-id={promptId || undefined}
|
||||
>
|
||||
{clickable ? (
|
||||
<a href={href} class="prompt-link">
|
||||
<span class="prompt">user@blog</span>
|
||||
@@ -35,44 +42,64 @@ const uniqueId = Math.random().toString(36).slice(2, 11);
|
||||
|
||||
<script is:inline>
|
||||
(function() {
|
||||
const prompts = document.querySelectorAll('[data-command]:not([data-typed])');
|
||||
|
||||
prompts.forEach(function(el) {
|
||||
// Mark as processed immediately
|
||||
el.setAttribute('data-typed', 'true');
|
||||
|
||||
const command = el.getAttribute('data-command');
|
||||
const typing = el.getAttribute('data-typing') === 'true';
|
||||
function renderPrompt(el, nextCommand, typingMode) {
|
||||
const id = el.getAttribute('data-id');
|
||||
const cmdEl = document.getElementById('cmd-' + id);
|
||||
const cursorEl = document.getElementById('cursor-' + id);
|
||||
|
||||
if (!cmdEl || !command) return;
|
||||
if (!cmdEl || !cursorEl) return;
|
||||
|
||||
if (typing) {
|
||||
// Typewriter effect - characters appear one by one
|
||||
let i = 0;
|
||||
cmdEl.textContent = '';
|
||||
cursorEl.style.animation = 'none';
|
||||
cursorEl.style.opacity = '1';
|
||||
const command = String(nextCommand || '');
|
||||
const typing = typingMode === true || typingMode === 'true';
|
||||
const renderSeq = String((Number(el.getAttribute('data-render-seq') || '0') || 0) + 1);
|
||||
|
||||
function typeChar() {
|
||||
if (i < command.length) {
|
||||
cmdEl.textContent += command.charAt(i);
|
||||
i++;
|
||||
setTimeout(typeChar, 80 + Math.random() * 40); // Random delay for realistic effect
|
||||
} else {
|
||||
// Start cursor blinking after typing completes
|
||||
cursorEl.style.animation = 'blink 1s infinite';
|
||||
}
|
||||
el.setAttribute('data-command', command);
|
||||
el.setAttribute('data-render-seq', renderSeq);
|
||||
cmdEl.textContent = '';
|
||||
cursorEl.style.animation = 'none';
|
||||
cursorEl.style.opacity = '1';
|
||||
|
||||
if (!typing) {
|
||||
cmdEl.textContent = command;
|
||||
cursorEl.style.animation = 'blink 1s infinite';
|
||||
return;
|
||||
}
|
||||
|
||||
let index = 0;
|
||||
|
||||
function typeChar() {
|
||||
if (el.getAttribute('data-render-seq') !== renderSeq) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start typing after a small delay
|
||||
setTimeout(typeChar, 300);
|
||||
} else {
|
||||
// Show all at once
|
||||
cmdEl.textContent = command;
|
||||
if (index < command.length) {
|
||||
cmdEl.textContent += command.charAt(index);
|
||||
index += 1;
|
||||
setTimeout(typeChar, 42 + Math.random() * 22);
|
||||
} else {
|
||||
cursorEl.style.animation = 'blink 1s infinite';
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(typeChar, 120);
|
||||
}
|
||||
|
||||
if (!window.__termiCommandPrompt) {
|
||||
window.__termiCommandPrompt = {
|
||||
set(promptId, command, options = {}) {
|
||||
if (!promptId) return;
|
||||
const el = document.querySelector(`[data-prompt-id="${promptId}"]`);
|
||||
if (!el) return;
|
||||
renderPrompt(el, command, options.typing ?? true);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const prompts = document.querySelectorAll('[data-command]:not([data-command-mounted])');
|
||||
|
||||
prompts.forEach(function(el) {
|
||||
el.setAttribute('data-command-mounted', 'true');
|
||||
renderPrompt(el, el.getAttribute('data-command') || '', el.getAttribute('data-typing') === 'true');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
interface Props {
|
||||
href?: string;
|
||||
active?: boolean;
|
||||
tone?: 'blue' | 'amber' | 'teal' | 'violet' | 'neutral';
|
||||
tone?: 'blue' | 'amber' | 'teal' | 'violet' | 'neutral' | 'accent';
|
||||
class?: string;
|
||||
style?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
|
||||
@@ -2,15 +2,84 @@
|
||||
interface Props {
|
||||
href: string;
|
||||
text: string;
|
||||
command?: string;
|
||||
}
|
||||
|
||||
const { href, text } = Astro.props;
|
||||
const { href, text, command = 'cd' } = Astro.props;
|
||||
---
|
||||
|
||||
<a
|
||||
href={href}
|
||||
class="inline-flex items-center gap-1.5 text-sm font-mono text-[var(--primary)] hover:underline transition-all"
|
||||
class="terminal-view-more-link"
|
||||
>
|
||||
<span>{text}</span>
|
||||
<i class="fas fa-arrow-right text-xs"></i>
|
||||
<span class="terminal-view-more-link__prompt">{command}</span>
|
||||
<span class="terminal-view-more-link__label">{text}</span>
|
||||
<span class="terminal-view-more-link__icon" aria-hidden="true">
|
||||
<i class="fas fa-arrow-right text-[10px]"></i>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.terminal-view-more-link {
|
||||
display: inline-grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
min-width: min(100%, 20rem);
|
||||
padding: 0.65rem 0.8rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid color-mix(in oklab, var(--primary) 14%, var(--border-color));
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 97%, transparent), color-mix(in oklab, var(--header-bg) 88%, transparent)),
|
||||
linear-gradient(90deg, rgba(var(--primary-rgb), 0.04), transparent 24%, rgba(var(--primary-rgb), 0.02) 76%, transparent);
|
||||
color: var(--title-color);
|
||||
text-decoration: none;
|
||||
box-shadow:
|
||||
0 14px 28px rgba(var(--text-rgb), 0.05),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.36);
|
||||
transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease, background 180ms ease;
|
||||
}
|
||||
|
||||
.terminal-view-more-link:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: color-mix(in oklab, var(--primary) 24%, var(--border-color));
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 95%, transparent), color-mix(in oklab, var(--primary) 5%, var(--header-bg))),
|
||||
linear-gradient(90deg, rgba(var(--primary-rgb), 0.06), transparent 22%, rgba(var(--primary-rgb), 0.025) 78%, transparent);
|
||||
box-shadow:
|
||||
0 16px 30px rgba(var(--text-rgb), 0.07),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.42);
|
||||
}
|
||||
|
||||
.terminal-view-more-link__prompt {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.28rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--primary) 10%, var(--terminal-bg));
|
||||
color: var(--primary);
|
||||
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.terminal-view-more-link__label {
|
||||
min-width: 0;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.terminal-view-more-link__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
border-radius: 999px;
|
||||
color: var(--primary);
|
||||
background: color-mix(in oklab, var(--primary) 10%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
8
frontend/src/env.d.ts
vendored
8
frontend/src/env.d.ts
vendored
@@ -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__?: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: 'InitCool,GitHub 用户名 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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user