Initial Commit

This commit is contained in:
RiseForever
2026-08-11 10:25:27 +08:00
parent 70d33c0996
commit cd5e2796c2
161 changed files with 29688 additions and 2 deletions
+39
View File
@@ -0,0 +1,39 @@
---
import { ArrowUp } from '@lucide/astro';
---
<button class="back-to-top" type="button" aria-label="返回顶部" title="返回顶部" data-back-to-top><ArrowUp aria-hidden="true" size={18} /></button>
<script>
const getButton = () => document.querySelector<HTMLButtonElement>('[data-back-to-top]');
const drawerOpen = () => {
const mobileDrawer = document.querySelector<HTMLElement>('[data-drawer]');
const tocDrawer = document.querySelector<HTMLElement>('[data-toc-drawer]');
return mobileDrawer?.getAttribute('aria-hidden') === 'false' || tocDrawer?.getAttribute('aria-hidden') === 'false';
};
const update = () => getButton()?.classList.toggle('is-visible', window.scrollY > 480 && !drawerOpen());
window.addEventListener('scroll', update, { passive: true });
document.addEventListener('click', (event) => {
if (event.target instanceof Element && event.target.closest('[data-back-to-top]')) {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
});
const observeDrawers = () => {
['[data-drawer]', '[data-toc-drawer]'].forEach((selector) => {
const drawer = document.querySelector<HTMLElement>(selector);
if (drawer) new MutationObserver(update).observe(drawer, { attributes: true, attributeFilter: ['aria-hidden'] });
});
};
document.addEventListener('astro:page-load', () => {
observeDrawers();
update();
});
observeDrawers();
update();
</script>
<style>
.back-to-top { position: fixed; z-index: 30; right: max(14px, env(safe-area-inset-right)); bottom: max(14px, env(safe-area-inset-bottom)); display: grid; width: 42px; height: 42px; border: 1px solid var(--color-border); border-radius: 50%; background: var(--color-surface); color: var(--color-muted); box-shadow: var(--shadow-soft); cursor: pointer; opacity: 0; pointer-events: none; transform: translateY(8px); transition: opacity var(--transition-fast), transform var(--transition-fast); place-items: center; }
.back-to-top.is-visible { opacity: 1; pointer-events: auto; transform: translateY(0); }
@media (max-width: 600px) { .back-to-top { width: 38px; height: 38px; } }
</style>
+62
View File
@@ -0,0 +1,62 @@
---
import { siteConfig } from '@/site.config';
import lightSticker from '@/images/Written-By-Human-white.svg';
import darkSticker from '@/images/Written-By-Human-black.svg';
const containsChinese = (value: string) => /[\u3400-\u9fff]/.test(value);
const blogDomain = new URL(siteConfig.site.url).hostname;
const blogsClubBadge = `https://www.blogsclub.org/badge/${blogDomain}/theme`;
---
<footer class="w-full border-t border-line bg-raised px-5 py-5 text-center font-mono text-xs leading-6 text-subtle" data-pagefind-ignore="all">
<div class="mx-auto max-w-[73.125rem]">
<div class="footer-badges">
<a href="https://notbyai.fyi" target="_blank" rel="noreferrer" aria-label="Not By AI">
<img class="footer-badge-image" src={lightSticker.src} data-light-src={lightSticker.src} data-dark-src={darkSticker.src} width="131" height="42" alt="Not By AI" />
</a>
<a href="https://www.blogsclub.org/rank.html" target="_blank" rel="noreferrer" aria-label="博阅榜">
<img class="footer-badge-image blogs-club-badge" src={`${blogsClubBadge}/simple`} data-light-src={`${blogsClubBadge}/simple`} data-dark-src={`${blogsClubBadge}/simple-dark`} height="42" alt="博阅榜" loading="lazy" />
</a>
</div>
{siteConfig.footer.links.length > 0 && <nav aria-label="页脚导航">{siteConfig.footer.links.map((item, index) => <Fragment>{index > 0 && ' · '}<a data-text-link href={item.href} target={item.external ? '_blank' : undefined} rel={item.external ? 'noreferrer' : undefined}><span class:list={{ 'footer-chinese': containsChinese(item.label) }} lang={containsChinese(item.label) ? 'zh-CN' : undefined}>{item.label}</span></a></Fragment>)}</nav>}
{siteConfig.footer.copyright && <p class="m-0">{siteConfig.footer.copyright}</p>}
{siteConfig.footer.note && <p class="footer-chinese m-0" lang="zh-CN">{siteConfig.footer.note}</p>}
{siteConfig.footer.theme && <p class="m-0"><a data-text-link href={siteConfig.footer.theme.href} target={siteConfig.footer.theme.external ? '_blank' : undefined} rel={siteConfig.footer.theme.external ? 'noreferrer' : undefined}>{siteConfig.footer.theme.label}</a></p>}
</div>
</footer>
<style>
.footer-chinese {
font-family: var(--mirages-font-sans);
}
.footer-badges {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.footer-badges a { line-height: 0; }
.footer-badge-image { display: block; }
.blogs-club-badge { height: 42px !important; width: auto; }
</style>
<script>
const root = document.documentElement;
const systemTheme = matchMedia('(prefers-color-scheme: dark)');
const syncBadges = () => {
const isDark = root.dataset.theme === 'dark' || (root.dataset.theme === 'auto' && systemTheme.matches);
document.querySelectorAll<HTMLImageElement>('.footer-badge-image').forEach((badge) => {
badge.src = isDark ? badge.dataset.darkSrc ?? badge.src : badge.dataset.lightSrc ?? badge.src;
});
};
syncBadges();
systemTheme.addEventListener('change', syncBadges);
const themeObserver = new MutationObserver(syncBadges);
themeObserver.observe(root, { attributes: true, attributeFilter: ['data-theme'] });
document.addEventListener('astro:page-load', syncBadges);
</script>
+88
View File
@@ -0,0 +1,88 @@
<script>
import 'photoswipe/style.css';
const imageSelector = '.prose img';
const excludedImageAncestors = '.mermaid, .katex, pre, [data-lightbox-ignore]';
let lightboxPromise: Promise<import('photoswipe/lightbox').default> | undefined;
const isLightboxImage = (image: HTMLImageElement, prose: HTMLElement) =>
prose.contains(image) && !image.closest(excludedImageAncestors) && !image.closest('a');
const getImages = (prose: HTMLElement) => [...prose.querySelectorAll<HTMLImageElement>('img')]
.filter((image) => isLightboxImage(image, prose));
const getSize = (image: HTMLImageElement) => {
const width = Number(image.dataset.originalWidth) || Number(image.getAttribute('width')) || image.naturalWidth || image.clientWidth || 1200;
const height = Number(image.dataset.originalHeight) || Number(image.getAttribute('height')) || image.naturalHeight || image.clientHeight || Math.round(width * 2 / 3);
return { width, height };
};
const open = async (image: HTMLImageElement, images: HTMLImageElement[]) => {
const index = images.indexOf(image);
const src = image.currentSrc || image.src;
if (index < 0 || !src) return;
const items = images.map((item) => ({
...getSize(item),
src: item.dataset.originalSrc || item.currentSrc || item.src,
alt: item.alt
}));
lightboxPromise ??= import('photoswipe/lightbox').then(({ default: PhotoSwipeLightbox }) => {
const lightbox = new PhotoSwipeLightbox({
pswpModule: () => import('photoswipe'),
bgClickAction: 'close'
});
lightbox.init();
return lightbox;
});
try {
const lightbox = await lightboxPromise;
lightbox.loadAndOpen(index, items);
} catch (error) {
lightboxPromise = undefined;
console.error('Unable to open image lightbox', error);
}
};
const init = () => {
document.querySelectorAll<HTMLElement>('.prose').forEach((prose) => {
if (prose.dataset.lightboxReady === 'true') return;
prose.dataset.lightboxReady = 'true';
const prepareImage = (image: HTMLImageElement) => {
if (!isLightboxImage(image, prose)) return;
image.classList.add('lightbox-image');
image.tabIndex = image.tabIndex >= 0 ? image.tabIndex : 0;
image.setAttribute('role', 'button');
image.setAttribute('aria-label', image.alt ? `查看大图:${image.alt}` : '查看大图');
};
getImages(prose).forEach(prepareImage);
const observer = new MutationObserver(() => getImages(prose).forEach(prepareImage));
observer.observe(prose, { childList: true, subtree: true });
prose.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof Element)) return;
const image = target.closest<HTMLImageElement>(imageSelector);
if (!image || !isLightboxImage(image, prose)) return;
event.preventDefault();
event.stopPropagation();
void open(image, getImages(prose));
});
prose.addEventListener('keydown', (event) => {
const target = event.target;
if (!(target instanceof Element)) return;
const image = target.closest<HTMLImageElement>(imageSelector);
if (!image || !isLightboxImage(image, prose) || (event.key !== 'Enter' && event.key !== ' ')) return;
event.preventDefault();
void open(image, getImages(prose));
});
});
};
init();
document.addEventListener('astro:page-load', init);
</script>
<style is:global>
.prose img.lightbox-image { cursor: zoom-in; }
.prose img.lightbox-image:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 4px; }
</style>
+129
View File
@@ -0,0 +1,129 @@
---
import { siteConfig } from '@/site.config';
import { slugify } from '@/lib/content';
import type { BannerTextTone } from '@/types/config';
interface Props {
article?: {
title: string;
date: { year: string; month: string; day: string };
categories: string[];
image?: string;
textTone?: BannerTextTone;
};
banner?: {
title: string;
subtitle?: string;
image?: string;
position?: string;
textTone?: BannerTextTone;
};
about?: boolean;
textTone?: BannerTextTone;
}
const { article, banner, about = false } = Astro.props;
const enabled = Boolean(about || article || banner || siteConfig.banner.enabled);
const image = banner ? banner.image : article?.image ?? (about ? undefined : siteConfig.banner.image);
const title = about ? siteConfig.site.author.name : (banner?.title ?? article?.title ?? siteConfig.banner.title);
const subtitle = banner?.subtitle ?? siteConfig.banner.subtitle;
const position = banner?.position ?? siteConfig.banner.position;
---
{enabled && <section
class="relative top-0 flex min-h-50 items-center justify-center overflow-hidden bg-raised pt-16 text-center text-foreground md:pt-16"
style={`background-position: ${position}; --mobile-height: ${siteConfig.banner.mobileHeightVh}vh; --desktop-height: ${siteConfig.banner.desktopHeightVh}vh; --overlay: ${siteConfig.banner.overlay}`}
data-banner-image={image || undefined}
data-banner-tone={image ? 'light' : undefined}
aria-labelledby="masthead-title"
data-pagefind-ignore="all"
data-banner
>
{image && <div
class="absolute inset-0 z-0 bg-cover bg-no-repeat"
style={`background-image: url("${image}"); background-position: ${position}`}
aria-hidden="true"
></div>}
{image && <div class="absolute inset-0 z-[1]" style={`background-color: rgb(0 0 0 / ${siteConfig.banner.overlay})`} aria-hidden="true"></div>}
<div class="relative z-10 flex w-[calc(100%-1.75rem)] flex-col items-center justify-center">
{about && <img
class:list={['mb-5 size-[7rem] rounded-full border-4 border-white/85 object-cover shadow-lg md:size-[9.375rem]', { 'about-avatar': siteConfig.site.author.rotateAvatar }]}
src={siteConfig.site.author.avatar}
alt={`${siteConfig.site.author.name} 的头像`}
width="150"
height="150"
loading="eager"
fetchpriority="high"
/>}
<h1 class="m-0 text-[2rem] leading-tight font-light tracking-[0] md:text-[2.5rem]" id="masthead-title">{title}</h1>
{article ? <p class="masthead-meta mt-3 mb-0 flex flex-wrap items-center justify-center gap-x-2 gap-y-1 text-[0.9375rem] md:text-base">
<span class="masthead-date"><span>{article.date.year}</span><strong>年</strong><span>{article.date.month}</span><strong>月</strong><span>{article.date.day}</span><strong>日</strong></span>
{article.categories.length > 0 && <span class="masthead-separator" aria-hidden="true">·</span>}
{article.categories.map((category, index) => <Fragment>
{index > 0 && <span class="masthead-separator" aria-hidden="true">/</span>}
<a class="masthead-category" data-text-link href={`/categories/${slugify(category)}/`}>{category}</a>
</Fragment>)}
</p> : !about && subtitle && <p class="mt-3 mb-0 text-[0.9375rem] md:text-base">{subtitle}</p>}
</div>
</section>}
{enabled && image && <script>
const updateBannerTone = () => {
const masthead = document.querySelector<HTMLElement>('[data-banner][data-banner-image]');
const imageUrl = masthead?.dataset.bannerImage;
if (!masthead || !imageUrl) return;
const source = new Image();
source.crossOrigin = 'anonymous';
source.onload = () => {
try {
const canvas = document.createElement('canvas');
const size = 32;
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d', { willReadFrequently: true });
if (!context) throw new Error('Canvas context unavailable');
context.drawImage(source, 0, 0, size, size);
const pixels = context.getImageData(0, 0, size, size).data;
let luma = 0;
let weight = 0;
for (let index = 0; index < pixels.length; index += 4) {
const alpha = pixels[index + 3] / 255;
luma += (0.2126 * pixels[index] + 0.7152 * pixels[index + 1] + 0.0722 * pixels[index + 2]) * alpha;
weight += alpha;
}
masthead.dataset.bannerTone = weight && luma / weight >= 150 ? 'dark' : 'light';
} catch {
masthead.dataset.bannerTone = 'light';
}
};
source.onerror = () => { masthead.dataset.bannerTone = 'light'; };
source.src = imageUrl;
};
document.addEventListener('astro:page-load', updateBannerTone);
updateBannerTone();
</script>}
<style>
section { height: var(--mobile-height); }
:global(:root[data-font='serif']) section h1 { font-weight: 700; }
section[data-banner-image][data-banner-tone='dark'] h1,
section[data-banner-image][data-banner-tone='dark'] p,
section[data-banner-image][data-banner-tone='dark'] .masthead-meta { color: #201e1d; text-shadow: 0 1px 2px rgb(255 255 255 / 18%); }
section[data-banner-image][data-banner-tone='light'] h1,
section[data-banner-image][data-banner-tone='light'] p,
section[data-banner-image][data-banner-tone='light'] .masthead-meta { color: #fff; text-shadow: 0 1px 2px rgb(0 0 0 / 24%); }
section[data-banner-image][data-banner-tone='dark'] .masthead-meta a,
section[data-banner-image][data-banner-tone='light'] .masthead-meta a { color: inherit; }
.masthead-meta a { color: inherit; }
.masthead-meta a::after { border-color: currentColor; }
.masthead-date,
.masthead-separator { font-family: Consolas, Menlo, Monaco, 'lucida_console', 'Liberation_Mono', 'Courier_New', 'andale_mono', monospaceX, monospace, sans-serif; }
.masthead-date strong { font-family: var(--mirages-font-ui); font-weight: 400; }
.masthead-category { font-family: var(--mirages-font-ui); }
.about-avatar { animation: avatar-rotate 8s ease-in-out infinite alternate; }
@keyframes avatar-rotate { from { transform: rotate(-2deg); } to { transform: rotate(2deg); } }
@media (min-width: 48rem) { section { height: var(--desktop-height); } }
@media (prefers-reduced-motion: reduce) { .about-avatar { animation: none; transform: none; } }
</style>
+22
View File
@@ -0,0 +1,22 @@
---
interface Props { enabled: boolean; }
const { enabled } = Astro.props;
---
{enabled && <script>
const blocks = [...document.querySelectorAll('pre code.language-mermaid, pre[data-language="mermaid"] code')];
if (blocks.length) {
const mermaid = await import('mermaid');
const dark = document.documentElement.dataset.theme === 'dark' || (document.documentElement.dataset.theme === 'auto' && matchMedia('(prefers-color-scheme: dark)').matches);
mermaid.default.initialize({ startOnLoad: false, theme: dark ? 'dark' : 'default', securityLevel: 'strict' });
await Promise.all(blocks.map(async (block, index) => {
const wrapper = block.parentElement;
if (!wrapper) return;
const container = document.createElement('div');
container.className = 'mermaid';
container.id = `mermaid-${index}`;
container.textContent = block.textContent ?? '';
wrapper.replaceWith(container);
await mermaid.default.run({ nodes: [container] });
}));
}
</script>}
+189
View File
@@ -0,0 +1,189 @@
---
import { Check, ChevronDown } from '@lucide/astro';
import { siteConfig } from '@/site.config';
import { deriveTaxonomy, getPublicPosts, slugify } from '@/lib/content';
import ThemeSwitcher from './ThemeSwitcher.astro';
import Search from './Search.astro';
import ToolbarIcon from './ToolbarIcon.astro';
const pageItems = siteConfig.navigation.filter((item) => item.href !== '/');
const categories = deriveTaxonomy(await getPublicPosts(), 'categories');
const toolbarItems = siteConfig.toolbarItems.filter((item) => item.type !== 'search' || siteConfig.search.provider === 'pagefind');
const sidebarToolbarItems = toolbarItems.filter((item) => item.type !== 'search');
const hasSidebarSearch = siteConfig.search.provider === 'pagefind';
interface Props { overlay?: boolean; }
const { overlay = false } = Astro.props;
---
<header class:list={['fixed inset-x-0 top-0 z-40 hidden h-[4rem] border-b border-line/60 bg-nav text-foreground shadow-[0_2px_10px_rgb(0_0_0/8%)] backdrop-blur-md md:flex', { 'navbar-over-banner': overlay }]} data-desktop-navbar data-pagefind-ignore="all">
<div class="mx-auto flex h-full min-w-0 w-[calc(100%-2.5rem)] max-w-[1170px] items-center gap-[1.75rem]">
<a class="shrink-0 text-[1.25rem] font-bold text-current no-underline hover:text-mirages-accent" href="/" aria-label={`${siteConfig.site.title} 首页`}>{siteConfig.site.title}</a>
<nav class="flex min-w-0 items-center gap-[1.5rem]" aria-label="主导航">
<details class="group relative" data-category-menu>
<summary class="flex cursor-pointer list-none items-center gap-1 text-[0.9375rem] text-current/80 hover:text-mirages-accent [&::-webkit-details-marker]:hidden">
<span>分类</span><ChevronDown class="transition-transform duration-150 group-open:rotate-180" aria-hidden="true" size="1em" />
</summary>
<div class="absolute top-[calc(100%+20px)] left-0 z-50 min-w-44 border border-line bg-page py-1 text-foreground shadow-mirages">
{categories.length > 0 ? categories.map((category) => (
<a class="flex items-center justify-between gap-5 px-3 py-2 text-sm no-underline hover:bg-raised" href={`/categories/${slugify(category.name)}/`}>
<span>{category.name}</span><span class="text-xs text-subtle">{category.count}</span>
</a>
)) : <span class="block px-3 py-2 text-sm text-subtle">暂无分类</span>}
</div>
</details>
{pageItems.map((item) => <a class="text-[0.9375rem] text-current/80 no-underline hover:text-mirages-accent" href={item.href} target={item.external ? '_blank' : undefined} rel={item.external ? 'noreferrer' : undefined}>{item.label}</a>)}
</nav>
<div class="ml-auto flex shrink-0 items-center" data-navbar-tools>
{toolbarItems.map((item) => (
<div class="relative flex items-center" data-toolbar-item={item.type} data-toolbar-name={item.name}>
{item.type === 'search' && <Search />}
{item.type === 'settings' && <ThemeSwitcher />}
{item.type === 'link' && <a class="grid size-[2.5rem] shrink-0 place-items-center text-current/80 no-underline hover:bg-current/10 hover:text-current" href={item.href} target={item.external ? '_blank' : undefined} rel={item.external ? 'noreferrer' : undefined} aria-label={item.name} title={item.name}><ToolbarIcon icon={item.icon} /><span class="sr-only">{item.name}</span></a>}
{item.type === 'rss' && <button class="relative grid size-[2.5rem] shrink-0 cursor-pointer place-items-center border-0 bg-transparent p-0 text-current/80 hover:bg-current/10 hover:text-current" type="button" aria-label={item.name} title={item.name} data-rss-copy data-rss-url={item.href}><span class="grid place-items-center leading-none" data-rss-default-icon><ToolbarIcon icon={item.icon} /></span><Check class="hidden" aria-hidden="true" size="1em" data-rss-success-icon /><span class="pointer-events-none absolute top-[calc(100%+0.5rem)] left-1/2 hidden -translate-x-1/2 whitespace-nowrap rounded-[4px] bg-foreground px-2 py-1 text-xs text-page shadow-mirages" role="status" data-rss-feedback>已复制</span><span class="sr-only">{item.name}</span></button>}
</div>
))}
</div>
</div>
</header>
<button class="fixed top-[0.75rem] left-[0.75rem] z-[70] h-[2.5rem] cursor-pointer rounded-full border border-line/70 bg-nav px-[1.25rem] text-xs font-semibold tracking-[0.08em] text-foreground shadow-[0_2px_8px_rgb(0_0_0/24%)] backdrop-blur-sm transition-transform duration-200 md:hidden" id="toggle-nav" type="button" aria-label="打开导航" aria-controls="site-navigation" aria-expanded="false" data-open-drawer><span>MENU</span></button>
<div class="pointer-events-none fixed inset-0 z-50 bg-black/40 opacity-0 transition-opacity duration-200 md:hidden" aria-hidden="true" data-drawer-backdrop></div>
<aside class="fixed inset-y-0 left-0 z-[60] flex w-[17.5rem] max-w-[84vw] -translate-x-full flex-col bg-page text-foreground shadow-none transition-transform duration-200 md:hidden" id="site-navigation" aria-label="移动端导航" aria-hidden="true" data-drawer data-pagefind-ignore="all">
<div class="min-h-0 flex-1 overflow-y-auto px-5 pt-9">
<div class="mb-6 flex justify-center" data-sidebar-avatar>
<a class="grid size-24 place-items-center overflow-hidden rounded-full border-2 border-line bg-raised text-2xl font-bold no-underline shadow-mirages" href="/about/" aria-label="关于作者">
<img class="size-full object-cover" src={siteConfig.site.author.avatar} alt={`${siteConfig.site.author.name} 的头像`} width="100" height="100" />
</a>
</div>
{hasSidebarSearch && <div class="mb-5" data-sidebar-search><Search mobile /></div>}
<nav aria-label="侧栏菜单">
<ul class="m-0 list-none p-0 text-[0.9375rem]">
<li><a class="block border-b border-line px-2 py-3 text-center no-underline" href="/">首页</a></li>
<li>
<button class="block w-full cursor-pointer border-0 border-b border-line bg-transparent px-2 py-3 text-center text-inherit" type="button" aria-expanded="false" aria-controls="mobile-category-menu" data-mobile-category-trigger>分类</button>
<details id="mobile-category-menu" data-mobile-category-menu>
<summary class="sr-only" aria-hidden="true">分类</summary>
<ul class="m-0 list-none p-0">
{categories.length > 0 ? categories.map((category) => <li><a class="ml-3 block border-l-2 border-line/60 bg-raised/45 px-3 py-2 text-center text-[0.8125rem] text-subtle no-underline hover:bg-raised hover:text-foreground" href={`/categories/${slugify(category.name)}/`}>{category.name}</a></li>) : <li class="ml-3 border-l-2 border-line/60 bg-raised/45 px-3 py-2 text-center text-[0.8125rem] text-subtle">暂无分类</li>}
</ul>
</details>
</li>
{pageItems.map((item) => <li><a class="block border-b border-line px-2 py-3 text-center no-underline" href={item.href} target={item.external ? '_blank' : undefined} rel={item.external ? 'noreferrer' : undefined}>{item.label}</a></li>)}
</ul>
</nav>
</div>
<div class="relative flex min-h-[4rem] items-center justify-center gap-[0.75rem] border-t border-line bg-raised px-[1rem]" data-sidebar-toolbar>
{sidebarToolbarItems.map((item) => (
<div class="static flex items-center" data-toolbar-item={item.type} data-toolbar-name={item.name}>
{item.type === 'settings' && <ThemeSwitcher placement="up" />}
{item.type === 'link' && <a class="grid size-[2.5rem] shrink-0 place-items-center text-foreground no-underline hover:bg-raised" href={item.href} target={item.external ? '_blank' : undefined} rel={item.external ? 'noreferrer' : undefined} aria-label={item.name} title={item.name}><ToolbarIcon icon={item.icon} /><span class="sr-only">{item.name}</span></a>}
{item.type === 'rss' && <button class="relative grid size-[2.5rem] shrink-0 cursor-pointer place-items-center border-0 bg-transparent p-0 text-foreground hover:bg-raised" type="button" aria-label={item.name} title={item.name} data-rss-copy data-rss-url={item.href}><span class="grid place-items-center leading-none" data-rss-default-icon><ToolbarIcon icon={item.icon} /></span><Check class="hidden" aria-hidden="true" size="1em" data-rss-success-icon /><span class="pointer-events-none absolute bottom-[calc(100%+0.5rem)] left-1/2 hidden -translate-x-1/2 whitespace-nowrap rounded-[4px] bg-foreground px-2 py-1 text-xs text-page shadow-mirages" role="status" data-rss-feedback>已复制</span><span class="sr-only">{item.name}</span></button>}
</div>
))}
</div>
</aside>
<script>
const setOpen = (open: boolean, restore = false) => {
const drawer = document.querySelector<HTMLElement>('[data-drawer]');
const backdrop = document.querySelector<HTMLElement>('[data-drawer-backdrop]');
const openButton = document.querySelector<HTMLButtonElement>('[data-open-drawer]');
drawer?.classList.toggle('-translate-x-full', !open);
openButton?.classList.toggle('is-drawer-open', open);
openButton?.setAttribute('aria-label', open ? '关闭导航' : '打开导航');
backdrop?.classList.toggle('pointer-events-none', !open);
backdrop?.classList.toggle('opacity-0', !open);
openButton?.setAttribute('aria-expanded', String(open));
drawer?.setAttribute('aria-hidden', String(!open));
document.body.classList.toggle('overflow-hidden', open);
if (restore) openButton?.focus();
};
document.addEventListener('click', (event) => {
if (!(event.target instanceof Element)) return;
const target = event.target;
const openButton = target.closest<HTMLButtonElement>('[data-open-drawer]');
const backdrop = target.closest<HTMLElement>('[data-drawer-backdrop]');
const drawer = target.closest<HTMLElement>('[data-drawer]');
const categoryTrigger = target.closest<HTMLButtonElement>('[data-mobile-category-trigger]');
const rssButton = target.closest<HTMLButtonElement>('[data-rss-copy]');
if (rssButton) {
const successIcon = rssButton.querySelector<HTMLElement>('[data-rss-success-icon]');
const defaultIcon = rssButton.querySelector<HTMLElement>('[data-rss-default-icon]');
const feedback = rssButton.querySelector<HTMLElement>('[data-rss-feedback]');
void navigator.clipboard.writeText(new URL(rssButton.dataset.rssUrl ?? '', document.baseURI).href).then(() => {
window.clearTimeout(Number(rssButton.dataset.rssResetTimer));
defaultIcon?.classList.add('hidden');
successIcon?.classList.remove('hidden');
feedback?.classList.remove('hidden');
rssButton.dataset.rssResetTimer = String(window.setTimeout(() => {
defaultIcon?.classList.remove('hidden');
successIcon?.classList.add('hidden');
feedback?.classList.add('hidden');
delete rssButton.dataset.rssResetTimer;
}, 1200));
});
} else if (openButton) {
setOpen(openButton.getAttribute('aria-expanded') !== 'true', true);
} else if (backdrop || (drawer && target.closest('a'))) {
setOpen(false, true);
} else if (categoryTrigger) {
const menu = document.getElementById(categoryTrigger.getAttribute('aria-controls') ?? '') as HTMLDetailsElement | null;
if (menu) menu.open = !menu.open;
}
});
document.addEventListener('toggle', (event) => {
const menu = event.target as HTMLDetailsElement;
if (!menu.matches('[data-mobile-category-menu]')) return;
document.querySelector<HTMLButtonElement>(`[aria-controls="${menu.id}"]`)?.setAttribute('aria-expanded', String(menu.open));
}, true);
document.addEventListener('keydown', (event) => {
const openButton = document.querySelector<HTMLButtonElement>('[data-open-drawer]');
if (event.key === 'Escape' && openButton?.getAttribute('aria-expanded') === 'true') setOpen(false, true);
const menu = event.target instanceof Element ? event.target.closest<HTMLDetailsElement>('[data-category-menu]') : null;
if (event.key === 'Escape' && menu) {
menu.removeAttribute('open');
menu.querySelector<HTMLElement>('summary')?.focus();
}
});
document.addEventListener('pointerdown', (event) => {
document.querySelectorAll<HTMLDetailsElement>('[data-category-menu]').forEach((menu) => {
if (!menu.contains(event.target as Node)) menu.removeAttribute('open');
});
});
document.addEventListener('astro:before-preparation', () => setOpen(false));
</script>
<style is:global>
[data-open-drawer] {
font-family: Arial, Helvetica, sans-serif;
}
[data-open-drawer].is-drawer-open {
transform: translateX(14.25rem);
}
.navbar-over-banner {
border-color: color-mix(in srgb, var(--color-border) 70%, transparent);
background: color-mix(in srgb, var(--color-bg) 82%, transparent);
box-shadow: 0 2px 10px color-mix(in srgb, var(--color-text) 12%, transparent);
}
.navbar-over-banner [data-navbar-tools] > [data-toolbar-item] > a:hover,
.navbar-over-banner [data-navbar-tools] > [data-toolbar-item] > details > summary:hover,
.navbar-over-banner [data-navbar-tools] > [data-toolbar-item] > [data-search-root] > button:hover {
background: color-mix(in srgb, currentColor 16%, transparent);
}
.navbar-over-banner [data-navbar-tools] .navbar-search-container > .search-form {
border-color: color-mix(in srgb, var(--color-text) 50%, transparent);
background: color-mix(in srgb, var(--color-bg) 92%, transparent);
color: var(--color-text);
}
.navbar-over-banner [data-navbar-tools] .navbar-search-container > .search-form input::placeholder {
color: color-mix(in srgb, currentColor 75%, transparent);
}
</style>
+131
View File
@@ -0,0 +1,131 @@
<div class="navigation-progress" data-navigation-progress aria-hidden="true" transition:persist="navigation-progress">
<span class="navigation-progress__bar"></span>
</div>
<script>
let timer: number | undefined;
let completionAnimation: Animation | undefined;
let hideAnimation: Animation | undefined;
const getElements = () => {
const root = document.querySelector<HTMLElement>('[data-navigation-progress]');
const bar = root?.querySelector<HTMLElement>('.navigation-progress__bar');
return { root, bar };
};
const setProgress = (bar: HTMLElement, value: number) => {
if (bar) bar.style.transform = `scaleX(${value})`;
};
const start = () => {
const { root, bar } = getElements();
if (!root || !bar) return;
window.clearInterval(timer);
completionAnimation?.cancel();
hideAnimation?.cancel();
root.classList.remove('is-finishing', 'is-hiding');
root.classList.add('is-visible');
bar.dataset.progress = '0';
bar.style.transition = '';
setProgress(bar, 0);
timer = window.setInterval(() => {
const currentElements = getElements();
if (!currentElements.bar) return;
const currentBar = currentElements.bar;
const current = Number.parseFloat(currentBar.dataset.progress ?? '0');
const next = current + (0.99 - current) * 0.025;
currentBar.dataset.progress = String(next);
setProgress(currentBar, next);
}, 240);
};
const finish = () => {
const { root, bar } = getElements();
if (!root || !bar) return;
window.clearInterval(timer);
completionAnimation?.cancel();
hideAnimation?.cancel();
const current = Number.parseFloat(bar.dataset.progress ?? '0');
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const duration = reducedMotion ? 1 : 300;
root.classList.add('is-finishing');
root.style.transform = 'translateY(0)';
bar.style.transition = 'none';
setProgress(bar, current);
completionAnimation = bar.animate(
[{ transform: `scaleX(${current})` }, { transform: 'scaleX(1)' }],
{ duration, easing: 'cubic-bezier(0.33, 1, 0.68, 1)', fill: 'forwards' }
);
completionAnimation.onfinish = () => {
const currentElements = getElements();
if (!currentElements.root || !currentElements.bar || currentElements.bar !== bar) return;
bar.dataset.progress = '1';
bar.style.transform = 'scaleX(1)';
currentElements.root.classList.add('is-hiding');
hideAnimation = currentElements.root.animate(
[{ transform: 'translateY(0)' }, { transform: 'translateY(-100%)' }],
{ duration: reducedMotion ? 1 : 300, easing: 'cubic-bezier(0.33, 1, 0.68, 1)', fill: 'forwards' }
);
hideAnimation.onfinish = () => {
const latestElements = getElements();
if (!latestElements.root || !latestElements.bar || latestElements.bar !== bar) return;
latestElements.root.classList.remove('is-visible', 'is-finishing', 'is-hiding');
latestElements.root.style.transform = '';
latestElements.bar.dataset.progress = '0';
latestElements.bar.style.transition = '';
setProgress(latestElements.bar, 0);
latestElements.bar.getAnimations().forEach((animation) => animation.cancel());
latestElements.root.getAnimations().forEach((animation) => animation.cancel());
};
};
};
document.addEventListener('astro:before-preparation', start);
document.addEventListener('astro:page-load', finish);
</script>
<style is:global>
.navigation-progress {
position: fixed;
z-index: 999;
top: 0;
right: 0;
left: 0;
height: 3px;
min-height: 3px;
max-height: 3px;
overflow: hidden;
box-sizing: border-box;
display: block;
pointer-events: none;
contain: strict;
transform: translateY(-100%);
transition: transform 240ms cubic-bezier(0.33, 1, 0.68, 1);
}
.navigation-progress.is-visible { transform: translateY(0); }
.navigation-progress.is-hiding { transform: translateY(-100%); }
.navigation-progress__bar {
display: block;
width: 100%;
height: 100%;
min-height: 3px;
max-height: 3px;
background: var(--color-accent);
box-shadow: 0 3px 8px rgb(0 0 0 / 35%);
transform: scaleX(0);
transform-origin: left center;
transition: transform 240ms linear;
}
.navigation-progress.is-finishing .navigation-progress__bar {
transition: transform 700ms cubic-bezier(0.33, 1, 0.68, 1);
}
@media (prefers-reduced-motion: reduce) {
.navigation-progress,
.navigation-progress__bar { transition: none; }
}
</style>
+10
View File
@@ -0,0 +1,10 @@
---
interface Props { current: number; total: number; basePath: string; }
const { current, total, basePath } = Astro.props;
const path = (page: number) => page === 1 ? `${basePath}/` : `${basePath}/page/${page}/`;
const buttonClass = 'rounded-[4px] border border-mirages-accent px-7 py-2 text-sm font-bold text-mirages-accent no-underline hover:bg-mirages-accent hover:text-white';
---
{total > 1 && <nav class="mt-8 flex min-h-10 items-center justify-between gap-4" aria-label="分页">
{current > 1 ? <a class={buttonClass} href={path(current - 1)} aria-label="上一页">上一页</a> : <span></span>}
{current < total && <a class={buttonClass} href={path(current + 1)} aria-label="下一页">下一页</a>}
</nav>}
+28
View File
@@ -0,0 +1,28 @@
---
import type { PostEntry } from '@/lib/content';
import { getPostCardImage, getPostPath, getPostTaxonomy } from '@/lib/content';
import { siteConfig } from '@/site.config';
interface Props { post: PostEntry; }
const { post } = Astro.props;
const postPath = getPostPath(post);
const taxonomy = getPostTaxonomy(post);
const categories = taxonomy.categories.join(', ');
const date = [post.data.pubDate.getFullYear(), post.data.pubDate.getMonth() + 1, post.data.pubDate.getDate()]
.map((part, index) => index === 0 ? String(part) : String(part).padStart(2, '0'))
.join('-');
const image = getPostCardImage(post, siteConfig.cards.defaultCovers, siteConfig.site.url);
---
<article class="py-[0.9375rem] md:py-[0.9375rem]" data-pagefind-ignore="index">
<a class="group relative block h-50 overflow-hidden rounded-[0.3125rem] bg-[#444] text-white no-underline shadow-sm transition-[transform,box-shadow] duration-300 ease-in-out hover:[transform:translateY(-4px)_scale(1.05)] hover:text-white hover:shadow-[0_22px_43px_rgb(0_0_0/15%)] max-[21rem]:h-42 md:h-62" href={postPath}>
{image && <img class="absolute inset-0 size-full object-cover" src={image} alt="" loading="lazy" />}
<span class="absolute inset-0 z-10 bg-black/25" aria-hidden="true"></span>
<div class="absolute inset-0 z-20 flex flex-col items-center justify-center px-4 py-4 text-center md:px-6">
<h2 class="post-card-title m-0 max-w-[90%] [overflow-wrap:anywhere] font-sans text-[1.5625rem] leading-tight font-normal tracking-[0]" data-pagefind-weight="3">{post.data.title}</h2>
<p class="mt-3 mb-0 max-w-[90%] [overflow-wrap:anywhere] text-[0.8125rem] leading-relaxed font-normal text-[#eee] [font-family:Consolas,Menlo,Monaco,'lucida_console','Liberation_Mono','Courier_New','andale_mono',monospaceX,monospace,sans-serif]"><time datetime={post.data.pubDate.toISOString()}>{date}</time>{categories && <><span> · </span><span class="[font-family:var(--mirages-font-ui)]">{categories}</span></>}</p>
</div>
</a>
</article>
<style>
:global(:root[data-font='serif']) .post-card-title { font-weight: 500; }
</style>
+15
View File
@@ -0,0 +1,15 @@
---
import type { PostEntry } from '@/lib/content';
import PostCard from './PostCard.astro';
import Pagination from './Pagination.astro';
interface Props { posts: PostEntry[]; title?: string; description?: string; current?: number; total?: number; basePath?: string; }
const { posts, title, description, current = 1, total = 1, basePath = '' } = Astro.props;
---
<section class="mx-auto w-full px-5 pt-8 pb-14 md:max-w-[752px] md:px-0 md:pt-10 min-[1302px]:max-w-[896px] min-[1600px]:max-w-[928px] min-[1800px]:max-w-[992px] min-[2000px]:max-w-[1024px] min-[2400px]:max-w-[1056px]" aria-labelledby={title ? 'list-title' : undefined}>
{title && <header class="mb-5 border-b border-line pb-3">
<h1 class="m-0 text-xl leading-tight font-normal tracking-[0]" id="list-title">{title}</h1>
{description && <p class="mt-1 mb-0 text-sm text-subtle">{description}</p>}
</header>}
{posts.length > 0 ? posts.map((post) => <PostCard post={post} />) : <p class="py-8 text-subtle">暂时还没有已发布的文章。</p>}
<Pagination current={current} total={total} basePath={basePath} />
</section>
+14
View File
@@ -0,0 +1,14 @@
---
import type { PostEntry } from '@/lib/content';
import { getPostTaxonomy, slugify } from '@/lib/content';
interface Props { post: PostEntry; compact?: boolean; showTags?: boolean; }
const { post, compact = false, showTags = true } = Astro.props;
const taxonomy = getPostTaxonomy(post);
const date = post.data.pubDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'short', day: 'numeric' });
---
<div class="flex flex-wrap items-center justify-center gap-2 text-sm text-subtle">
<time datetime={post.data.pubDate.toISOString()}>{date}</time>
{!compact && taxonomy.categories.length > 0 && <span aria-hidden="true">·</span>}
{!compact && taxonomy.categories.map((category) => <a data-text-link href={`/categories/${slugify(category)}/`}>{category}</a>)}
{!compact && showTags && taxonomy.tags.length > 0 && <span class="inline-flex flex-wrap gap-2" aria-label="标签">{taxonomy.tags.map((tag) => <a class="no-underline" href={`/tags/${slugify(tag)}/`}>#{tag}</a>)}</span>}
</div>
+90
View File
@@ -0,0 +1,90 @@
---
import { Search as SearchIcon } from '@lucide/astro';
import { siteConfig } from '@/site.config';
interface Props { mobile?: boolean; }
const { mobile = false } = Astro.props;
const placeholder = siteConfig.search.provider === 'pagefind' ? siteConfig.search.placeholder : '搜索';
---
<div class:list={['relative', { 'w-full': mobile, 'navbar-search-container': !mobile }]} data-search-root data-mobile-search={mobile ? '' : undefined}>
{!mobile && <button class="grid size-[2.5rem] cursor-pointer place-items-center border-0 bg-transparent text-current hover:bg-current/10" type="button" aria-label="展开搜索" title="搜索" aria-expanded="false" data-search-toggle>
<SearchIcon aria-hidden="true" size="1em" />
</button>}
<form class:list={['search-form items-center border-b text-current', mobile ? 'flex w-full border-line px-1' : 'absolute top-0 right-[2.5rem] flex h-[2.5rem] w-[18rem] border-current/50 bg-nav px-2 opacity-0 shadow-sm pointer-events-none']} action={`${import.meta.env.BASE_URL}search/`} method="get" role="search" data-search-form>
<input class="min-w-0 flex-1 border-0 bg-transparent px-2 py-2 text-sm text-current outline-none ring-0 shadow-none focus:border-0 focus:outline-none focus:ring-0 focus:shadow-none placeholder:text-current/65" type="search" name="q" required placeholder={placeholder} autocomplete="off" spellcheck="false" aria-label="搜索文章" data-search-input />
{mobile && <button class="grid size-[2rem] shrink-0 cursor-pointer place-items-center border-0 bg-transparent text-current hover:bg-current/10" type="submit" aria-label="搜索" title="搜索"><SearchIcon aria-hidden="true" size="1em" /></button>}
</form>
</div>
<script>
const closeSearch = (root: HTMLElement, restoreFocus = false) => {
const input = root.querySelector<HTMLInputElement>('[data-search-input]');
const toggle = root.querySelector<HTMLButtonElement>('[data-search-toggle]');
if (root.hasAttribute('data-mobile-search')) input?.blur();
else {
root.classList.remove('is-open');
toggle?.setAttribute('aria-expanded', 'false');
if (restoreFocus) toggle?.focus();
}
};
document.addEventListener('click', (event) => {
if (!(event.target instanceof Element)) return;
const toggle = event.target.closest<HTMLButtonElement>('[data-search-toggle]');
if (!toggle) return;
const root = toggle.closest<HTMLElement>('[data-search-root]');
if (!root) return;
if (root.classList.contains('is-open')) closeSearch(root, true);
else {
root.classList.add('is-open');
toggle.setAttribute('aria-expanded', 'true');
requestAnimationFrame(() => root.querySelector<HTMLInputElement>('[data-search-input]')?.focus());
}
});
document.addEventListener('submit', (event) => {
const form = event.target;
if (!(form instanceof HTMLFormElement) || !form.matches('[data-search-form]')) return;
if (!form.querySelector<HTMLInputElement>('[data-search-input]')?.value.trim()) event.preventDefault();
});
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || !(event.target instanceof Element)) return;
const root = event.target.closest<HTMLElement>('[data-search-root]');
if (!root) return;
event.preventDefault();
closeSearch(root, true);
});
document.addEventListener('pointerdown', (event) => {
document.querySelectorAll<HTMLElement>('[data-search-root].is-open').forEach((root) => {
if (!root.contains(event.target as Node)) closeSearch(root);
});
});
</script>
<style is:global>
.navbar-search-container > .search-form {
transform-origin: right center;
scale: 0 1;
transition: scale 200ms ease-out, opacity 200ms ease-out;
}
.search-form input[type='search']::-webkit-search-cancel-button {
width: 1em;
height: 1em;
margin-inline: 0.25em;
}
.navbar-search-container.is-open > .search-form {
pointer-events: auto;
scale: 1 1;
opacity: 1;
}
/* The search field deliberately keeps focus semantics without a visible focus treatment. */
.search-form [data-search-input]:is(:focus, :focus-visible) {
border-color: transparent !important;
outline: none !important;
outline-offset: 0 !important;
box-shadow: none !important;
}
</style>
+34
View File
@@ -0,0 +1,34 @@
---
import { siteConfig } from '@/site.config';
interface Props {
title?: string;
description?: string;
image?: string;
type?: 'website' | 'article';
noindex?: boolean;
}
const {
title,
description = siteConfig.site.description,
image,
type = 'website',
noindex = false
} = Astro.props;
const pageTitle = title ? `${title} - ${siteConfig.site.title}` : siteConfig.site.title;
const canonical = new URL(Astro.url.pathname, siteConfig.site.url);
const imageUrl = image ? new URL(image, siteConfig.site.url) : undefined;
---
<title>{pageTitle}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
<meta property="og:type" content={type} />
<meta property="og:title" content={pageTitle} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:site_name" content={siteConfig.site.title} />
{imageUrl && <meta property="og:image" content={imageUrl} />}
<meta name="twitter:card" content={imageUrl ? 'summary_large_image' : 'summary'} />
{noindex && <meta name="robots" content="noindex, nofollow" />}
+227
View File
@@ -0,0 +1,227 @@
---
import type { MarkdownHeading } from 'astro';
import { ListTree, X } from '@lucide/astro';
interface Props {
headings: MarkdownHeading[];
position?: 'left' | 'right';
}
const { headings } = Astro.props;
const visibleHeadings = headings.filter((heading) => heading.depth >= 2 && heading.depth <= 4);
---
{visibleHeadings.length > 0 && (
<div class="toc" data-toc-root data-pagefind-ignore="all">
<button class="toc-toggle" type="button" aria-label="打开文章目录" aria-controls="article-toc" aria-expanded="false" aria-hidden="false" data-toc-toggle>
<ListTree class="toc-open-icon" aria-hidden="true" size={18} />
<X class="toc-close-icon" aria-hidden="true" size={18} />
</button>
<div class="toc-backdrop" aria-hidden="true" data-toc-backdrop></div>
<aside class="toc-drawer" id="article-toc" aria-label="文章目录" aria-hidden="true" data-toc-drawer>
<header class="toc-header">
<h2>文章目录</h2>
</header>
<nav>
<ul>
{visibleHeadings.map((heading) => (
<li class={`depth-${heading.depth}`}><a href={`#${heading.slug}`}>{heading.text}</a></li>
))}
</ul>
</nav>
</aside>
</div>
)}
<style>
.toc {
--toc-drawer-width: min(280px, calc(100vw - 18px));
}
.toc-toggle {
position: fixed;
z-index: 43;
top: 50%;
right: 0;
display: grid;
width: 32px;
height: 32px;
padding: 0;
border: 1px solid var(--color-border);
border-right: 0;
border-radius: 4px 0 0 4px;
background: var(--color-surface);
color: var(--color-muted);
box-shadow: none;
cursor: pointer;
transform: translateY(-50%);
transition: right 220ms ease, color var(--transition-fast), background var(--transition-fast);
place-items: center;
}
.toc-close-icon { display: none; }
.toc-toggle:hover,
.toc-toggle[aria-expanded='true'] {
color: var(--color-accent-strong);
}
.toc-backdrop {
position: fixed;
z-index: 41;
inset: 0;
background: rgb(0 0 0 / 28%);
opacity: 0;
pointer-events: none;
transition: opacity var(--transition-fast);
}
.toc-drawer {
position: fixed;
z-index: 42;
top: 0;
right: 0;
bottom: 0;
width: var(--toc-drawer-width);
padding: max(18px, env(safe-area-inset-top)) 22px max(18px, env(safe-area-inset-bottom));
border-left: 1px solid var(--color-border);
background: var(--color-bg);
color: var(--color-text);
box-shadow: none;
overflow-y: auto;
transform: translateX(100%);
transition: transform 220ms ease;
}
.toc.is-open .toc-backdrop {
opacity: 1;
pointer-events: auto;
}
.toc.is-open .toc-drawer {
transform: translateX(0);
}
.toc.is-open .toc-toggle {
right: var(--toc-drawer-width);
}
.toc.is-open .toc-open-icon { display: none; }
.toc.is-open .toc-close-icon { display: block; }
.toc-header {
display: flex;
min-height: 42px;
align-items: center;
margin-bottom: 14px;
border-bottom: 1px solid var(--color-border);
padding-bottom: 10px;
}
.toc-header h2 {
margin: 0;
font-family: var(--reading-font-family);
font-size: .9rem;
font-weight: 700;
}
.toc ul {
margin: 0;
padding: 0;
list-style: none;
}
.toc li {
margin: 4px 0;
font-family: var(--reading-font-family);
font-size: .86rem;
line-height: 1.45;
}
.toc li a {
display: block;
border-left: 2px solid transparent;
padding: 7px 8px;
color: var(--color-muted);
text-decoration: none;
}
.toc li a:hover,
.toc li a[aria-current] {
border-left-color: var(--color-accent);
color: var(--color-accent-strong);
}
.toc li a[aria-current] {
background: color-mix(in srgb, var(--color-accent) 9%, transparent);
font-weight: 700;
}
.toc .depth-3 { padding-left: 12px; }
.toc .depth-4 { padding-left: 24px; font-size: .8rem; }
</style>
<script>
const setOpen = (open: boolean, restoreFocus = false) => {
const root = document.querySelector<HTMLElement>('[data-toc-root]');
const toggle = root?.querySelector<HTMLButtonElement>('[data-toc-toggle]');
const drawer = root?.querySelector<HTMLElement>('[data-toc-drawer]');
const backdrop = root?.querySelector<HTMLElement>('[data-toc-backdrop]');
root?.classList.toggle('is-open', open);
toggle?.setAttribute('aria-expanded', String(open));
toggle?.setAttribute('aria-label', open ? '关闭文章目录' : '打开文章目录');
drawer?.setAttribute('aria-hidden', String(!open));
backdrop?.setAttribute('aria-hidden', String(!open));
document.body.classList.toggle('toc-drawer-open', open);
if (restoreFocus) toggle?.focus();
};
document.addEventListener('click', (event) => {
if (!(event.target instanceof Element)) return;
const target = event.target;
const toggle = target.closest<HTMLButtonElement>('[data-toc-toggle]');
const backdrop = target.closest<HTMLElement>('[data-toc-backdrop]');
const link = target.closest<HTMLAnchorElement>('[data-toc-drawer] a[href^="#"]');
if (toggle) {
setOpen(toggle.getAttribute('aria-expanded') !== 'true', true);
} else if (backdrop) {
setOpen(false, true);
} else if (link) {
setOpen(false);
}
});
document.addEventListener('keydown', (event) => {
const toggle = document.querySelector<HTMLButtonElement>('[data-toc-toggle]');
if (event.key === 'Escape' && toggle?.getAttribute('aria-expanded') === 'true') setOpen(false, true);
});
const updateCurrent = () => {
const root = document.querySelector<HTMLElement>('[data-toc-root]');
const links = [...(root?.querySelectorAll<HTMLAnchorElement>('a[href^="#"]') ?? [])];
const headingLinks = links.map((link) => ({ link, heading: document.getElementById(decodeURIComponent(link.hash.slice(1))) })).filter((item): item is { link: HTMLAnchorElement; heading: HTMLElement } => Boolean(item.heading));
if (!headingLinks.length) return;
const current = headingLinks.reduce((active, item) => item.heading.getBoundingClientRect().top <= 112 ? item : active, headingLinks[0]);
headingLinks.forEach(({ link }) => {
if (link === current.link) link.setAttribute('aria-current', 'location');
else link.removeAttribute('aria-current');
});
};
{
let ticking = false;
window.addEventListener('scroll', () => {
if (!ticking) {
ticking = true;
requestAnimationFrame(() => {
updateCurrent();
ticking = false;
});
}
}, { passive: true });
updateCurrent();
document.addEventListener('astro:page-load', updateCurrent);
document.addEventListener('astro:before-preparation', () => setOpen(false));
}
</script>
+165
View File
@@ -0,0 +1,165 @@
---
import { CloudSun, Monitor, Moon, Minus, Plus, Sun, Type } from '@lucide/astro';
interface Props { placement?: 'down' | 'up'; }
const { placement = 'down' } = Astro.props;
const themes = [
{ value: 'auto', label: '自动', icon: Monitor },
{ value: 'light', label: '日间', icon: Sun },
{ value: 'sunset', label: '日落', icon: CloudSun },
{ value: 'dark', label: '夜间', icon: Moon }
] as const;
---
<details class:list={[placement === 'down' ? 'relative' : 'static', { 'placement-up': placement === 'up' }]} data-reading-settings>
<summary class="grid size-[2.5rem] cursor-pointer list-none place-items-center text-current/80 hover:bg-current/10 hover:text-current [&::-webkit-details-marker]:hidden" aria-label="打开阅读设置" aria-expanded="false" title="阅读设置">
<Type aria-hidden="true" size="1em" />
</summary>
<div class:list={['reading-settings-panel absolute z-[80] border border-line bg-raised p-[0.75rem] shadow-mirages', placement === 'up' ? 'bottom-[calc(100%+0.5rem)] left-0 w-[min(17.5rem,84vw)] max-w-[calc(100vw-1.75rem)] max-h-[calc(100svh-1.75rem)] overflow-y-auto' : 'top-[calc(100%+0.5rem)] right-0 w-[min(17.875rem,calc(100vw-1.75rem))]']} role="dialog" aria-label="阅读设置">
<div class="m-0">
<div class="mb-[0.25rem] flex items-center gap-[0.5rem]">
<h3 class="m-0 shrink-0 text-xs font-normal leading-5 text-subtle">颜色主题</h3>
<span class="h-px min-w-0 flex-1 bg-line" aria-hidden="true"></span>
</div>
<div class="grid grid-cols-4 gap-1" role="radiogroup" aria-label="颜色主题">
{themes.map(({ value, label, icon: Icon }) => (
<button class="grid min-h-[3.5rem] cursor-pointer place-items-center gap-[0.125rem] border border-transparent bg-transparent px-[0.25rem] py-[0.25rem] text-xs text-foreground hover:border-line hover:bg-mirages-accent/10" type="button" role="radio" aria-checked="false" data-theme-value={value}>
<Icon aria-hidden="true" size="1.0625rem" />
<span>{label}</span>
</button>
))}
</div>
</div>
<div class="mt-[0.375rem]">
<div class="mb-[0.25rem] flex items-center gap-[0.5rem]">
<h3 class="m-0 shrink-0 text-xs font-normal leading-5 text-subtle">字号</h3>
<span class="h-px min-w-0 flex-1 bg-line" aria-hidden="true"></span>
</div>
<div class="flex items-center gap-[0.5rem]">
<button class="grid h-[2rem] min-w-0 flex-[2] cursor-pointer place-items-center border border-line bg-transparent text-foreground hover:bg-mirages-accent/10" type="button" aria-label="减小字号" data-scale-step="-5"><Minus aria-hidden="true" size="1em" /></button>
<output class="min-w-0 flex-1 text-center text-xs font-semibold" data-scale-output>100%</output>
<button class="grid h-[2rem] min-w-0 flex-[2] cursor-pointer place-items-center border border-line bg-transparent text-foreground hover:bg-mirages-accent/10" type="button" aria-label="增大字号" data-scale-step="5"><Plus aria-hidden="true" size="1em" /></button>
</div>
</div>
<div class="mt-[0.375rem]">
<div class="mb-[0.25rem] flex items-center gap-[0.5rem]">
<h3 class="m-0 shrink-0 text-xs font-normal leading-5 text-subtle">字体</h3>
<span class="h-px min-w-0 flex-1 bg-line" aria-hidden="true"></span>
</div>
<div class="grid grid-cols-2 gap-1" role="radiogroup" aria-label="字体">
<button class="cursor-pointer border border-line bg-transparent px-[0.5rem] py-[0.375rem] text-xs text-foreground hover:bg-mirages-accent/10 [font-family:var(--mirages-font-serif)]" type="button" role="radio" aria-checked="false" data-font-value="serif">宋体</button>
<button class="cursor-pointer border border-line bg-transparent px-[0.5rem] py-[0.375rem] text-xs text-foreground hover:bg-mirages-accent/10 [font-family:var(--mirages-font-sans)]" type="button" role="radio" aria-checked="false" data-font-value="sans">黑体</button>
</div>
</div>
</div>
</details>
<script>
const root = document.documentElement;
const getStoredFont = () => {
const font = localStorage.getItem('mirages-font-family');
return font === 'serif' || font === 'sans' ? font : 'sans';
};
const getStoredTheme = () => {
const theme = localStorage.getItem('mirages-theme');
return theme === 'auto' || theme === 'light' || theme === 'sunset' || theme === 'dark' ? theme : null;
};
const syncSwitchers = (theme: string) => {
const switchers = document.querySelectorAll<HTMLElement>('[data-reading-settings]');
switchers.forEach((switcher) => {
switcher.querySelectorAll<HTMLButtonElement>('[data-theme-value]').forEach((button) => {
button.setAttribute('aria-checked', String(button.dataset.themeValue === theme));
});
switcher.querySelectorAll<HTMLButtonElement>('[data-font-value]').forEach((button) => {
button.setAttribute('aria-checked', String(button.dataset.fontValue === (root.dataset.font ?? 'sans')));
});
});
};
const getScale = () => {
const storedScale = Number(localStorage.getItem('mirages-font-scale'));
return Math.min(150, Math.max(80, Math.round((storedScale >= 0.8 && storedScale <= 1.5 ? storedScale * 100 : storedScale >= 80 && storedScale <= 150 ? storedScale : 100) / 5) * 5));
};
const syncScale = (value: number, persist = true) => {
const scale = Math.min(150, Math.max(80, Math.round(value / 5) * 5));
root.style.fontSize = `${scale}%`;
document.querySelectorAll<HTMLOutputElement>('[data-scale-output]').forEach((output) => {
output.value = `${scale}%`;
output.textContent = `${scale}%`;
});
if (persist) localStorage.setItem('mirages-font-scale', String(scale / 100));
};
root.dataset.font = getStoredFont();
syncSwitchers(root.dataset.theme ?? 'auto');
syncScale(getScale(), false);
document.addEventListener('click', (event) => {
if (!(event.target instanceof Element)) return;
const target = event.target;
const switcher = target.closest<HTMLElement>('[data-reading-settings]');
if (!switcher) return;
const theme = target.closest<HTMLButtonElement>('[data-theme-value]')?.dataset.themeValue;
if (theme) {
root.dataset.theme = theme;
localStorage.setItem('mirages-theme', theme);
syncSwitchers(theme);
return;
}
const step = target.closest<HTMLButtonElement>('[data-scale-step]')?.dataset.scaleStep;
if (step) {
syncScale((Number.parseFloat(root.style.fontSize) || getScale()) + Number(step));
return;
}
const font = target.closest<HTMLButtonElement>('[data-font-value]')?.dataset.fontValue;
if (font) {
root.dataset.font = font;
localStorage.setItem('mirages-font-family', font);
syncSwitchers(root.dataset.theme ?? 'auto');
}
});
document.addEventListener('toggle', (event) => {
const switcher = event.target as HTMLElement;
if (!switcher.matches('[data-reading-settings]')) return;
const trigger = switcher.querySelector<HTMLElement>('summary');
const open = switcher.hasAttribute('open');
trigger?.setAttribute('aria-expanded', String(open));
if (open) {
document.querySelectorAll<HTMLElement>('[data-reading-settings]').forEach((other) => { if (other !== switcher) other.removeAttribute('open'); });
switcher.querySelector<HTMLButtonElement>('[data-theme-value]')?.focus();
}
}, true);
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || !(event.target instanceof Element)) return;
const switcher = event.target.closest<HTMLElement>('[data-reading-settings]');
if (!switcher) return;
event.preventDefault();
event.stopPropagation();
switcher.removeAttribute('open');
switcher.querySelector<HTMLElement>('summary')?.focus();
});
document.addEventListener('pointerdown', (event) => {
document.querySelectorAll<HTMLElement>('[data-reading-settings]').forEach((switcher) => {
if (!switcher.contains(event.target as Node)) switcher.removeAttribute('open');
});
});
document.addEventListener('astro:before-swap', (event) => {
const theme = getStoredTheme();
if (theme) event.newDocument.documentElement.dataset.theme = theme;
});
document.addEventListener('astro:page-load', () => {
root.dataset.theme = getStoredTheme() ?? root.dataset.theme ?? 'auto';
root.dataset.font = getStoredFont();
syncSwitchers(root.dataset.theme ?? 'auto');
syncScale(getScale(), false);
});
</script>
<style>
button[aria-checked='true'] { border-color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 10%, transparent); }
[data-reading-settings] > summary { border-radius: var(--radius-sm); }
.reading-settings-panel,
.reading-settings-panel button { font-family: var(--reading-font-family); }
.reading-settings-panel button[data-font-value='serif'] { font-family: var(--mirages-font-serif) !important; }
.reading-settings-panel button[data-font-value='sans'] { font-family: var(--mirages-font-sans) !important; }
</style>
+11
View File
@@ -0,0 +1,11 @@
---
import { Link, Rss, Search, TramFront, Type } from '@lucide/astro';
import type { ToolbarIcon as ToolbarIconName } from '@/types/config';
interface Props { icon: ToolbarIconName; }
const { icon } = Astro.props;
const icons = { link: Link, rss: Rss, search: Search, settings: Type, 'tram-front': TramFront } as const;
const Icon = icons[icon];
---
<Icon aria-hidden="true" size="1em" />
+69
View File
@@ -0,0 +1,69 @@
<script is:inline>
(() => {
const state = window.__miragesTwikoo ??= {};
if (state.clientBound) return;
state.clientBound = true;
const loadScript = () => {
if (state.script) return state.script;
state.script = new Promise((resolve, reject) => {
if (window.twikoo) return resolve(window.twikoo);
const sources = [
'https://registry.npmmirror.com/twikoo/1.7.15/files/dist/twikoo.nocss.js',
'https://cdn.jsdelivr.net/npm/twikoo@1.7.15/dist/twikoo.nocss.js'
];
const loadSource = (index) => {
if (index >= sources.length) return reject(new Error('Twikoo 加载失败'));
const script = document.createElement('script');
const timer = window.setTimeout(() => { script.remove(); loadSource(index + 1); }, 8000);
script.src = sources[index];
script.async = true;
script.onload = () => { window.clearTimeout(timer); window.twikoo ? resolve(window.twikoo) : loadSource(index + 1); };
script.onerror = () => { window.clearTimeout(timer); loadSource(index + 1); };
document.head.appendChild(script);
};
loadSource(0);
});
return state.script;
};
const waitForStyles = () => {
const link = document.querySelector('link[data-twikoo-styles]');
if (!(link instanceof HTMLLinkElement) || link.sheet) return Promise.resolve();
return new Promise((resolve) => {
link.addEventListener('load', () => resolve(), { once: true });
link.addEventListener('error', () => resolve(), { once: true });
});
};
const initCurrentComments = () => {
const root = document.querySelector('#twikoo-comments');
if (!(root instanceof HTMLElement) || root.dataset.twikooBound) return;
let config;
try {
config = JSON.parse(root.dataset.twikooConfig ?? '{}');
} catch {
return;
}
root.dataset.twikooBound = 'true';
root.dataset.twikooState = 'loading';
Promise.all([loadScript(), waitForStyles()]).then(([twikoo]) => {
if (!root.isConnected || document.querySelector('#twikoo-comments') !== root) return;
return twikoo.init({ ...config, el: '#twikoo-comments' });
}).then(() => {
if (root.isConnected) root.dataset.twikooState = 'ready';
}).catch(() => {
if (root.isConnected) {
root.dataset.twikooState = 'error';
root.setAttribute('data-twikoo-error', 'true');
}
});
};
initCurrentComments();
document.addEventListener('astro:page-load', initCurrentComments);
document.addEventListener('astro:before-swap', () => {
document.querySelector('#twikoo-comments')?.replaceChildren();
});
})();
</script>
+37
View File
@@ -0,0 +1,37 @@
---
import { siteConfig } from '@/site.config';
const enabled = siteConfig.comments.provider === 'twikoo' && Boolean(siteConfig.comments.envId.trim());
const clientConfig = siteConfig.comments.provider === 'twikoo' && siteConfig.comments.envId.trim() ? {
envId: siteConfig.comments.envId,
...(siteConfig.comments.envId.startsWith('http') ? {} : { region: siteConfig.comments.region })
} : null;
---
{enabled && clientConfig && <script is:inline define:vars={{ config: clientConfig }}>
(() => {
const nodes = [...document.querySelectorAll('[data-twikoo-url]')];
if (!nodes.length) return;
const start = () => {
const urls = [...new Set(nodes.map((node) => node.dataset.twikooUrl).filter(Boolean))];
const state = window.__miragesTwikoo ??= {};
const load = state.script ??= new Promise((resolve, reject) => {
if (window.twikoo) return resolve(window.twikoo);
const script = document.createElement('script');
script.src = 'https://registry.npmmirror.com/twikoo/1.7.15/files/dist/twikoo.nocss.js';
script.async = true;
script.onload = () => window.twikoo ? resolve(window.twikoo) : reject(new Error('Twikoo 不可用'));
script.onerror = () => reject(new Error('Twikoo 加载失败'));
document.head.appendChild(script);
});
load.then((twikoo) => twikoo.getCommentsCount({ envId: config.envId, region: config.region, urls, includeReply: false }))
.then((counts) => (counts ?? []).forEach((item) => {
const target = nodes.find((node) => node.dataset.twikooUrl === item.url)?.querySelector('[data-twikoo-count]');
if (typeof item.count === 'number' && target) {
target.textContent = `${item.count} 条评论`;
target.hidden = false;
}
})).catch(() => undefined);
};
const idle = window.requestIdleCallback ?? ((callback) => window.setTimeout(callback, 1));
idle(start);
})();
</script>}
+173
View File
@@ -0,0 +1,173 @@
---
import { siteConfig } from '@/site.config';
interface Props { enabled: boolean; path?: string; }
const { enabled, path } = Astro.props;
const comments = siteConfig.comments;
const config = comments.provider === 'twikoo' && comments.envId.trim() ? {
envId: comments.envId,
lang: comments.lang ?? siteConfig.site.locale,
path: comments.path ?? path ?? Astro.url.pathname,
...(comments.envId.startsWith('http') ? {} : { region: comments.region })
} : null;
---
{enabled && config && <section class="comments-shell" id="comments" aria-labelledby="comments-title">
<h2 id="comments-title">评论</h2>
<div id="twikoo-comments" data-twikoo-config={JSON.stringify(config)} data-twikoo-state="idle"></div>
</section>}
{enabled && !config && <section class="comments-shell comments-preview" id="comments" aria-labelledby="comments-title" data-pagefind-ignore="all">
<h2 id="comments-title">评论</h2>
<div class="twikoo-preview" aria-label="Twikoo 评论预览">
<p class="twikoo-preview-status">Twikoo 尚未配置</p>
<div class="twikoo-preview-fields" aria-hidden="true">
<span>昵称</span><span>邮箱</span><span>网址</span>
</div>
<div class="twikoo-preview-editor" aria-hidden="true">留下你的评论...</div>
<div class="twikoo-preview-actions" aria-hidden="true"><span>表情</span><button type="button" disabled>发布评论</button></div>
<p class="twikoo-preview-empty">暂无评论</p>
</div>
</section>}
<style is:global>
#comments { width: min(100%, var(--article-content-width)); margin: 4.375rem auto 0; }
#comments > h2 { margin: 0 0 1.5rem; padding-top: 1.375rem; border-top: 1px solid var(--color-border); color: var(--color-accent-strong); font-family: var(--reading-font-family); font-size: 1.5625rem; font-weight: 500; }
#twikoo-comments, #comments > #twikoo { color: var(--color-text); font-size: .94rem; }
#twikoo-comments .tk-comments-title, #comments > #twikoo .tk-comments-title { margin: 0 0 18px; color: var(--color-text); font-family: var(--font-serif); font-size: 1.1rem; font-weight: 600; }
#twikoo-comments .tk-comment, #comments > #twikoo .tk-comment { padding: 1.375rem 0; border-bottom: 1px solid var(--color-border); }
#twikoo-comments .tk-content, #twikoo-comments .tk-comment-content, #comments > #twikoo .tk-content, #comments > #twikoo .tk-comment-content { margin-top: .5rem; color: var(--color-text); line-height: 1.75; overflow-wrap: anywhere; }
#twikoo-comments .tk-content p, #twikoo-comments .tk-comment-content p, #comments > #twikoo .tk-content p, #comments > #twikoo .tk-comment-content p { margin: .35em 0; }
#twikoo-comments .tk-avatar, #twikoo-comments .tk-comment-avatar, #comments > #twikoo .tk-avatar, #comments > #twikoo .tk-comment-avatar { width: 3.125rem; height: 3.125rem; border-radius: 50%; background: var(--color-code); }
#twikoo-comments .tk-comment-name, #twikoo-comments .tk-nick, #comments > #twikoo .tk-comment-name, #comments > #twikoo .tk-nick { color: var(--color-text); font-weight: 700; }
#twikoo-comments .tk-meta, #twikoo-comments .tk-time, #twikoo-comments .tk-comment-actions, #twikoo-comments .tk-comment-edit, #comments > #twikoo .tk-meta, #comments > #twikoo .tk-time, #comments > #twikoo .tk-comment-actions, #comments > #twikoo .tk-comment-edit { color: var(--color-muted); font-size: .78rem; }
#twikoo-comments .tk-replies, #comments > #twikoo .tk-replies { margin: 1rem 0 0 -.25rem; padding-left: .375rem; border-left: 2px solid var(--color-border); }
#twikoo-comments .tk-replies .tk-comment, #comments > #twikoo .tk-replies .tk-comment { padding-block: .875rem; }
#twikoo-comments .tk-main, #comments > #twikoo .tk-main { margin-top: 1.375rem; }
#twikoo-comments .tk-input, #comments > #twikoo .tk-input { width: 100%; }
#twikoo-comments textarea, #twikoo-comments input, #comments > #twikoo textarea, #comments > #twikoo input { width: 100%; border: 2px solid var(--color-accent); border-radius: var(--radius-sm); background: var(--color-surface); color: var(--color-text); }
#twikoo-comments textarea, #comments > #twikoo textarea { min-height: 120px; padding: 13px 15px; resize: vertical; }
#twikoo-comments input, #comments > #twikoo input { padding: 9px 11px; }
#twikoo-comments textarea:focus, #twikoo-comments input:focus, #comments > #twikoo textarea:focus, #comments > #twikoo input:focus { border-color: var(--color-accent-strong); outline: none; }
#twikoo-comments button, #twikoo-comments .tk-submit, #comments > #twikoo button, #comments > #twikoo .tk-submit { border: 1px solid var(--color-accent-strong); border-radius: var(--radius-sm); background: var(--color-accent); color: var(--color-accent-contrast); cursor: pointer; font-weight: 700; }
#twikoo-comments .tk-owo, #twikoo-comments .OwO, #comments > #twikoo .tk-owo, #comments > #twikoo .OwO { color: var(--color-muted); }
#twikoo-comments .OwO .OwO-body, #comments > #twikoo .OwO .OwO-body { border-color: var(--color-border); background: var(--color-surface); }
#twikoo-comments .tk-loading, #twikoo-comments .tk-empty, #twikoo-comments .tk-error, #comments > #twikoo .tk-loading, #comments > #twikoo .tk-empty, #comments > #twikoo .tk-error { padding: 22px 0; color: var(--color-muted); }
#twikoo-comments .tk-loading, #comments > #twikoo .tk-loading, #twikoo-comments .el-loading-spinner, #comments > #twikoo .el-loading-spinner { color: var(--color-accent) !important; }
#twikoo-comments .tk-loading :is(svg, path), #comments > #twikoo .tk-loading :is(svg, path), #twikoo-comments .el-loading-spinner :is(svg, path), #comments > #twikoo .el-loading-spinner :is(svg, path) { color: var(--color-accent) !important; stroke: currentColor !important; }
#twikoo-comments .el-loading-spinner .path, #comments > #twikoo .el-loading-spinner .path { stroke: var(--color-accent) !important; }
#twikoo-comments .tk-loading::before, #comments > #twikoo .tk-loading::before { border-color: color-mix(in srgb, var(--color-accent) 25%, transparent) !important; border-top-color: var(--color-accent) !important; }
#twikoo-comments[data-twikoo-state='idle'] { min-height: 80px; }
#twikoo-comments[data-twikoo-state='idle']::before { content: '评论将在滚动到此处时加载'; display: block; color: var(--color-muted); font-size: .86rem; }
#twikoo-comments[data-twikoo-error='true']::before { content: '评论暂时不可用。'; display: block; padding: 22px 0; color: var(--color-muted); }
.twikoo-preview { border: 1px solid var(--color-border); background: var(--color-surface); color: var(--color-text); }
.twikoo-preview-status { margin: 0; padding: 12px 15px; border-bottom: 1px solid var(--color-border); color: var(--color-muted); font-size: .86rem; }
.twikoo-preview-fields { display: grid; grid-template-columns: repeat(3, 1fr); border-bottom: 1px solid var(--color-border); }
.twikoo-preview-fields span { padding: 10px 12px; color: var(--color-muted); font-size: .82rem; }
.twikoo-preview-fields span + span { border-left: 1px solid var(--color-border); }
.twikoo-preview-editor { min-height: 120px; padding: 14px 15px; color: var(--color-muted); }
.twikoo-preview-actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border-top: 1px solid var(--color-border); color: var(--color-muted); font-size: .85rem; }
.twikoo-preview-actions button { padding: 7px 12px; border: 1px solid var(--color-border); border-radius: var(--radius-sm); background: var(--color-raised); color: var(--color-muted); cursor: not-allowed; font-weight: 700; }
.twikoo-preview-empty { margin: 0; padding: 18px 15px; border-top: 1px solid var(--color-border); color: var(--color-muted); font-size: .9rem; text-align: center; }
#comments > #twikoo { margin: 0; background: transparent !important; color: var(--color-text) !important; font-family: var(--reading-font-family) !important; }
#comments > #twikoo a { color: var(--color-accent) !important; }
#comments > #twikoo .tk-submit { padding: 0 !important; border: 0 !important; background: transparent !important; box-shadow: none !important; }
#comments > #twikoo .tk-row { align-items: flex-start !important; gap: .625rem !important; }
#comments > #twikoo .tk-submit > .tk-row { display: block !important; position: relative !important; }
#comments > #twikoo .tk-submit > .tk-row > .tk-avatar { position: absolute !important; top: 0 !important; left: 0 !important; }
#comments > #twikoo .tk-avatar { flex: 0 0 3.125rem !important; width: 3.125rem !important; height: 3.125rem !important; margin: 0 .625rem 0 0 !important; border-radius: 50% !important; background: var(--color-code) !important; color: var(--color-muted) !important; }
#comments > #twikoo .tk-avatar-img { width: 100% !important; height: 100% !important; }
#comments > #twikoo .tk-avatar-img svg { display: block !important; width: calc(100% + 2px) !important; height: calc(100% + 2px) !important; margin: -1px !important; padding: 0 !important; shape-rendering: geometricPrecision; }
#comments > #twikoo .tk-submit > .tk-row > .tk-col { box-sizing: border-box !important; width: 100% !important; min-width: 0 !important; padding-left: 3.75rem !important; }
#comments > #twikoo .tk-meta-input { box-sizing: border-box !important; display: grid !important; grid-template-columns: repeat(3, minmax(0, 1fr)) !important; gap: .625rem !important; width: 100% !important; margin-bottom: .625rem !important; }
#comments > #twikoo .tk-meta-input > .el-input { width: 100% !important; min-width: 0 !important; height: 2.125rem !important; }
#comments > #twikoo .tk-meta-input > .el-input.el-input-group { display: flex !important; box-sizing: border-box !important; margin-left: 0 !important; }
#comments > #twikoo .tk-meta-input > .el-input.el-input-group .el-input-group__prepend { box-sizing: border-box !important; display: flex !important; flex: 0 0 3.75rem !important; align-items: center !important; justify-content: center !important; min-width: 3.75rem !important; padding-inline: .35rem !important; text-align: center !important; white-space: nowrap !important; }
#comments > #twikoo .tk-meta-input > .el-input.el-input-group .el-input__inner { min-width: 0 !important; flex: 1 1 auto !important; }
#comments > #twikoo .el-input-group__prepend { padding: 0 .55rem !important; border: 1px solid var(--color-border) !important; border-right: 0 !important; border-radius: var(--radius-sm) 0 0 var(--radius-sm) !important; background: var(--color-code) !important; color: var(--color-muted) !important; font-size: .78rem !important; }
#comments > #twikoo .el-input__inner { height: 2.125rem !important; padding: .438rem .525rem !important; border: 1px solid var(--color-border) !important; border-radius: 0 var(--radius-sm) var(--radius-sm) 0 !important; background: var(--color-surface) !important; color: var(--color-text) !important; font-family: var(--reading-font-family) !important; box-shadow: none !important; }
#comments > #twikoo .el-input__inner:focus, #comments > #twikoo .el-textarea__inner:focus { border-color: var(--color-accent) !important; }
#comments > #twikoo .tk-input { width: 100% !important; }
#comments > #twikoo .el-textarea__inner { min-height: 7.5rem !important; padding: .75rem .875rem !important; border: 1px solid var(--color-border) !important; border-radius: var(--radius-sm) !important; background: var(--color-surface) !important; color: var(--color-text) !important; font-family: var(--reading-font-family) !important; line-height: 1.6 !important; box-shadow: none !important; resize: vertical !important; }
#comments > #twikoo .el-input__count { right: .625rem !important; bottom: .35rem !important; background: transparent !important; color: var(--color-muted) !important; }
#comments > #twikoo .tk-row.actions { box-sizing: border-box !important; display: flex !important; justify-content: flex-end !important; width: calc(100% - 3.75rem) !important; margin: .625rem 0 0 3.75rem !important; padding-top: .625rem !important; }
#comments > #twikoo .tk-row-actions-start { gap: .5rem !important; }
#comments > #twikoo .tk-submit-action-icon { color: var(--color-muted) !important; }
#comments > #twikoo .OwO-logo { color: var(--color-muted) !important; }
#comments > #twikoo .OwO-body { border: 1px solid var(--color-border) !important; border-radius: var(--radius-sm) !important; background: var(--color-surface) !important; color: var(--color-text) !important; box-shadow: var(--shadow-soft) !important; }
#comments > #twikoo .tk-send { min-width: 7rem !important; height: 2.25rem !important; border: 0 !important; border-radius: var(--radius-sm) !important; background: var(--color-accent) !important; color: var(--color-accent-contrast) !important; font-weight: 500 !important; }
#comments > #twikoo .tk-send.is-disabled { opacity: .55 !important; }
#comments > #twikoo .tk-preview { border: 1px solid var(--color-border) !important; border-radius: var(--radius-sm) !important; background: transparent !important; color: var(--color-muted) !important; }
#comments > #twikoo .tk-comments-container { margin-top: 3rem !important; }
#comments > #twikoo .tk-comments-title { display: flex !important; align-items: center !important; justify-content: space-between !important; gap: 1rem !important; }
#comments > #twikoo .tk-comments-actions { display: flex !important; align-items: center !important; gap: .5rem !important; margin-left: auto !important; }
#comments > #twikoo .tk-comments-actions .tk-sort-item { display: inline-flex !important; align-items: center !important; height: 1.75rem !important; padding: .125rem .35rem !important; border: 0 !important; border-radius: var(--radius-sm) !important; background: transparent !important; color: var(--color-muted) !important; font-family: var(--reading-font-family) !important; font-size: .75rem !important; font-weight: 400 !important; }
#comments > #twikoo .tk-comments-actions .tk-sort-item:hover, #comments > #twikoo .tk-comments-actions .tk-sort-item.__active { background: var(--color-code) !important; color: var(--color-accent) !important; }
#comments > #twikoo .tk-comments-actions .tk-icon:first-of-type { display: none !important; }
#comments > #twikoo .tk-comments-actions .tk-icon.__comments { color: var(--color-accent) !important; }
#comments > #twikoo .tk-comments-title { margin: 0 0 1rem !important; padding-top: 1.125rem !important; border-top: 1px solid var(--color-border) !important; color: var(--color-accent) !important; font-family: var(--reading-font-family) !important; font-size: 1.25rem !important; font-weight: 500 !important; }
#comments > #twikoo .tk-comment { align-items: flex-start !important; margin-top: 1.625rem !important; padding: 0 0 .5rem !important; border: 0 !important; background: transparent !important; }
#comments > #twikoo .tk-comment > .tk-avatar { flex: 0 0 2.25rem !important; width: 2.25rem !important; height: 2.25rem !important; margin: 0 .75rem 0 0 !important; }
#comments > #twikoo .tk-comment > .tk-main { min-width: 0 !important; margin-top: 0 !important; }
#comments > #twikoo .tk-comment > .tk-main > .tk-row { align-items: flex-start !important; }
#comments > #twikoo .tk-nick { color: var(--color-text) !important; font-size: .875rem !important; font-weight: 500 !important; }
#comments > #twikoo .tk-nick strong { color: inherit !important; font-weight: 500 !important; }
#comments > #twikoo .tk-time, #comments > #twikoo .tk-meta, #comments > #twikoo .tk-comment-actions { color: var(--color-muted) !important; font-size: .75rem !important; font-weight: 400 !important; }
#comments > #twikoo .tk-meta { align-items: baseline !important; gap: .5rem !important; }
#comments > #twikoo .tk-time { margin-left: .5rem !important; }
#comments > #twikoo .tk-action { display: flex !important; align-items: center !important; gap: .25rem !important; }
#comments > #twikoo .tk-action-link { display: inline-flex !important; align-items: center !important; gap: .2rem !important; min-width: 2.5rem !important; height: 1.75rem !important; padding: .125rem .3rem !important; border: 0 !important; border-radius: var(--radius-sm) !important; background: transparent !important; color: var(--color-muted) !important; font-family: var(--reading-font-family) !important; font-size: .75rem !important; font-weight: 400 !important; }
#comments > #twikoo .tk-action-link:hover { background: var(--color-code) !important; color: var(--color-accent) !important; }
#comments > #twikoo .tk-action-link:focus-visible { outline: 1px solid var(--color-accent) !important; outline-offset: 1px !important; }
#comments > #twikoo .tk-action-icon { display: inline-flex !important; width: .8rem !important; height: .8rem !important; color: currentColor !important; }
#comments > #twikoo .tk-action-icon svg { width: 100% !important; height: 100% !important; }
#comments > #twikoo .tk-action-icon-solid { display: none !important; }
#comments > #twikoo .tk-action-link:is(.tk-liked, .tk-disliked) .tk-action-icon { display: none !important; }
#comments > #twikoo .tk-action-link:is(.tk-liked, .tk-disliked) .tk-action-icon-solid { display: inline-flex !important; width: .8rem !important; height: .8rem !important; color: currentColor !important; }
#comments > #twikoo .tk-action-link:not(:is(.tk-liked, .tk-disliked)):hover .tk-action-icon { display: inline-flex !important; }
#comments > #twikoo .tk-action-link:not(:is(.tk-liked, .tk-disliked)):hover .tk-action-icon-solid { display: none !important; }
#comments > #twikoo .tk-action-link:nth-child(1)::after { content: '赞'; }
#comments > #twikoo .tk-action-link:nth-child(2)::after { content: '踩'; }
#comments > #twikoo .tk-action-link:nth-child(3)::after { content: '回复'; }
#comments > #twikoo .tk-action:has(> .tk-action-link:nth-child(4)) .tk-action-link:nth-child(1)::after { content: '删除'; }
#comments > #twikoo .tk-action:has(> .tk-action-link:nth-child(4)) .tk-action-link:nth-child(2)::after { content: '赞'; }
#comments > #twikoo .tk-action:has(> .tk-action-link:nth-child(4)) .tk-action-link:nth-child(3)::after { content: '踩'; }
#comments > #twikoo .tk-action:has(> .tk-action-link:nth-child(4)) .tk-action-link:nth-child(4)::after { content: '回复'; }
#comments > #twikoo .tk-action-count { color: inherit !important; font-size: .7rem !important; }
#comments > #twikoo .tk-action-link.tk-delete::after, #comments > #twikoo .tk-action-link[data-action='delete']::after { content: '删除'; }
#comments > #twikoo .tk-content, #comments > #twikoo .tk-comment-content { margin-top: .625rem !important; padding: 0 !important; color: var(--color-text) !important; font-size: .9375rem !important; line-height: 1.6 !important; }
#comments > #twikoo .tk-content p, #comments > #twikoo .tk-comment-content p { margin: .35em 0 !important; }
#comments > #twikoo .tk-replies { margin: 1rem 0 0 0 !important; padding-left: 1rem !important; border-left: .25rem solid var(--color-code) !important; }
#comments > #twikoo .tk-replies .tk-comment { padding-block: .875rem !important; }
#comments > #twikoo .tk-empty, #comments > #twikoo .tk-error { padding: 1rem 0 !important; color: var(--color-muted) !important; }
@media (max-width: 37.5rem) {
#comments { width: 100%; }
#comments > #twikoo { font-size: .9rem !important; }
#comments > #twikoo .tk-comments-title { align-items: flex-start !important; flex-wrap: wrap !important; }
#comments > #twikoo .tk-comments-title { gap: .625rem !important; }
#comments > #twikoo .tk-comments-actions { display: flex !important; flex-wrap: wrap !important; width: 100% !important; margin-left: 0 !important; gap: .25rem !important; }
#comments > #twikoo .tk-comments-actions .tk-sort-item { padding-inline: .25rem !important; }
#comments > #twikoo .tk-submit > .tk-row { display: flex !important; align-items: flex-start !important; gap: .5rem !important; }
#comments > #twikoo .tk-submit > .tk-row > .tk-avatar { position: static !important; flex: 0 0 1.875rem !important; width: 1.875rem !important; height: 1.875rem !important; margin: 0 !important; }
#comments > #twikoo .tk-submit > .tk-row > .tk-col { flex: 1 1 auto !important; width: auto !important; padding-left: 0 !important; }
#comments > #twikoo .tk-comment > .tk-avatar { flex: 0 0 2rem !important; width: 2rem !important; height: 2rem !important; margin: 0 .625rem 0 0 !important; }
#comments > #twikoo .tk-avatar { flex-shrink: 0 !important; }
#comments > #twikoo .tk-meta-input { grid-template-columns: 1fr !important; gap: .35rem !important; }
#comments > #twikoo .tk-meta-input > .el-input { min-width: 0 !important; }
#comments > #twikoo .tk-row.actions { display: flex !important; flex-wrap: wrap !important; align-items: center !important; justify-content: space-between !important; width: 100% !important; margin: .625rem 0 0 !important; padding-top: .5rem !important; gap: .5rem !important; }
#comments > #twikoo .tk-row-actions-start { flex: 1 1 auto !important; min-width: 0 !important; flex-wrap: wrap !important; }
#comments > #twikoo .tk-send { flex: 0 0 auto !important; min-width: 5.5rem !important; }
#comments > #twikoo .tk-meta { min-width: 0 !important; flex-wrap: wrap !important; gap: .25rem .5rem !important; }
#comments > #twikoo .tk-time { margin-left: 0 !important; }
#comments > #twikoo .tk-action { flex-wrap: wrap !important; gap: .125rem !important; }
#comments > #twikoo .tk-action-link { box-sizing: border-box !important; flex: 0 0 1.75rem !important; justify-content: center !important; width: 1.75rem !important; min-width: 1.75rem !important; height: 1.75rem !important; margin: 0 !important; padding: 0 !important; gap: 0 !important; }
#comments > #twikoo .tk-action-link .tk-action-count { display: none !important; margin: 0 !important; }
#comments > #twikoo .tk-action-link .tk-action-icon { flex: 0 0 .8rem !important; }
#comments > #twikoo .tk-action-link::after { content: none !important; }
#comments > #twikoo .tk-content, #comments > #twikoo .tk-comment-content { min-width: 0 !important; overflow-wrap: anywhere !important; }
#comments > #twikoo .tk-replies { width: 100% !important; margin-left: 0 !important; padding-left: .625rem !important; overflow: hidden !important; }
#comments > #twikoo .tk-replies .tk-comment { min-width: 0 !important; }
#comments > #twikoo .tk-replies .tk-comment > .tk-avatar { flex-basis: 1.75rem !important; width: 1.75rem !important; height: 1.75rem !important; margin-right: .5rem !important; }
#comments > #twikoo .OwO .OwO-body { right: 0 !important; left: auto !important; width: min(18rem, 100vw - 2rem) !important; }
}
@media (max-width: 37.5rem) { #comments { margin-top: 3.25rem; } #twikoo-comments .tk-avatar, #twikoo-comments .tk-comment-avatar { width: 1.875rem; height: 1.875rem; } }
</style>
+262
View File
@@ -0,0 +1,262 @@
---
import type { PostEntry } from '@/lib/content';
interface Props { posts: PostEntry[]; }
interface WeekCell { week: number; words: number; level: number; }
const { posts } = Astro.props;
const currentYear = new Date().getFullYear();
const years = Array.from({ length: 3 }, (_, index) => currentYear - 2 + index);
function countWords(source: string): number {
const text = source
.replace(/```[\s\S]*?```/g, '')
.replace(/`[^`]+`/g, '')
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/<[^>]+>/g, '')
.replace(/https?:\/\/\S+/g, '')
.replace(/[#{*_~>`|+-]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const chineseCharacters = text.match(/[\u3400-\u4dbf\u4e00-\u9fff]/g)?.length ?? 0;
const otherWords = text
.replace(/[\u3400-\u4dbf\u4e00-\u9fff]/g, ' ')
.split(/\s+/)
.filter(Boolean).length;
return chineseCharacters + otherWords;
}
function getIsoWeek(date: Date): { year: number; week: number } {
const utcDate = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const day = utcDate.getUTCDay() || 7;
utcDate.setUTCDate(utcDate.getUTCDate() + 4 - day);
const year = utcDate.getUTCFullYear();
const yearStart = new Date(Date.UTC(year, 0, 1));
const week = Math.ceil(((utcDate.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7);
return { year, week };
}
function getLevel(words: number): number {
if (words === 0) return 0;
if (words < 1_000) return 1;
if (words < 3_000) return 2;
if (words < 8_000) return 3;
return 4;
}
const wordsByWeek = new Map<string, number>();
let totalWords = 0;
for (const post of posts) {
const words = countWords(post.body ?? '');
totalWords += words;
const { year, week } = getIsoWeek(post.data.pubDate);
if (!years.includes(year)) continue;
const key = `${year}-${week}`;
wordsByWeek.set(key, (wordsByWeek.get(key) ?? 0) + words);
}
const yearRows = years.map((year) => ({
year,
weeks: Array.from({ length: 53 }, (_, index): WeekCell => {
const week = index + 1;
const words = wordsByWeek.get(`${year}-${week}`) ?? 0;
return { week, words, level: getLevel(words) };
})
}));
const totalWordsLabel = totalWords >= 10_000
? `${(totalWords / 10_000).toFixed(2)}W`
: totalWords.toLocaleString('zh-CN');
---
<section class="writing-heatmap" aria-labelledby="writing-heatmap-title" data-pagefind-ignore="all">
<header>
<div class="writing-heading">
<h2 id="writing-heatmap-title">写作热力图</h2>
<p class="writing-total">自博客建立以来,已输出 <span class="writing-total-value">{totalWordsLabel}</span><span class="writing-total-label">字</span></p>
</div>
<div class="heatmap-legend" aria-label="写作字数:从少到多">
<span>少</span>
{[0, 1, 2, 3, 4].map((level) => <i class={`level-${level}`} aria-hidden="true"></i>)}
<span>多</span>
</div>
</header>
<div class="heatmap-viewport" data-heatmap-viewport>
<div class="heatmap-scroll" aria-label="最近三年每周写作字数热力图" data-heatmap-scroll>
<div class="heatmap-grid">
{yearRows.map((row) => <div class="heatmap-row">
<strong>{row.year}</strong>
{row.weeks.map((cell) => <span
class={`heatmap-cell level-${cell.level}`}
aria-label={`${row.year} 年第 ${cell.week} 周,${cell.words.toLocaleString('zh-CN')} 字`}
>
<span class="heatmap-tooltip" role="tooltip"><span class="heatmap-tooltip-code">{row.year} W{String(cell.week).padStart(2, '0')} · {cell.words.toLocaleString('zh-CN')}</span><span class="heatmap-tooltip-label">字</span></span>
</span>)}
</div>)}
<div class="week-labels" aria-hidden="true">
<span></span>
{Array.from({ length: 53 }, (_, index) => <span>{[1, 14, 27, 40, 53].includes(index + 1) ? `W${String(index + 1).padStart(2, '0')}` : ''}</span>)}
</div>
</div>
</div>
</div>
</section>
<script>
let cleanupHeatmap: (() => void) | undefined;
const initHeatmap = () => {
cleanupHeatmap?.();
cleanupHeatmap = undefined;
const heatmapScroll = document.querySelector<HTMLElement>('[data-heatmap-scroll]');
if (!heatmapScroll) return;
const heatmapViewport = heatmapScroll.closest<HTMLElement>('[data-heatmap-viewport]');
const heatmapGrid = heatmapScroll.querySelector<HTMLElement>('.heatmap-grid');
const root = document.documentElement;
const baseRootFontSize = 16;
let baseCellSize = 0;
let activeTooltip: { cell: HTMLElement; tooltip: HTMLElement; placeholder: Comment } | undefined;
const updateTooltipPosition = () => {
if (!activeTooltip) return;
const { cell, tooltip } = activeTooltip;
const cellRect = cell.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
const gap = 6;
const horizontalPadding = 8;
const left = Math.min(
Math.max(cellRect.left + cellRect.width / 2, tooltipRect.width / 2 + horizontalPadding),
window.innerWidth - tooltipRect.width / 2 - horizontalPadding
);
const fitsAbove = cellRect.top >= tooltipRect.height + gap;
const fitsBelow = cellRect.bottom + tooltipRect.height + gap <= window.innerHeight;
const top = fitsAbove || !fitsBelow
? cellRect.top - tooltipRect.height - gap
: cellRect.bottom + gap;
tooltip.style.left = `${left}px`;
tooltip.style.top = `${Math.max(horizontalPadding, top)}px`;
};
const closeTooltip = () => {
if (!activeTooltip) return;
const { tooltip, placeholder } = activeTooltip;
tooltip.removeAttribute('data-heatmap-portal');
tooltip.removeAttribute('style');
placeholder.parentNode?.insertBefore(tooltip, placeholder);
placeholder.remove();
activeTooltip = undefined;
};
const openTooltip = (cell: HTMLElement) => {
closeTooltip();
const tooltip = cell.querySelector<HTMLElement>('.heatmap-tooltip');
if (!tooltip) return;
const placeholder = document.createComment('heatmap-tooltip');
tooltip.replaceWith(placeholder);
tooltip.setAttribute('data-heatmap-portal', '');
document.body.append(tooltip);
activeTooltip = { cell, tooltip, placeholder };
updateTooltipPosition();
requestAnimationFrame(() => tooltip.classList.add('is-visible'));
};
const heatmapCells = [...heatmapScroll.querySelectorAll<HTMLElement>('.heatmap-cell')];
const cellListeners = heatmapCells.map((cell) => {
const onMouseEnter = () => openTooltip(cell);
cell.addEventListener('mouseenter', onMouseEnter);
cell.addEventListener('mouseleave', closeTooltip);
return { cell, onMouseEnter };
});
window.addEventListener('resize', updateTooltipPosition);
window.addEventListener('scroll', updateTooltipPosition, true);
const updateHeatmapFades = () => {
if (!heatmapViewport || !heatmapGrid) return;
const maxScrollLeft = Math.max(0, heatmapGrid.getBoundingClientRect().width - heatmapScroll.clientWidth);
heatmapViewport.classList.toggle('can-scroll-left', heatmapScroll.scrollLeft > 1);
heatmapViewport.classList.toggle('can-scroll-right', heatmapScroll.scrollLeft < maxScrollLeft - 1);
};
const updateHeatmapOverflow = () => {
if (!heatmapGrid) return;
const rootFontSize = Number.parseFloat(getComputedStyle(root).fontSize) || baseRootFontSize;
const scale = rootFontSize / baseRootFontSize;
const availableCellSize = (heatmapScroll.clientWidth - 3.25 * baseRootFontSize) / 53;
if (scale <= 1.01 || baseCellSize === 0) {
baseCellSize = Math.min(12, Math.max(8, availableCellSize));
}
heatmapGrid.style.setProperty('--heatmap-cell-size', `${baseCellSize * scale}px`);
heatmapViewport?.classList.toggle('has-overflow', heatmapGrid.getBoundingClientRect().width > heatmapScroll.clientWidth + 1);
updateHeatmapFades();
};
const resizeObserver = new ResizeObserver(updateHeatmapOverflow);
const fontScaleObserver = new MutationObserver(updateHeatmapOverflow);
resizeObserver.observe(heatmapScroll);
fontScaleObserver.observe(root, { attributes: true, attributeFilter: ['style'] });
heatmapScroll.addEventListener('scroll', updateHeatmapFades, { passive: true });
updateHeatmapOverflow();
cleanupHeatmap = () => {
closeTooltip();
cellListeners.forEach(({ cell, onMouseEnter }) => {
cell.removeEventListener('mouseenter', onMouseEnter);
cell.removeEventListener('mouseleave', closeTooltip);
});
window.removeEventListener('resize', updateTooltipPosition);
window.removeEventListener('scroll', updateTooltipPosition, true);
resizeObserver.disconnect();
fontScaleObserver.disconnect();
heatmapScroll.removeEventListener('scroll', updateHeatmapFades);
};
};
document.addEventListener('astro:page-load', initHeatmap);
initHeatmap();
</script>
<style>
.writing-heatmap { margin-bottom: 3.5rem; }
.writing-heatmap header { display: flex; gap: 1rem; align-items: flex-start; justify-content: space-between; margin-bottom: 1rem; }
.writing-heading { min-width: 0; }
.writing-heatmap h2 { margin: 0; font-size: 1.375rem; font-weight: 400; }
.writing-total { margin: .25rem 0 0; color: var(--color-muted); font-size: .8125rem; }
.writing-total-value { font-family: Consolas, Menlo, Monaco, 'lucida_console', 'Liberation_Mono', 'Courier_New', 'andale_mono', monospaceX, monospace, sans-serif; }
.writing-total-label { font-family: var(--reading-font-family); }
.heatmap-legend { display: flex; gap: .25rem; align-items: center; padding-top: .25rem; color: var(--color-muted); font-size: .75rem; }
.heatmap-legend i { width: .75rem; height: .75rem; }
.heatmap-viewport { position: relative; }
.heatmap-viewport::before,
.heatmap-viewport::after { display: none; position: absolute; z-index: 1; top: 0; bottom: .5rem; width: 1.75rem; content: ''; pointer-events: none; }
.heatmap-viewport::before { left: 0; background: linear-gradient(90deg, var(--color-bg) 15%, transparent); }
.heatmap-viewport::after { right: 0; background: linear-gradient(90deg, transparent, var(--color-bg) 85%); }
.heatmap-viewport.has-overflow.can-scroll-left::before,
.heatmap-viewport.has-overflow.can-scroll-right::after { display: block; }
.heatmap-scroll { overflow-x: hidden; padding: .125rem 0 .5rem; container-type: inline-size; scrollbar-width: thin; }
.heatmap-viewport.has-overflow .heatmap-scroll { overflow-x: auto; }
.heatmap-grid { --heatmap-label-width: 3.25rem; --heatmap-cell-size: clamp(8px, .75rem, 18px); display: grid; width: max-content; gap: 4px; }
.heatmap-row,
.week-labels { display: grid; grid-template-columns: var(--heatmap-label-width) repeat(53, var(--heatmap-cell-size)); column-gap: 0; row-gap: 2px; align-items: center; }
.heatmap-row strong { color: var(--color-text); font-family: Consolas, Menlo, Monaco, 'lucida_console', 'Liberation_Mono', 'Courier_New', 'andale_mono', monospaceX, monospace, sans-serif; font-size: .8125rem; }
.heatmap-cell { position: relative; display: block; width: var(--heatmap-cell-size); height: var(--heatmap-cell-size); }
.heatmap-tooltip { position: absolute; bottom: calc(100% + .375rem); left: 50%; z-index: 10; display: inline-block; padding: .3rem .5rem; border: 1px solid var(--color-border); background: var(--color-surface); color: var(--color-text); box-shadow: var(--shadow-soft); font-size: .75rem; line-height: 1.2; white-space: nowrap; pointer-events: none; opacity: 0; transform: translate(-50%, .25rem); transition: opacity 160ms ease, transform 160ms ease; }
.heatmap-cell:hover .heatmap-tooltip { opacity: 1; transform: translate(-50%, 0); }
.heatmap-tooltip[data-heatmap-portal] { position: fixed; top: 0; left: 0; z-index: 1000; bottom: auto; transform: translateX(-50%); opacity: 0; }
.heatmap-tooltip[data-heatmap-portal].is-visible { opacity: 1; }
.heatmap-tooltip-code { font-family: Consolas, Menlo, Monaco, 'lucida_console', 'Liberation_Mono', 'Courier_New', 'andale_mono', monospaceX, monospace, sans-serif; }
.heatmap-tooltip-label { font-family: var(--reading-font-family); }
.week-labels span { color: var(--color-muted); font-family: Consolas, Menlo, Monaco, 'lucida_console', 'Liberation_Mono', 'Courier_New', 'andale_mono', monospaceX, monospace, sans-serif; font-size: .625rem; text-align: center; white-space: nowrap; }
.level-0 { background: var(--heatmap-0); }
.level-1 { background: var(--heatmap-1); }
.level-2 { background: var(--heatmap-2); }
.level-3 { background: var(--heatmap-3); }
.level-4 { background: var(--heatmap-4); }
@media (max-width: 37.5rem) {
.writing-heatmap { display: none; }
}
</style>