Initial Commit
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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" />}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
@@ -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>
|
||||
@@ -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>}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user