Skip to content

Commit 132fabf

Browse files
committed
feat(wip): Implement show listing, detail pages, and user subscription management for shows.
1 parent e720e4a commit 132fabf

14 files changed

Lines changed: 727 additions & 65 deletions

File tree

apps/vps/src/routes/shows/show.handlers.ts

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import type {
1010
GetAllShowsRoute,
1111
GetShowBySlugRoute,
1212
GetShowEpisodesRoute,
13-
GetUserSubscriptionsRoute,
1413
SubscribeToShowRoute,
1514
UnsubscribeFromShowRoute,
1615
UpdateShowBySlugRoute
@@ -288,30 +287,3 @@ export const unsubscribeFromShow: AppRouteHandler<
288287

289288
return c.body(null, HttpStatusCodes.NO_CONTENT)
290289
}
291-
292-
export const getUserSubscriptions: AppRouteHandler<
293-
GetUserSubscriptionsRoute
294-
> = async (c) => {
295-
const { limit, offset } = c.req.valid('query')
296-
const user = c.get('user')
297-
298-
const program = Effect.gen(function* () {
299-
const showService = yield* ShowService
300-
return yield* showService.getUserSubscriptions(user.id, { limit, offset })
301-
}).pipe(
302-
Effect.catchTag('DatabaseError', (e) =>
303-
Effect.succeed({
304-
error: e.message,
305-
status: HttpStatusCodes.INTERNAL_SERVER_ERROR
306-
} as const)
307-
)
308-
)
309-
310-
const result = await AppRuntime.runPromise(program)
311-
312-
if ('error' in result) {
313-
return c.json({ error: result.error }, result.status)
314-
}
315-
316-
return c.json(result, HttpStatusCodes.OK)
317-
}

apps/vps/src/routes/shows/show.index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,5 @@ const router = createRouter()
1212
.openapi(routes.getShowEpisodes, handlers.getShowEpisodes)
1313
.openapi(routes.subscribeToShow, handlers.subscribeToShow)
1414
.openapi(routes.unsubscribeFromShow, handlers.unsubscribeFromShow)
15-
.openapi(routes.getUserSubscriptions, handlers.getUserSubscriptions)
1615

1716
export default router

apps/vps/src/routes/shows/show.routes.ts

Lines changed: 8 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import {
88
selectMdxCompiledShowSchema,
99
selectShowSchema,
1010
selectSubscriptionSchema,
11-
subscriptionWithShowSchema,
1211
updateShowSchema
1312
} from '@/db/show.schema'
1413
import {
@@ -33,7 +32,7 @@ const showWithHostsSchema = selectShowSchema
3332
.openapi('ShowWithHosts')
3433

3534
export const getAllShows = createRoute({
36-
path: '/shows',
35+
path: '/',
3736
method: 'get',
3837
request: {
3938
query: paginationQuerySchema
@@ -52,7 +51,7 @@ export const getAllShows = createRoute({
5251
})
5352

5453
export const getShowBySlug = createRoute({
55-
path: '/shows/{slug}',
54+
path: '/{slug}',
5655
method: 'get',
5756
request: {
5857
params: z.object({
@@ -77,7 +76,7 @@ export const getShowBySlug = createRoute({
7776
})
7877

7978
export const createShow = createRoute({
80-
path: '/shows',
79+
path: '/',
8180
method: 'post',
8281
middleware: [betterAuthMiddleware],
8382
request: {
@@ -109,7 +108,7 @@ export const createShow = createRoute({
109108
})
110109

111110
export const updateShowBySlug = createRoute({
112-
path: '/shows/{slug}',
111+
path: '/{slug}',
113112
method: 'patch',
114113
middleware: [betterAuthMiddleware],
115114
request: {
@@ -140,7 +139,7 @@ export const updateShowBySlug = createRoute({
140139
})
141140

142141
export const deleteShowBySlug = createRoute({
143-
path: '/shows/{slug}',
142+
path: '/{slug}',
144143
method: 'delete',
145144
middleware: [betterAuthMiddleware],
146145
request: {
@@ -169,7 +168,7 @@ export const deleteShowBySlug = createRoute({
169168
})
170169

171170
export const getShowEpisodes = createRoute({
172-
path: '/shows/{slug}/episodes',
171+
path: '/{slug}/episodes',
173172
method: 'get',
174173
request: {
175174
params: z.object({
@@ -195,7 +194,7 @@ export const getShowEpisodes = createRoute({
195194
})
196195

197196
export const subscribeToShow = createRoute({
198-
path: '/shows/{id}/subscribe',
197+
path: '/{id}/subscribe',
199198
method: 'post',
200199
middleware: [betterAuthMiddleware],
201200
request: {
@@ -225,7 +224,7 @@ export const subscribeToShow = createRoute({
225224
})
226225

227226
export const unsubscribeFromShow = createRoute({
228-
path: '/shows/{id}/unsubscribe',
227+
path: '/{id}/unsubscribe',
229228
method: 'delete',
230229
middleware: [betterAuthMiddleware],
231230
request: {
@@ -253,30 +252,6 @@ export const unsubscribeFromShow = createRoute({
253252
}
254253
})
255254

256-
export const getUserSubscriptions = createRoute({
257-
path: '/user/subscriptions',
258-
method: 'get',
259-
middleware: [betterAuthMiddleware],
260-
request: {
261-
query: paginationQuerySchema
262-
},
263-
tags,
264-
responses: {
265-
[HttpStatusCodes.OK]: jsonContent(
266-
createPaginatedResponseSchema(subscriptionWithShowSchema),
267-
'User subscriptions'
268-
),
269-
[HttpStatusCodes.UNAUTHORIZED]: jsonContent(
270-
z.object({ error: z.string() }),
271-
'Unauthorized'
272-
),
273-
[HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent(
274-
z.object({ error: z.string() }),
275-
'Failed to fetch subscriptions'
276-
)
277-
}
278-
})
279-
280255
export type GetAllShowsRoute = typeof getAllShows
281256
export type GetShowBySlugRoute = typeof getShowBySlug
282257
export type CreateShowRoute = typeof createShow
@@ -285,4 +260,3 @@ export type DeleteShowBySlugRoute = typeof deleteShowBySlug
285260
export type GetShowEpisodesRoute = typeof getShowEpisodes
286261
export type SubscribeToShowRoute = typeof subscribeToShow
287262
export type UnsubscribeFromShowRoute = typeof unsubscribeFromShow
288-
export type GetUserSubscriptionsRoute = typeof getUserSubscriptions

apps/vps/src/routes/user/user.handlers.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import { Effect } from 'effect'
22
import * as HttpStatusCodes from 'stoker/http-status-codes'
33
import type { AppRouteHandler } from '@/lib/types'
4+
import { AppRuntime } from '@/runtime'
45
import { runApp } from '@/runtime'
6+
import { ShowService } from '@/services/show.service'
57
import { UserService } from '@/services/user.service'
68

79
import type {
810
GetEmailPreferencesRoute,
911
GetProfileRoute,
12+
GetUserSubscriptionsRoute,
1013
UpdateEmailPreferencesRoute,
1114
UpdateProfileRoute
1215
} from './user.routes'
@@ -163,3 +166,30 @@ export const updateEmailPreferences: AppRouteHandler<
163166

164167
return c.json(result.right, HttpStatusCodes.OK)
165168
}
169+
170+
export const getUserSubscriptions: AppRouteHandler<
171+
GetUserSubscriptionsRoute
172+
> = async (c) => {
173+
const { limit, offset } = c.req.valid('query')
174+
const user = c.get('user')
175+
176+
const program = Effect.gen(function* () {
177+
const showService = yield* ShowService
178+
return yield* showService.getUserSubscriptions(user.id, { limit, offset })
179+
}).pipe(
180+
Effect.catchTag('DatabaseError', (e) =>
181+
Effect.succeed({
182+
error: e.message,
183+
status: HttpStatusCodes.INTERNAL_SERVER_ERROR
184+
} as const)
185+
)
186+
)
187+
188+
const result = await AppRuntime.runPromise(program)
189+
190+
if ('error' in result) {
191+
return c.json({ error: result.error }, result.status)
192+
}
193+
194+
return c.json(result, HttpStatusCodes.OK)
195+
}

apps/vps/src/routes/user/user.index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ const router = createRouter()
88
.openapi(routes.getProfile, handlers.getProfile)
99
.openapi(routes.getEmailPreferences, handlers.getEmailPreferences)
1010
.openapi(routes.updateEmailPreferences, handlers.updateEmailPreferences)
11+
.openapi(routes.getUserSubscriptions, handlers.getUserSubscriptions)
1112

1213
export default router

apps/vps/src/routes/user/user.routes.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import {
55
selectAuthorEmailPreferencesSchema,
66
updateAuthorEmailPreferencesSchema
77
} from '@/db/email.schema'
8+
import { subscriptionWithShowSchema } from '@/db/show.schema'
9+
import {
10+
createPaginatedResponseSchema,
11+
paginationQuerySchema
12+
} from '@/lib/pagination'
813

914
// Better Auth compatible schemas
1015
const selectUserSchema = z.object({
@@ -178,7 +183,32 @@ export const updateEmailPreferences = createRoute({
178183
}
179184
})
180185

186+
export const getUserSubscriptions = createRoute({
187+
path: '/subscriptions',
188+
method: 'get',
189+
middleware: [betterAuthMiddleware],
190+
request: {
191+
query: paginationQuerySchema
192+
},
193+
tags,
194+
responses: {
195+
[HttpStatusCodes.OK]: jsonContent(
196+
createPaginatedResponseSchema(subscriptionWithShowSchema),
197+
'User subscriptions'
198+
),
199+
[HttpStatusCodes.UNAUTHORIZED]: jsonContent(
200+
z.object({ error: z.string() }),
201+
'Unauthorized'
202+
),
203+
[HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent(
204+
z.object({ error: z.string() }),
205+
'Failed to fetch subscriptions'
206+
)
207+
}
208+
})
209+
181210
export type UpdateProfileRoute = typeof updateProfile
182211
export type GetProfileRoute = typeof getProfile
183212
export type GetEmailPreferencesRoute = typeof getEmailPreferences
184213
export type UpdateEmailPreferencesRoute = typeof updateEmailPreferences
214+
export type GetUserSubscriptionsRoute = typeof getUserSubscriptions
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { Link } from '@tanstack/react-router'
2+
import { GiPauseButton, GiPlayButton } from 'react-icons/gi'
3+
import { LoadMoreTrigger } from '@/components/LoadMoreTrigger'
4+
import { DEFAULT_IMAGE_URL } from '@/lib/constants'
5+
import { useShowEpisodes } from '@/lib/http'
6+
import { cn } from '@/lib/utils'
7+
import { useAudioPlayerActions, useAudioPlayerState } from '@/store/audioPlayer'
8+
9+
interface EpisodeGridProps {
10+
showSlug: string
11+
}
12+
13+
export function EpisodeGrid({ showSlug }: EpisodeGridProps) {
14+
const {
15+
data: episodes,
16+
isPending,
17+
error,
18+
fetchNextPage,
19+
hasNextPage,
20+
isFetchingNextPage
21+
} = useShowEpisodes(showSlug)
22+
23+
const { isPlaying, nowPlayingContext } = useAudioPlayerState()
24+
const { loadTrack } = useAudioPlayerActions()
25+
26+
if (isPending) {
27+
return (
28+
<div className='grid gap-2'>
29+
{Array.from({ length: 5 }).map((_, i) => (
30+
<div key={i} className='flex gap-3 items-start p-2'>
31+
<div className='w-14 h-14 rounded-sm bg-muted/50 animate-pulse flex-shrink-0' />
32+
<div className='flex-1 space-y-2'>
33+
<div className='h-4 w-3/4 rounded bg-muted/50 animate-pulse' />
34+
<div className='h-3 w-1/2 rounded bg-muted/50 animate-pulse' />
35+
</div>
36+
</div>
37+
))}
38+
</div>
39+
)
40+
}
41+
42+
if (error) {
43+
return (
44+
<div className='text-center text-destructive py-8'>
45+
Error loading episodes: {error.message}
46+
</div>
47+
)
48+
}
49+
50+
if (!episodes || episodes.length === 0) {
51+
return (
52+
<div className='text-center text-muted-foreground py-8'>
53+
No episodes yet
54+
</div>
55+
)
56+
}
57+
58+
return (
59+
<div className='space-y-4'>
60+
<h2 className='text-xl font-bold'>Episodes</h2>
61+
<div className='grid gap-2'>
62+
{episodes.map((episode) => {
63+
const isActive = nowPlayingContext?.title === episode.title
64+
65+
return (
66+
<article
67+
key={episode.id}
68+
className={cn(
69+
'flex gap-3 items-start p-2 transition-all duration-300 hover:bg-muted/50 rounded-sm group',
70+
isActive && 'ring-1 ring-border bg-accent/5 shadow-sm'
71+
)}>
72+
<button
73+
type='button'
74+
className='relative flex-shrink-0 focus:outline-none'
75+
onClick={() =>
76+
loadTrack(
77+
episode.url,
78+
episode.thumbnailUrl || DEFAULT_IMAGE_URL,
79+
episode.title,
80+
episode.id
81+
)
82+
}>
83+
<img
84+
src={episode.thumbnailUrl || DEFAULT_IMAGE_URL}
85+
alt={episode.title}
86+
className='object-cover transition-transform duration-300 border rounded-sm w-14 h-14 border-border bg-background group-hover:scale-105'
87+
/>
88+
<span
89+
className={cn(
90+
'absolute inset-0 flex items-center justify-center transition-all duration-300 rounded-sm bg-black/50',
91+
isActive
92+
? 'opacity-100'
93+
: 'opacity-0 group-hover:opacity-100 group-focus:opacity-100'
94+
)}>
95+
{isActive && isPlaying ? (
96+
<GiPauseButton className='text-2xl text-white drop-shadow' />
97+
) : (
98+
<GiPlayButton className='text-2xl text-white drop-shadow' />
99+
)}
100+
</span>
101+
</button>
102+
<div className='flex-1 min-w-0'>
103+
<Link
104+
to='/mixes/$mixId'
105+
params={{ mixId: episode.slug }}
106+
className='block font-bold leading-none truncate text-foreground hover:underline decoration-foreground/30 underline-offset-4'>
107+
{episode.title}
108+
</Link>
109+
{episode.description && (
110+
<div className='mt-1 text-sm leading-relaxed text-foreground/60 line-clamp-2'>
111+
{episode.description}
112+
</div>
113+
)}
114+
</div>
115+
</article>
116+
)
117+
})}
118+
119+
<LoadMoreTrigger
120+
onLoadMore={fetchNextPage}
121+
hasNextPage={hasNextPage}
122+
isFetchingNextPage={isFetchingNextPage}
123+
/>
124+
</div>
125+
</div>
126+
)
127+
}

0 commit comments

Comments
 (0)