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:
@@ -0,0 +1,202 @@
|
||||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
|
||||
interface Props {
|
||||
episode: CollectionEntry<'episodes'>;
|
||||
}
|
||||
|
||||
const { episode } = Astro.props;
|
||||
const { title, season, episode: episodeNum, pubDate, duration, tags, audioUrl } = episode.data;
|
||||
|
||||
const formattedDate = new Date(pubDate).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
const seasonLabel = season === 0 ? 'Special' : `S${season}`;
|
||||
const episodeLabel = `${seasonLabel} E${String(episodeNum).padStart(2, '0')}`;
|
||||
|
||||
const description = episode.body
|
||||
? episode.body.replace(/\\?\*\\?\*\\?\*/g, '').replace(/\\\[.*?\\\]/g, '').replace(/\[.*?\]/g, '').trim()
|
||||
: '';
|
||||
---
|
||||
|
||||
<article
|
||||
class="episode-card card"
|
||||
data-season={String(season)}
|
||||
data-tags={tags.join(',')}
|
||||
data-title={title.toLowerCase()}
|
||||
data-description={description.toLowerCase()}
|
||||
>
|
||||
<div class="episode-card__header">
|
||||
<span class="badge">{episodeLabel}</span>
|
||||
<span class="episode-card__duration">{duration}</span>
|
||||
</div>
|
||||
|
||||
<a href={`/episodes/${episode.id}`} class="episode-card__title-link">
|
||||
<h3 class="episode-card__title">{title}</h3>
|
||||
</a>
|
||||
|
||||
<time class="episode-card__date" datetime={pubDate.toISOString()}>
|
||||
{formattedDate}
|
||||
</time>
|
||||
|
||||
{description && (
|
||||
<p class="episode-card__description">{description}</p>
|
||||
)}
|
||||
|
||||
<div class="episode-card__footer">
|
||||
{tags.length > 0 && (
|
||||
<div class="episode-card__tags">
|
||||
{tags.slice(0, 4).map((tag: string) => (
|
||||
<span class="episode-card__tag">{tag}</span>
|
||||
))}
|
||||
{tags.length > 4 && (
|
||||
<span class="episode-card__tag episode-card__tag--more">+{tags.length - 4}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
class="btn btn--primary episode-card__play"
|
||||
data-audio-url={audioUrl}
|
||||
data-episode-title={title}
|
||||
data-season={String(season)}
|
||||
data-episode-num={String(episodeNum)}
|
||||
aria-label={`Play ${title}`}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<polygon points="5 3 19 12 5 21 5 3"></polygon>
|
||||
</svg>
|
||||
Play
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.episode-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
border-left: 3px solid transparent;
|
||||
transition:
|
||||
transform var(--transition-base),
|
||||
box-shadow var(--transition-base),
|
||||
border-color var(--transition-base);
|
||||
}
|
||||
|
||||
.episode-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-left-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.episode-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.episode-card__duration {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.episode-card__title-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.episode-card__title-link:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.episode-card__title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.episode-card__date {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.episode-card__description {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.episode-card__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
margin-top: auto;
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.episode-card__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.episode-card__tag {
|
||||
font-size: 0.65rem;
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: 9999px;
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.episode-card__tag--more {
|
||||
background: var(--color-accent-glow);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.episode-card__play {
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.episode-card__play svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
document.querySelectorAll<HTMLButtonElement>('.episode-card__play').forEach((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>
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
interface SeasonCount {
|
||||
season: number;
|
||||
count: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
seasonCounts: SeasonCount[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
const { seasonCounts, totalCount } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="season-filter" id="season-filter">
|
||||
<div class="season-filter__scroll">
|
||||
<button
|
||||
class="season-filter__tab season-filter__tab--active"
|
||||
data-season="all"
|
||||
type="button"
|
||||
>
|
||||
All Episodes
|
||||
<span class="season-filter__count">{totalCount}</span>
|
||||
</button>
|
||||
|
||||
{seasonCounts.map(({ season, count, label }) => (
|
||||
<button
|
||||
class="season-filter__tab"
|
||||
data-season={String(season)}
|
||||
type="button"
|
||||
>
|
||||
{label}
|
||||
<span class="season-filter__count">{count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.season-filter {
|
||||
margin-bottom: var(--space-6);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.season-filter__scroll {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-border) transparent;
|
||||
padding-bottom: 1px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.season-filter__scroll::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.season-filter__scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.season-filter__scroll::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.season-filter__tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
color var(--transition-fast),
|
||||
border-color var(--transition-fast);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.season-filter__tab:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.season-filter__tab--active {
|
||||
color: var(--color-accent);
|
||||
border-bottom-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.season-filter__count {
|
||||
font-size: var(--text-xs);
|
||||
padding: 1px var(--space-2);
|
||||
border-radius: 9999px;
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.season-filter__tab--active .season-filter__count {
|
||||
background: var(--color-accent-glow);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,919 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface EpisodeInfo {
|
||||
audioUrl: string;
|
||||
title: string;
|
||||
season: number;
|
||||
episode: number;
|
||||
}
|
||||
|
||||
interface PlayerState extends EpisodeInfo {
|
||||
currentTime: number;
|
||||
}
|
||||
|
||||
interface PlayEpisodeEvent extends CustomEvent {
|
||||
detail: EpisodeInfo;
|
||||
}
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_KEY = 'persistent-player-state';
|
||||
const PLAYBACK_SPEEDS = [0.75, 1, 1.25, 1.5, 2] as const;
|
||||
const SKIP_SECONDS = 15;
|
||||
const SAVE_INTERVAL_MS = 5000;
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!isFinite(seconds) || seconds < 0) return '0:00';
|
||||
const hrs = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
const paddedSecs = secs.toString().padStart(2, '0');
|
||||
if (hrs > 0) {
|
||||
return `${hrs}:${mins.toString().padStart(2, '0')}:${paddedSecs}`;
|
||||
}
|
||||
return `${mins}:${paddedSecs}`;
|
||||
}
|
||||
|
||||
function loadState(): PlayerState | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as PlayerState;
|
||||
if (parsed.audioUrl && parsed.title != null) {
|
||||
return parsed;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveState(state: PlayerState): void {
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch {
|
||||
// sessionStorage may be unavailable or full; silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
function clearState(): void {
|
||||
try {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function buildEpisodeLabel(season: number, episode: number): string {
|
||||
if (season === 0) return 'Special';
|
||||
return `S${season}E${String(episode).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function PersistentPlayer() {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const progressBarRef = useRef<HTMLDivElement | null>(null);
|
||||
const saveTimerRef = useRef<number | null>(null);
|
||||
|
||||
const [episodeInfo, setEpisodeInfo] = useState<EpisodeInfo | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [buffered, setBuffered] = useState(0);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [speedIndex, setSpeedIndex] = useState(1); // default 1x
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isDraggingProgress, setIsDraggingProgress] = useState(false);
|
||||
|
||||
// ── Restore state on mount ──────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
const saved = loadState();
|
||||
if (saved) {
|
||||
setEpisodeInfo({
|
||||
audioUrl: saved.audioUrl,
|
||||
title: saved.title,
|
||||
season: saved.season,
|
||||
episode: saved.episode,
|
||||
});
|
||||
setCurrentTime(saved.currentTime);
|
||||
setIsVisible(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Listen for play-episode custom events ───────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
function handlePlayEpisode(e: Event) {
|
||||
const event = e as PlayEpisodeEvent;
|
||||
const { audioUrl, title, season, episode } = event.detail;
|
||||
if (!audioUrl) return;
|
||||
|
||||
setEpisodeInfo({ audioUrl, title, season, episode });
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
setBuffered(0);
|
||||
setIsVisible(true);
|
||||
|
||||
// Playback will start once the audio element loads the new source
|
||||
// (handled in the audioUrl effect below)
|
||||
}
|
||||
|
||||
document.addEventListener('play-episode', handlePlayEpisode);
|
||||
return () => document.removeEventListener('play-episode', handlePlayEpisode);
|
||||
}, []);
|
||||
|
||||
// ── Wire up audio element when episode changes ──────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || !episodeInfo) return;
|
||||
|
||||
// If source changed, load new source
|
||||
if (audio.src !== episodeInfo.audioUrl) {
|
||||
audio.src = episodeInfo.audioUrl;
|
||||
audio.load();
|
||||
}
|
||||
|
||||
// Restore saved position for restored state (non-zero currentTime means restored)
|
||||
if (currentTime > 0 && audio.readyState >= 1) {
|
||||
audio.currentTime = currentTime;
|
||||
}
|
||||
|
||||
// Auto-play if this is a fresh play-episode event (currentTime === 0)
|
||||
// For restored sessions, don't auto-play
|
||||
const isRestoredSession = currentTime > 0;
|
||||
if (!isRestoredSession) {
|
||||
const playOnReady = () => {
|
||||
audio.play().catch(() => {
|
||||
// Browser may block autoplay; user will click play manually
|
||||
});
|
||||
audio.removeEventListener('canplay', playOnReady);
|
||||
};
|
||||
if (audio.readyState >= 3) {
|
||||
audio.play().catch(() => {});
|
||||
} else {
|
||||
audio.addEventListener('canplay', playOnReady);
|
||||
return () => audio.removeEventListener('canplay', playOnReady);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [episodeInfo?.audioUrl]);
|
||||
|
||||
// ── Restore currentTime once audio metadata is loaded ───────────────────
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || !episodeInfo) return;
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
const saved = loadState();
|
||||
if (saved && saved.audioUrl === episodeInfo.audioUrl && saved.currentTime > 0) {
|
||||
audio.currentTime = saved.currentTime;
|
||||
setCurrentTime(saved.currentTime);
|
||||
}
|
||||
};
|
||||
|
||||
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
return () => audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
}, [episodeInfo]);
|
||||
|
||||
// ── Audio event handlers ────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
const onPlay = () => setIsPlaying(true);
|
||||
const onPause = () => setIsPlaying(false);
|
||||
const onEnded = () => {
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
};
|
||||
const onTimeUpdate = () => {
|
||||
if (!isDraggingProgress) {
|
||||
setCurrentTime(audio.currentTime);
|
||||
}
|
||||
};
|
||||
const onDurationChange = () => {
|
||||
if (isFinite(audio.duration)) {
|
||||
setDuration(audio.duration);
|
||||
}
|
||||
};
|
||||
const onProgress = () => {
|
||||
if (audio.buffered.length > 0) {
|
||||
setBuffered(audio.buffered.end(audio.buffered.length - 1));
|
||||
}
|
||||
};
|
||||
|
||||
audio.addEventListener('play', onPlay);
|
||||
audio.addEventListener('pause', onPause);
|
||||
audio.addEventListener('ended', onEnded);
|
||||
audio.addEventListener('timeupdate', onTimeUpdate);
|
||||
audio.addEventListener('durationchange', onDurationChange);
|
||||
audio.addEventListener('progress', onProgress);
|
||||
|
||||
return () => {
|
||||
audio.removeEventListener('play', onPlay);
|
||||
audio.removeEventListener('pause', onPause);
|
||||
audio.removeEventListener('ended', onEnded);
|
||||
audio.removeEventListener('timeupdate', onTimeUpdate);
|
||||
audio.removeEventListener('durationchange', onDurationChange);
|
||||
audio.removeEventListener('progress', onProgress);
|
||||
};
|
||||
}, [isDraggingProgress]);
|
||||
|
||||
// ── Periodic state persistence ──────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (!episodeInfo || !isPlaying) {
|
||||
if (saveTimerRef.current != null) {
|
||||
window.clearInterval(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
saveTimerRef.current = window.setInterval(() => {
|
||||
const audio = audioRef.current;
|
||||
if (audio && episodeInfo) {
|
||||
saveState({
|
||||
...episodeInfo,
|
||||
currentTime: audio.currentTime,
|
||||
});
|
||||
}
|
||||
}, SAVE_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
if (saveTimerRef.current != null) {
|
||||
window.clearInterval(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [episodeInfo, isPlaying]);
|
||||
|
||||
// ── Save state on pause / before unload ─────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
function handleBeforeUnload() {
|
||||
const audio = audioRef.current;
|
||||
if (audio && episodeInfo) {
|
||||
saveState({ ...episodeInfo, currentTime: audio.currentTime });
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
}, [episodeInfo]);
|
||||
|
||||
// ── Controls ────────────────────────────────────────────────────────────
|
||||
|
||||
const togglePlayPause = useCallback(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
if (audio.paused) {
|
||||
audio.play().catch(() => {});
|
||||
} else {
|
||||
audio.pause();
|
||||
if (episodeInfo) {
|
||||
saveState({ ...episodeInfo, currentTime: audio.currentTime });
|
||||
}
|
||||
}
|
||||
}, [episodeInfo]);
|
||||
|
||||
const skipBack = useCallback(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
audio.currentTime = Math.max(0, audio.currentTime - SKIP_SECONDS);
|
||||
}, []);
|
||||
|
||||
const skipForward = useCallback(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
audio.currentTime = Math.min(audio.duration || 0, audio.currentTime + SKIP_SECONDS);
|
||||
}, []);
|
||||
|
||||
const cycleSpeed = useCallback(() => {
|
||||
setSpeedIndex((prev) => {
|
||||
const next = (prev + 1) % PLAYBACK_SPEEDS.length;
|
||||
const audio = audioRef.current;
|
||||
if (audio) {
|
||||
audio.playbackRate = PLAYBACK_SPEEDS[next];
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleVolumeChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = parseFloat(e.target.value);
|
||||
setVolume(val);
|
||||
const audio = audioRef.current;
|
||||
if (audio) {
|
||||
audio.volume = val;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleProgressClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const bar = progressBarRef.current;
|
||||
const audio = audioRef.current;
|
||||
if (!bar || !audio || !duration) return;
|
||||
|
||||
const rect = bar.getBoundingClientRect();
|
||||
const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const newTime = fraction * duration;
|
||||
audio.currentTime = newTime;
|
||||
setCurrentTime(newTime);
|
||||
},
|
||||
[duration],
|
||||
);
|
||||
|
||||
const handleProgressMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
setIsDraggingProgress(true);
|
||||
handleProgressClick(e);
|
||||
|
||||
const handleMouseMove = (ev: MouseEvent) => {
|
||||
const bar = progressBarRef.current;
|
||||
const audio = audioRef.current;
|
||||
if (!bar || !audio || !duration) return;
|
||||
const rect = bar.getBoundingClientRect();
|
||||
const fraction = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width));
|
||||
const newTime = fraction * duration;
|
||||
audio.currentTime = newTime;
|
||||
setCurrentTime(newTime);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDraggingProgress(false);
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
},
|
||||
[duration, handleProgressClick],
|
||||
);
|
||||
|
||||
const closePlayer = useCallback(() => {
|
||||
const audio = audioRef.current;
|
||||
if (audio) {
|
||||
audio.pause();
|
||||
audio.removeAttribute('src');
|
||||
audio.load();
|
||||
}
|
||||
setIsPlaying(false);
|
||||
setEpisodeInfo(null);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
setBuffered(0);
|
||||
setIsVisible(false);
|
||||
clearState();
|
||||
}, []);
|
||||
|
||||
// ── Keyboard shortcuts ──────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (!episodeInfo) return;
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
// Only respond when not typing in an input
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'SELECT' ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
togglePlayPause();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
skipBack();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
skipForward();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [episodeInfo, togglePlayPause, skipBack, skipForward]);
|
||||
|
||||
// ── Derived values ──────────────────────────────────────────────────────
|
||||
|
||||
const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
const bufferedPercent = duration > 0 ? (buffered / duration) * 100 : 0;
|
||||
const currentSpeed = PLAYBACK_SPEEDS[speedIndex];
|
||||
const episodeLabel = episodeInfo ? buildEpisodeLabel(episodeInfo.season, episodeInfo.episode) : '';
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hidden audio element - always mounted so it survives re-renders */}
|
||||
<audio ref={audioRef} preload="metadata" />
|
||||
|
||||
<div
|
||||
className="persistent-player"
|
||||
style={{
|
||||
transform: isVisible ? 'translateY(0)' : 'translateY(100%)',
|
||||
}}
|
||||
role="region"
|
||||
aria-label="Audio player"
|
||||
>
|
||||
{/* Progress bar - full width across top of player */}
|
||||
<div
|
||||
className="persistent-player__progress-bar"
|
||||
ref={progressBarRef}
|
||||
onClick={handleProgressClick}
|
||||
onMouseDown={handleProgressMouseDown}
|
||||
role="slider"
|
||||
aria-label="Playback progress"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={duration}
|
||||
aria-valuenow={currentTime}
|
||||
aria-valuetext={`${formatTime(currentTime)} of ${formatTime(duration)}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div
|
||||
className="persistent-player__progress-buffered"
|
||||
style={{ width: `${bufferedPercent}%` }}
|
||||
/>
|
||||
<div
|
||||
className="persistent-player__progress-fill"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
<div
|
||||
className="persistent-player__progress-handle"
|
||||
style={{ left: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="persistent-player__content">
|
||||
{/* Left: Episode info */}
|
||||
<div className="persistent-player__info">
|
||||
{episodeLabel && (
|
||||
<span className="persistent-player__badge">{episodeLabel}</span>
|
||||
)}
|
||||
<span className="persistent-player__title" title={episodeInfo?.title ?? ''}>
|
||||
{episodeInfo?.title ?? ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Center: Playback controls */}
|
||||
<div className="persistent-player__controls">
|
||||
<button
|
||||
className="persistent-player__btn persistent-player__btn--skip"
|
||||
onClick={skipBack}
|
||||
aria-label="Skip back 15 seconds"
|
||||
title="Skip back 15 seconds"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="1 4 1 10 7 10" />
|
||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
|
||||
<text x="12" y="16" textAnchor="middle" fill="currentColor" stroke="none" fontSize="8" fontWeight="700">15</text>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="persistent-player__btn persistent-player__btn--play"
|
||||
onClick={togglePlayPause}
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
title={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="6" y="4" width="4" height="16" rx="1" />
|
||||
<rect x="14" y="4" width="4" height="16" rx="1" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||
<polygon points="6 3 20 12 6 21 6 3" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="persistent-player__btn persistent-player__btn--skip"
|
||||
onClick={skipForward}
|
||||
aria-label="Skip forward 15 seconds"
|
||||
title="Skip forward 15 seconds"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="23 4 23 10 17 10" />
|
||||
<path d="M20.49 15a9 9 0 1 1-2.13-9.36L23 10" />
|
||||
<text x="12" y="16" textAnchor="middle" fill="currentColor" stroke="none" fontSize="8" fontWeight="700">15</text>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Time display */}
|
||||
<div className="persistent-player__time">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span className="persistent-player__time-separator">/</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
|
||||
{/* Right: Volume, speed, close */}
|
||||
<div className="persistent-player__right">
|
||||
<div className="persistent-player__volume">
|
||||
<button
|
||||
className="persistent-player__btn persistent-player__btn--icon"
|
||||
onClick={() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
if (audio.volume > 0) {
|
||||
audio.volume = 0;
|
||||
setVolume(0);
|
||||
} else {
|
||||
audio.volume = 1;
|
||||
setVolume(1);
|
||||
}
|
||||
}}
|
||||
aria-label={volume === 0 ? 'Unmute' : 'Mute'}
|
||||
title={volume === 0 ? 'Unmute' : 'Mute'}
|
||||
>
|
||||
{volume === 0 ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
<line x1="23" y1="9" x2="17" y2="15" />
|
||||
<line x1="17" y1="9" x2="23" y2="15" />
|
||||
</svg>
|
||||
) : volume < 0.5 ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
className="persistent-player__volume-slider"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={volume}
|
||||
onChange={handleVolumeChange}
|
||||
aria-label="Volume"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="persistent-player__btn persistent-player__btn--speed"
|
||||
onClick={cycleSpeed}
|
||||
aria-label={`Playback speed: ${currentSpeed}x`}
|
||||
title={`Playback speed: ${currentSpeed}x`}
|
||||
>
|
||||
{currentSpeed}x
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="persistent-player__btn persistent-player__btn--close"
|
||||
onClick={closePlayer}
|
||||
aria-label="Close player"
|
||||
title="Close player"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.persistent-player {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
height: 72px;
|
||||
background: hsl(220 20% 6% / 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-top: 1px solid hsl(200 85% 55% / 0.15);
|
||||
transition: transform 350ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
[data-theme="light"] .persistent-player {
|
||||
background: hsl(220 20% 97% / 0.92);
|
||||
border-top-color: hsl(200 85% 42% / 0.2);
|
||||
}
|
||||
|
||||
/* ── Progress bar ─────────────────────────────────────── */
|
||||
|
||||
.persistent-player__progress-bar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: hsl(220 12% 24% / 0.5);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.persistent-player__progress-bar:hover {
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.persistent-player__progress-bar:hover .persistent-player__progress-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-theme="light"] .persistent-player__progress-bar {
|
||||
background: hsl(220 12% 85% / 0.5);
|
||||
}
|
||||
|
||||
.persistent-player__progress-buffered {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: hsl(220 8% 48% / 0.3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.persistent-player__progress-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: var(--color-accent, hsl(200 85% 55%));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.persistent-player__progress-handle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent, hsl(200 85% 55%));
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Content layout ───────────────────────────────────── */
|
||||
|
||||
.persistent-player__content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
padding: 0 16px;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Episode info ─────────────────────────────────────── */
|
||||
|
||||
.persistent-player__info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.persistent-player__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
background: hsl(200 85% 55% / 0.2);
|
||||
color: var(--color-accent, hsl(200 85% 55%));
|
||||
border: 1px solid hsl(200 85% 55% / 0.3);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.persistent-player__title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary, hsl(220 10% 92%));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Controls ─────────────────────────────────────────── */
|
||||
|
||||
.persistent-player__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.persistent-player__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary, hsl(220 8% 68%));
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
transition: color 150ms ease, background 150ms ease;
|
||||
}
|
||||
|
||||
.persistent-player__btn:hover {
|
||||
color: var(--color-text-primary, hsl(220 10% 92%));
|
||||
background: hsl(220 16% 16% / 0.6);
|
||||
}
|
||||
|
||||
[data-theme="light"] .persistent-player__btn:hover {
|
||||
background: hsl(220 16% 92% / 0.6);
|
||||
}
|
||||
|
||||
.persistent-player__btn--play {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
color: hsl(220 20% 8%);
|
||||
background: var(--color-accent, hsl(200 85% 55%));
|
||||
}
|
||||
|
||||
.persistent-player__btn--play:hover {
|
||||
color: hsl(220 20% 8%);
|
||||
background: var(--color-accent-hover, hsl(200 85% 65%));
|
||||
box-shadow: 0 0 16px hsl(200 85% 55% / 0.3);
|
||||
}
|
||||
|
||||
.persistent-player__btn--speed {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
min-width: 36px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.persistent-player__btn--close {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.persistent-player__btn--close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Time display ─────────────────────────────────────── */
|
||||
|
||||
.persistent-player__time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-family: var(--font-mono, monospace);
|
||||
color: var(--color-text-muted, hsl(220 6% 48%));
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.persistent-player__time-separator {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ── Volume ───────────────────────────────────────────── */
|
||||
|
||||
.persistent-player__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.persistent-player__volume {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.persistent-player__volume-slider {
|
||||
width: 72px;
|
||||
height: 4px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: hsl(220 12% 24%);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-theme="light"] .persistent-player__volume-slider {
|
||||
background: hsl(220 12% 80%);
|
||||
}
|
||||
|
||||
.persistent-player__volume-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent, hsl(200 85% 55%));
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.persistent-player__volume-slider::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent, hsl(200 85% 55%));
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Mobile responsive ────────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.persistent-player {
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.persistent-player__content {
|
||||
padding: 0 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.persistent-player__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.persistent-player__title {
|
||||
font-size: 0.8rem;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.persistent-player__volume {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.persistent-player__btn--speed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.persistent-player__time {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.persistent-player__btn--play {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.persistent-player__btn--play svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.persistent-player__btn--skip svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.persistent-player__btn--skip {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.persistent-player__time {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.persistent-player__title {
|
||||
max-width: 140px;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
---
|
||||
---
|
||||
|
||||
<section class="section about-preview fade-in">
|
||||
<div class="container">
|
||||
<div class="about-grid">
|
||||
<div class="about-text">
|
||||
<span class="badge">About the Show</span>
|
||||
<h2 class="about-title">Meet Your Host</h2>
|
||||
<p class="about-lead">
|
||||
Mike Swanson has been breaking down technology for everyday people since 2014.
|
||||
As a Tucson-based tech professional and broadcaster, he brings decades of
|
||||
hands-on experience to every episode.
|
||||
</p>
|
||||
<blockquote class="about-quote">
|
||||
"Technology should empower you, not intimidate you. That is what this show is all about."
|
||||
</blockquote>
|
||||
<p class="about-body">
|
||||
From cybersecurity and privacy to the latest gadgets and internet culture,
|
||||
The Computer Guru Show covers it all -- with a healthy dose of humor, real-world
|
||||
advice, and honest opinions. No corporate sponsors telling us what to say.
|
||||
Just straight talk about tech.
|
||||
</p>
|
||||
<a href="/about" class="btn btn--primary">
|
||||
Learn More
|
||||
<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="5" y1="12" x2="19" y2="12"></line>
|
||||
<polyline points="12 5 19 12 12 19"></polyline>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="about-visual">
|
||||
<div class="about-image-placeholder">
|
||||
<div class="about-image-inner">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
|
||||
<line x1="12" y1="19" x2="12" y2="23"></line>
|
||||
<line x1="8" y1="23" x2="16" y2="23"></line>
|
||||
</svg>
|
||||
<span class="about-image-text">The Computer Guru</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="about-visual-accent"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.about-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-12);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.about-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.about-title {
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.about-lead {
|
||||
font-size: var(--text-lg);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.about-quote {
|
||||
font-size: var(--text-lg);
|
||||
font-style: italic;
|
||||
color: var(--color-accent);
|
||||
padding-left: var(--space-6);
|
||||
border-left: 3px solid var(--color-accent);
|
||||
margin-block: var(--space-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.about-body {
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.about-text .btn {
|
||||
align-self: flex-start;
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
/* Visual / placeholder area */
|
||||
.about-visual {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.about-image-placeholder {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 5;
|
||||
max-width: 400px;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.about-image-placeholder::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(135deg, hsl(200 85% 55% / 0.05), transparent 50%),
|
||||
linear-gradient(315deg, hsl(270 60% 50% / 0.03), transparent 50%);
|
||||
}
|
||||
|
||||
.about-image-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
color: var(--color-text-muted);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.about-image-text {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.about-visual-accent {
|
||||
position: absolute;
|
||||
width: 120%;
|
||||
height: 120%;
|
||||
top: -10%;
|
||||
left: -10%;
|
||||
border: 1px solid hsl(200 85% 55% / 0.06);
|
||||
border-radius: var(--radius-lg);
|
||||
transform: rotate(3deg);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.about-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.about-visual {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.about-image-placeholder {
|
||||
max-width: 280px;
|
||||
margin-inline: auto;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
const allPosts = await getCollection('blog');
|
||||
const publishedPosts = allPosts
|
||||
.filter((post) => !post.data.draft)
|
||||
.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime())
|
||||
.slice(0, 3);
|
||||
|
||||
const isSingle = publishedPosts.length === 1;
|
||||
---
|
||||
|
||||
{publishedPosts.length > 0 && (
|
||||
<section class="section blog-highlights fade-in">
|
||||
<div class="container">
|
||||
<div class="section-header">
|
||||
<h2 class="section__title">From the Blog</h2>
|
||||
<a href="/blog" class="section-header__link">
|
||||
All Posts
|
||||
<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">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class:list={['blog-grid', { 'blog-grid--single': isSingle }]}>
|
||||
{publishedPosts.map((post) => {
|
||||
const formattedDate = new Date(post.data.pubDate).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
return (
|
||||
<article class="blog-card card">
|
||||
<div class="blog-card__meta">
|
||||
<time datetime={post.data.pubDate.toISOString()}>{formattedDate}</time>
|
||||
{post.data.tags.length > 0 && (
|
||||
<div class="blog-card__tags">
|
||||
{post.data.tags.slice(0, 2).map((tag: string) => (
|
||||
<span class="blog-card__tag">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<a href={`/blog/${post.id}`} class="blog-card__title-link">
|
||||
<h3 class="blog-card__title">{post.data.title}</h3>
|
||||
</a>
|
||||
|
||||
<p class="blog-card__description">{post.data.description}</p>
|
||||
|
||||
<div class="blog-card__footer">
|
||||
<span class="blog-card__author">By {post.data.author}</span>
|
||||
<a href={`/blog/${post.id}`} class="blog-card__read-more">
|
||||
Read More
|
||||
<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">
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
<polyline points="12 5 19 12 12 19"></polyline>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<style>
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.section-header__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-accent);
|
||||
transition: gap var(--transition-fast);
|
||||
}
|
||||
|
||||
.section-header__link:hover {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.blog-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.blog-grid--single {
|
||||
max-width: 600px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.blog-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.blog-card__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.blog-card__meta time {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.blog-card__tags {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.blog-card__tag {
|
||||
font-size: 0.65rem;
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: 9999px;
|
||||
background: var(--color-accent-glow);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.blog-card__title-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.blog-card__title-link:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.blog-card__title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.blog-card__description {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.blog-card__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: auto;
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.blog-card__author {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.blog-card__read-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-accent);
|
||||
transition: gap var(--transition-fast);
|
||||
}
|
||||
|
||||
.blog-card__read-more:hover {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,219 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
const allEpisodes = await getCollection('episodes');
|
||||
|
||||
const classicEpisodes = allEpisodes.filter((ep) => ep.data.classic);
|
||||
|
||||
// If no episodes marked as classic, pick a curated selection:
|
||||
// the first episode of each season for variety
|
||||
const fallbackEpisodes = (() => {
|
||||
const seasonFirsts = new Map<number, typeof allEpisodes[0]>();
|
||||
for (const ep of allEpisodes) {
|
||||
const s = ep.data.season;
|
||||
if (s === 0) continue;
|
||||
if (!seasonFirsts.has(s) || ep.data.episode < seasonFirsts.get(s)!.data.episode) {
|
||||
seasonFirsts.set(s, ep);
|
||||
}
|
||||
}
|
||||
return Array.from(seasonFirsts.values())
|
||||
.sort((a, b) => b.data.season - a.data.season)
|
||||
.slice(0, 5);
|
||||
})();
|
||||
|
||||
const displayEpisodes = classicEpisodes.length > 0 ? classicEpisodes : fallbackEpisodes;
|
||||
|
||||
const hasClassics = classicEpisodes.length > 0;
|
||||
---
|
||||
|
||||
{displayEpisodes.length > 0 && (
|
||||
<section class="section classics fade-in">
|
||||
<div class="container">
|
||||
<div class="section-header">
|
||||
<h2 class="section__title">
|
||||
{hasClassics ? 'Fan Favorites' : 'From the Archive'}
|
||||
</h2>
|
||||
<a href="/episodes" class="section-header__link">
|
||||
Browse All Episodes
|
||||
<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">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="classics-scroll">
|
||||
<div class="classics-track">
|
||||
{displayEpisodes.map((episode) => {
|
||||
const { title, season, episode: episodeNum, pubDate, duration } = episode.data;
|
||||
const seasonLabel = season === 0 ? 'Special' : `S${season}`;
|
||||
const episodeLabel = `${seasonLabel} E${String(episodeNum).padStart(2, '0')}`;
|
||||
const formattedDate = new Date(pubDate).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
const description = episode.body
|
||||
? episode.body.replace(/\\?\*\\?\*\\?\*/g, '').replace(/\\\[.*?\\\]/g, '').replace(/\[.*?\]/g, '').trim()
|
||||
: '';
|
||||
|
||||
return (
|
||||
<a href={`/episodes/${episode.id}`} class="classic-card">
|
||||
{hasClassics && <span class="classic-card__badge">Classic</span>}
|
||||
{!hasClassics && <span class="classic-card__badge classic-card__badge--season">Season {season}</span>}
|
||||
<div class="classic-card__meta">
|
||||
<span class="badge">{episodeLabel}</span>
|
||||
<span class="classic-card__duration">{duration}</span>
|
||||
</div>
|
||||
<h3 class="classic-card__title">{title}</h3>
|
||||
{description && (
|
||||
<p class="classic-card__desc">{description.slice(0, 100)}{description.length > 100 ? '...' : ''}</p>
|
||||
)}
|
||||
<time class="classic-card__date" datetime={pubDate.toISOString()}>{formattedDate}</time>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<style>
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.section-header__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-accent);
|
||||
transition: gap var(--transition-fast);
|
||||
}
|
||||
|
||||
.section-header__link:hover {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.classics-scroll {
|
||||
overflow-x: auto;
|
||||
margin-inline: calc(-1 * var(--space-6));
|
||||
padding-inline: var(--space-6);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-border) transparent;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.classics-scroll::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.classics-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.classics-scroll::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.classics-track {
|
||||
display: flex;
|
||||
gap: var(--space-6);
|
||||
padding-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.classic-card {
|
||||
flex: 0 0 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-6);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition:
|
||||
transform var(--transition-base),
|
||||
box-shadow var(--transition-base),
|
||||
border-color var(--transition-base);
|
||||
}
|
||||
|
||||
.classic-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-color: var(--color-accent);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.classic-card__badge {
|
||||
align-self: flex-start;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
background: hsl(40 90% 55% / 0.15);
|
||||
color: var(--color-warning);
|
||||
border: 1px solid hsl(40 90% 55% / 0.25);
|
||||
}
|
||||
|
||||
.classic-card__badge--season {
|
||||
background: var(--color-accent-glow);
|
||||
color: var(--color-accent);
|
||||
border-color: hsl(200 85% 55% / 0.25);
|
||||
}
|
||||
|
||||
.classic-card__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.classic-card__duration {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.classic-card__title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: var(--color-text-primary);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.classic-card__desc {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.classic-card__date {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.classic-card {
|
||||
flex: 0 0 240px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,295 @@
|
||||
---
|
||||
---
|
||||
|
||||
<section class="hero">
|
||||
<div class="hero-bg-effects">
|
||||
<div class="hero-glow hero-glow--primary"></div>
|
||||
<div class="hero-glow hero-glow--secondary"></div>
|
||||
<div class="hero-ring"></div>
|
||||
</div>
|
||||
|
||||
<div class="hero-content container">
|
||||
<span class="badge hero-badge">Returning 2026</span>
|
||||
<h1 class="hero-title">
|
||||
<span class="hero-title__line hero-title__line--1">The Computer</span>
|
||||
<span class="hero-title__line hero-title__line--2">Guru Show</span>
|
||||
</h1>
|
||||
<p class="hero-tagline">Technology: Fun and Simple</p>
|
||||
<p class="hero-description">
|
||||
Your source for making sense of the tech world without the jargon.
|
||||
Hosted by Mike Swanson from Tucson, Arizona -- cutting through the noise
|
||||
so you can enjoy technology the way it was meant to be.
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<a href="/episodes" class="btn btn--primary btn--lg">
|
||||
<svg 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="12" cy="12" r="10"></circle>
|
||||
<polygon points="10 8 16 12 10 16 10 8" fill="currentColor" stroke="none"></polygon>
|
||||
</svg>
|
||||
Browse Episodes
|
||||
</a>
|
||||
<a href="/subscribe" class="btn btn--ghost btn--lg">Subscribe</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-scroll-hint" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="7 13 12 18 17 13"></polyline>
|
||||
<polyline points="7 6 12 11 17 6"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
min-height: calc(100vh - 64px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(135deg, hsl(220 30% 6% / 0.95), hsl(200 40% 8% / 0.9)),
|
||||
radial-gradient(ellipse at 20% 50%, hsl(200 85% 55% / 0.12), transparent 50%),
|
||||
radial-gradient(ellipse at 80% 20%, hsl(270 60% 50% / 0.08), transparent 50%);
|
||||
}
|
||||
|
||||
[data-theme="light"] .hero {
|
||||
background:
|
||||
linear-gradient(135deg, hsl(220 20% 95% / 0.95), hsl(200 30% 92% / 0.9)),
|
||||
radial-gradient(ellipse at 20% 50%, hsl(200 85% 55% / 0.08), transparent 50%),
|
||||
radial-gradient(ellipse at 80% 20%, hsl(270 60% 50% / 0.05), transparent 50%);
|
||||
}
|
||||
|
||||
/* Background effects */
|
||||
.hero-bg-effects {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.hero-glow {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.hero-glow--primary {
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
top: -200px;
|
||||
right: -100px;
|
||||
background: radial-gradient(circle, hsl(200 85% 55% / 0.15), transparent 70%);
|
||||
animation: glowDrift 8s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.hero-glow--secondary {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
bottom: -100px;
|
||||
left: -50px;
|
||||
background: radial-gradient(circle, hsl(270 60% 50% / 0.1), transparent 70%);
|
||||
animation: glowDrift 10s ease-in-out infinite alternate-reverse;
|
||||
}
|
||||
|
||||
.hero-ring {
|
||||
position: absolute;
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
top: 50%;
|
||||
right: 5%;
|
||||
transform: translate(0, -50%);
|
||||
border: 1px solid hsl(200 85% 55% / 0.08);
|
||||
border-radius: 50%;
|
||||
animation: ringPulse 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.hero-ring::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 40px;
|
||||
border: 1px solid hsl(200 85% 55% / 0.06);
|
||||
border-radius: 50%;
|
||||
animation: ringPulse 4s ease-in-out infinite 0.5s;
|
||||
}
|
||||
|
||||
.hero-ring::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 80px;
|
||||
border: 1px solid hsl(200 85% 55% / 0.04);
|
||||
border-radius: 50%;
|
||||
animation: ringPulse 4s ease-in-out infinite 1s;
|
||||
}
|
||||
|
||||
[data-theme="light"] .hero-glow--primary {
|
||||
background: radial-gradient(circle, hsl(200 85% 55% / 0.08), transparent 70%);
|
||||
}
|
||||
|
||||
[data-theme="light"] .hero-glow--secondary {
|
||||
background: radial-gradient(circle, hsl(270 60% 50% / 0.06), transparent 70%);
|
||||
}
|
||||
|
||||
[data-theme="light"] .hero-ring {
|
||||
border-color: hsl(200 85% 55% / 0.12);
|
||||
}
|
||||
|
||||
[data-theme="light"] .hero-ring::before {
|
||||
border-color: hsl(200 85% 55% / 0.08);
|
||||
}
|
||||
|
||||
[data-theme="light"] .hero-ring::after {
|
||||
border-color: hsl(200 85% 55% / 0.05);
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.hero-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
animation: fadeSlideIn 0.6s ease-out both;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
margin-top: var(--space-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.hero-title__line {
|
||||
display: block;
|
||||
font-size: clamp(2.5rem, 6vw, 5rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.05;
|
||||
background: linear-gradient(135deg, var(--color-text-primary) 30%, var(--color-accent) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.hero-title__line--1 {
|
||||
animation: fadeSlideIn 0.6s ease-out 0.15s both;
|
||||
}
|
||||
|
||||
.hero-title__line--2 {
|
||||
animation: fadeSlideIn 0.6s ease-out 0.3s both;
|
||||
background: linear-gradient(135deg, var(--color-accent) 0%, hsl(220 70% 65%) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.hero-tagline {
|
||||
font-size: var(--text-xl);
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
margin-top: var(--space-4);
|
||||
animation: fadeSlideIn 0.6s ease-out 0.45s both;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.hero-description {
|
||||
font-size: var(--text-lg);
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: var(--space-4);
|
||||
line-height: 1.7;
|
||||
max-width: 560px;
|
||||
animation: fadeSlideIn 0.6s ease-out 0.6s both;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
margin-top: var(--space-8);
|
||||
flex-wrap: wrap;
|
||||
animation: fadeSlideIn 0.6s ease-out 0.75s both;
|
||||
}
|
||||
|
||||
.btn--lg {
|
||||
padding: var(--space-4) var(--space-8);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
/* Scroll hint */
|
||||
.hero-scroll-hint {
|
||||
position: absolute;
|
||||
bottom: var(--space-8);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: var(--color-text-muted);
|
||||
animation: bounceDown 2s ease-in-out infinite;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Keyframes */
|
||||
@keyframes fadeSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glowDrift {
|
||||
from {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
to {
|
||||
transform: translate(30px, -20px) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ringPulse {
|
||||
0%, 100% {
|
||||
opacity: 0.3;
|
||||
transform: translate(0, -50%) scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
transform: translate(0, -50%) scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounceDown {
|
||||
0%, 100% {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
transform: translateX(-50%) translateY(8px);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero {
|
||||
min-height: auto;
|
||||
padding-block: var(--space-16) var(--space-24);
|
||||
}
|
||||
|
||||
.hero-ring {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
right: -80px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.hero-scroll-hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.hero-title__line {
|
||||
font-size: clamp(2rem, 10vw, 3rem);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import EpisodeCard from '../episodes/EpisodeCard.astro';
|
||||
|
||||
const allEpisodes = await getCollection('episodes');
|
||||
const latestEpisodes = allEpisodes
|
||||
.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime())
|
||||
.slice(0, 6);
|
||||
---
|
||||
|
||||
<section class="section latest-episodes fade-in">
|
||||
<div class="container">
|
||||
<div class="section-header">
|
||||
<h2 class="section__title">Latest Episodes</h2>
|
||||
<a href="/episodes" class="section-header__link">
|
||||
View All
|
||||
<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">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{latestEpisodes.length > 0 ? (
|
||||
<div class="grid grid--3">
|
||||
{latestEpisodes.map((episode) => (
|
||||
<EpisodeCard episode={episode} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div class="empty-state">
|
||||
<p>New episodes coming soon. Stay tuned for the return of The Computer Guru Show.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.section-header__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-accent);
|
||||
transition: gap var(--transition-fast);
|
||||
}
|
||||
|
||||
.section-header__link:hover {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--space-16) var(--space-8);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-lg);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
const allEpisodes = await getCollection('episodes');
|
||||
const totalEpisodes = allEpisodes.length;
|
||||
|
||||
const seasons = new Set(allEpisodes.map((ep) => ep.data.season).filter((s) => s > 0));
|
||||
const totalSeasons = seasons.size;
|
||||
|
||||
const stats = [
|
||||
{ value: `${totalEpisodes}+`, label: 'Episodes', sublabel: 'and counting' },
|
||||
{ value: String(totalSeasons), label: 'Seasons', sublabel: 'on the air' },
|
||||
{ value: '2014', label: 'Since', sublabel: 'year one' },
|
||||
{ value: 'Tucson', label: 'Arizona', sublabel: 'home base' },
|
||||
];
|
||||
---
|
||||
|
||||
<section class="section stats fade-in">
|
||||
<div class="container">
|
||||
<div class="stats-grid">
|
||||
{stats.map((stat) => (
|
||||
<div class="stat-card">
|
||||
<span class="stat-card__value">{stat.value}</span>
|
||||
<span class="stat-card__label">{stat.label}</span>
|
||||
<span class="stat-card__sublabel">{stat.sublabel}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.stats {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.stats::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse at 50% 50%, hsl(200 85% 55% / 0.04), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: var(--space-8) var(--space-6);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
transform var(--transition-base),
|
||||
box-shadow var(--transition-base),
|
||||
border-color var(--transition-base);
|
||||
}
|
||||
|
||||
.stat-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background: var(--color-accent);
|
||||
transition: width var(--transition-base);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow:
|
||||
var(--shadow-md),
|
||||
0 0 30px hsl(200 85% 55% / 0.08);
|
||||
border-color: hsl(200 85% 55% / 0.3);
|
||||
}
|
||||
|
||||
.stat-card:hover::after {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.stat-card__value {
|
||||
font-size: clamp(var(--text-2xl), 4vw, var(--text-4xl));
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
background: linear-gradient(135deg, var(--color-text-primary), var(--color-accent));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.stat-card__label {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.stat-card__sublabel {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,153 @@
|
||||
---
|
||||
import { platforms } from '../../data/platforms';
|
||||
|
||||
const platformIcons: Record<string, string> = {
|
||||
apple: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/></svg>`,
|
||||
spotify: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/></svg>`,
|
||||
google: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.372 0 0 5.373 0 12s5.372 12 12 12c6.627 0 12-5.373 12-12S18.627 0 12 0zm-.5 4.5h1v3.258l2.82-1.628.5.866-2.82 1.628 2.82 1.628-.5.866L12.5 9.49V12.5h-1V9.49l-2.82 1.628-.5-.866 2.82-1.628-2.82-1.628.5-.866L11.5 7.758V4.5z"/></svg>`,
|
||||
overcast: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="3"/><line x1="12" y1="5" x2="12" y2="7" stroke="currentColor" stroke-width="2"/><line x1="12" y1="17" x2="12" y2="19" stroke="currentColor" stroke-width="2"/><line x1="5" y1="12" x2="7" y2="12" stroke="currentColor" stroke-width="2"/><line x1="17" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2"/></svg>`,
|
||||
pocketcasts: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.372 0 0 5.372 0 12s5.372 12 12 12 12-5.372 12-12S18.628 0 12 0zm0 3.6c4.636 0 8.4 3.764 8.4 8.4h-2.4c0-3.312-2.688-6-6-6s-6 2.688-6 6 2.688 6 6 6v2.4c-4.636 0-8.4-3.764-8.4-8.4S7.364 3.6 12 3.6zm0 4.8a3.6 3.6 0 110 7.2 3.6 3.6 0 010-7.2z"/></svg>`,
|
||||
rss: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" 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" fill="currentColor"></circle></svg>`,
|
||||
};
|
||||
---
|
||||
|
||||
<section class="subscribe-cta fade-in">
|
||||
<div class="subscribe-cta__bg"></div>
|
||||
<div class="container subscribe-cta__content">
|
||||
<h2 class="subscribe-cta__title">Never Miss an Episode</h2>
|
||||
<p class="subscribe-cta__subtitle">
|
||||
Subscribe on your favorite platform and get notified when new episodes drop.
|
||||
</p>
|
||||
<div class="subscribe-cta__platforms">
|
||||
{platforms.map((platform) => (
|
||||
<a
|
||||
href={platform.url}
|
||||
class="subscribe-cta__platform"
|
||||
target={platform.url.startsWith('http') ? '_blank' : undefined}
|
||||
rel={platform.url.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
aria-label={`Subscribe on ${platform.name}`}
|
||||
>
|
||||
<span class="subscribe-cta__icon" set:html={platformIcons[platform.icon] || platformIcons.rss} />
|
||||
<span class="subscribe-cta__platform-name">{platform.name}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.subscribe-cta {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding-block: var(--space-16);
|
||||
}
|
||||
|
||||
.subscribe-cta__bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(135deg, hsl(200 85% 55% / 0.08), hsl(270 60% 50% / 0.05)),
|
||||
linear-gradient(to bottom, var(--color-bg-secondary), var(--color-bg-primary));
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.subscribe-cta__bg::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, transparent, var(--color-accent), transparent);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.subscribe-cta__bg::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, transparent, var(--color-accent), transparent);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.subscribe-cta__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subscribe-cta__title {
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, var(--color-text-primary), var(--color-accent));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.subscribe-cta__subtitle {
|
||||
font-size: var(--text-lg);
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: var(--space-3);
|
||||
max-width: 500px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.subscribe-cta__platforms {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: var(--space-4);
|
||||
margin-top: var(--space-8);
|
||||
}
|
||||
|
||||
.subscribe-cta__platform {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-6);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
transform var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.subscribe-cta__platform:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: var(--shadow-glow);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.subscribe-cta__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.subscribe-cta__platforms {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.subscribe-cta__platform {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user