@@ -16,11 +16,27 @@ type Props = { entry: CollectionEntry<'blog'> }
1616const { entry } = Astro .props as Props
1717const { Content, headings } = await entry .render ()
1818const { title, description, pubDate, tags } = entry .data
19+
20+ const AVERAGE_READING_WPM = 220
21+ const wordCount = entry .body ?.split (/ \s + / ).length ?? 0
22+ const readingTimeMinutes =
23+ wordCount > 0 ? Math .max (1 , Math .ceil (wordCount / AVERAGE_READING_WPM )) : 1
24+
25+ const allPosts = await getCollection (' blog' )
26+ const relatedPosts = allPosts
27+ .filter ((post ) => post .id !== entry .id )
28+ .filter ((post ) => {
29+ if (! tags || ! post .data .tags ) return false
30+ return post .data .tags .some ((tag ) => tags .includes (tag ))
31+ })
32+ .sort ((a , b ) => b .data .pubDate .getTime () - a .data .pubDate .getTime ())
33+ .slice (0 , 3 )
1934---
2035
2136<BaseLayout
2237 title ={ ` ${title } — Блог | Eldar Muhamethanov ` }
2338 description ={ description }
39+ showReadingProgress ={ true }
2440>
2541 <main
2642 class =" relative flex min-h-[80vh] flex-auto flex-col"
@@ -44,18 +60,19 @@ const { title, description, pubDate, tags } = entry.data
4460 { title }
4561 </h1 >
4662 <div class =" mt-2 flex flex-wrap items-center gap-3" >
47- <time
48- datetime ={ pubDate .toISOString ()}
49- class =" block text-sm text-muted-foreground"
50- >
51- {
52- pubDate .toLocaleDateString (' ru-RU' , {
53- day: ' numeric' ,
54- month: ' long' ,
55- year: ' numeric' ,
56- })
57- }
58- </time >
63+ <div class =" flex items-center gap-2 text-sm text-muted-foreground" >
64+ <time datetime ={ pubDate .toISOString ()} >
65+ {
66+ pubDate .toLocaleDateString (' ru-RU' , {
67+ day: ' numeric' ,
68+ month: ' long' ,
69+ year: ' numeric' ,
70+ })
71+ }
72+ </time >
73+ <span aria-hidden =" true" >•</span >
74+ <span >{ readingTimeMinutes } мин чтения</span >
75+ </div >
5976 { tags && tags .length > 0 && (
6077 <ul class = " flex flex-wrap gap-1.5" >
6178 { tags .map ((tag ) => (
@@ -70,20 +87,55 @@ const { title, description, pubDate, tags } = entry.data
7087 </header >
7188 <div
7289 class =" prose prose-neutral dark:prose-invert prose-headings:font-bold prose-pre:rounded-xl prose-code:rounded prose-code:px-1 prose-code:py-0.5 prose-code:before:content-none prose-code:after:content-none max-w-none"
90+ data-article-content
7391 >
7492 <Content />
7593 </div >
7694 </BlogArticleLayout >
7795 </div >
7896 </div >
7997 <BottomLayout size =" lg" >
80- <div class =" border-t border-border pt-8" >
81- <a
82- href =" /blog"
83- class =" text-sm font-medium text-primary hover:underline"
84- >
85- ← Все статьи
86- </a >
98+ <div class =" border-t border-border pt-8 space-y-8" >
99+ { relatedPosts .length > 0 && (
100+ <section aria-labelledby = " related-posts-heading" >
101+ <h2
102+ id = " related-posts-heading"
103+ class = " text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3"
104+ >
105+ Похожие статьи
106+ </h2 >
107+ <ul class = " grid gap-4 md:grid-cols-3" >
108+ { relatedPosts .map ((post ) => (
109+ <li >
110+ <a
111+ href = { ` /blog/${post .id .replace (/ \. (mdx? | md)$ / , ' ' )} ` }
112+ class = " block rounded-xl border border-border/80 bg-card/40 px-3 py-3 text-sm hover:border-primary/30 hover:bg-card/70 transition-colors"
113+ >
114+ <div class = " text-[11px] uppercase tracking-wide text-muted-foreground mb-1" >
115+ { post .data .pubDate .toLocaleDateString (' ru-RU' , {
116+ day: ' numeric' ,
117+ month: ' short' ,
118+ year: ' numeric' ,
119+ })}
120+ </div >
121+ <div class = " font-medium text-foreground line-clamp-2" >
122+ { post .data .title }
123+ </div >
124+ </a >
125+ </li >
126+ ))}
127+ </ul >
128+ </section >
129+ )}
130+
131+ <div >
132+ <a
133+ href =" /blog"
134+ class =" text-sm font-medium text-primary hover:underline"
135+ >
136+ ← Все статьи
137+ </a >
138+ </div >
87139 </div >
88140 </BottomLayout >
89141 </main >
@@ -95,4 +147,52 @@ const { title, description, pubDate, tags } = entry.data
95147 overflow-y: hidden;
96148 max-width: 85vw;
97149 }
150+
151+ #reading-progress {
152+ transform-origin: left center;
153+ border-radius: 0;
154+ }
98155</style >
156+
157+ <script is:inline >
158+ ;(function () {
159+ function attachReadingProgress() {
160+ const progressBar = document.getElementById('reading-progress')
161+ const contentScrollContainer = document.getElementById('content-scroll-container')
162+
163+ if (!progressBar || !contentScrollContainer) {
164+ return
165+ }
166+
167+ if (window.__readingProgressDetach) {
168+ window.__readingProgressDetach()
169+ }
170+
171+ const updateProgress = () => {
172+ const maxScroll =
173+ contentScrollContainer.scrollHeight - contentScrollContainer.clientHeight
174+
175+ if (maxScroll <= 0) {
176+ progressBar.style.width = '0%'
177+ return
178+ }
179+
180+ const current = contentScrollContainer.scrollTop
181+ const progress = Math.min(1, Math.max(0, current / maxScroll))
182+ progressBar.style.width = `${progress * 100}%`
183+ }
184+
185+ contentScrollContainer.addEventListener('scroll', updateProgress, { passive: true })
186+ window.addEventListener('resize', updateProgress)
187+ updateProgress()
188+
189+ window.__readingProgressDetach = () => {
190+ contentScrollContainer.removeEventListener('scroll', updateProgress)
191+ window.removeEventListener('resize', updateProgress)
192+ }
193+ }
194+
195+ attachReadingProgress()
196+ document.addEventListener('astro:after-swap', attachReadingProgress)
197+ })()
198+ </script >
0 commit comments