Skip to content

Commit c0b0a2e

Browse files
authored
Podcast latest season links (#661)
1 parent 566f165 commit c0b0a2e

File tree

3 files changed

+173
-18
lines changed

3 files changed

+173
-18
lines changed

app/components/navbar.tsx

Lines changed: 56 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,47 @@ import {
2323
} from './icons.tsx'
2424
import { TeamCircle } from './team-circle.tsx'
2525

26-
const LINKS = [
27-
{ id: 'blog', name: 'Blog', to: '/blog' },
28-
{ id: 'talks', name: 'Talks', to: '/talks' },
29-
{ id: 'courses', name: 'Courses', to: '/courses' },
30-
{ id: 'discord', name: 'Discord', to: '/discord' },
31-
{ id: 'chats', name: 'Chats', to: '/chats/05' },
32-
{ id: 'calls', name: 'Calls', to: '/calls/05' },
33-
{ id: 'about', name: 'About', to: '/about' },
34-
]
35-
36-
const MOBILE_LINKS = [{ name: 'Home', to: '/' }, ...LINKS]
26+
type NavbarLinkItem = {
27+
id: string
28+
name: string
29+
to: string
30+
/**
31+
* Optional path prefix to determine active state. Useful when the `to` path
32+
* points at a specific season (e.g. `/chats/06`), but we want the nav item
33+
* active for all `/chats/*` routes.
34+
*/
35+
activeTo?: string
36+
}
37+
38+
function useNavbarLinks(): {
39+
links: Array<NavbarLinkItem>
40+
mobileLinks: Array<{ name: string; to: string }>
41+
} {
42+
const { latestPodcastSeasonLinks } = useRootData()
43+
44+
const chatsTo = latestPodcastSeasonLinks?.chats.latestSeasonPath ?? '/chats'
45+
const callsTo = latestPodcastSeasonLinks?.calls.latestSeasonPath ?? '/calls'
46+
47+
const links = React.useMemo<Array<NavbarLinkItem>>(
48+
() => [
49+
{ id: 'blog', name: 'Blog', to: '/blog' },
50+
{ id: 'talks', name: 'Talks', to: '/talks' },
51+
{ id: 'courses', name: 'Courses', to: '/courses' },
52+
{ id: 'discord', name: 'Discord', to: '/discord' },
53+
{ id: 'chats', name: 'Chats', to: chatsTo, activeTo: '/chats' },
54+
{ id: 'calls', name: 'Calls', to: callsTo, activeTo: '/calls' },
55+
{ id: 'about', name: 'About', to: '/about' },
56+
],
57+
[chatsTo, callsTo],
58+
)
59+
60+
const mobileLinks = React.useMemo(
61+
() => [{ name: 'Home', to: '/' }, ...links.map((l) => ({ name: l.name, to: l.to }))],
62+
[links],
63+
)
64+
65+
return { links, mobileLinks }
66+
}
3767
const searchHotkeyOptions = {
3868
ignoreInputs: true,
3969
preventDefault: true,
@@ -48,15 +78,18 @@ const searchHotkeyModifierOptions = {
4878

4979
function NavLink({
5080
to,
81+
activeTo,
5182
navItem,
5283
...rest
5384
}: Omit<Parameters<typeof Link>['0'], 'to'> & {
5485
to: string
86+
activeTo?: string
5587
navItem?: string
5688
}) {
5789
const location = useLocation()
90+
const matchTo = activeTo ?? to
5891
const isSelected =
59-
to === location.pathname || location.pathname.startsWith(`${to}/`)
92+
matchTo === location.pathname || location.pathname.startsWith(`${matchTo}/`)
6093

6194
return (
6295
<li className="px-5 py-2" data-nav-item={navItem}>
@@ -610,7 +643,7 @@ function NavSearch({
610643
)
611644
}
612645

613-
function MobileMenu() {
646+
function MobileMenu({ links }: { links: Array<{ name: string; to: string }> }) {
614647
const menuButtonRef = React.useRef<HTMLButtonElement>(null)
615648
const popoverRef = React.useRef<HTMLDivElement>(null)
616649
const location = useLocation()
@@ -720,7 +753,7 @@ function MobileMenu() {
720753
}}
721754
/>
722755
</div>
723-
{MOBILE_LINKS.map((link) => (
756+
{links.map((link) => (
724757
<Link
725758
className="hover:bg-secondary focus:bg-secondary text-primary px-5vw hover:text-team-current border-b border-gray-200 py-9 dark:border-gray-600"
726759
key={link.to}
@@ -815,6 +848,7 @@ function Navbar() {
815848
const navigate = useNavigate()
816849
const [team] = useTeam()
817850
const { requestInfo, userInfo } = useRootData()
851+
const { links, mobileLinks } = useNavbarLinks()
818852
const avatar = userInfo ? userInfo.avatar : kodyProfiles[team]
819853
const navLinksRef = React.useRef<HTMLDivElement>(null)
820854
const searchIconRef = React.useRef<HTMLAnchorElement>(null)
@@ -886,8 +920,13 @@ function Navbar() {
886920
className="navbar-links flex-none justify-center overflow-visible max-lg:hidden lg:flex"
887921
>
888922
<ul className="flex">
889-
{LINKS.map((link) => (
890-
<NavLink key={link.to} to={link.to} navItem={link.id}>
923+
{links.map((link) => (
924+
<NavLink
925+
key={link.id}
926+
to={link.to}
927+
activeTo={link.activeTo}
928+
navItem={link.id}
929+
>
891930
{link.name}
892931
</NavLink>
893932
))}
@@ -898,7 +937,7 @@ function Navbar() {
898937
{/* Right: theme + profile */}
899938
<div className="flex min-w-0 shrink-0 items-center justify-end">
900939
<div className="block lg:hidden">
901-
<MobileMenu />
940+
<MobileMenu links={mobileLinks} />
902941
</div>
903942
<div className="ml-4 flex items-center gap-4 lg:ml-0">
904943
<div className="noscript-hidden hidden lg:block">

app/root.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { getClientSession } from './utils/client.server.ts'
3737
import { getEnv } from './utils/env.server.ts'
3838
import { getLoginInfoSession } from './utils/login.server.ts'
3939
import { useNonce } from './utils/nonce-provider.ts'
40+
import { getLatestPodcastSeasonLinks } from './utils/podcast-latest-season.server.ts'
4041
import { getSocialMetas } from './utils/seo.ts'
4142
import { getSession } from './utils/session.server.ts'
4243
import { TeamProvider, useTeam } from './utils/team-provider.tsx'
@@ -107,11 +108,21 @@ export const links: LinksFunction = () => {
107108
export async function loader({ request }: Route.LoaderArgs) {
108109
const timings = {}
109110
const session = await getSession(request)
110-
const [user, clientSession, loginInfoSession, primaryInstance] = await Promise.all([
111+
const [
112+
user,
113+
clientSession,
114+
loginInfoSession,
115+
primaryInstance,
116+
latestPodcastSeasonLinks,
117+
] = await Promise.all([
111118
session.getUser({ timings }),
112119
getClientSession(request, session.getUser({ timings })),
113120
getLoginInfoSession(request),
114121
getInstanceInfo().then((i) => i.primaryInstance),
122+
getLatestPodcastSeasonLinks({ request, timings }).catch(() => ({
123+
chats: { latestSeasonNumber: null, latestSeasonPath: '/chats' },
124+
calls: { latestSeasonNumber: null, latestSeasonPath: '/calls' },
125+
})),
115126
])
116127

117128
const randomFooterImageKeys = Object.keys(illustrationImages)
@@ -122,6 +133,7 @@ export async function loader({ request }: Route.LoaderArgs) {
122133
const data = {
123134
user,
124135
userInfo: user ? await getUserInfo(user, { request, timings }) : null,
136+
latestPodcastSeasonLinks,
125137
ENV: getEnv(),
126138
randomFooterImageKey,
127139
requestInfo: {
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { z } from 'zod'
2+
import { cache, cachified } from './cache.server.ts'
3+
import { type Timings } from './timing.server.ts'
4+
5+
const latestPodcastSeasonLinksSchema = z.object({
6+
chats: z.object({
7+
latestSeasonNumber: z.number().nullable(),
8+
latestSeasonPath: z.string().min(1),
9+
}),
10+
calls: z.object({
11+
latestSeasonNumber: z.number().nullable(),
12+
latestSeasonPath: z.string().min(1),
13+
}),
14+
})
15+
16+
function formatSeasonParam(seasonNumber: number) {
17+
return String(seasonNumber).padStart(2, '0')
18+
}
19+
20+
async function getLatestChatsSeasonNumber({
21+
request,
22+
timings,
23+
}: {
24+
request: Request
25+
timings?: Timings
26+
}) {
27+
try {
28+
// Dynamic import so missing podcast env vars don't crash the whole app.
29+
const { getSeasonListItems } = await import('./simplecast.server.ts')
30+
const seasons = await getSeasonListItems({ request, timings })
31+
const latestSeasonNumber = seasons.reduce(
32+
(max, s) => Math.max(max, s.seasonNumber ?? 0),
33+
0,
34+
)
35+
return latestSeasonNumber || null
36+
} catch (error: unknown) {
37+
console.error('podcast-latest-season: failed to load chats seasons', error)
38+
return null
39+
}
40+
}
41+
42+
async function getLatestCallsSeasonNumber({
43+
request,
44+
timings,
45+
}: {
46+
request: Request
47+
timings?: Timings
48+
}) {
49+
try {
50+
// Dynamic import so missing podcast env vars don't crash the whole app.
51+
const { getEpisodes } = await import('./transistor.server.ts')
52+
const episodes = await getEpisodes({ request, timings })
53+
const latestSeasonNumber = episodes.reduce(
54+
(max, e) => Math.max(max, e.seasonNumber ?? 0),
55+
0,
56+
)
57+
return latestSeasonNumber || null
58+
} catch (error: unknown) {
59+
console.error('podcast-latest-season: failed to load calls episodes', error)
60+
return null
61+
}
62+
}
63+
64+
export async function getLatestPodcastSeasonLinks({
65+
request,
66+
timings,
67+
}: {
68+
request: Request
69+
timings?: Timings
70+
}) {
71+
return cachified({
72+
cache,
73+
request,
74+
timings,
75+
key: 'podcasts:latest-season-links',
76+
// Keep this fairly fresh; it's used on every page load for nav links.
77+
ttl: 1000 * 60 * 5,
78+
staleWhileRevalidate: 1000 * 60 * 60 * 24,
79+
checkValue: latestPodcastSeasonLinksSchema,
80+
getFreshValue: async () => {
81+
const [latestChatsSeasonNumber, latestCallsSeasonNumber] =
82+
await Promise.all([
83+
getLatestChatsSeasonNumber({ request, timings }),
84+
getLatestCallsSeasonNumber({ request, timings }),
85+
])
86+
87+
return {
88+
chats: {
89+
latestSeasonNumber: latestChatsSeasonNumber,
90+
latestSeasonPath: latestChatsSeasonNumber
91+
? `/chats/${formatSeasonParam(latestChatsSeasonNumber)}`
92+
: '/chats',
93+
},
94+
calls: {
95+
latestSeasonNumber: latestCallsSeasonNumber,
96+
latestSeasonPath: latestCallsSeasonNumber
97+
? `/calls/${formatSeasonParam(latestCallsSeasonNumber)}`
98+
: '/calls',
99+
},
100+
}
101+
},
102+
})
103+
}
104+

0 commit comments

Comments
 (0)