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
73 changes: 56 additions & 17 deletions app/components/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,47 @@ import {
} from './icons.tsx'
import { TeamCircle } from './team-circle.tsx'

const LINKS = [
{ id: 'blog', name: 'Blog', to: '/blog' },
{ id: 'talks', name: 'Talks', to: '/talks' },
{ id: 'courses', name: 'Courses', to: '/courses' },
{ id: 'discord', name: 'Discord', to: '/discord' },
{ id: 'chats', name: 'Chats', to: '/chats/05' },
{ id: 'calls', name: 'Calls', to: '/calls/05' },
{ id: 'about', name: 'About', to: '/about' },
]

const MOBILE_LINKS = [{ name: 'Home', to: '/' }, ...LINKS]
type NavbarLinkItem = {
id: string
name: string
to: string
/**
* Optional path prefix to determine active state. Useful when the `to` path
* points at a specific season (e.g. `/chats/06`), but we want the nav item
* active for all `/chats/*` routes.
*/
activeTo?: string
}

function useNavbarLinks(): {
links: Array<NavbarLinkItem>
mobileLinks: Array<{ name: string; to: string }>
} {
const { latestPodcastSeasonLinks } = useRootData()

const chatsTo = latestPodcastSeasonLinks?.chats.latestSeasonPath ?? '/chats'
const callsTo = latestPodcastSeasonLinks?.calls.latestSeasonPath ?? '/calls'

const links = React.useMemo<Array<NavbarLinkItem>>(
() => [
{ id: 'blog', name: 'Blog', to: '/blog' },
{ id: 'talks', name: 'Talks', to: '/talks' },
{ id: 'courses', name: 'Courses', to: '/courses' },
{ id: 'discord', name: 'Discord', to: '/discord' },
{ id: 'chats', name: 'Chats', to: chatsTo, activeTo: '/chats' },
{ id: 'calls', name: 'Calls', to: callsTo, activeTo: '/calls' },
{ id: 'about', name: 'About', to: '/about' },
],
[chatsTo, callsTo],
)

const mobileLinks = React.useMemo(
() => [{ name: 'Home', to: '/' }, ...links.map((l) => ({ name: l.name, to: l.to }))],
[links],
)

return { links, mobileLinks }
}
const searchHotkeyOptions = {
ignoreInputs: true,
preventDefault: true,
Expand All @@ -48,15 +78,18 @@ const searchHotkeyModifierOptions = {

function NavLink({
to,
activeTo,
navItem,
...rest
}: Omit<Parameters<typeof Link>['0'], 'to'> & {
to: string
activeTo?: string
navItem?: string
}) {
const location = useLocation()
const matchTo = activeTo ?? to
const isSelected =
to === location.pathname || location.pathname.startsWith(`${to}/`)
matchTo === location.pathname || location.pathname.startsWith(`${matchTo}/`)

return (
<li className="px-5 py-2" data-nav-item={navItem}>
Expand Down Expand Up @@ -610,7 +643,7 @@ function NavSearch({
)
}

function MobileMenu() {
function MobileMenu({ links }: { links: Array<{ name: string; to: string }> }) {
const menuButtonRef = React.useRef<HTMLButtonElement>(null)
const popoverRef = React.useRef<HTMLDivElement>(null)
const location = useLocation()
Expand Down Expand Up @@ -720,7 +753,7 @@ function MobileMenu() {
}}
/>
</div>
{MOBILE_LINKS.map((link) => (
{links.map((link) => (
<Link
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"
key={link.to}
Expand Down Expand Up @@ -815,6 +848,7 @@ function Navbar() {
const navigate = useNavigate()
const [team] = useTeam()
const { requestInfo, userInfo } = useRootData()
const { links, mobileLinks } = useNavbarLinks()
const avatar = userInfo ? userInfo.avatar : kodyProfiles[team]
const navLinksRef = React.useRef<HTMLDivElement>(null)
const searchIconRef = React.useRef<HTMLAnchorElement>(null)
Expand Down Expand Up @@ -886,8 +920,13 @@ function Navbar() {
className="navbar-links flex-none justify-center overflow-visible max-lg:hidden lg:flex"
>
<ul className="flex">
{LINKS.map((link) => (
<NavLink key={link.to} to={link.to} navItem={link.id}>
{links.map((link) => (
<NavLink
key={link.id}
to={link.to}
activeTo={link.activeTo}
navItem={link.id}
>
{link.name}
</NavLink>
))}
Expand All @@ -898,7 +937,7 @@ function Navbar() {
{/* Right: theme + profile */}
<div className="flex min-w-0 shrink-0 items-center justify-end">
<div className="block lg:hidden">
<MobileMenu />
<MobileMenu links={mobileLinks} />
</div>
<div className="ml-4 flex items-center gap-4 lg:ml-0">
<div className="noscript-hidden hidden lg:block">
Expand Down
14 changes: 13 additions & 1 deletion app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { getClientSession } from './utils/client.server.ts'
import { getEnv } from './utils/env.server.ts'
import { getLoginInfoSession } from './utils/login.server.ts'
import { useNonce } from './utils/nonce-provider.ts'
import { getLatestPodcastSeasonLinks } from './utils/podcast-latest-season.server.ts'
import { getSocialMetas } from './utils/seo.ts'
import { getSession } from './utils/session.server.ts'
import { TeamProvider, useTeam } from './utils/team-provider.tsx'
Expand Down Expand Up @@ -107,11 +108,21 @@ export const links: LinksFunction = () => {
export async function loader({ request }: Route.LoaderArgs) {
const timings = {}
const session = await getSession(request)
const [user, clientSession, loginInfoSession, primaryInstance] = await Promise.all([
const [
user,
clientSession,
loginInfoSession,
primaryInstance,
latestPodcastSeasonLinks,
] = await Promise.all([
session.getUser({ timings }),
getClientSession(request, session.getUser({ timings })),
getLoginInfoSession(request),
getInstanceInfo().then((i) => i.primaryInstance),
getLatestPodcastSeasonLinks({ request, timings }).catch(() => ({
chats: { latestSeasonNumber: null, latestSeasonPath: '/chats' },
calls: { latestSeasonNumber: null, latestSeasonPath: '/calls' },
})),
])

const randomFooterImageKeys = Object.keys(illustrationImages)
Expand All @@ -122,6 +133,7 @@ export async function loader({ request }: Route.LoaderArgs) {
const data = {
user,
userInfo: user ? await getUserInfo(user, { request, timings }) : null,
latestPodcastSeasonLinks,
ENV: getEnv(),
randomFooterImageKey,
requestInfo: {
Expand Down
104 changes: 104 additions & 0 deletions app/utils/podcast-latest-season.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { z } from 'zod'
import { cache, cachified } from './cache.server.ts'
import { type Timings } from './timing.server.ts'

const latestPodcastSeasonLinksSchema = z.object({
chats: z.object({
latestSeasonNumber: z.number().nullable(),
latestSeasonPath: z.string().min(1),
}),
calls: z.object({
latestSeasonNumber: z.number().nullable(),
latestSeasonPath: z.string().min(1),
}),
})

function formatSeasonParam(seasonNumber: number) {
return String(seasonNumber).padStart(2, '0')
}

async function getLatestChatsSeasonNumber({
request,
timings,
}: {
request: Request
timings?: Timings
}) {
try {
// Dynamic import so missing podcast env vars don't crash the whole app.
const { getSeasonListItems } = await import('./simplecast.server.ts')
const seasons = await getSeasonListItems({ request, timings })
const latestSeasonNumber = seasons.reduce(
(max, s) => Math.max(max, s.seasonNumber ?? 0),
0,
)
return latestSeasonNumber || null
} catch (error: unknown) {
console.error('podcast-latest-season: failed to load chats seasons', error)
return null
}
}

async function getLatestCallsSeasonNumber({
request,
timings,
}: {
request: Request
timings?: Timings
}) {
try {
// Dynamic import so missing podcast env vars don't crash the whole app.
const { getEpisodes } = await import('./transistor.server.ts')
const episodes = await getEpisodes({ request, timings })
const latestSeasonNumber = episodes.reduce(
(max, e) => Math.max(max, e.seasonNumber ?? 0),
0,
)
return latestSeasonNumber || null
} catch (error: unknown) {
console.error('podcast-latest-season: failed to load calls episodes', error)
return null
}
}

export async function getLatestPodcastSeasonLinks({
request,
timings,
}: {
request: Request
timings?: Timings
}) {
return cachified({
cache,
request,
timings,
key: 'podcasts:latest-season-links',
// Keep this fairly fresh; it's used on every page load for nav links.
ttl: 1000 * 60 * 5,
staleWhileRevalidate: 1000 * 60 * 60 * 24,
checkValue: latestPodcastSeasonLinksSchema,
getFreshValue: async () => {
const [latestChatsSeasonNumber, latestCallsSeasonNumber] =
await Promise.all([
getLatestChatsSeasonNumber({ request, timings }),
getLatestCallsSeasonNumber({ request, timings }),
])

return {
chats: {
latestSeasonNumber: latestChatsSeasonNumber,
latestSeasonPath: latestChatsSeasonNumber
? `/chats/${formatSeasonParam(latestChatsSeasonNumber)}`
: '/chats',
},
calls: {
latestSeasonNumber: latestCallsSeasonNumber,
latestSeasonPath: latestCallsSeasonNumber
? `/calls/${formatSeasonParam(latestCallsSeasonNumber)}`
: '/calls',
},
}
},
})
}