From d7213f3a04858dae5f743c5fa29e1b20b2d138c8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 16:08:48 +0000 Subject: [PATCH 1/8] Initial plan From f76e5e9e1756f6962e84c08017b378a7e5d91880 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 16:16:36 +0000 Subject: [PATCH 2/8] feat: add cmd+k style search dialog for episode discovery - Add SearchDialog component with keyboard shortcuts (cmd+k, ctrl+k) - Add SearchButton component for visual trigger - Add /api/episodes/search.json endpoint for searchable data - Add search state signal in state.ts - Integrate search into Layout.astro - Include unit tests for new components Co-authored-by: argyleink <1134620+argyleink@users.noreply.github.com> --- src/components/SearchButton.tsx | 31 ++++ src/components/SearchDialog.tsx | 244 ++++++++++++++++++++++++++ src/components/state.ts | 3 + src/layouts/Layout.astro | 6 + src/pages/api/episodes/search.json.ts | 24 +++ tests/unit/SearchButton.test.tsx | 48 +++++ tests/unit/SearchDialog.test.tsx | 153 ++++++++++++++++ 7 files changed, 509 insertions(+) create mode 100644 src/components/SearchButton.tsx create mode 100644 src/components/SearchDialog.tsx create mode 100644 src/pages/api/episodes/search.json.ts create mode 100644 tests/unit/SearchButton.test.tsx create mode 100644 tests/unit/SearchDialog.test.tsx diff --git a/src/components/SearchButton.tsx b/src/components/SearchButton.tsx new file mode 100644 index 0000000..109e9e2 --- /dev/null +++ b/src/components/SearchButton.tsx @@ -0,0 +1,31 @@ +import { isSearchOpen } from './state'; + +export default function SearchButton() { + return ( + + ); +} diff --git a/src/components/SearchDialog.tsx b/src/components/SearchDialog.tsx new file mode 100644 index 0000000..385a66f --- /dev/null +++ b/src/components/SearchDialog.tsx @@ -0,0 +1,244 @@ +import { useEffect, useState, useRef, useCallback } from 'preact/hooks'; +import { isSearchOpen } from './state'; + +interface SearchableEpisode { + id: string; + title: string; + description: string; + episodeNumber: string; + episodeSlug: string; + episodeThumbnail?: string; +} + +export default function SearchDialog() { + const [query, setQuery] = useState(''); + const [episodes, setEpisodes] = useState([]); + const [filteredEpisodes, setFilteredEpisodes] = useState( + [] + ); + const [selectedIndex, setSelectedIndex] = useState(0); + const inputRef = useRef(null); + const resultsRef = useRef(null); + + // Load episodes on mount + useEffect(() => { + fetch('/api/episodes/search.json') + .then((res) => res.json()) + .then((data) => setEpisodes(data)) + .catch(console.error); + }, []); + + // Filter episodes based on query + useEffect(() => { + if (!query.trim()) { + setFilteredEpisodes(episodes.slice(0, 8)); + setSelectedIndex(0); + return; + } + + const lowerQuery = query.toLowerCase(); + const filtered = episodes + .filter( + (episode) => + episode.title.toLowerCase().includes(lowerQuery) || + episode.description.toLowerCase().includes(lowerQuery) || + episode.episodeNumber.toLowerCase().includes(lowerQuery) + ) + .slice(0, 8); + + setFilteredEpisodes(filtered); + setSelectedIndex(0); + }, [query, episodes]); + + // Focus input when dialog opens + useEffect(() => { + if (isSearchOpen.value) { + setQuery(''); + setSelectedIndex(0); + setTimeout(() => inputRef.current?.focus(), 0); + } + }, [isSearchOpen.value]); + + // Handle keyboard shortcuts + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + // Open search with cmd+k or ctrl+k + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + isSearchOpen.value = !isSearchOpen.value; + return; + } + + // Close on escape + if (e.key === 'Escape' && isSearchOpen.value) { + e.preventDefault(); + isSearchOpen.value = false; + return; + } + + // Navigation and selection when dialog is open + if (isSearchOpen.value && filteredEpisodes.length > 0) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex((prev) => + prev < filteredEpisodes.length - 1 ? prev + 1 : prev + ); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev)); + } else if (e.key === 'Enter') { + e.preventDefault(); + const selected = filteredEpisodes[selectedIndex]; + if (selected) { + window.location.href = `/${selected.episodeSlug}`; + } + } + } + }, + [filteredEpisodes, selectedIndex] + ); + + // Add global keyboard listener + useEffect(() => { + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [handleKeyDown]); + + // Scroll selected item into view + useEffect(() => { + if (resultsRef.current) { + const selectedEl = resultsRef.current.querySelector( + `[data-index="${selectedIndex}"]` + ); + selectedEl?.scrollIntoView({ block: 'nearest' }); + } + }, [selectedIndex]); + + // Handle backdrop click + const handleBackdropClick = (e: MouseEvent) => { + if (e.target === e.currentTarget) { + isSearchOpen.value = false; + } + }; + + if (!isSearchOpen.value) { + return null; + } + + return ( + + ); +} diff --git a/src/components/state.ts b/src/components/state.ts index 9e5f38a..bbd2212 100644 --- a/src/components/state.ts +++ b/src/components/state.ts @@ -7,3 +7,6 @@ export const currentEpisode = signal | null>(null); export const isPlaying = signal(false); export const isMuted = signal(false); + +// Search state +export const isSearchOpen = signal(false); diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index f14d496..fd7057e 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -11,6 +11,8 @@ import Hosts from '../components/Hosts.astro'; import InfoCard from '../components/InfoCard.astro'; import Platforms from '../components/Platforms.astro'; import Player from '../components/Player'; +import SearchButton from '../components/SearchButton'; +import SearchDialog from '../components/SearchDialog'; import ShowArtwork from '../components/ShowArtwork.astro'; import { getShowInfo } from '../lib/rss'; @@ -164,6 +166,9 @@ const description = Astro.props.description ?? starpodConfig.description;
+
+ +
+ diff --git a/src/pages/api/episodes/search.json.ts b/src/pages/api/episodes/search.json.ts new file mode 100644 index 0000000..a40fa4e --- /dev/null +++ b/src/pages/api/episodes/search.json.ts @@ -0,0 +1,24 @@ +import type { APIRoute } from 'astro'; +import { getAllEpisodes } from '../../../lib/rss'; + +export const prerender = true; + +export const GET: APIRoute = async () => { + const allEpisodes = await getAllEpisodes(); + + // Return a simplified list of episodes optimized for search + const searchableEpisodes = allEpisodes.map((episode) => ({ + id: episode.id, + title: episode.title, + description: episode.description, + episodeNumber: episode.episodeNumber, + episodeSlug: episode.episodeSlug, + episodeThumbnail: episode.episodeThumbnail + })); + + return new Response(JSON.stringify(searchableEpisodes), { + headers: { + 'Content-Type': 'application/json' + } + }); +}; diff --git a/tests/unit/SearchButton.test.tsx b/tests/unit/SearchButton.test.tsx new file mode 100644 index 0000000..ae7f5a7 --- /dev/null +++ b/tests/unit/SearchButton.test.tsx @@ -0,0 +1,48 @@ +import { render, screen, fireEvent } from '@testing-library/preact'; +import { beforeEach, describe, expect, it } from 'vitest'; +import SearchButton from '../../src/components/SearchButton'; +import { isSearchOpen } from '../../src/components/state'; + +describe('SearchButton', () => { + beforeEach(() => { + isSearchOpen.value = false; + }); + + it('renders the search button', () => { + render(); + + const button = screen.getByRole('button'); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute('aria-label', 'Search episodes (⌘K)'); + }); + + it('opens search dialog when clicked', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + expect(isSearchOpen.value).toBe(true); + }); + + it('contains search text', () => { + render(); + + expect(screen.getByText('Search')).toBeInTheDocument(); + }); + + it('displays keyboard shortcut hint', () => { + render(); + + expect(screen.getByText('⌘')).toBeInTheDocument(); + expect(screen.getByText('K')).toBeInTheDocument(); + }); + + it('contains search icon', () => { + render(); + + const svg = screen.getByRole('button').querySelector('svg'); + expect(svg).toBeInTheDocument(); + expect(svg).toHaveAttribute('aria-hidden', 'true'); + }); +}); diff --git a/tests/unit/SearchDialog.test.tsx b/tests/unit/SearchDialog.test.tsx new file mode 100644 index 0000000..fd4a795 --- /dev/null +++ b/tests/unit/SearchDialog.test.tsx @@ -0,0 +1,153 @@ +import { render, screen, fireEvent } from '@testing-library/preact'; +import { beforeEach, describe, expect, it, vi, afterEach } from 'vitest'; +import SearchDialog from '../../src/components/SearchDialog'; +import { isSearchOpen } from '../../src/components/state'; + +// Mock fetch for episodes +const mockEpisodes = [ + { + id: 'episode-1', + title: 'Episode One', + description: 'This is the first episode', + episodeNumber: '1', + episodeSlug: 'episode-one', + episodeThumbnail: '/thumb1.jpg' + }, + { + id: 'episode-2', + title: 'Episode Two', + description: 'This is the second episode', + episodeNumber: '2', + episodeSlug: 'episode-two', + episodeThumbnail: '/thumb2.jpg' + }, + { + id: 'episode-3', + title: 'Different Title', + description: 'Something completely different', + episodeNumber: '3', + episodeSlug: 'different-title', + episodeThumbnail: '/thumb3.jpg' + } +]; + +describe('SearchDialog', () => { + beforeEach(() => { + isSearchOpen.value = false; + global.fetch = vi.fn().mockResolvedValue({ + json: () => Promise.resolve(mockEpisodes) + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders nothing when search is closed', () => { + isSearchOpen.value = false; + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders the search dialog when open', async () => { + isSearchOpen.value = true; + render(); + + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Search episodes...')).toBeInTheDocument(); + }); + + it('filters episodes based on search query', async () => { + isSearchOpen.value = true; + render(); + + // Wait for episodes to load + await screen.findByRole('dialog'); + + const input = screen.getByPlaceholderText('Search episodes...'); + fireEvent.input(input, { target: { value: 'Episode' } }); + + // Should show Episode One and Episode Two, but not Different Title + expect(await screen.findByText('Episode One')).toBeInTheDocument(); + expect(screen.getByText('Episode Two')).toBeInTheDocument(); + expect(screen.queryByText('Different Title')).not.toBeInTheDocument(); + }); + + it('shows no results message when no matches found', async () => { + isSearchOpen.value = true; + render(); + + await screen.findByRole('dialog'); + + const input = screen.getByPlaceholderText('Search episodes...'); + fireEvent.input(input, { target: { value: 'nonexistent query' } }); + + expect(await screen.findByText('No episodes found')).toBeInTheDocument(); + }); + + it('closes when Escape is pressed', async () => { + isSearchOpen.value = true; + render(); + + await screen.findByRole('dialog'); + + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(isSearchOpen.value).toBe(false); + }); + + it('opens and closes with cmd+k keyboard shortcut', async () => { + isSearchOpen.value = false; + render(); + + // Open with cmd+k + fireEvent.keyDown(document, { key: 'k', metaKey: true }); + expect(isSearchOpen.value).toBe(true); + + // Close with cmd+k + fireEvent.keyDown(document, { key: 'k', metaKey: true }); + expect(isSearchOpen.value).toBe(false); + }); + + it('opens with ctrl+k keyboard shortcut', async () => { + isSearchOpen.value = false; + render(); + + fireEvent.keyDown(document, { key: 'k', ctrlKey: true }); + expect(isSearchOpen.value).toBe(true); + }); + + it('has proper accessibility attributes', async () => { + isSearchOpen.value = true; + render(); + + const dialog = await screen.findByRole('dialog'); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + expect(dialog).toHaveAttribute('aria-label', 'Search episodes'); + + const input = screen.getByPlaceholderText('Search episodes...'); + expect(input).toHaveAttribute('aria-label', 'Search episodes'); + expect(input).toHaveAttribute('aria-autocomplete', 'list'); + expect(input).toHaveAttribute('aria-controls', 'search-results'); + + const results = screen.getByRole('listbox'); + expect(results).toBeInTheDocument(); + }); + + it('supports keyboard navigation', async () => { + isSearchOpen.value = true; + render(); + + await screen.findByRole('dialog'); + + // Wait for episodes to load + await screen.findByText('Episode One'); + + // Verify arrow key events don't throw errors + fireEvent.keyDown(document, { key: 'ArrowDown' }); + fireEvent.keyDown(document, { key: 'ArrowUp' }); + + // Verify Enter navigates (won't actually navigate in test, but verifies no crash) + fireEvent.keyDown(document, { key: 'Enter' }); + }); +}); From c2206487c54602e3831d4e62368fbce8db31928d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 16:18:33 +0000 Subject: [PATCH 3/8] fix: address code review feedback for SearchDialog - Use JSX.TargetedMouseEvent for type safety - Pluralize results count correctly (1 result vs N results) Co-authored-by: argyleink <1134620+argyleink@users.noreply.github.com> --- src/components/SearchDialog.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/SearchDialog.tsx b/src/components/SearchDialog.tsx index 385a66f..5f9e75d 100644 --- a/src/components/SearchDialog.tsx +++ b/src/components/SearchDialog.tsx @@ -1,3 +1,4 @@ +import type { JSX } from 'preact/jsx-runtime'; import { useEffect, useState, useRef, useCallback } from 'preact/hooks'; import { isSearchOpen } from './state'; @@ -115,7 +116,7 @@ export default function SearchDialog() { }, [selectedIndex]); // Handle backdrop click - const handleBackdropClick = (e: MouseEvent) => { + const handleBackdropClick = (e: JSX.TargetedMouseEvent) => { if (e.target === e.currentTarget) { isSearchOpen.value = false; } @@ -236,7 +237,10 @@ export default function SearchDialog() { to select
- {filteredEpisodes.length} results + + {filteredEpisodes.length}{' '} + {filteredEpisodes.length === 1 ? 'result' : 'results'} + From 8e5ad5a264dd13f52d5443ef60774ceedc980132 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 14:38:46 +0000 Subject: [PATCH 4/8] refactor: use Pick instead of redundant interface Replaced the redundant SearchableEpisode interface with a type alias that uses Pick to reuse the existing Episode type. Co-authored-by: RobbieTheWagner <2640861+RobbieTheWagner@users.noreply.github.com> --- src/components/SearchDialog.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/components/SearchDialog.tsx b/src/components/SearchDialog.tsx index 5f9e75d..923fa19 100644 --- a/src/components/SearchDialog.tsx +++ b/src/components/SearchDialog.tsx @@ -1,15 +1,12 @@ import type { JSX } from 'preact/jsx-runtime'; import { useEffect, useState, useRef, useCallback } from 'preact/hooks'; import { isSearchOpen } from './state'; +import type { Episode } from '../lib/rss'; -interface SearchableEpisode { - id: string; - title: string; - description: string; - episodeNumber: string; - episodeSlug: string; - episodeThumbnail?: string; -} +type SearchableEpisode = Pick< + Episode, + 'id' | 'title' | 'description' | 'episodeNumber' | 'episodeSlug' | 'episodeThumbnail' +>; export default function SearchDialog() { const [query, setQuery] = useState(''); From 0a0ab4701ee28765174071c49cd47aff590ed3ab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 14:57:02 +0000 Subject: [PATCH 5/8] refactor: move inline SVGs to external file with CSS mask-image - Added public/images/search-icon.svg - Created src/styles/search-icon.css with mask-image styling - Updated SearchDialog.tsx and SearchButton.tsx to use CSS class - Updated tests to match new icon implementation Co-authored-by: RobbieTheWagner <2640861+RobbieTheWagner@users.noreply.github.com> --- public/images/search-icon.svg | 15 +++++++++++++++ src/components/SearchButton.tsx | 15 +-------------- src/components/SearchDialog.tsx | 16 +++------------- src/styles/global.css | 1 + src/styles/search-icon.css | 7 +++++++ tests/unit/SearchButton.test.tsx | 6 +++--- 6 files changed, 30 insertions(+), 30 deletions(-) create mode 100644 public/images/search-icon.svg create mode 100644 src/styles/search-icon.css diff --git a/public/images/search-icon.svg b/public/images/search-icon.svg new file mode 100644 index 0000000..92c7224 --- /dev/null +++ b/public/images/search-icon.svg @@ -0,0 +1,15 @@ + + + diff --git a/src/components/SearchButton.tsx b/src/components/SearchButton.tsx index 109e9e2..7711643 100644 --- a/src/components/SearchButton.tsx +++ b/src/components/SearchButton.tsx @@ -8,20 +8,7 @@ export default function SearchButton() { onClick={() => (isSearchOpen.value = true)} aria-label="Search episodes (⌘K)" > - +