From d0f6f85f06850b047440e0f01bc062bcd3f8b9a0 Mon Sep 17 00:00:00 2001 From: Saber Karmous Date: Sun, 15 Mar 2026 11:16:11 +0100 Subject: [PATCH 1/3] feat: add 5 new features consuming codekletshost API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Hoogtepunten — Featured moments explorer (new screen) - Paginated list with tag filtering via tab key - Enter jumps to transcript at moment timestamp 2. Topic summaries — AI-generated summaries on topic detail - Shows summary text above episode list when available 3. Search suggestions — Typeahead when search input is empty - Navigate suggestions with j/k, enter to search 4. Platform links — Spotify, Apple Podcasts etc. on episode detail - New "Platforms" tab with o to open in browser 5. Quote van de dag — Rotating easter egg quotes in footer - Fetches from /api/easter-egg/quotes, rotates every 30s Requires backend endpoints from codekletshost PR #194. --- src/api/moments.ts | 15 +++ src/api/quotes.ts | 38 +++++++ src/api/search-suggestions.ts | 10 ++ src/api/types.ts | 32 ++++++ src/app.tsx | 3 + src/components/footer.tsx | 59 +++++++---- src/components/header.tsx | 1 + src/screens/episode-detail.tsx | 53 +++++++++- src/screens/home.tsx | 8 +- src/screens/moments.tsx | 179 +++++++++++++++++++++++++++++++++ src/screens/search.tsx | 74 +++++++++++++- src/screens/topic-detail.tsx | 8 ++ src/store/navigation.ts | 3 +- tests/screens/home.test.tsx | 2 +- 14 files changed, 458 insertions(+), 27 deletions(-) create mode 100644 src/api/moments.ts create mode 100644 src/api/quotes.ts create mode 100644 src/api/search-suggestions.ts create mode 100644 src/screens/moments.tsx diff --git a/src/api/moments.ts b/src/api/moments.ts new file mode 100644 index 0000000..35a264e --- /dev/null +++ b/src/api/moments.ts @@ -0,0 +1,15 @@ +import { api } from './client.js'; +import type { Moment, PaginatedResponse } from './types.js'; + +export async function getMoments( + options: { page?: number; limit?: number; tag?: string } = {}, +): Promise> { + const searchParams: Record = {}; + if (options.page) searchParams['page'] = options.page; + if (options.limit) searchParams['limit'] = options.limit; + if (options.tag) searchParams['tag'] = options.tag; + + return api + .get('moments', { searchParams }) + .json>(); +} diff --git a/src/api/quotes.ts b/src/api/quotes.ts new file mode 100644 index 0000000..1623b92 --- /dev/null +++ b/src/api/quotes.ts @@ -0,0 +1,38 @@ +import got from 'got'; + +const BASE = + process.env['CODEKLETS_API_URL']?.replace('/api/v1', '') || + 'https://codeklets.nl'; + +interface CharacterQuotes { + name: string; + quotes: string[]; +} + +interface QuotesResponse { + characters: CharacterQuotes[]; + timestamp: string; + signature: string; +} + +export interface Quote { + text: string; + attribution: string; +} + +export async function getRandomQuote(): Promise { + try { + const data = await got + .get(`${BASE}/api/easter-egg/quotes`, { timeout: { request: 3000 } }) + .json(); + + const allQuotes: Quote[] = data.characters.flatMap((c) => + c.quotes.map((q) => ({ text: q, attribution: c.name })), + ); + + if (allQuotes.length === 0) return null; + return allQuotes[Math.floor(Math.random() * allQuotes.length)]!; + } catch { + return null; + } +} diff --git a/src/api/search-suggestions.ts b/src/api/search-suggestions.ts new file mode 100644 index 0000000..f636084 --- /dev/null +++ b/src/api/search-suggestions.ts @@ -0,0 +1,10 @@ +import { api } from './client.js'; +import type { SearchSuggestion, SingleResponse } from './types.js'; + +export async function getSearchSuggestions( + limit = 10, +): Promise> { + return api + .get('search-suggestions', { searchParams: { limit } }) + .json>(); +} diff --git a/src/api/types.ts b/src/api/types.ts index 8d8155d..432347f 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -115,6 +115,7 @@ export interface EpisodeDetail { chapters: Chapter[]; learningPoints: LearningPoint[]; links: EpisodeLink[]; + platformLinks: PlatformLink[]; hasTranscript: boolean; } @@ -146,6 +147,7 @@ export interface TopicDetail { name: string; slug: string; episodeCount: number; + summary: string | null; episodes: EpisodeListItem[]; } @@ -177,6 +179,36 @@ export interface SearchData { transcripts: SearchTranscriptResult[]; } +// ── Featured Moments ──────────────────────────────────────── +export interface Moment { + id: number; + title: string; + description: string | null; + episodeSlug: string; + episodeTitle: string; + startTimeMs: number; + endTimeMs: number; + tag: Tag | null; +} + +// ── Search Suggestions ───────────────────────────────────── +export interface SearchSuggestion { + term: string; + featured: boolean; +} + +// ── Platform Links ───────────────────────────────────────── +export interface PlatformLink { + platform: string; + url: string; +} + +// ── Easter Egg Quotes ────────────────────────────────────── +export interface Quote { + text: string; + attribution: string; +} + // ── Query params ──────────────────────────────────────────── export interface EpisodesQuery { page?: number; diff --git a/src/app.tsx b/src/app.tsx index ea34589..e6c5099 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -18,6 +18,7 @@ import { SearchScreen } from './screens/search.js'; import { TranscriptScreen } from './screens/transcript.js'; import { HelpScreen } from './screens/help.js'; import { FavoritesScreen } from './screens/favorites.js'; +import { MomentsScreen } from './screens/moments.js'; import { useHistoryTracker } from './hooks/use-history-tracker.js'; function ScreenRouter() { @@ -47,6 +48,8 @@ function ScreenRouter() { return ; case 'favorites': return ; + case 'moments': + return ; default: return ; } diff --git a/src/components/footer.tsx b/src/components/footer.tsx index 97527a6..aed0106 100644 --- a/src/components/footer.tsx +++ b/src/components/footer.tsx @@ -1,7 +1,8 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { Box, Text } from 'ink'; import { colors } from '../theme/colors.js'; import { useNavigation } from '../hooks/use-navigation.js'; +import { getRandomQuote, type Quote } from '../api/quotes.js'; export function Footer() { const { stack, current } = useNavigation(); @@ -9,37 +10,55 @@ export function Footer() { const isDetail = current.screen === 'episode-detail' || current.screen === 'person-detail'; + const [quote, setQuote] = useState(null); + + useEffect(() => { + getRandomQuote().then(setQuote); + const interval = setInterval(() => { + getRandomQuote().then(setQuote); + }, 30_000); // Rotate every 30s + return () => clearInterval(interval); + }, []); return ( - - {canGoBack && ( + + + {canGoBack && ( + + esc terug + + )} - esc terug + j/k navigeer - )} - - j/k navigeer - - {!isDetail && ( + {!isDetail && ( + + enter open + + )} + + - enter open + ? help - )} - - - - ? help - - - q stop - + + q stop + + + {quote && ( + + + "{quote.text}" — {quote.attribution} + + + )} ); } diff --git a/src/components/header.tsx b/src/components/header.tsx index b735a21..249e21c 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -16,6 +16,7 @@ const screenLabels: Record = { search: 'Zoeken', help: 'Help', favorites: 'Favorieten', + moments: 'Hoogtepunten', }; export function Header() { diff --git a/src/screens/episode-detail.tsx b/src/screens/episode-detail.tsx index e05b3de..30b2f17 100644 --- a/src/screens/episode-detail.tsx +++ b/src/screens/episode-detail.tsx @@ -26,6 +26,7 @@ export function EpisodeDetailScreen() { const [selectedLinkIndex, setSelectedLinkIndex] = useState(0); const [selectedPersonIndex, setSelectedPersonIndex] = useState(0); const [selectedTagIndex, setSelectedTagIndex] = useState(0); + const [selectedPlatformIndex, setSelectedPlatformIndex] = useState(0); const [resumeMessage, setResumeMessage] = useState(null); const player = usePlayer(); const getHistoryEntry = useStore((s) => s.getHistoryEntry); @@ -51,6 +52,8 @@ export function EpisodeDetailScreen() { if (episode.persons.length > 0) s.push({ label: 'Deelnemers', key: 'persons' }); if (episode.links.length > 0) s.push({ label: 'Links', key: 'links' }); + if (episode.platformLinks?.length > 0) + s.push({ label: 'Platforms', key: 'platforms' }); return s; }, [episode]); @@ -112,16 +115,34 @@ export function EpisodeDetailScreen() { } } + // Platforms tab: j/k to navigate, o to open + if (currentKey === 'platforms' && episode.platformLinks?.length > 0) { + if (input === 'j' || key.downArrow) { + setSelectedPlatformIndex((i) => + Math.min(i + 1, episode.platformLinks.length - 1), + ); + return; + } else if (input === 'k' || key.upArrow) { + setSelectedPlatformIndex((i) => Math.max(i - 1, 0)); + return; + } else if (input === 'o' && episode.platformLinks[selectedPlatformIndex]) { + openUrl(episode.platformLinks[selectedPlatformIndex]!.url); + return; + } + } + if (input === 'l' || key.rightArrow) { setActiveSection((i) => Math.min(i + 1, sections.length - 1)); setSelectedLinkIndex(0); setSelectedPersonIndex(0); setSelectedTagIndex(0); + setSelectedPlatformIndex(0); } else if (input === 'h' || key.leftArrow) { setActiveSection((i) => Math.max(i - 1, 0)); setSelectedLinkIndex(0); setSelectedPersonIndex(0); setSelectedTagIndex(0); + setSelectedPlatformIndex(0); } else if (input === 't' && episode.hasTranscript) { navigate('transcript', { slug, title: episode.title }); } else if (input === 'a') { @@ -274,7 +295,7 @@ export function EpisodeDetailScreen() { ` (${player.playbackSpeed}x)`} )} - {currentKey === 'links' && ( + {(currentKey === 'links' || currentKey === 'platforms') && ( o openen @@ -396,6 +417,36 @@ export function EpisodeDetailScreen() { })} )} + + {currentKey === 'platforms' && episode.platformLinks && ( + + {episode.platformLinks.map((pl, i) => { + const isSelected = i === selectedPlatformIndex; + return ( + + + {isSelected ? '▸' : '•'} + + + {pl.platform} + + + {pl.url} + + + ); + })} + + )} ); } diff --git a/src/screens/home.tsx b/src/screens/home.tsx index da9a6ef..0fd1f2b 100644 --- a/src/screens/home.tsx +++ b/src/screens/home.tsx @@ -33,7 +33,8 @@ interface MenuItem { | 'topics-list' | 'persons-list' | 'search' - | 'favorites'; + | 'favorites' + | 'moments'; description: string; } @@ -43,6 +44,11 @@ const menuItems: MenuItem[] = [ screen: 'episodes-list', description: 'Bekijk alle afleveringen', }, + { + label: 'Hoogtepunten', + screen: 'moments', + description: 'Beste momenten uit de podcast', + }, { label: 'Onderwerpen', screen: 'topics-list', diff --git a/src/screens/moments.tsx b/src/screens/moments.tsx new file mode 100644 index 0000000..5df2cc5 --- /dev/null +++ b/src/screens/moments.tsx @@ -0,0 +1,179 @@ +import React, { useState, useCallback, useMemo } from 'react'; +import { Box, Text, useInput } from 'ink'; +import { colors } from '../theme/colors.js'; +import { useNavigation } from '../hooks/use-navigation.js'; +import { useApi } from '../hooks/use-api.js'; +import { getMoments } from '../api/moments.js'; +import { ScreenContainer } from '../components/screen-container.js'; +import { Loading } from '../components/loading.js'; +import { ErrorDisplay } from '../components/error-display.js'; +import { EmptyState } from '../components/empty-state.js'; +import { Pagination } from '../components/pagination.js'; +import type { Moment, PaginatedResponse } from '../api/types.js'; + +function formatMs(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const m = Math.floor(totalSeconds / 60); + const s = totalSeconds % 60; + return `${m}:${s.toString().padStart(2, '0')}`; +} + +export function MomentsScreen() { + const { current, navigate } = useNavigation(); + const tagFilter = current.params?.['tag'] as string | undefined; + const [selectedIndex, setSelectedIndex] = useState(0); + const [page, setPage] = useState(1); + + const fetcher = useCallback( + () => getMoments({ page, limit: 15, tag: tagFilter }), + [page, tagFilter], + ); + const { data, loading, error, refetch } = useApi< + PaginatedResponse + >(fetcher, `moments-${tagFilter ?? 'all'}-p${page}`, 10 * 60 * 1000); + + const moments = data?.data ?? []; + const meta = data?.meta; + + const tags = useMemo(() => { + const seen = new Set(); + return moments + .filter((m) => m.tag && !seen.has(m.tag.slug) && seen.add(m.tag.slug)) + .map((m) => m.tag!); + }, [moments]); + + useInput((input, key) => { + if (loading) return; + + if (input === 'j' || key.downArrow) { + setSelectedIndex((i) => Math.min(i + 1, moments.length - 1)); + } else if (input === 'k' || key.upArrow) { + setSelectedIndex((i) => Math.max(i - 1, 0)); + } else if (key.return && moments[selectedIndex]) { + const m = moments[selectedIndex]!; + navigate('transcript', { + slug: m.episodeSlug, + title: m.episodeTitle, + seekMs: m.startTimeMs, + }); + } else if (input === 'l' || key.rightArrow) { + if (meta && meta.hasMore) { + setPage((p) => p + 1); + setSelectedIndex(0); + } + } else if (input === 'h' || key.leftArrow) { + if (page > 1) { + setPage((p) => p - 1); + setSelectedIndex(0); + } + } else if (key.tab && tags.length > 0) { + // Cycle through tag filters + if (!tagFilter) { + navigate('moments', { tag: tags[0]!.slug }); + } else { + const idx = tags.findIndex((t) => t.slug === tagFilter); + if (idx >= 0 && idx < tags.length - 1) { + navigate('moments', { tag: tags[idx + 1]!.slug }); + } else { + navigate('moments'); + } + } + } else if (input === 'r' && error) { + refetch(); + } + }); + + if (loading) return ; + if (error) return ; + + return ( + + + + Hoogtepunten + + {tagFilter && ( + [{tagFilter}] + )} + + + + + enter bekijk transcript + + {tags.length > 0 && ( + + tab filter onderwerp + + )} + + h/l pagina + + + + {moments.length === 0 ? ( + + ) : ( + + {moments.map((m, i) => { + const isSelected = i === selectedIndex; + return ( + + + + {isSelected ? '▸' : ' '} + + + {formatMs(m.startTimeMs)} + + + {m.title} + + {m.tag && ( + + [{m.tag.name}] + + )} + + {isSelected && ( + + {m.description && ( + + {m.description} + + )} + + {m.episodeTitle} + + + )} + + ); + })} + + )} + + {meta && meta.totalPages > 1 && ( + + + + )} + + ); +} diff --git a/src/screens/search.tsx b/src/screens/search.tsx index 0266f59..a3cc313 100644 --- a/src/screens/search.tsx +++ b/src/screens/search.tsx @@ -1,13 +1,15 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { Box, Text, useInput } from 'ink'; import { colors } from '../theme/colors.js'; import { useNavigation } from '../hooks/use-navigation.js'; import { search as searchApi } from '../api/search.js'; +import { getSearchSuggestions } from '../api/search-suggestions.js'; +import { useApi } from '../hooks/use-api.js'; import { ScreenContainer } from '../components/screen-container.js'; import { Loading } from '../components/loading.js'; import { ErrorDisplay } from '../components/error-display.js'; import { formatEpisodeNumber } from '../theme/format.js'; -import type { SearchData } from '../api/types.js'; +import type { SearchData, SearchSuggestion, SingleResponse } from '../api/types.js'; type ResultTab = 'episodes' | 'transcripts'; @@ -22,12 +24,21 @@ export function SearchScreen() { const [query, setQuery] = useState(''); const [submitted, setSubmitted] = useState(''); const [selectedIndex, setSelectedIndex] = useState(0); + const [suggestionIndex, setSuggestionIndex] = useState(0); const [activeTab, setActiveTab] = useState('episodes'); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [results, setResults] = useState(null); const { navigate } = useNavigation(); + const suggestionsFetcher = useCallback(() => getSearchSuggestions(10), []); + const { data: suggestionsData } = useApi>( + suggestionsFetcher, + 'search-suggestions', + 60 * 60 * 1000, // 1 hour cache + ); + const suggestions = suggestionsData?.data ?? []; + useEffect(() => { if (!submitted) return; let cancelled = false; @@ -55,7 +66,27 @@ export function SearchScreen() { const transcripts = results?.transcripts ?? []; const currentItems = activeTab === 'episodes' ? episodes : transcripts; + const showSuggestions = !submitted && query.length === 0 && suggestions.length > 0; + useInput((input, key) => { + // Suggestion navigation when no query typed + if (showSuggestions && !submitted) { + if (input === 'j' || key.downArrow) { + setSuggestionIndex((i) => Math.min(i + 1, suggestions.length - 1)); + return; + } else if (input === 'k' || key.upArrow) { + setSuggestionIndex((i) => Math.max(i - 1, 0)); + return; + } else if (key.return && suggestions[suggestionIndex]) { + const term = suggestions[suggestionIndex]!.term; + setQuery(term); + setSubmitted(term); + setSelectedIndex(0); + setActiveTab('episodes'); + return; + } + } + if (key.return && query.length >= 2 && !submitted) { setSubmitted(query); setSelectedIndex(0); @@ -119,12 +150,49 @@ export function SearchScreen() { - {!submitted && ( + {!submitted && !showSuggestions && ( Typ minimaal 2 tekens en druk op enter )} + {showSuggestions && ( + + + Suggesties: + + + {suggestions.map((s, i) => { + const isSelected = i === suggestionIndex; + return ( + + + {isSelected ? '▸' : ' '} + + + {s.term} + + {s.featured && ( + + )} + + ); + })} + + + )} + {submitted && loading && } {submitted && error && ( setSubmitted(query)} /> diff --git a/src/screens/topic-detail.tsx b/src/screens/topic-detail.tsx index 655e78b..ac60cfc 100644 --- a/src/screens/topic-detail.tsx +++ b/src/screens/topic-detail.tsx @@ -53,6 +53,14 @@ export function TopicDetailScreen() { + {topic.summary && ( + + + {topic.summary} + + + )} + {episodes.length === 0 ? ( ) : ( diff --git a/src/store/navigation.ts b/src/store/navigation.ts index 32df5bd..5ce20d1 100644 --- a/src/store/navigation.ts +++ b/src/store/navigation.ts @@ -11,7 +11,8 @@ export type ScreenName = | 'person-detail' | 'search' | 'help' - | 'favorites'; + | 'favorites' + | 'moments'; export interface ScreenEntry { screen: ScreenName; diff --git a/tests/screens/home.test.tsx b/tests/screens/home.test.tsx index 82e4bf1..e08a61e 100644 --- a/tests/screens/home.test.tsx +++ b/tests/screens/home.test.tsx @@ -39,6 +39,6 @@ describe('HomeScreen', () => { await delay(50); const { stack } = useStore.getState(); expect(stack).toHaveLength(2); - expect(stack[1]!.screen).toBe('topics-list'); + expect(stack[1]!.screen).toBe('moments'); }); }); From 8a3a5555ba7c76d4cd8b7b2985a22b90100460df Mon Sep 17 00:00:00 2001 From: Saber Karmous Date: Sun, 15 Mar 2026 11:22:13 +0100 Subject: [PATCH 2/3] fix: address code review feedback - Fix unmounted state update in footer quote rotation - Extract duplicated formatMs to shared theme/format.ts --- src/components/footer.tsx | 9 +++++---- src/screens/moments.tsx | 8 +------- src/screens/search.tsx | 9 +-------- src/theme/format.ts | 7 +++++++ 4 files changed, 14 insertions(+), 19 deletions(-) diff --git a/src/components/footer.tsx b/src/components/footer.tsx index aed0106..0ee9be4 100644 --- a/src/components/footer.tsx +++ b/src/components/footer.tsx @@ -13,11 +13,12 @@ export function Footer() { const [quote, setQuote] = useState(null); useEffect(() => { - getRandomQuote().then(setQuote); + let cancelled = false; + getRandomQuote().then((q) => { if (!cancelled) setQuote(q); }); const interval = setInterval(() => { - getRandomQuote().then(setQuote); - }, 30_000); // Rotate every 30s - return () => clearInterval(interval); + getRandomQuote().then((q) => { if (!cancelled) setQuote(q); }); + }, 30_000); + return () => { cancelled = true; clearInterval(interval); }; }, []); return ( diff --git a/src/screens/moments.tsx b/src/screens/moments.tsx index 5df2cc5..7e610cd 100644 --- a/src/screens/moments.tsx +++ b/src/screens/moments.tsx @@ -9,15 +9,9 @@ import { Loading } from '../components/loading.js'; import { ErrorDisplay } from '../components/error-display.js'; import { EmptyState } from '../components/empty-state.js'; import { Pagination } from '../components/pagination.js'; +import { formatMs } from '../theme/format.js'; import type { Moment, PaginatedResponse } from '../api/types.js'; -function formatMs(ms: number): string { - const totalSeconds = Math.floor(ms / 1000); - const m = Math.floor(totalSeconds / 60); - const s = totalSeconds % 60; - return `${m}:${s.toString().padStart(2, '0')}`; -} - export function MomentsScreen() { const { current, navigate } = useNavigation(); const tagFilter = current.params?.['tag'] as string | undefined; diff --git a/src/screens/search.tsx b/src/screens/search.tsx index a3cc313..6a15d91 100644 --- a/src/screens/search.tsx +++ b/src/screens/search.tsx @@ -8,18 +8,11 @@ import { useApi } from '../hooks/use-api.js'; import { ScreenContainer } from '../components/screen-container.js'; import { Loading } from '../components/loading.js'; import { ErrorDisplay } from '../components/error-display.js'; -import { formatEpisodeNumber } from '../theme/format.js'; +import { formatEpisodeNumber, formatMs } from '../theme/format.js'; import type { SearchData, SearchSuggestion, SingleResponse } from '../api/types.js'; type ResultTab = 'episodes' | 'transcripts'; -function formatMs(ms: number): string { - const totalSeconds = Math.floor(ms / 1000); - const m = Math.floor(totalSeconds / 60); - const s = totalSeconds % 60; - return `${m}:${s.toString().padStart(2, '0')}`; -} - export function SearchScreen() { const [query, setQuery] = useState(''); const [submitted, setSubmitted] = useState(''); diff --git a/src/theme/format.ts b/src/theme/format.ts index bef70b4..d4c037c 100644 --- a/src/theme/format.ts +++ b/src/theme/format.ts @@ -18,6 +18,13 @@ export function formatDate(isoDate: string): string { }); } +export function formatMs(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const m = Math.floor(totalSeconds / 60); + const s = totalSeconds % 60; + return `${m}:${s.toString().padStart(2, '0')}`; +} + export function formatEpisodeNumber( season: number, episode: number, From 415e4605f69e1664ce981c6febb12e056088c757 Mon Sep 17 00:00:00 2001 From: Saber Karmous Date: Sun, 15 Mar 2026 11:54:09 +0100 Subject: [PATCH 3/3] chore: add changeset for new API features --- .changeset/new-api-features.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/new-api-features.md diff --git a/.changeset/new-api-features.md b/.changeset/new-api-features.md new file mode 100644 index 0000000..680b445 --- /dev/null +++ b/.changeset/new-api-features.md @@ -0,0 +1,5 @@ +--- +"klets": minor +--- + +Add 5 new features: Hoogtepunten (featured moments explorer), topic summaries, search suggestions, platform links on episodes, and rotating easter egg quotes in footer