阅读进度 + 继续阅读 + 文章系列 + 完整 SEO
This commit is contained in:
@@ -47,6 +47,13 @@ export const siteConfig = {
|
|||||||
pagination: {
|
pagination: {
|
||||||
postsPerPage: 16,
|
postsPerPage: 16,
|
||||||
},
|
},
|
||||||
|
series: {
|
||||||
|
labels: {
|
||||||
|
webcustom: '博客定制手记',
|
||||||
|
opencv: 'OpenCV 学习笔记',
|
||||||
|
OpenGL: 'OpenGL 学习笔记',
|
||||||
|
},
|
||||||
|
},
|
||||||
comments: {
|
comments: {
|
||||||
envId: 'https://comment.biss.click',
|
envId: 'https://comment.biss.click',
|
||||||
region: '',
|
region: '',
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
import type { BlogPost } from '@/lib/posts';
|
||||||
|
import { siteConfig } from '../../site.config.mjs';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
currentPost: BlogPost;
|
||||||
|
posts: BlogPost[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { currentPost, posts } = Astro.props;
|
||||||
|
const currentIndex = posts.findIndex((post) => post.slug === currentPost.slug);
|
||||||
|
const previousPost = currentIndex > 0 ? posts[currentIndex - 1] : undefined;
|
||||||
|
const nextPost = currentIndex >= 0 && currentIndex < posts.length - 1 ? posts[currentIndex + 1] : undefined;
|
||||||
|
const labels = siteConfig.series?.labels ?? {};
|
||||||
|
const seriesKey = currentPost.series ?? '';
|
||||||
|
const seriesTitle = labels[seriesKey] ?? seriesKey;
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="post-series" aria-labelledby="post-series-title">
|
||||||
|
<header class="post-series-head">
|
||||||
|
<div>
|
||||||
|
<span>ARTICLE SERIES</span>
|
||||||
|
<h2 id="post-series-title">{seriesTitle}</h2>
|
||||||
|
</div>
|
||||||
|
<strong>第 {currentIndex + 1} / {posts.length} 篇</strong>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ol class="post-series-list">
|
||||||
|
{posts.map((post, index) => (
|
||||||
|
<li class:list={{ 'is-current': post.slug === currentPost.slug }}>
|
||||||
|
<a href={post.href} aria-current={post.slug === currentPost.slug ? 'page' : undefined}>
|
||||||
|
<span>{String(index + 1).padStart(2, '0')}</span>
|
||||||
|
<strong>{post.title}</strong>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{(previousPost || nextPost) && (
|
||||||
|
<nav class="post-series-nav" aria-label="系列文章导航">
|
||||||
|
{previousPost ? (
|
||||||
|
<a href={previousPost.href} rel="prev"><span>上一篇</span><strong>{previousPost.title}</strong></a>
|
||||||
|
) : <span></span>}
|
||||||
|
{nextPost ? (
|
||||||
|
<a href={nextPost.href} rel="next"><span>下一篇</span><strong>{nextPost.title}</strong></a>
|
||||||
|
) : <span></span>}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
@@ -6,6 +6,7 @@ const posts = defineCollection({
|
|||||||
schema: z.object({
|
schema: z.object({
|
||||||
title: z.any().optional(),
|
title: z.any().optional(),
|
||||||
date: z.any().optional(),
|
date: z.any().optional(),
|
||||||
|
updated: z.any().optional(),
|
||||||
abbrlink: z.any().optional(),
|
abbrlink: z.any().optional(),
|
||||||
tags: z.any().optional(),
|
tags: z.any().optional(),
|
||||||
category: z.any().optional(),
|
category: z.any().optional(),
|
||||||
@@ -14,6 +15,8 @@ const posts = defineCollection({
|
|||||||
description: z.any().optional(),
|
description: z.any().optional(),
|
||||||
comments: z.any().optional(),
|
comments: z.any().optional(),
|
||||||
cover: z.any().optional(),
|
cover: z.any().optional(),
|
||||||
|
series: z.any().optional(),
|
||||||
|
seriesOrder: z.any().optional(),
|
||||||
}).passthrough(),
|
}).passthrough(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,31 @@ import { publicSiteConfig, siteConfig } from '../../site.config.mjs';
|
|||||||
interface Props {
|
interface Props {
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
canonicalPath?: string;
|
||||||
|
image?: string;
|
||||||
|
type?: 'website' | 'article';
|
||||||
|
publishedTime?: string;
|
||||||
|
modifiedTime?: string;
|
||||||
|
keywords?: string[];
|
||||||
|
jsonLd?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const siteTitle = siteConfig.site.name;
|
const siteTitle = siteConfig.site.name;
|
||||||
const { title = siteTitle, description = siteConfig.site.description } = Astro.props;
|
const {
|
||||||
|
title = siteTitle,
|
||||||
|
description = siteConfig.site.description,
|
||||||
|
canonicalPath = Astro.url.pathname,
|
||||||
|
image = siteConfig.author.avatar,
|
||||||
|
type = 'website',
|
||||||
|
publishedTime,
|
||||||
|
modifiedTime,
|
||||||
|
keywords = [],
|
||||||
|
jsonLd,
|
||||||
|
} = Astro.props;
|
||||||
const pageTitle = title === siteTitle ? siteTitle : `${title} | ${siteTitle}`;
|
const pageTitle = title === siteTitle ? siteTitle : `${title} | ${siteTitle}`;
|
||||||
|
const canonicalUrl = new URL(canonicalPath, siteConfig.site.url).href;
|
||||||
|
const socialImageUrl = new URL(image, siteConfig.site.url).href;
|
||||||
|
const jsonLdHtml = jsonLd ? JSON.stringify(jsonLd).replace(/</g, '\\u003c') : '';
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
const publicConfigJson = JSON.stringify(publicSiteConfig).replace(/</g, '\\u003c');
|
const publicConfigJson = JSON.stringify(publicSiteConfig).replace(/</g, '\\u003c');
|
||||||
const consoleSites = [
|
const consoleSites = [
|
||||||
@@ -48,6 +68,27 @@ const consoleSites = [
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content={description} />
|
<meta name="description" content={description} />
|
||||||
|
<meta name="author" content={siteConfig.author.name} />
|
||||||
|
{keywords.length > 0 && <meta name="keywords" content={keywords.join(', ')} />}
|
||||||
|
<link rel="canonical" href={canonicalUrl} />
|
||||||
|
<link rel="alternate" type="application/rss+xml" title={`${siteTitle} RSS`} href={new URL('/rss.xml', siteConfig.site.url).href} />
|
||||||
|
<meta property="og:site_name" content={siteTitle} />
|
||||||
|
<meta property="og:locale" content="zh_CN" />
|
||||||
|
<meta property="og:type" content={type} />
|
||||||
|
<meta property="og:title" content={pageTitle} />
|
||||||
|
<meta property="og:description" content={description} />
|
||||||
|
<meta property="og:url" content={canonicalUrl} />
|
||||||
|
<meta property="og:image" content={socialImageUrl} />
|
||||||
|
<meta property="og:image:alt" content={title} />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content={pageTitle} />
|
||||||
|
<meta name="twitter:description" content={description} />
|
||||||
|
<meta name="twitter:image" content={socialImageUrl} />
|
||||||
|
{publishedTime && <meta property="article:published_time" content={publishedTime} />}
|
||||||
|
{modifiedTime && <meta property="article:modified_time" content={modifiedTime} />}
|
||||||
|
{type === 'article' && <meta property="article:author" content={siteConfig.author.name} />}
|
||||||
|
{keywords.map((keyword) => <meta property="article:tag" content={keyword} />)}
|
||||||
|
{jsonLd && <script type="application/ld+json" is:inline set:html={jsonLdHtml}></script>}
|
||||||
<link rel="icon" href={siteConfig.assets.icon} />
|
<link rel="icon" href={siteConfig.assets.icon} />
|
||||||
<script defer src={siteConfig.analytics.umami.script} data-website-id={siteConfig.analytics.umami.websiteId}></script>
|
<script defer src={siteConfig.analytics.umami.script} data-website-id={siteConfig.analytics.umami.websiteId}></script>
|
||||||
<script is:inline set:html={`window.SITE_CONFIG = ${publicConfigJson};`}></script>
|
<script is:inline set:html={`window.SITE_CONFIG = ${publicConfigJson};`}></script>
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ export type BlogPost = {
|
|||||||
slug: string;
|
slug: string;
|
||||||
date: Date;
|
date: Date;
|
||||||
dateText: string;
|
dateText: string;
|
||||||
|
updated?: Date;
|
||||||
|
updatedText?: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
categories: string[];
|
categories: string[];
|
||||||
|
series?: string;
|
||||||
|
seriesOrder?: number;
|
||||||
summary: string;
|
summary: string;
|
||||||
comments: boolean;
|
comments: boolean;
|
||||||
cover?: string;
|
cover?: string;
|
||||||
@@ -44,6 +48,12 @@ function toDate(value: unknown): Date {
|
|||||||
return new Date(0);
|
return new Date(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toOptionalDate(value: unknown): Date | undefined {
|
||||||
|
if (value === undefined || value === null || value === '') return undefined;
|
||||||
|
const date = toDate(value);
|
||||||
|
return date.valueOf() > 0 ? date : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(date: Date): string {
|
function formatDate(date: Date): string {
|
||||||
return new Intl.DateTimeFormat('zh-CN', {
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -79,8 +89,13 @@ export async function getAllPosts(): Promise<BlogPost[]> {
|
|||||||
return entries
|
return entries
|
||||||
.map((entry) => {
|
.map((entry) => {
|
||||||
const date = toDate(entry.data.date);
|
const date = toDate(entry.data.date);
|
||||||
|
const updated = toOptionalDate(entry.data.updated);
|
||||||
const slug = String(entry.data.abbrlink ?? fallbackSlug(entry.id));
|
const slug = String(entry.data.abbrlink ?? fallbackSlug(entry.id));
|
||||||
const title = String(entry.data.title ?? slug);
|
const title = String(entry.data.title ?? slug);
|
||||||
|
const series = typeof entry.data.series === 'string' && entry.data.series.trim()
|
||||||
|
? entry.data.series.trim()
|
||||||
|
: undefined;
|
||||||
|
const rawSeriesOrder = Number(entry.data.seriesOrder);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
entry,
|
entry,
|
||||||
@@ -88,8 +103,12 @@ export async function getAllPosts(): Promise<BlogPost[]> {
|
|||||||
slug,
|
slug,
|
||||||
date,
|
date,
|
||||||
dateText: formatDate(date),
|
dateText: formatDate(date),
|
||||||
|
updated,
|
||||||
|
updatedText: updated ? formatDate(updated) : undefined,
|
||||||
tags: asArray(entry.data.tags),
|
tags: asArray(entry.data.tags),
|
||||||
categories: getCategories(entry),
|
categories: getCategories(entry),
|
||||||
|
series,
|
||||||
|
seriesOrder: Number.isFinite(rawSeriesOrder) ? rawSeriesOrder : undefined,
|
||||||
summary: makeSummary(entry),
|
summary: makeSummary(entry),
|
||||||
comments: entry.data.comments !== false,
|
comments: entry.data.comments !== false,
|
||||||
cover: typeof entry.data.cover === 'string' ? entry.data.cover : undefined,
|
cover: typeof entry.data.cover === 'string' ? entry.data.cover : undefined,
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ const pageData = getPageSlice(posts, 1);
|
|||||||
<h1>{siteConfig.site.name}</h1>
|
<h1>{siteConfig.site.name}</h1>
|
||||||
<p>把折腾、学习和生活片段安静地收在这里。</p>
|
<p>把折腾、学习和生活片段安静地收在这里。</p>
|
||||||
</div>
|
</div>
|
||||||
<aside id="bber-talk" class="hero-talk" aria-label="璇磋婊氬姩灞曠ず">
|
<aside id="bber-talk" class="hero-talk" aria-label="说说滚动展示">
|
||||||
<div class="hero-talk-head">
|
<div class="hero-talk-head">
|
||||||
<span>Recent Talks</span>
|
<span>Recent Talks</span>
|
||||||
<a href="/shuoshuo/" aria-label="鏌ョ湅鍏ㄩ儴璇磋">
|
<a href="/shuoshuo/" aria-label="查看全部说说">
|
||||||
<i class="fa-regular fa-comments" aria-hidden="true"></i>
|
<i class="fa-regular fa-comments" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
import ArticleSidebar from '@/components/ArticleSidebar.astro';
|
import ArticleSidebar from '@/components/ArticleSidebar.astro';
|
||||||
import PostCopyrightCard from '@/components/PostCopyrightCard.astro';
|
import PostCopyrightCard from '@/components/PostCopyrightCard.astro';
|
||||||
|
import PostSeries from '@/components/PostSeries.astro';
|
||||||
import TwikooComments from '@/components/TwikooComments.astro';
|
import TwikooComments from '@/components/TwikooComments.astro';
|
||||||
import BaseLayout from '@/layouts/BaseLayout.astro';
|
import BaseLayout from '@/layouts/BaseLayout.astro';
|
||||||
import { render } from 'astro:content';
|
import { render } from 'astro:content';
|
||||||
@@ -76,9 +77,68 @@ const relatedPosts = allPosts
|
|||||||
.filter((item) => item.slug !== post.slug)
|
.filter((item) => item.slug !== post.slug)
|
||||||
.filter((item) => item.categories.some((category) => post.categories.includes(category)))
|
.filter((item) => item.categories.some((category) => post.categories.includes(category)))
|
||||||
.slice(0, 8);
|
.slice(0, 8);
|
||||||
|
const seriesPosts = post.series
|
||||||
|
? allPosts
|
||||||
|
.filter((item) => item.series === post.series)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const orderA = a.seriesOrder ?? Number.POSITIVE_INFINITY;
|
||||||
|
const orderB = b.seriesOrder ?? Number.POSITIVE_INFINITY;
|
||||||
|
if (orderA !== orderB) return orderA - orderB;
|
||||||
|
return a.date.valueOf() - b.date.valueOf();
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const canonicalUrl = new URL(post.href, siteConfig.site.url).href;
|
||||||
|
const articleImage = new URL(post.cover ?? siteConfig.author.avatar, siteConfig.site.url).href;
|
||||||
|
const articleJsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'BlogPosting',
|
||||||
|
headline: post.title,
|
||||||
|
description: post.summary,
|
||||||
|
image: [articleImage],
|
||||||
|
datePublished: post.date.toISOString(),
|
||||||
|
dateModified: (post.updated ?? post.date).toISOString(),
|
||||||
|
wordCount,
|
||||||
|
inLanguage: siteConfig.site.language,
|
||||||
|
mainEntityOfPage: { '@type': 'WebPage', '@id': canonicalUrl },
|
||||||
|
author: {
|
||||||
|
'@type': 'Person',
|
||||||
|
name: siteConfig.author.name,
|
||||||
|
url: siteConfig.author.github,
|
||||||
|
},
|
||||||
|
publisher: {
|
||||||
|
'@type': 'Person',
|
||||||
|
name: siteConfig.author.name,
|
||||||
|
image: new URL(siteConfig.author.avatar, siteConfig.site.url).href,
|
||||||
|
},
|
||||||
|
keywords: [...post.categories, ...post.tags].join(', '),
|
||||||
|
articleSection: post.categories[0],
|
||||||
|
isPartOf: post.series ? { '@type': 'CreativeWorkSeries', name: post.series } : undefined,
|
||||||
|
};
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title={post.title} description={post.summary}>
|
<BaseLayout
|
||||||
|
title={post.title}
|
||||||
|
description={post.summary}
|
||||||
|
canonicalPath={post.href}
|
||||||
|
image={post.cover}
|
||||||
|
type="article"
|
||||||
|
publishedTime={post.date.toISOString()}
|
||||||
|
modifiedTime={(post.updated ?? post.date).toISOString()}
|
||||||
|
keywords={[...post.categories, ...post.tags]}
|
||||||
|
jsonLd={articleJsonLd}
|
||||||
|
>
|
||||||
|
<div class="reading-progress" aria-hidden="true"><span data-reading-progress></span></div>
|
||||||
|
<aside class="reading-resume" data-reading-resume hidden aria-live="polite">
|
||||||
|
<div class="reading-resume-icon" aria-hidden="true"><i class="fa-solid fa-book-open"></i></div>
|
||||||
|
<div>
|
||||||
|
<strong>继续上次阅读?</strong>
|
||||||
|
<p>你上次读到了 <span data-reading-resume-percent>--</span>。</p>
|
||||||
|
</div>
|
||||||
|
<div class="reading-resume-actions">
|
||||||
|
<button type="button" data-reading-continue>继续阅读</button>
|
||||||
|
<button type="button" data-reading-restart>从头开始</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
<section class="post-layout">
|
<section class="post-layout">
|
||||||
<article class="article post-article">
|
<article class="article post-article">
|
||||||
<header class={post.cover ? 'article-header article-header-cover' : 'article-header'}>
|
<header class={post.cover ? 'article-header article-header-cover' : 'article-header'}>
|
||||||
@@ -89,6 +149,7 @@ const relatedPosts = allPosts
|
|||||||
{post.cover ? (
|
{post.cover ? (
|
||||||
<div class="article-hero-meta" aria-label="文章信息">
|
<div class="article-hero-meta" aria-label="文章信息">
|
||||||
<span>发布于 {post.dateText}</span>
|
<span>发布于 {post.dateText}</span>
|
||||||
|
{post.updatedText && <span>更新于 {post.updatedText}</span>}
|
||||||
{post.categories[0] && <a href={getCategoryHref(post.categories[0])}>{post.categories[0]}</a>}
|
{post.categories[0] && <a href={getCategoryHref(post.categories[0])}>{post.categories[0]}</a>}
|
||||||
<span>总字数:{wordCountText}</span>
|
<span>总字数:{wordCountText}</span>
|
||||||
<span>阅读时长:{readingMinutes} 分钟</span>
|
<span>阅读时长:{readingMinutes} 分钟</span>
|
||||||
@@ -121,6 +182,7 @@ const relatedPosts = allPosts
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
{seriesPosts.length > 1 && <PostSeries currentPost={post} posts={seriesPosts} />}
|
||||||
<aside
|
<aside
|
||||||
class="butterfly-note butterfly-note--warning article-outdated-note"
|
class="butterfly-note butterfly-note--warning article-outdated-note"
|
||||||
aria-label="文章时效性提示"
|
aria-label="文章时效性提示"
|
||||||
@@ -144,6 +206,91 @@ const relatedPosts = allPosts
|
|||||||
</section>
|
</section>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
|
<script define:vars={{ postSlug: post.slug }}>
|
||||||
|
(() => {
|
||||||
|
const article = document.querySelector('.post-article');
|
||||||
|
const progressBar = document.querySelector('[data-reading-progress]');
|
||||||
|
const resume = document.querySelector('[data-reading-resume]');
|
||||||
|
const resumePercent = document.querySelector('[data-reading-resume-percent]');
|
||||||
|
const continueButton = document.querySelector('[data-reading-continue]');
|
||||||
|
const restartButton = document.querySelector('[data-reading-restart]');
|
||||||
|
if (!article || !progressBar || !resume) return;
|
||||||
|
|
||||||
|
const storageKey = `reading-progress:${postSlug}`;
|
||||||
|
let saved = null;
|
||||||
|
let canPersist = true;
|
||||||
|
let frame = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
saved = JSON.parse(localStorage.getItem(storageKey) || 'null');
|
||||||
|
} catch {
|
||||||
|
saved = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const articleMetrics = () => {
|
||||||
|
const top = article.getBoundingClientRect().top + window.scrollY;
|
||||||
|
const distance = Math.max(1, article.offsetHeight - window.innerHeight);
|
||||||
|
return { top, distance };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProgress = () => {
|
||||||
|
const { top, distance } = articleMetrics();
|
||||||
|
return Math.min(1, Math.max(0, (window.scrollY - top) / distance));
|
||||||
|
};
|
||||||
|
|
||||||
|
const persist = (progress) => {
|
||||||
|
if (!canPersist) return;
|
||||||
|
try {
|
||||||
|
if (progress >= 0.97) localStorage.removeItem(storageKey);
|
||||||
|
else if (progress >= 0.02) localStorage.setItem(storageKey, JSON.stringify({ progress, savedAt: Date.now() }));
|
||||||
|
} catch {
|
||||||
|
// Storage can be unavailable in strict privacy modes.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
frame = 0;
|
||||||
|
const progress = getProgress();
|
||||||
|
progressBar.style.transform = `scaleX(${progress})`;
|
||||||
|
persist(progress);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestUpdate = () => {
|
||||||
|
if (!frame) frame = window.requestAnimationFrame(update);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeResume = () => {
|
||||||
|
resume.hidden = true;
|
||||||
|
canPersist = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const savedIsFresh = saved && Date.now() - Number(saved.savedAt) < 90 * 86400000;
|
||||||
|
const savedProgress = Number(saved?.progress);
|
||||||
|
if (savedIsFresh && savedProgress >= 0.05 && savedProgress < 0.97) {
|
||||||
|
canPersist = false;
|
||||||
|
if (resumePercent) resumePercent.textContent = `${Math.round(savedProgress * 100)}%`;
|
||||||
|
window.setTimeout(() => { resume.hidden = false; }, 350);
|
||||||
|
}
|
||||||
|
|
||||||
|
continueButton?.addEventListener('click', () => {
|
||||||
|
closeResume();
|
||||||
|
const { top, distance } = articleMetrics();
|
||||||
|
window.scrollTo({ top: top + distance * savedProgress, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
|
||||||
|
restartButton?.addEventListener('click', () => {
|
||||||
|
try { localStorage.removeItem(storageKey); } catch {}
|
||||||
|
closeResume();
|
||||||
|
window.scrollTo({ top: Math.max(0, articleMetrics().top - 90), behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
|
||||||
|
update();
|
||||||
|
window.addEventListener('scroll', requestUpdate, { passive: true });
|
||||||
|
window.addEventListener('resize', requestUpdate);
|
||||||
|
window.addEventListener('pagehide', () => persist(getProgress()));
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<script is:inline>
|
<script is:inline>
|
||||||
(() => {
|
(() => {
|
||||||
document.querySelectorAll('.article-outdated-note[data-published-at]').forEach((note) => {
|
document.querySelectorAll('.article-outdated-note[data-published-at]').forEach((note) => {
|
||||||
|
|||||||
@@ -12,3 +12,4 @@
|
|||||||
@import "./site/12-theme.css";
|
@import "./site/12-theme.css";
|
||||||
@import "./site/13-responsive.css";
|
@import "./site/13-responsive.css";
|
||||||
@import "./site/14-animations.css";
|
@import "./site/14-animations.css";
|
||||||
|
@import "./site/15-reading-series.css";
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
.reading-progress {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0 0 auto;
|
||||||
|
z-index: 40;
|
||||||
|
height: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(15, 118, 110, 0.12);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reading-progress span {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: left center;
|
||||||
|
background: linear-gradient(90deg, #3eb8be, #0f766e);
|
||||||
|
box-shadow: 0 0 12px rgba(62, 184, 190, 0.56);
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reading-resume {
|
||||||
|
position: fixed;
|
||||||
|
right: max(18px, var(--page-gutter));
|
||||||
|
bottom: 24px;
|
||||||
|
z-index: 32;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 42px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 13px;
|
||||||
|
width: min(520px, calc(100vw - 32px));
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.66);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
color: #27313a;
|
||||||
|
background: rgba(255, 253, 248, 0.92);
|
||||||
|
box-shadow: 0 22px 60px rgba(44, 55, 63, 0.24);
|
||||||
|
backdrop-filter: blur(20px) saturate(165%);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(165%);
|
||||||
|
animation: reading-resume-in 0.3s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reading-resume[hidden] { display: none; }
|
||||||
|
|
||||||
|
.reading-resume-icon {
|
||||||
|
display: grid;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #fff;
|
||||||
|
background: #3eb8be;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reading-resume strong,
|
||||||
|
.reading-resume p { margin: 0; }
|
||||||
|
.reading-resume p { color: var(--muted); font-size: 0.88rem; }
|
||||||
|
|
||||||
|
.reading-resume-actions { display: flex; gap: 7px; }
|
||||||
|
.reading-resume button {
|
||||||
|
border: 1px solid rgba(15, 118, 110, 0.18);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 7px 11px;
|
||||||
|
color: var(--accent);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 800;
|
||||||
|
background: rgba(236, 253, 245, 0.72);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.reading-resume button:first-child { color: #fff; background: #3eb8be; }
|
||||||
|
|
||||||
|
.post-series {
|
||||||
|
margin: 0 0 30px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(15, 118, 110, 0.18);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(236, 253, 245, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-series-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
border-bottom: 1px solid rgba(15, 118, 110, 0.14);
|
||||||
|
}
|
||||||
|
.post-series-head span { color: var(--accent); font-size: 0.72rem; font-weight: 900; letter-spacing: 0.1em; }
|
||||||
|
.post-series-head h2 { margin: 2px 0 0; font-size: 1.25rem; }
|
||||||
|
.post-series-head > strong { color: var(--muted); font-size: 0.86rem; white-space: nowrap; }
|
||||||
|
|
||||||
|
.post-series-list {
|
||||||
|
display: grid;
|
||||||
|
max-height: 310px;
|
||||||
|
margin: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.post-series-list a {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 32px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
.post-series-list a:hover { background: rgba(62, 184, 190, 0.1); }
|
||||||
|
.post-series-list li.is-current a { color: #fff; background: #3eb8be; }
|
||||||
|
.post-series-list span { font-variant-numeric: tabular-nums; opacity: 0.7; }
|
||||||
|
.post-series-list strong { overflow: hidden; font-size: 0.92rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
.post-series-nav {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
border-top: 1px solid rgba(15, 118, 110, 0.14);
|
||||||
|
}
|
||||||
|
.post-series-nav a { display: grid; gap: 2px; padding: 13px 18px; }
|
||||||
|
.post-series-nav a:last-child { text-align: right; border-left: 1px solid rgba(15, 118, 110, 0.14); }
|
||||||
|
.post-series-nav span { color: var(--muted); font-size: 0.76rem; }
|
||||||
|
.post-series-nav strong { overflow: hidden; color: var(--accent); font-size: 0.88rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .reading-resume,
|
||||||
|
:root[data-theme='dark'] .post-series {
|
||||||
|
border-color: rgba(139, 224, 213, 0.22);
|
||||||
|
color: #edf7f5;
|
||||||
|
background: rgba(13, 23, 29, 0.92);
|
||||||
|
}
|
||||||
|
:root[data-theme='dark'] .post-series-list a:hover { background: rgba(139, 224, 213, 0.1); }
|
||||||
|
|
||||||
|
@keyframes reading-resume-in {
|
||||||
|
from { opacity: 0; transform: translateY(16px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.reading-resume {
|
||||||
|
right: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
grid-template-columns: 38px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.reading-resume-icon { width: 38px; height: 38px; }
|
||||||
|
.reading-resume-actions { grid-column: 1 / -1; }
|
||||||
|
.reading-resume button { flex: 1; }
|
||||||
|
.post-series-head { align-items: start; flex-direction: column; gap: 6px; }
|
||||||
|
.post-series-nav { grid-template-columns: 1fr; }
|
||||||
|
.post-series-nav a:last-child { border-top: 1px solid rgba(15, 118, 110, 0.14); border-left: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.reading-resume { animation: none; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user