Radio show website: Full Astro build with 194 episodes imported

Complete website for The Computer Guru Show (radio.azcomputerguru.com):
- Astro 6.0.4 static site with React islands
- 194 episodes imported from gurushow.com RSS feed
- Dark/light mode HSL design system
- Persistent audio player with session persistence
- Episode archive with search and season filtering
- Home page with animated hero, stats, latest episodes
- All pages: About, Subscribe, Community, Live, Contact, Blog, 404
- Podcast RSS feed with iTunes namespace
- Session log updated

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-14 20:44:42 -07:00
parent 1adc2ed3a4
commit ee89727662
236 changed files with 16513 additions and 0 deletions

View File

@@ -0,0 +1,458 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import EpisodeCard from '../../components/episodes/EpisodeCard.astro';
import { getCollection, render } from 'astro:content';
import type { CollectionEntry } from 'astro:content';
export async function getStaticPaths() {
const episodes = await getCollection('episodes');
return episodes.map((episode) => ({
params: { slug: episode.id },
props: { episode },
}));
}
interface Props {
episode: CollectionEntry<'episodes'>;
}
const { episode } = Astro.props;
const { title, season, episode: episodeNum, pubDate, duration, audioUrl, tags, chapters, originalUrl } = episode.data;
const { Content } = await render(episode);
const formattedDate = new Date(pubDate).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
const seasonLabel = season === 0 ? 'Special' : `Season ${season}`;
const episodeLabel = season === 0 ? 'Special' : `S${season} E${String(episodeNum).padStart(2, '0')}`;
const allEpisodes = await getCollection('episodes');
const relatedEpisodes = allEpisodes
.filter((ep) => ep.data.season === season && ep.id !== episode.id)
.sort((a, b) => new Date(b.data.pubDate).getTime() - new Date(a.data.pubDate).getTime())
.slice(0, 4);
---
<BaseLayout title={title} description={`${episodeLabel} of The Computer Guru Show`}>
<article class="episode-detail">
<div class="container container--narrow">
<nav class="breadcrumb" aria-label="Breadcrumb">
<ol class="breadcrumb__list">
<li class="breadcrumb__item">
<a href="/episodes">Episodes</a>
</li>
<li class="breadcrumb__separator" aria-hidden="true">/</li>
<li class="breadcrumb__item">
<a href={`/episodes?season=${season}`}>{seasonLabel}</a>
</li>
<li class="breadcrumb__separator" aria-hidden="true">/</li>
<li class="breadcrumb__item breadcrumb__item--current" aria-current="page">
{title}
</li>
</ol>
</nav>
<header class="episode-detail__header">
<span class="badge">{episodeLabel}</span>
<h1 class="episode-detail__title">{title}</h1>
<div class="episode-detail__meta">
<time datetime={pubDate.toISOString()} class="episode-detail__date">
{formattedDate}
</time>
<span class="episode-detail__divider" aria-hidden="true">|</span>
<span class="episode-detail__duration">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
{duration}
</span>
</div>
</header>
<section class="episode-detail__player">
<audio
controls
preload="metadata"
class="episode-detail__audio"
src={audioUrl}
>
Your browser does not support the audio element.
</audio>
<button
class="btn btn--ghost episode-detail__play-persistent"
data-audio-url={audioUrl}
data-episode-title={title}
data-season={String(season)}
data-episode-num={String(episodeNum)}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="5 3 19 12 5 21 5 3" fill="currentColor"></polygon>
</svg>
Play in Persistent Player
</button>
</section>
{chapters && chapters.length > 0 && (
<section class="episode-detail__chapters">
<h2 class="episode-detail__section-title">Chapters</h2>
<ol class="chapters-list">
{chapters.map((chapter) => (
<li class="chapters-list__item">
<span class="chapters-list__time">{chapter.time}</span>
<span class="chapters-list__title">{chapter.title}</span>
</li>
))}
</ol>
</section>
)}
<section class="episode-detail__content">
<h2 class="episode-detail__section-title">Show Notes</h2>
<div class="prose">
<Content />
</div>
</section>
{tags.length > 0 && (
<section class="episode-detail__tags">
<h2 class="episode-detail__section-title">Tags</h2>
<div class="tag-list">
{tags.map((tag: string) => (
<span class="badge">{tag}</span>
))}
</div>
</section>
)}
<section class="episode-detail__platforms">
<h2 class="episode-detail__section-title">Listen On</h2>
<div class="platform-links">
<a
href={audioUrl}
class="btn btn--ghost"
download
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
Download MP3
</a>
{originalUrl && (
<a
href={originalUrl}
class="btn btn--ghost"
target="_blank"
rel="noopener noreferrer"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>
Original Post
</a>
)}
<a
href="/feed.xml"
class="btn btn--ghost"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 11a9 9 0 0 1 9 9"></path>
<path d="M4 4a16 16 0 0 1 16 16"></path>
<circle cx="5" cy="19" r="1"></circle>
</svg>
RSS Feed
</a>
</div>
</section>
</div>
{relatedEpisodes.length > 0 && (
<section class="episode-detail__related section">
<div class="container">
<h2 class="section__title">More from {seasonLabel}</h2>
<div class="grid grid--3">
{relatedEpisodes.map((ep) => (
<EpisodeCard episode={ep} />
))}
</div>
</div>
</section>
)}
</article>
</BaseLayout>
<style>
.breadcrumb {
margin-bottom: var(--space-8);
padding-top: var(--space-6);
}
.breadcrumb__list {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
list-style: none;
padding: 0;
margin: 0;
}
.breadcrumb__item a {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.breadcrumb__item a:hover {
color: var(--color-accent);
}
.breadcrumb__item--current {
font-size: var(--text-sm);
color: var(--color-text-secondary);
max-width: 300px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.breadcrumb__separator {
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.episode-detail__header {
margin-bottom: var(--space-8);
}
.episode-detail__header .badge {
margin-bottom: var(--space-3);
}
.episode-detail__title {
font-size: var(--text-3xl);
margin-top: var(--space-3);
margin-bottom: var(--space-4);
line-height: 1.15;
}
.episode-detail__meta {
display: flex;
align-items: center;
gap: var(--space-3);
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.episode-detail__duration {
display: inline-flex;
align-items: center;
gap: var(--space-1);
}
.episode-detail__divider {
color: var(--color-border);
}
.episode-detail__player {
margin-bottom: var(--space-8);
padding: var(--space-6);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.episode-detail__audio {
width: 100%;
height: 48px;
}
.episode-detail__play-persistent {
margin-top: var(--space-3);
font-size: var(--text-sm);
}
.episode-detail__audio::-webkit-media-controls-panel {
background: var(--color-bg-tertiary);
}
.episode-detail__section-title {
font-size: var(--text-xl);
margin-bottom: var(--space-4);
}
.episode-detail__chapters {
margin-bottom: var(--space-8);
}
.chapters-list {
list-style: none;
padding: 0;
margin: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
}
.chapters-list__item {
display: flex;
align-items: center;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border);
transition: background var(--transition-fast);
}
.chapters-list__item:last-child {
border-bottom: none;
}
.chapters-list__item:hover {
background: var(--color-bg-tertiary);
}
.chapters-list__time {
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--color-accent);
min-width: 60px;
flex-shrink: 0;
}
.chapters-list__title {
font-size: var(--text-sm);
color: var(--color-text-primary);
}
.episode-detail__content {
margin-bottom: var(--space-8);
}
.prose {
color: var(--color-text-secondary);
line-height: 1.8;
font-size: var(--text-base);
}
.prose :global(p) {
margin-bottom: var(--space-4);
}
.prose :global(a) {
color: var(--color-accent);
text-decoration: underline;
text-underline-offset: 2px;
}
.prose :global(a:hover) {
color: var(--color-accent-hover);
}
.prose :global(ul),
.prose :global(ol) {
margin-bottom: var(--space-4);
padding-left: var(--space-6);
}
.prose :global(li) {
margin-bottom: var(--space-2);
}
.prose :global(strong) {
color: var(--color-text-primary);
font-weight: 600;
}
.prose :global(h2),
.prose :global(h3) {
margin-top: var(--space-8);
margin-bottom: var(--space-3);
}
.episode-detail__tags {
margin-bottom: var(--space-8);
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.episode-detail__platforms {
margin-bottom: var(--space-8);
padding: var(--space-6);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.platform-links {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
}
.episode-detail__related {
border-top: 1px solid var(--color-border);
}
@media (max-width: 640px) {
.episode-detail__title {
font-size: var(--text-2xl);
}
.episode-detail__meta {
flex-direction: column;
align-items: flex-start;
gap: var(--space-1);
}
.episode-detail__divider {
display: none;
}
.platform-links {
flex-direction: column;
}
.platform-links .btn {
width: 100%;
justify-content: center;
}
}
</style>
<script>
document.addEventListener('astro:page-load', () => {
const btn = document.querySelector<HTMLButtonElement>('.episode-detail__play-persistent');
if (btn) {
btn.addEventListener('click', (e) => {
e.preventDefault();
const audioUrl = btn.dataset.audioUrl;
const title = btn.dataset.episodeTitle;
const season = parseInt(btn.dataset.season ?? '0', 10);
const episode = parseInt(btn.dataset.episodeNum ?? '0', 10);
if (audioUrl) {
document.dispatchEvent(
new CustomEvent('play-episode', {
detail: { audioUrl, title, season, episode },
})
);
}
});
}
});
</script>

View File

@@ -0,0 +1,250 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import EpisodeCard from '../../components/episodes/EpisodeCard.astro';
import SeasonFilter from '../../components/episodes/SeasonFilter.astro';
import { getCollection } from 'astro:content';
const allEpisodes = await getCollection('episodes');
const sortedEpisodes = allEpisodes.sort((a, b) =>
new Date(b.data.pubDate).getTime() - new Date(a.data.pubDate).getTime()
);
const seasonMap = new Map<number, number>();
for (const ep of sortedEpisodes) {
const s = ep.data.season;
seasonMap.set(s, (seasonMap.get(s) || 0) + 1);
}
const seasonCounts = Array.from(seasonMap.entries())
.filter(([season]) => season > 0)
.sort((a, b) => b[0] - a[0])
.map(([season, count]) => ({
season,
count,
label: `Season ${season}`,
}));
const specialCount = seasonMap.get(0) || 0;
if (specialCount > 0) {
seasonCounts.push({ season: 0, count: specialCount, label: 'Specials' });
}
---
<BaseLayout title="Episodes" description="Browse all episodes of The Computer Guru Show">
<section class="section">
<div class="container">
<div class="episodes-header">
<h1 class="episodes-header__title">Episodes</h1>
<span class="episodes-header__count">{sortedEpisodes.length} episodes</span>
</div>
<div class="episodes-search">
<div class="episodes-search__input-wrap">
<svg class="episodes-search__icon" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input
type="text"
id="episode-search"
class="episodes-search__input"
placeholder="Search episodes..."
autocomplete="off"
/>
<button
type="button"
id="search-clear"
class="episodes-search__clear"
aria-label="Clear search"
hidden
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</div>
<SeasonFilter seasonCounts={seasonCounts} totalCount={sortedEpisodes.length} />
<p id="results-count" class="results-count" hidden></p>
<p id="no-results" class="no-results" hidden>
No episodes match your search. Try different keywords or clear the filter.
</p>
<div class="grid grid--3" id="episodes-grid">
{sortedEpisodes.map((episode) => (
<EpisodeCard episode={episode} />
))}
</div>
</div>
</section>
<script is:inline>
(function () {
const searchInput = document.getElementById('episode-search');
const clearBtn = document.getElementById('search-clear');
const grid = document.getElementById('episodes-grid');
const cards = Array.from(grid.querySelectorAll('.episode-card'));
const filterTabs = document.querySelectorAll('.season-filter__tab');
const resultsCount = document.getElementById('results-count');
const noResults = document.getElementById('no-results');
let activeSeason = 'all';
let searchTerm = '';
function filterCards() {
let visibleCount = 0;
cards.forEach(function (card) {
const cardSeason = card.getAttribute('data-season');
const cardTitle = card.getAttribute('data-title') || '';
const cardDescription = card.getAttribute('data-description') || '';
const matchesSeason = activeSeason === 'all' || cardSeason === activeSeason;
const matchesSearch = !searchTerm ||
cardTitle.includes(searchTerm) ||
cardDescription.includes(searchTerm);
if (matchesSeason && matchesSearch) {
card.style.display = '';
visibleCount++;
} else {
card.style.display = 'none';
}
});
if (searchTerm || activeSeason !== 'all') {
resultsCount.textContent = visibleCount + ' episode' + (visibleCount !== 1 ? 's' : '') + ' found';
resultsCount.hidden = false;
} else {
resultsCount.hidden = true;
}
noResults.hidden = visibleCount > 0;
}
filterTabs.forEach(function (tab) {
tab.addEventListener('click', function () {
filterTabs.forEach(function (t) {
t.classList.remove('season-filter__tab--active');
});
tab.classList.add('season-filter__tab--active');
activeSeason = tab.getAttribute('data-season');
filterCards();
});
});
searchInput.addEventListener('input', function () {
searchTerm = searchInput.value.toLowerCase().trim();
clearBtn.hidden = searchTerm.length === 0;
filterCards();
});
clearBtn.addEventListener('click', function () {
searchInput.value = '';
searchTerm = '';
clearBtn.hidden = true;
filterCards();
searchInput.focus();
});
})();
</script>
</BaseLayout>
<style>
.episodes-header {
display: flex;
align-items: baseline;
gap: var(--space-4);
margin-bottom: var(--space-8);
}
.episodes-header__title {
font-size: var(--text-3xl);
}
.episodes-header__count {
font-size: var(--text-sm);
color: var(--color-text-muted);
font-weight: 500;
}
.episodes-search {
margin-bottom: var(--space-6);
}
.episodes-search__input-wrap {
position: relative;
max-width: 480px;
}
.episodes-search__icon {
position: absolute;
left: var(--space-4);
top: 50%;
transform: translateY(-50%);
color: var(--color-text-muted);
pointer-events: none;
}
.episodes-search__input {
width: 100%;
padding: var(--space-3) var(--space-4);
padding-left: calc(var(--space-4) + 18px + var(--space-3));
padding-right: calc(var(--space-4) + 16px + var(--space-3));
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-primary);
font-size: var(--text-base);
font-family: var(--font-sans);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.episodes-search__input::placeholder {
color: var(--color-text-muted);
}
.episodes-search__input:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-glow);
}
.episodes-search__clear {
position: absolute;
right: var(--space-3);
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
padding: var(--space-1);
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
transition: color var(--transition-fast);
}
.episodes-search__clear:hover {
color: var(--color-text-primary);
}
.results-count {
font-size: var(--text-sm);
color: var(--color-text-muted);
margin-bottom: var(--space-4);
}
.no-results {
text-align: center;
color: var(--color-text-muted);
padding: var(--space-16) var(--space-4);
font-size: var(--text-lg);
}
</style>