Skip to content

Commit ac8bb62

Browse files
feat: update blog and blog detail pages
1 parent e804ea3 commit ac8bb62

File tree

14 files changed

+1281
-310
lines changed

14 files changed

+1281
-310
lines changed
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
'use client'
2+
3+
import { useEffect, useRef, useState } from 'react'
4+
5+
const COLORS = ['#2ABBF8', '#FA4EDF', '#FFCC02', '#00F701'] as const
6+
7+
const ENTER_STAGGER_MS = 60
8+
const ENTER_DURATION_MS = 300
9+
const HOLD_MS = 3000
10+
const EXIT_STAGGER_MS = 120
11+
const EXIT_DURATION_MS = 500
12+
13+
interface BlockState {
14+
opacity: number
15+
transitioning: boolean
16+
}
17+
18+
export function AnimatedColorBlocks() {
19+
const prefersReducedMotion = usePrefersReducedMotion()
20+
const [blocks, setBlocks] = useState<BlockState[]>(
21+
COLORS.map(() => ({ opacity: prefersReducedMotion ? 1 : 0, transitioning: false }))
22+
)
23+
const mounted = useRef(true)
24+
25+
useEffect(() => {
26+
mounted.current = true
27+
if (prefersReducedMotion) return
28+
29+
COLORS.forEach((_, i) => {
30+
setTimeout(() => {
31+
if (!mounted.current) return
32+
setBlocks((prev) =>
33+
prev.map((b, idx) => (idx === i ? { opacity: 1, transitioning: true } : b))
34+
)
35+
}, i * ENTER_STAGGER_MS)
36+
})
37+
38+
const totalEnterMs = COLORS.length * ENTER_STAGGER_MS + ENTER_DURATION_MS + HOLD_MS
39+
const cycleTimer = setTimeout(() => {
40+
if (!mounted.current) return
41+
startCycle()
42+
}, totalEnterMs)
43+
44+
return () => {
45+
mounted.current = false
46+
clearTimeout(cycleTimer)
47+
}
48+
}, [prefersReducedMotion])
49+
50+
function startCycle() {
51+
if (!mounted.current) return
52+
53+
COLORS.forEach((_, i) => {
54+
setTimeout(() => {
55+
if (!mounted.current) return
56+
setBlocks((prev) =>
57+
prev.map((b, idx) => (idx === i ? { opacity: 0.15, transitioning: true } : b))
58+
)
59+
}, i * EXIT_STAGGER_MS)
60+
})
61+
62+
const exitTotalMs = COLORS.length * EXIT_STAGGER_MS + EXIT_DURATION_MS
63+
setTimeout(() => {
64+
if (!mounted.current) return
65+
COLORS.forEach((_, i) => {
66+
setTimeout(() => {
67+
if (!mounted.current) return
68+
setBlocks((prev) =>
69+
prev.map((b, idx) =>
70+
idx === i ? { opacity: [1, 0.8, 0.6, 0.9][i], transitioning: true } : b
71+
)
72+
)
73+
}, i * ENTER_STAGGER_MS)
74+
})
75+
}, exitTotalMs + 200)
76+
77+
const cycleDuration =
78+
exitTotalMs + 200 + COLORS.length * ENTER_STAGGER_MS + ENTER_DURATION_MS + HOLD_MS
79+
setTimeout(() => startCycle(), cycleDuration)
80+
}
81+
82+
return (
83+
<div className='flex gap-0' aria-hidden='true'>
84+
{COLORS.map((color, i) => (
85+
<span
86+
key={color}
87+
className='inline-block h-3 w-3'
88+
style={{
89+
backgroundColor: color,
90+
opacity: blocks[i]?.opacity ?? 0,
91+
transition: `opacity ${blocks[i]?.transitioning ? ENTER_DURATION_MS : 0}ms ease-out`,
92+
}}
93+
/>
94+
))}
95+
</div>
96+
)
97+
}
98+
99+
export function AnimatedColorBlocksVertical() {
100+
const prefersReducedMotion = usePrefersReducedMotion()
101+
const [blocks, setBlocks] = useState<BlockState[]>(
102+
COLORS.slice(0, 3).map(() => ({
103+
opacity: prefersReducedMotion ? 1 : 0,
104+
transitioning: false,
105+
}))
106+
)
107+
const mounted = useRef(true)
108+
109+
useEffect(() => {
110+
mounted.current = true
111+
if (prefersReducedMotion) return
112+
113+
const baseDelay = COLORS.length * ENTER_STAGGER_MS + 100
114+
115+
COLORS.slice(0, 3).forEach((_, i) => {
116+
setTimeout(
117+
() => {
118+
if (!mounted.current) return
119+
setBlocks((prev) =>
120+
prev.map((b, idx) => (idx === i ? { opacity: 1, transitioning: true } : b))
121+
)
122+
},
123+
baseDelay + i * ENTER_STAGGER_MS
124+
)
125+
})
126+
127+
return () => {
128+
mounted.current = false
129+
}
130+
}, [prefersReducedMotion])
131+
132+
const verticalColors = [COLORS[0], COLORS[1], COLORS[2]]
133+
134+
return (
135+
<div className='flex flex-col gap-0' aria-hidden='true'>
136+
{verticalColors.map((color, i) => (
137+
<span
138+
key={color}
139+
className='inline-block h-3 w-3'
140+
style={{
141+
backgroundColor: color,
142+
opacity: blocks[i]?.opacity ?? 0,
143+
transition: `opacity ${blocks[i]?.transitioning ? ENTER_DURATION_MS : 0}ms ease-out`,
144+
}}
145+
/>
146+
))}
147+
</div>
148+
)
149+
}
150+
151+
function usePrefersReducedMotion(): boolean {
152+
const [prefersReduced, setPrefersReduced] = useState(false)
153+
154+
useEffect(() => {
155+
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
156+
setPrefersReduced(mq.matches)
157+
158+
const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches)
159+
mq.addEventListener('change', handler)
160+
return () => mq.removeEventListener('change', handler)
161+
}, [])
162+
163+
return prefersReduced
164+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import Image from 'next/image'
2+
import Link from 'next/link'
3+
import type { Author, BlogMeta } from '@/lib/blog/schema'
4+
import { TableOfContents } from '@/app/(landing)/studio/[slug]/table-of-contents'
5+
import { getTagColor } from '@/app/(landing)/studio/tag-colors'
6+
7+
interface ArticleSidebarProps {
8+
author: Author
9+
authors: Author[]
10+
tags: string[]
11+
headings: { text: string; id: string }[]
12+
related: BlogMeta[]
13+
}
14+
15+
function formatDate(iso: string) {
16+
return new Date(iso).toLocaleDateString('en-US', {
17+
month: 'short',
18+
day: 'numeric',
19+
year: 'numeric',
20+
})
21+
}
22+
23+
export function ArticleSidebar({ author, authors, tags, headings, related }: ArticleSidebarProps) {
24+
const displayAuthors = authors.length > 0 ? authors : [author]
25+
26+
return (
27+
<aside className='w-full shrink-0 space-y-6 xl:sticky xl:top-[76px] xl:w-72'>
28+
{displayAuthors.map((a) => (
29+
<div
30+
key={a.id}
31+
className='flex items-start gap-4 border border-[#2A2A2A] bg-[#232323] p-5'
32+
style={{ borderRadius: '5px' }}
33+
>
34+
<div
35+
className='flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden border border-[#2A2A2A] bg-[#1C1C1C] font-mono text-lg text-[#2ABBF8]'
36+
style={{ borderRadius: '5px' }}
37+
>
38+
{a.avatarUrl ? (
39+
<Image
40+
src={a.avatarUrl}
41+
alt={a.name}
42+
width={48}
43+
height={48}
44+
className='h-full w-full object-cover'
45+
unoptimized
46+
/>
47+
) : (
48+
a.name.slice(0, 2).toUpperCase()
49+
)}
50+
</div>
51+
<div>
52+
<div className='mb-1 font-mono text-[10px] uppercase tracking-widest text-[#FA4EDF]'>
53+
Author
54+
</div>
55+
<h3 className='font-[500] text-[#ECECEC]'>{a.name}</h3>
56+
{a.url && (
57+
<Link
58+
href={a.url}
59+
target='_blank'
60+
rel='noopener noreferrer'
61+
className='font-mono text-[11px] text-[#999] transition-colors hover:text-[#ECECEC]'
62+
>
63+
{a.xHandle ? `@${a.xHandle}` : 'Profile'}
64+
</Link>
65+
)}
66+
</div>
67+
</div>
68+
))}
69+
{headings.length > 0 && (
70+
<div className='border border-[#2A2A2A] bg-[#232323] p-5' style={{ borderRadius: '5px' }}>
71+
<TableOfContents headings={headings} />
72+
</div>
73+
)}
74+
{tags.length > 0 && (
75+
<div className='border border-[#2A2A2A] bg-[#232323] p-5' style={{ borderRadius: '5px' }}>
76+
<div className='mb-4 flex items-center gap-2 border-b border-[#2A2A2A] pb-3 font-mono text-[11px] uppercase tracking-widest text-[#ECECEC]'>
77+
<span className='inline-block h-1.5 w-1.5 bg-[#FA4EDF]' aria-hidden='true' />
78+
Topics
79+
</div>
80+
<div className='flex flex-wrap gap-2'>
81+
{tags.map((tag) => {
82+
const color = getTagColor(tag)
83+
return (
84+
<Link
85+
key={tag}
86+
href={`/studio?tag=${encodeURIComponent(tag)}`}
87+
className='font-mono text-[10px] uppercase tracking-wider transition-colors'
88+
style={{
89+
padding: '4px 8px',
90+
borderRadius: '5px',
91+
border: `1px solid ${color || '#3d3d3d'}`,
92+
color: color || '#999',
93+
backgroundColor: color ? `${color}08` : 'transparent',
94+
}}
95+
>
96+
{tag}
97+
</Link>
98+
)
99+
})}
100+
</div>
101+
</div>
102+
)}
103+
{related.length > 0 && (
104+
<div className='border border-[#2A2A2A] bg-[#232323] p-5' style={{ borderRadius: '5px' }}>
105+
<div className='mb-4 flex items-center gap-2 border-b border-[#2A2A2A] pb-3 font-mono text-[11px] uppercase tracking-widest text-[#ECECEC]'>
106+
<span className='inline-block h-1.5 w-1.5 bg-[#FFCC02]' aria-hidden='true' />
107+
Recent Logs
108+
</div>
109+
<div className='space-y-4'>
110+
{related.map((p) => {
111+
const color = getTagColor(p.tags[0]) || '#999'
112+
return (
113+
<Link key={p.slug} href={`/studio/${p.slug}`} className='group block'>
114+
<div
115+
className='mb-1 font-mono text-[9px] uppercase tracking-widest'
116+
style={{ color }}
117+
>
118+
{p.tags[0] || 'Post'}
119+
</div>
120+
<h4 className='mb-1 text-[13px] font-[500] leading-tight text-[#ECECEC] transition-colors group-hover:text-[#FFCC02]'>
121+
{p.title}
122+
</h4>
123+
<div className='font-mono text-[10px] text-[#666]'>{formatDate(p.date)}</div>
124+
</Link>
125+
)
126+
})}
127+
</div>
128+
</div>
129+
)}
130+
</aside>
131+
)
132+
}

0 commit comments

Comments
 (0)