Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/new-api-features.md
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions src/api/moments.ts
Original file line number Diff line number Diff line change
@@ -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<PaginatedResponse<Moment>> {
const searchParams: Record<string, string | number> = {};
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<PaginatedResponse<Moment>>();
}
38 changes: 38 additions & 0 deletions src/api/quotes.ts
Original file line number Diff line number Diff line change
@@ -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<Quote | null> {
try {
const data = await got
.get(`${BASE}/api/easter-egg/quotes`, { timeout: { request: 3000 } })
.json<QuotesResponse>();

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;
}
}
10 changes: 10 additions & 0 deletions src/api/search-suggestions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { api } from './client.js';
import type { SearchSuggestion, SingleResponse } from './types.js';

export async function getSearchSuggestions(
limit = 10,
): Promise<SingleResponse<SearchSuggestion[]>> {
return api
.get('search-suggestions', { searchParams: { limit } })
.json<SingleResponse<SearchSuggestion[]>>();
}
32 changes: 32 additions & 0 deletions src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export interface EpisodeDetail {
chapters: Chapter[];
learningPoints: LearningPoint[];
links: EpisodeLink[];
platformLinks: PlatformLink[];
hasTranscript: boolean;
}

Expand Down Expand Up @@ -146,6 +147,7 @@ export interface TopicDetail {
name: string;
slug: string;
episodeCount: number;
summary: string | null;
episodes: EpisodeListItem[];
}

Expand Down Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -47,6 +48,8 @@ function ScreenRouter() {
return <HelpScreen />;
case 'favorites':
return <FavoritesScreen />;
case 'moments':
return <MomentsScreen />;
default:
return <HomeScreen />;
}
Expand Down
60 changes: 40 additions & 20 deletions src/components/footer.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,65 @@
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();
const canGoBack = stack.length > 1;
const isDetail =
current.screen === 'episode-detail' ||
current.screen === 'person-detail';
const [quote, setQuote] = useState<Quote | null>(null);

useEffect(() => {
let cancelled = false;
getRandomQuote().then((q) => { if (!cancelled) setQuote(q); });
const interval = setInterval(() => {
getRandomQuote().then((q) => { if (!cancelled) setQuote(q); });
}, 30_000);
return () => { cancelled = true; clearInterval(interval); };
}, []);

return (
<Box
borderStyle="single"
borderColor={colors.border}
paddingX={1}
justifyContent="space-between"
flexDirection="column"
>
<Box gap={2}>
{canGoBack && (
<Box justifyContent="space-between">
<Box gap={2}>
{canGoBack && (
<Text color={colors.textSubtle}>
<Text color={colors.cyan}>esc</Text> terug
</Text>
)}
<Text color={colors.textSubtle}>
<Text color={colors.cyan}>esc</Text> terug
<Text color={colors.cyan}>j/k</Text> navigeer
</Text>
)}
<Text color={colors.textSubtle}>
<Text color={colors.cyan}>j/k</Text> navigeer
</Text>
{!isDetail && (
{!isDetail && (
<Text color={colors.textSubtle}>
<Text color={colors.cyan}>enter</Text> open
</Text>
)}
</Box>
<Box gap={2}>
<Text color={colors.textSubtle}>
<Text color={colors.cyan}>enter</Text> open
<Text color={colors.cyan}>?</Text> help
</Text>
)}
</Box>
<Box gap={2}>
<Text color={colors.textSubtle}>
<Text color={colors.cyan}>?</Text> help
</Text>
<Text color={colors.textSubtle}>
<Text color={colors.cyan}>q</Text> stop
</Text>
<Text color={colors.textSubtle}>
<Text color={colors.cyan}>q</Text> stop
</Text>
</Box>
</Box>
{quote && (
<Box>
<Text color={colors.textSubtle} italic>
&quot;{quote.text}&quot; — {quote.attribution}
</Text>
</Box>
)}
</Box>
);
}
1 change: 1 addition & 0 deletions src/components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const screenLabels: Record<ScreenName, string> = {
search: 'Zoeken',
help: 'Help',
favorites: 'Favorieten',
moments: 'Hoogtepunten',
};

export function Header() {
Expand Down
53 changes: 52 additions & 1 deletion src/screens/episode-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);
const player = usePlayer();
const getHistoryEntry = useStore((s) => s.getHistoryEntry);
Expand All @@ -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]);

Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -274,7 +295,7 @@ export function EpisodeDetailScreen() {
` (${player.playbackSpeed}x)`}
</Text>
)}
{currentKey === 'links' && (
{(currentKey === 'links' || currentKey === 'platforms') && (
<Text color={colors.textSubtle}>
<Text color={colors.cyan}>o</Text> openen
</Text>
Expand Down Expand Up @@ -396,6 +417,36 @@ export function EpisodeDetailScreen() {
})}
</Box>
)}

{currentKey === 'platforms' && episode.platformLinks && (
<Box flexDirection="column">
{episode.platformLinks.map((pl, i) => {
const isSelected = i === selectedPlatformIndex;
return (
<Box key={pl.platform} gap={1}>
<Text
color={
isSelected
? colors.cyan
: colors.textSubtle
}
>
{isSelected ? '▸' : '•'}
</Text>
<Text
color={isSelected ? colors.cyan : colors.text}
bold={isSelected}
>
{pl.platform}
</Text>
<Text color={colors.textSubtle}>
{pl.url}
</Text>
</Box>
);
})}
</Box>
)}
</ScreenContainer>
);
}
8 changes: 7 additions & 1 deletion src/screens/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ interface MenuItem {
| 'topics-list'
| 'persons-list'
| 'search'
| 'favorites';
| 'favorites'
| 'moments';
description: string;
}

Expand All @@ -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',
Expand Down
Loading
Loading