Build out the podcast: live feed, subscribe surface, episode pages
validate / validate (push) Failing after 16s
validate / validate (push) Failing after 16s
- /podcast/feed.xml: valid podcast RSS with iTunes tags and audio enclosures, populated only by episodes that have real audio - Podcast index: subscribe row with the feed address; episode rows gain guests and duration; empty state points at the live feed - Episode pages: guests/duration line, framed player with download link - Front page: compact podcast rail appears once the first episode exists - Nav shows Podcast when the feed is configured; footer links the feed Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import { site } from '../site.config';
|
||||
<div>
|
||||
<p class="footer-label">Read independently</p>
|
||||
<a href="/rss.xml">RSS feed</a><br />
|
||||
<a href={site.podcastFeedUrl}>Podcast feed</a><br />
|
||||
<a href={site.repositoryUrl}>Public repository</a>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -12,7 +12,7 @@ const [stories, briefings, episodes, guides] = await Promise.all([
|
||||
const visibleNavigation = navigation.filter((item) => {
|
||||
if (item.href === '/stories') return stories.length > 0;
|
||||
if (item.href === '/briefing') return briefings.length > 0;
|
||||
if (item.href === '/podcast') return episodes.length > 0;
|
||||
if (item.href === '/podcast') return episodes.length > 0 || site.podcastFeedUrl !== '';
|
||||
if (item.href === '/guides') return guides.length > 0;
|
||||
if (item.href === '/book') return site.book.enabled;
|
||||
return true;
|
||||
|
||||
@@ -70,6 +70,7 @@ const episodes = defineCollection({
|
||||
published: z.coerce.date(),
|
||||
duration: z.string().optional(),
|
||||
audioUrl: z.url().optional(),
|
||||
audioBytes: z.number().int().positive().optional(),
|
||||
guests: z.array(z.string()).default([]),
|
||||
...maintenance,
|
||||
}),
|
||||
|
||||
@@ -11,9 +11,11 @@ status: developing
|
||||
draft: true
|
||||
duration: "00:00"
|
||||
guests: []
|
||||
# audioUrl: https://…/episode-001.mp3 (durable location only)
|
||||
# audioBytes: 0 (file size in bytes, for the feed enclosure)
|
||||
---
|
||||
|
||||
> Draft episode template. Add the audio URL in frontmatter only after the durable feed location is known.
|
||||
> Draft episode template. Add the audio URL in frontmatter only after the durable feed location is known. Episodes without an audio URL never enter the podcast feed.
|
||||
|
||||
## Listen
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ interface FrontPageItem {
|
||||
|
||||
const stories = (await getCollection('stories', ({ data }) => !data.draft))
|
||||
.sort((a, b) => a.data.editorialOrder - b.data.editorialOrder);
|
||||
const episodes = (await getCollection('episodes', ({ data }) => !data.draft))
|
||||
.sort((a, b) => b.data.published.getTime() - a.data.published.getTime());
|
||||
const latestEpisode = episodes[0];
|
||||
const toneCycle = ['blue', 'paper', 'ink', 'soft', 'amend', 'paper'] as const;
|
||||
const frontPageItems: FrontPageItem[] = stories
|
||||
.filter(({ data }) => data.homepage)
|
||||
@@ -55,6 +58,21 @@ const sizeCycle = ['wide', 'standard', 'compact', 'compact', 'compact'] as const
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
{latestEpisode && (
|
||||
<section class="podcast-rail shell" aria-label="The podcast">
|
||||
<div class="rail-wave" aria-hidden="true">
|
||||
{Array.from({ length: 9 }).map((_, index) => <i style={`--n:${(index * 41) % 100}`} />)}
|
||||
</div>
|
||||
<div class="rail-copy">
|
||||
<p class="eyebrow">Podcast · Episode {latestEpisode.data.episode}</p>
|
||||
<h2><a href={`/podcast/${latestEpisode.id}`}>{latestEpisode.data.title}</a></h2>
|
||||
</div>
|
||||
<div class="rail-links">
|
||||
<a href={`/podcast/${latestEpisode.id}`}>Listen →</a>
|
||||
<a href={site.podcastFeedUrl}>Subscribe</a>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<div class="more-stories shell">
|
||||
<a href="/stories">Read more <span>{Math.max(0, stories.length - frontPageItems.length)} more stories</span></a>
|
||||
</div>
|
||||
@@ -70,6 +88,79 @@ const sizeCycle = ['wide', 'standard', 'compact', 'compact', 'compact'] as const
|
||||
background: var(--rule);
|
||||
}
|
||||
|
||||
.podcast-rail {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: clamp(1.25rem, 3vw, 2.5rem);
|
||||
align-items: center;
|
||||
margin-top: clamp(1.5rem, 3vw, 2.5rem);
|
||||
padding: clamp(1.1rem, 2.5vw, 1.75rem) clamp(1.1rem, 2.5vw, 2rem);
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
}
|
||||
|
||||
.rail-wave {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.rail-wave i {
|
||||
display: block;
|
||||
width: 2px;
|
||||
height: calc(25% + var(--n) * 0.7%);
|
||||
background: color-mix(in srgb, var(--paper) calc(30% + var(--n) * .5%), var(--amend));
|
||||
}
|
||||
|
||||
.podcast-rail .eyebrow {
|
||||
margin-bottom: 0.3rem;
|
||||
color: #9fb8d4;
|
||||
}
|
||||
|
||||
.podcast-rail h2 {
|
||||
margin: 0;
|
||||
font-family: 'Newsreader Variable', Georgia, serif;
|
||||
font-size: clamp(1.4rem, 2.6vw, 2rem);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.02;
|
||||
}
|
||||
|
||||
.podcast-rail h2 a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.podcast-rail h2 a:hover {
|
||||
color: #9fb8d4;
|
||||
}
|
||||
|
||||
.rail-links {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rail-links a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.rail-links a:hover {
|
||||
color: #9fb8d4;
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.podcast-rail {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.rail-wave {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.more-stories {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -23,10 +23,49 @@ const { Content } = await render(entry);
|
||||
status={entry.data.status}
|
||||
sourcePath={`src/content/episodes/${entry.id}.md`}
|
||||
>
|
||||
{entry.data.audioUrl && <audio controls preload="metadata" src={entry.data.audioUrl}>Your browser does not support embedded audio.</audio>}
|
||||
{(entry.data.guests.length > 0 || entry.data.duration) && (
|
||||
<p class="episode-facts">
|
||||
{entry.data.guests.length > 0 && <span>With {entry.data.guests.join(', ')}</span>}
|
||||
{entry.data.duration && <span>{entry.data.duration}</span>}
|
||||
</p>
|
||||
)}
|
||||
{entry.data.audioUrl && (
|
||||
<div class="player">
|
||||
<audio controls preload="metadata" src={entry.data.audioUrl}>Your browser does not support embedded audio.</audio>
|
||||
<a href={entry.data.audioUrl} download>Download audio ↓</a>
|
||||
</div>
|
||||
)}
|
||||
<Content />
|
||||
</ArticleLayout>
|
||||
|
||||
<style>
|
||||
audio { width: 100%; margin-bottom: 2.5rem; }
|
||||
.episode-facts {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin: 0 0 1.5rem;
|
||||
color: var(--muted);
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.player {
|
||||
margin-bottom: 2.5rem;
|
||||
padding: 1.1rem;
|
||||
border: 1px solid var(--rule);
|
||||
background: var(--paper-raised);
|
||||
}
|
||||
|
||||
.player audio { width: 100%; }
|
||||
|
||||
.player a {
|
||||
display: inline-block;
|
||||
margin-top: 0.7rem;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import rss from '@astrojs/rss';
|
||||
import type { APIContext } from 'astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { site } from '../../site.config';
|
||||
|
||||
const AUDIO_TYPES: Record<string, string> = {
|
||||
mp3: 'audio/mpeg',
|
||||
m4a: 'audio/mp4',
|
||||
ogg: 'audio/ogg',
|
||||
opus: 'audio/opus',
|
||||
wav: 'audio/wav',
|
||||
};
|
||||
|
||||
function audioType(url: string): string {
|
||||
const extension = new URL(url).pathname.split('.').pop()?.toLowerCase() ?? '';
|
||||
return AUDIO_TYPES[extension] ?? 'audio/mpeg';
|
||||
}
|
||||
|
||||
export async function GET(context: APIContext) {
|
||||
const episodes = (
|
||||
await getCollection('episodes', ({ data }) => !data.draft && Boolean(data.audioUrl))
|
||||
).sort((a, b) => b.data.published.getTime() - a.data.published.getTime());
|
||||
|
||||
return rss({
|
||||
title: `${site.title} — the podcast`,
|
||||
description:
|
||||
'Who gets to act in our name? Conversations about authority, delegation, recovery, and responsibility, with full transcripts published beside every episode.',
|
||||
site: new URL('/podcast', context.site).href,
|
||||
xmlns: {
|
||||
itunes: 'http://www.itunes.com/dtds/podcast-1.0.dtd',
|
||||
},
|
||||
customData: [
|
||||
'<language>en</language>',
|
||||
`<itunes:author>${site.author.name}</itunes:author>`,
|
||||
'<itunes:explicit>false</itunes:explicit>',
|
||||
`<link>${new URL('/podcast', context.site).href}</link>`,
|
||||
].join(''),
|
||||
items: episodes.map((entry) => ({
|
||||
title: entry.data.title,
|
||||
description: entry.data.description,
|
||||
link: `/podcast/${entry.id}`,
|
||||
pubDate: entry.data.published,
|
||||
enclosure: {
|
||||
url: entry.data.audioUrl!,
|
||||
length: entry.data.audioBytes ?? 0,
|
||||
type: audioType(entry.data.audioUrl!),
|
||||
},
|
||||
customData: [
|
||||
`<itunes:episode>${entry.data.episode}</itunes:episode>`,
|
||||
entry.data.duration ? `<itunes:duration>${entry.data.duration}</itunes:duration>` : '',
|
||||
].join(''),
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
import { getCollection } from 'astro:content';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { formatDate, newestFirst } from '../../lib/content';
|
||||
import { site } from '../../site.config';
|
||||
|
||||
const entries = newestFirst(await getCollection('episodes', ({ data }) => !data.draft));
|
||||
const feedUrl = new URL(site.podcastFeedUrl, Astro.site).href;
|
||||
---
|
||||
|
||||
<BaseLayout title="Podcast" description="Conversations about authority, delegation, recovery, and responsibility.">
|
||||
@@ -19,21 +21,47 @@ const entries = newestFirst(await getCollection('episodes', ({ data }) => !data.
|
||||
{Array.from({ length: 21 }).map((_, index) => <i style={`--n:${(index * 37) % 100}`} />)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="shell subscribe" aria-label="Subscribe to the podcast">
|
||||
<div>
|
||||
<p class="eyebrow">Subscribe</p>
|
||||
<p class="subscribe-copy">
|
||||
Paste the feed into any podcast app. New episodes arrive there the moment they are published here —
|
||||
no platform account between you and the audio.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feed-box">
|
||||
<span class="feed-label">Feed</span>
|
||||
<a href={site.podcastFeedUrl}>{feedUrl}</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="shell episode-list">
|
||||
<div class="section-rule">
|
||||
<h2 class="section-title">Episodes</h2>
|
||||
<a href="/rss.xml">RSS →</a>
|
||||
<a href={site.podcastFeedUrl}>Podcast feed →</a>
|
||||
</div>
|
||||
{entries.length > 0 ? entries.map((entry) => (
|
||||
<article>
|
||||
<span>{String(entry.data.episode).padStart(2, '0')}</span>
|
||||
<span class="episode-number">{String(entry.data.episode).padStart(2, '0')}</span>
|
||||
<div>
|
||||
<h3><a href={`/podcast/${entry.id}`}>{entry.data.title}</a></h3>
|
||||
<p>{entry.data.description}</p>
|
||||
{entry.data.guests.length > 0 && <p class="guests">With {entry.data.guests.join(', ')}</p>}
|
||||
</div>
|
||||
<div class="episode-facts">
|
||||
<time datetime={entry.data.published.toISOString()}>{formatDate(entry.data.published)}</time>
|
||||
{entry.data.duration && <span>{entry.data.duration}</span>}
|
||||
</div>
|
||||
</article>
|
||||
)) : <p class="empty-state">The archive is ready for real episodes and transcripts. No synthetic placeholder episode has been published.</p>}
|
||||
)) : (
|
||||
<div class="empty-state">
|
||||
<p>
|
||||
The archive is empty because no episode has been recorded yet — this publication does not ship
|
||||
synthetic placeholders. The feed above is already live: subscribe now and episode one will simply appear.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -44,11 +72,72 @@ const entries = newestFirst(await getCollection('episodes', ({ data }) => !data.
|
||||
.podcast-grid > p { color: #bcb8af; }
|
||||
.wave { display: flex; height: 210px; align-items: center; justify-content: space-between; margin-top: 4rem; overflow: hidden; }
|
||||
.wave i { display: block; width: 2px; height: calc(20% + var(--n) * .75%); background: color-mix(in srgb, var(--paper) calc(25% + var(--n) * .5%), var(--amend)); }
|
||||
.episode-list { margin-top: 6rem; }
|
||||
.episode-list article { display: grid; grid-template-columns: 4rem 1fr 8rem; gap: 2rem; padding: 2rem 0; border-bottom: 1px solid var(--rule); }
|
||||
.episode-list article > span, time { color: var(--muted); font-family: 'IBM Plex Mono', monospace; font-size: .7rem; }
|
||||
|
||||
.subscribe {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr);
|
||||
gap: clamp(1.5rem, 4vw, 4rem);
|
||||
align-items: center;
|
||||
margin-top: clamp(3rem, 6vw, 5rem);
|
||||
padding: clamp(1.5rem, 3vw, 2.5rem);
|
||||
border: 1px solid var(--rule);
|
||||
background: var(--paper-raised);
|
||||
}
|
||||
|
||||
.subscribe-copy { max-width: 44ch; margin: 0; color: var(--muted); font-size: 0.92rem; }
|
||||
|
||||
.feed-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
padding: 0.9rem 1.1rem;
|
||||
border: 1px solid var(--ink);
|
||||
background: var(--paper);
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.feed-label {
|
||||
color: var(--amend);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.feed-box a {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.episode-list { margin-top: clamp(3.5rem, 7vw, 6rem); }
|
||||
.episode-list article { display: grid; grid-template-columns: 4rem 1fr 9rem; gap: 2rem; padding: 2rem 0; border-bottom: 1px solid var(--rule); }
|
||||
.episode-number { color: var(--muted); font-family: 'IBM Plex Mono', monospace; font-size: .7rem; }
|
||||
h3 { margin: 0; font-family: 'Newsreader Variable', Georgia, serif; font-size: 2.3rem; line-height: 1; }
|
||||
h3 a { text-decoration: none; }
|
||||
.episode-list p { color: var(--muted); }
|
||||
@media (max-width: 700px) { .podcast-grid { grid-template-columns: 1fr; } .episode-list article { grid-template-columns: 2rem 1fr; } time { grid-column: 2; } }
|
||||
.guests { font-size: 0.82rem; }
|
||||
|
||||
.episode-facts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
color: var(--muted);
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: .7rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.empty-state { margin-top: 2rem; }
|
||||
.empty-state p { max-width: 60ch; margin: 0; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.podcast-grid { grid-template-columns: 1fr; }
|
||||
.subscribe { grid-template-columns: 1fr; }
|
||||
.episode-list article { grid-template-columns: 2rem 1fr; }
|
||||
.episode-facts { flex-direction: row; grid-column: 2; text-align: left; }
|
||||
}
|
||||
</style>
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ export const site = {
|
||||
},
|
||||
repositoryUrl: 'https://git.frrn.life/ana/trust-issues',
|
||||
newsletterUrl: '',
|
||||
podcastFeedUrl: '',
|
||||
podcastFeedUrl: '/podcast/feed.xml',
|
||||
contactUrl: '',
|
||||
book: {
|
||||
enabled: false,
|
||||
|
||||
Reference in New Issue
Block a user