11import { ArrowLeft } from 'lucide-react'
22import type { Metadata } from 'next'
3+ import Image from 'next/image'
34import Link from 'next/link'
45import { FAQ } from '@/lib/blog/faq'
56import '@/app/(landing)/blog/[slug]/prose-studio.css'
67import { getAllPostMeta , getPostBySlug , getRelatedPosts } from '@/lib/blog/registry'
8+ import type { Author , BlogMeta } from '@/lib/blog/schema'
79import { buildArticleJsonLd , buildBreadcrumbJsonLd , buildPostMetadata } from '@/lib/blog/seo'
10+ import { formatDate } from '@/lib/core/utils/formatting'
811import { getBaseUrl } from '@/lib/core/utils/urls'
912import {
1013 AnimatedColorBlocks ,
@@ -13,7 +16,7 @@ import {
1316import { ArticleHeaderItem , ArticleHeaderMotion } from '@/app/(landing)/blog/[slug]/article-header'
1417import { ArticleSidebar } from '@/app/(landing)/blog/[slug]/article-sidebar'
1518import { ShareButtons } from '@/app/(landing)/blog/[slug]/share-button'
16- import { getPrimaryCategory , getTagCategory } from '@/app/(landing)/blog/tag-colors'
19+ import { getPrimaryCategory , getTagCategory , getTagColor } from '@/app/(landing)/blog/tag-colors'
1720
1821export async function generateStaticParams ( ) {
1922 const posts = await getAllPostMeta ( )
@@ -53,8 +56,8 @@ export default async function Page({ params }: { params: Promise<{ slug: string
5356 dangerouslySetInnerHTML = { { __html : JSON . stringify ( breadcrumbLd ) } }
5457 />
5558
56- < div className = 'mx-auto flex w-full max-w-[1400px ] flex-col items-start gap-8 px-6 pb-24 pt-16 xl:flex-row' >
57- < div className = 'max-w-4xl flex-grow xl: mx-auto' >
59+ < div className = 'mx-auto flex w-full max-w-[1500px ] flex-col items-start gap-2 pb-24 pt-16 xl:flex-row' >
60+ < div className = 'max-w-5xl flex-grow mx-auto' >
5861 < Link
5962 href = '/blog'
6063 className = 'group mb-8 inline-flex items-center gap-2 border border-[#2A2A2A] bg-[#232323] px-4 py-2 font-season text-[11px] uppercase tracking-widest text-[#999] transition-colors hover:text-[#ECECEC]'
@@ -66,30 +69,30 @@ export default async function Page({ params }: { params: Promise<{ slug: string
6669 />
6770 All Posts
6871 </ Link >
69- < header className = 'relative mb-12 border-b border-[#2A2A2A] pb-8' >
70- < div className = 'absolute right-0 top-0' >
71- < AnimatedColorBlocks />
72- < div className = 'absolute right-0 top-[12px]' >
73- < AnimatedColorBlocksVertical />
74- </ div >
75- </ div >
72+ < header className = 'mb-12 border-b border-[#2A2A2A] pb-8' >
7673 < ArticleHeaderMotion >
77- < ArticleHeaderItem className = 'mb-6 flex items-center gap-3' >
78- < span
79- className = 'inline-block h-3 w-3'
80- style = { { backgroundColor : categoryColor } }
81- aria-hidden = 'true'
82- />
83- < div className = 'font-season text-[11px] uppercase tracking-widest text-[#999]' >
84- < time dateTime = { post . date } itemProp = 'datePublished' >
85- { new Date ( post . date ) . toLocaleDateString ( 'en-US' , {
86- month : 'short' ,
87- day : '2-digit' ,
88- year : 'numeric' ,
89- } ) }
90- </ time >
91- { ' // ' }
92- < span style = { { color : categoryColor } } > { category . label } </ span >
74+ < ArticleHeaderItem className = 'mb-6 flex w-full flex-col gap-4 sm:flex-row sm:items-center sm:justify-between' >
75+ < div className = 'flex items-center gap-3' >
76+ < span
77+ className = 'inline-block h-3 w-3'
78+ style = { { backgroundColor : categoryColor } }
79+ aria-hidden = 'true'
80+ />
81+ < div className = 'font-season text-[11px] uppercase tracking-widest text-[#999]' >
82+ < time dateTime = { post . date } itemProp = 'datePublished' >
83+ { new Date ( post . date ) . toLocaleDateString ( 'en-US' , {
84+ month : 'short' ,
85+ day : '2-digit' ,
86+ year : 'numeric' ,
87+ } ) }
88+ </ time >
89+ { ' // ' }
90+ < span style = { { color : categoryColor } } > { category . label } </ span >
91+ </ div >
92+ </ div >
93+
94+ < div className = 'shrink-0' >
95+ < ShareButtons url = { shareUrl } title = { post . title } />
9396 </ div >
9497 </ ArticleHeaderItem >
9598 < ArticleHeaderItem >
@@ -135,17 +138,15 @@ export default async function Page({ params }: { params: Promise<{ slug: string
135138 { post . faq && post . faq . length > 0 ? < FAQ items = { post . faq } /> : null }
136139 </ div >
137140 </ div >
138- < div className = 'mt-16 flex items-center justify-between border-t border-[#2A2A2A] pt-8' >
139- < div className = 'font-season text-[11px] text-[#999]' > Share this entry:</ div >
140- < ShareButtons url = { shareUrl } title = { post . title } />
141- </ div >
141+
142+ { /* Authors */ }
143+ < ArticleAuthors authors = { displayAuthors } />
144+
145+ { /* Related articles */ }
146+ { related . length > 0 && < RelatedArticles posts = { related } /> }
142147 </ div >
143- < ArticleSidebar
144- author = { post . author }
145- authors = { displayAuthors }
146- headings = { post . headings ?? [ ] }
147- related = { related }
148- />
148+
149+ < ArticleSidebar headings = { post . headings ?? [ ] } />
149150 </ div >
150151
151152 < meta itemProp = 'publisher' content = 'Sim' />
@@ -160,3 +161,105 @@ export default async function Page({ params }: { params: Promise<{ slug: string
160161 </ article >
161162 )
162163}
164+
165+ interface ArticleAuthorsProps {
166+ authors : Author [ ]
167+ }
168+
169+ function ArticleAuthors ( { authors } : ArticleAuthorsProps ) {
170+ return (
171+ < div className = 'mt-12' >
172+ < div className = 'mb-6 flex items-center gap-2 font-season text-[11px] uppercase tracking-widest text-[#666]' >
173+ < span className = 'inline-block h-2 w-2 bg-[#FA4EDF]' aria-hidden = 'true' />
174+ { authors . length > 1 ? 'Authors' : 'Written by' }
175+ </ div >
176+ < div className = 'flex flex-wrap gap-6' >
177+ { authors . map ( ( a ) => (
178+ < div
179+ key = { a . id }
180+ className = 'flex items-center gap-4 border border-[#2A2A2A] bg-[#232323] p-5'
181+ style = { { borderRadius : '2px' } }
182+ >
183+ < div
184+ className = 'flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden border border-[#2A2A2A] bg-[#1C1C1C] font-season text-lg text-[#2ABBF8]'
185+ style = { { borderRadius : '2px' } }
186+ >
187+ { a . avatarUrl ? (
188+ < Image
189+ src = { a . avatarUrl }
190+ alt = { a . name }
191+ width = { 48 }
192+ height = { 48 }
193+ className = 'h-full w-full object-cover'
194+ unoptimized
195+ />
196+ ) : (
197+ a . name . slice ( 0 , 2 ) . toUpperCase ( )
198+ ) }
199+ </ div >
200+ < div >
201+ < h3 className = 'font-[500] text-[#ECECEC]' > { a . name } </ h3 >
202+ { a . url && (
203+ < Link
204+ href = { a . url }
205+ target = '_blank'
206+ rel = 'noopener noreferrer'
207+ className = 'font-season text-[11px] text-[#999] transition-colors hover:text-[#ECECEC]'
208+ >
209+ { a . xHandle ? `@${ a . xHandle } ` : 'Profile' }
210+ </ Link >
211+ ) }
212+ </ div >
213+ </ div >
214+ ) ) }
215+ </ div >
216+ </ div >
217+ )
218+ }
219+
220+ interface RelatedArticlesProps {
221+ posts : BlogMeta [ ]
222+ }
223+
224+ function RelatedArticles ( { posts } : RelatedArticlesProps ) {
225+ return (
226+ < div className = 'mt-12 border-t border-[#2A2A2A] pt-8' >
227+ < div className = 'mb-6 flex items-center gap-2 font-season text-[11px] uppercase tracking-widest text-[#666]' >
228+ < span className = 'inline-block h-2 w-2 bg-[#FFCC02]' aria-hidden = 'true' />
229+ Related articles
230+ </ div >
231+ < div className = 'grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3' >
232+ { posts . map ( ( p ) => {
233+ const color = getTagColor ( p . tags [ 0 ] ) || '#999'
234+ const cat = getPrimaryCategory ( p . tags )
235+ return (
236+ < Link
237+ key = { p . slug }
238+ href = { `/blog/${ p . slug } ` }
239+ className = 'group flex flex-col border border-[#2A2A2A] bg-[#232323] p-5 transition-[border-color,background-color,transform] duration-200 ease-out [@media(hover:hover)]:hover:border-[#3d3d3d] [@media(hover:hover)]:hover:bg-[#282828] [@media(hover:hover)]:hover:-translate-y-0.5'
240+ style = { { borderRadius : '2px' } }
241+ >
242+ < div className = 'mb-3 flex items-center gap-3' >
243+ < span
244+ className = 'inline-block px-2 py-0.5 font-season text-[10px] font-bold uppercase tracking-wider text-black'
245+ style = { { backgroundColor : color } }
246+ >
247+ { cat . label }
248+ </ span >
249+ </ div >
250+ < h4 className = 'mb-2 text-[15px] font-[500] leading-tight text-[#ECECEC] transition-colors duration-150 [@media(hover:hover)]:group-hover:text-[#FFCC02]' >
251+ { p . title }
252+ </ h4 >
253+ < p className = 'mb-4 line-clamp-2 text-[13px] leading-relaxed text-[#999]' >
254+ { p . description }
255+ </ p >
256+ < div className = 'mt-auto font-season text-[10px] text-[#666]' >
257+ { formatDate ( new Date ( p . date ) ) }
258+ </ div >
259+ </ Link >
260+ )
261+ } ) }
262+ </ div >
263+ </ div >
264+ )
265+ }
0 commit comments