Skip to content

Commit afa222f

Browse files
committed
feat: add podcast page with episode listing and audio player
- Implemented PodcastPage component to display a list of podcast episodes. - Created PodcastPlayer component for audio playback functionality. - Added podcast episode data structure and sample episode for testing.
1 parent 1e9d9ea commit afa222f

File tree

6 files changed

+233
-1
lines changed

6 files changed

+233
-1
lines changed
15.5 MB
Binary file not shown.

src/app/podcast/page.tsx

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { AppSidebar } from '@/components/app-sidebar'
2+
import { cn, formatDateUTC } from '@/lib/utils'
3+
import { podcastEpisodes } from '@/data/podcasts'
4+
import { Calendar, Clock } from 'lucide-react'
5+
import { PodcastPlayer } from '@/components/podcast-player'
6+
7+
export const metadata = {
8+
title: 'Podcast | Ruixen Atlas',
9+
description: 'A weekly audio signal distilling the newest Atlas dialogues, bridges, and monologues into one concise briefing.',
10+
}
11+
12+
export default function PodcastPage() {
13+
const episodes = [...podcastEpisodes].sort(
14+
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
15+
)
16+
17+
return (
18+
<div
19+
className={cn(
20+
'flex flex-col md:flex-row bg-gray-100 dark:bg-neutral-800 w-full flex-1 mx-auto border border-neutral-200 dark:border-neutral-700 overflow-hidden min-h-screen',
21+
)}
22+
>
23+
<AppSidebar />
24+
<div className="flex flex-1">
25+
<div className="p-4 md:p-10 rounded-tl-2xl border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 flex flex-col gap-6 flex-1 w-full h-full overflow-y-auto">
26+
<header className="max-w-4xl">
27+
<h1 className="text-3xl md:text-4xl font-bold text-neutral-900 dark:text-neutral-100 mb-4">
28+
Atlas Podcast
29+
</h1>
30+
<p className="text-neutral-600 dark:text-neutral-400 text-base md:text-lg leading-relaxed">
31+
Each episode is a fast signal—distilling new dialogues, bridge reports, and monologues into one listenable briefing so you can track how the Atlas is evolving.
32+
</p>
33+
</header>
34+
35+
<section className="space-y-6">
36+
{episodes.map((episode) => (
37+
<article
38+
key={episode.id}
39+
className="relative overflow-hidden border border-neutral-200 dark:border-neutral-700 rounded-xl bg-white/95 dark:bg-neutral-900/90 shadow-sm hover:shadow-md transition-shadow px-6 py-6 md:px-8"
40+
>
41+
<span className="pointer-events-none absolute inset-y-0 left-0 w-1 bg-gradient-to-b from-blue-500 via-purple-500 to-blue-500 opacity-80" aria-hidden="true" />
42+
<div className="grid gap-6 md:grid-cols-[minmax(0,1fr)_auto] md:items-center">
43+
<div className="space-y-4 md:pr-6">
44+
<h2 className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
45+
{episode.title}
46+
</h2>
47+
<div className="flex flex-wrap items-center gap-3 text-sm text-neutral-600 dark:text-neutral-400">
48+
<span className="inline-flex items-center gap-1">
49+
<Calendar className="h-4 w-4" />
50+
{formatDateUTC(episode.date)}
51+
</span>
52+
<span className="inline-flex items-center gap-2 rounded-full bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700 dark:bg-blue-900/30 dark:text-blue-200">
53+
<Clock className="h-3 w-3" />
54+
{episode.duration}
55+
</span>
56+
</div>
57+
<p className="text-neutral-600 dark:text-neutral-300 leading-relaxed max-w-2xl">
58+
{episode.description}
59+
</p>
60+
</div>
61+
<div className="flex flex-col gap-3 justify-center w-full md:w-[22rem] lg:w-[24rem]">
62+
<PodcastPlayer src={episode.audioSrc} className="w-full" />
63+
</div>
64+
</div>
65+
</article>
66+
))}
67+
</section>
68+
</div>
69+
</div>
70+
</div>
71+
)
72+
}

src/components/app-sidebar.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
Users,
1111
Info,
1212
Search,
13+
Mic,
1314
} from "lucide-react"
1415

1516
const links = [
@@ -33,6 +34,11 @@ const links = [
3334
href: "/explore",
3435
icon: <Search className="h-5 w-5 flex-shrink-0" />,
3536
},
37+
{
38+
label: "PODCAST",
39+
href: "/podcast",
40+
icon: <Mic className="h-5 w-5 flex-shrink-0" />,
41+
},
3642
{
3743
label: "ABOUT",
3844
href: "/about",

src/components/podcast-player.tsx

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"use client"
2+
3+
import { useEffect, useRef, useState } from 'react'
4+
import { Play, Pause } from 'lucide-react'
5+
import { cn } from '@/lib/utils'
6+
7+
interface PodcastPlayerProps {
8+
src: string
9+
className?: string
10+
}
11+
12+
function formatTime(seconds: number) {
13+
if (!Number.isFinite(seconds)) return '0:00'
14+
const mins = Math.floor(seconds / 60)
15+
const secs = Math.floor(seconds % 60)
16+
return `${mins}:${secs.toString().padStart(2, '0')}`
17+
}
18+
19+
export function PodcastPlayer({ src, className }: PodcastPlayerProps) {
20+
const audioRef = useRef<HTMLAudioElement | null>(null)
21+
const [isPlaying, setIsPlaying] = useState(false)
22+
const [currentTime, setCurrentTime] = useState(0)
23+
const [duration, setDuration] = useState(0)
24+
const isLoaded = duration > 0
25+
26+
useEffect(() => {
27+
const audio = audioRef.current
28+
if (!audio) return
29+
30+
const handleLoaded = () => {
31+
setDuration(audio.duration || 0)
32+
}
33+
34+
const handleTimeUpdate = () => {
35+
setCurrentTime(audio.currentTime || 0)
36+
}
37+
38+
const handleEnded = () => {
39+
setIsPlaying(false)
40+
setCurrentTime(audio.duration || 0)
41+
}
42+
43+
audio.addEventListener('loadedmetadata', handleLoaded)
44+
audio.addEventListener('timeupdate', handleTimeUpdate)
45+
audio.addEventListener('ended', handleEnded)
46+
47+
return () => {
48+
audio.removeEventListener('loadedmetadata', handleLoaded)
49+
audio.removeEventListener('timeupdate', handleTimeUpdate)
50+
audio.removeEventListener('ended', handleEnded)
51+
}
52+
}, [])
53+
54+
const togglePlay = () => {
55+
const audio = audioRef.current
56+
if (!audio) return
57+
58+
if (isPlaying) {
59+
audio.pause()
60+
setIsPlaying(false)
61+
} else {
62+
audio.play().then(() => setIsPlaying(true)).catch(() => setIsPlaying(false))
63+
}
64+
}
65+
66+
const handleSeek = (value: number) => {
67+
const audio = audioRef.current
68+
if (!audio) return
69+
audio.currentTime = value
70+
setCurrentTime(value)
71+
}
72+
73+
return (
74+
<div className={cn('w-full rounded-lg border border-neutral-200 dark:border-neutral-700 bg-neutral-100/70 dark:bg-neutral-800/70 backdrop-blur-sm px-4 py-3', className)}>
75+
<div className="flex items-center gap-4">
76+
<button
77+
type="button"
78+
onClick={togglePlay}
79+
className={cn(
80+
'flex h-12 w-12 items-center justify-center rounded-full transition-colors',
81+
isPlaying
82+
? 'bg-purple-600 text-white hover:bg-purple-500'
83+
: 'bg-blue-600 text-white hover:bg-blue-500',
84+
)}
85+
aria-label={isPlaying ? 'Pause podcast' : 'Play podcast'}
86+
>
87+
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
88+
</button>
89+
<div className="flex-1">
90+
<div className="flex items-center gap-3 text-xs text-neutral-500 dark:text-neutral-400">
91+
<span>{formatTime(currentTime)}</span>
92+
<input
93+
type="range"
94+
min={0}
95+
max={isLoaded ? duration : 0}
96+
step={0.1}
97+
value={currentTime}
98+
onChange={(event) => handleSeek(Number(event.target.value))}
99+
className="flex-1 accent-neutral-900 dark:accent-neutral-100 h-1 rounded-full appearance-none bg-neutral-300 dark:bg-neutral-700"
100+
aria-valuemax={duration}
101+
aria-valuemin={0}
102+
aria-valuenow={currentTime}
103+
/>
104+
<span>{formatTime(duration)}</span>
105+
</div>
106+
</div>
107+
</div>
108+
<audio ref={audioRef} preload="none">
109+
<source src={src} type="audio/mpeg" />
110+
Your browser does not support the audio element.
111+
</audio>
112+
</div>
113+
)
114+
}

src/data/podcasts.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export interface PodcastEpisode {
2+
id: string
3+
title: string
4+
description: string
5+
date: string
6+
duration: string
7+
audioSrc: string
8+
topics: string[]
9+
}
10+
11+
export const podcastEpisodes: PodcastEpisode[] = [
12+
{
13+
id: '2025-week-01',
14+
title: 'Weekly Atlas Signals — September 27, 2025',
15+
description:
16+
'Sixteen-minute signal: the week’s dialogues on modular manifolds, evolutionary meta-learning, and cybernetic governance compressed into one take—why geometric seams, aesthetic structure, and adaptive feedback still decide what survives.',
17+
date: '2025-09-27',
18+
duration: '16:53',
19+
audioSrc: '/podcasts/weekly-atlas-2025-09-28.mp3',
20+
topics: [],
21+
},
22+
]

src/lib/utils.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,22 @@ import { twMerge } from "tailwind-merge"
33

44
export function cn(...inputs: ClassValue[]) {
55
return twMerge(clsx(inputs))
6-
}
6+
}
7+
8+
export function formatDateUTC(
9+
dateString: string,
10+
locale: string = 'en-US',
11+
options: Intl.DateTimeFormatOptions = {},
12+
) {
13+
if (!dateString) return ''
14+
const safeDate = new Date(`${dateString}T00:00:00Z`)
15+
if (Number.isNaN(safeDate.getTime())) return dateString
16+
const formatter = new Intl.DateTimeFormat(locale, {
17+
year: 'numeric',
18+
month: 'long',
19+
day: 'numeric',
20+
timeZone: 'UTC',
21+
...options,
22+
})
23+
return formatter.format(safeDate)
24+
}

0 commit comments

Comments
 (0)