|
1 | 1 | // Route: Shows the public playlist page for a single channel by slug. |
2 | 2 | import { useQuery, useQueryClient } from "@tanstack/react-query"; |
3 | 3 | import { createFileRoute } from "@tanstack/react-router"; |
4 | | -import { useEffect } from "react"; |
| 4 | +import { ChevronDown, ChevronUp, History } from "lucide-react"; |
| 5 | +import { useEffect, useState } from "react"; |
5 | 6 | import { SongSearchPanel } from "~/components/song-search-panel"; |
| 7 | +import { Button } from "~/components/ui/button"; |
| 8 | +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; |
| 9 | +import { |
| 10 | + Pagination, |
| 11 | + PaginationContent, |
| 12 | + PaginationItem, |
| 13 | + PaginationNext, |
| 14 | + PaginationPrevious, |
| 15 | +} from "~/components/ui/pagination"; |
6 | 16 | import { formatSlugTitle, pageTitle } from "~/lib/page-title"; |
7 | 17 | import { decodeHtmlEntities } from "~/lib/utils"; |
8 | 18 |
|
@@ -39,6 +49,22 @@ type PublicChannelPageData = { |
39 | 49 | }; |
40 | 50 | }; |
41 | 51 |
|
| 52 | +type PublicPlayedSong = { |
| 53 | + id: string; |
| 54 | + songTitle: string; |
| 55 | + songArtist?: string | null; |
| 56 | + requestedByDisplayName?: string | null; |
| 57 | + requestedByLogin?: string | null; |
| 58 | + playedAt: number; |
| 59 | +}; |
| 60 | + |
| 61 | +type PublicPlayedHistoryResponse = { |
| 62 | + results: PublicPlayedSong[]; |
| 63 | + page: number; |
| 64 | + pageSize: number; |
| 65 | + hasNextPage: boolean; |
| 66 | +}; |
| 67 | + |
42 | 68 | export const Route = createFileRoute("/$slug/")({ |
43 | 69 | head: ({ params }) => ({ |
44 | 70 | meta: [{ title: pageTitle(`${formatSlugTitle(params.slug)} Playlist`) }], |
@@ -86,6 +112,8 @@ function toPlaylistItems( |
86 | 112 | function PublicChannelPage() { |
87 | 113 | const { slug } = Route.useParams(); |
88 | 114 | const queryClient = useQueryClient(); |
| 115 | + const [historyOpen, setHistoryOpen] = useState(false); |
| 116 | + const [historyPage, setHistoryPage] = useState(1); |
89 | 117 | const { data, isLoading } = useQuery({ |
90 | 118 | queryKey: ["public-channel-page", slug], |
91 | 119 | queryFn: async (): Promise<PublicChannelPageData> => { |
@@ -140,6 +168,17 @@ function PublicChannelPage() { |
140 | 168 | }; |
141 | 169 | }, [queryClient, slug]); |
142 | 170 |
|
| 171 | + const playedHistoryQuery = useQuery<PublicPlayedHistoryResponse>({ |
| 172 | + queryKey: ["public-played-history", slug, historyPage], |
| 173 | + queryFn: async () => { |
| 174 | + const response = await fetch( |
| 175 | + `/api/channel/${slug}/played?page=${historyPage}&pageSize=20` |
| 176 | + ); |
| 177 | + return response.json() as Promise<PublicPlayedHistoryResponse>; |
| 178 | + }, |
| 179 | + enabled: historyOpen, |
| 180 | + }); |
| 181 | + |
143 | 182 | const channelDisplayName = data?.playlist?.channel?.displayName ?? slug; |
144 | 183 |
|
145 | 184 | return ( |
@@ -167,6 +206,103 @@ function PublicChannelPage() { |
167 | 206 | </div> |
168 | 207 | </div> |
169 | 208 |
|
| 209 | + <Card> |
| 210 | + <CardHeader className="flex flex-row items-center justify-between gap-4"> |
| 211 | + <div className="flex items-center gap-3"> |
| 212 | + <div className="rounded-full border border-(--border) bg-(--panel-soft) p-2 text-(--brand)"> |
| 213 | + <History className="h-4 w-4" /> |
| 214 | + </div> |
| 215 | + <div> |
| 216 | + <CardTitle>Played history</CardTitle> |
| 217 | + <p className="mt-1 text-sm text-(--muted)"> |
| 218 | + View the 20 most recent played songs. |
| 219 | + </p> |
| 220 | + </div> |
| 221 | + </div> |
| 222 | + <Button |
| 223 | + variant="outline" |
| 224 | + onClick={() => setHistoryOpen((current) => !current)} |
| 225 | + > |
| 226 | + {historyOpen ? ( |
| 227 | + <> |
| 228 | + <ChevronUp className="h-4 w-4" /> |
| 229 | + Hide history |
| 230 | + </> |
| 231 | + ) : ( |
| 232 | + <> |
| 233 | + <ChevronDown className="h-4 w-4" /> |
| 234 | + Show history |
| 235 | + </> |
| 236 | + )} |
| 237 | + </Button> |
| 238 | + </CardHeader> |
| 239 | + {historyOpen ? ( |
| 240 | + <CardContent className="grid gap-3"> |
| 241 | + {playedHistoryQuery.isLoading ? ( |
| 242 | + <p className="text-sm text-(--muted)"> |
| 243 | + Loading played history... |
| 244 | + </p> |
| 245 | + ) : null} |
| 246 | + {!playedHistoryQuery.isLoading && |
| 247 | + (playedHistoryQuery.data?.results.length ?? 0) === 0 ? ( |
| 248 | + <p className="text-sm text-(--muted)"> |
| 249 | + No songs have been marked played yet. |
| 250 | + </p> |
| 251 | + ) : null} |
| 252 | + {playedHistoryQuery.data?.results.map((song, index) => ( |
| 253 | + <div |
| 254 | + key={song.id} |
| 255 | + className={`rounded-[22px] border px-4 py-3 ${ |
| 256 | + index % 2 === 0 |
| 257 | + ? "border-(--border) bg-(--panel-soft)" |
| 258 | + : "border-(--border) bg-(--panel-muted)" |
| 259 | + }`} |
| 260 | + > |
| 261 | + <p className="font-medium text-(--text)"> |
| 262 | + {decodeHtmlEntities(song.songTitle)} |
| 263 | + {song.songArtist |
| 264 | + ? ` by ${decodeHtmlEntities(song.songArtist)}` |
| 265 | + : ""} |
| 266 | + </p> |
| 267 | + <p className="mt-1 text-sm text-(--muted)"> |
| 268 | + {(song.requestedByDisplayName ?? song.requestedByLogin) |
| 269 | + ? `Requested by ${song.requestedByDisplayName ?? song.requestedByLogin} · ` |
| 270 | + : ""} |
| 271 | + {new Date(song.playedAt).toLocaleString()} |
| 272 | + </p> |
| 273 | + </div> |
| 274 | + ))} |
| 275 | + {playedHistoryQuery.data && |
| 276 | + ((playedHistoryQuery.data.page ?? 1) > 1 || |
| 277 | + playedHistoryQuery.data.hasNextPage) ? ( |
| 278 | + <div className="flex flex-wrap items-center justify-between gap-4 pt-2"> |
| 279 | + <p className="text-sm text-(--muted)"> |
| 280 | + Page {playedHistoryQuery.data.page} |
| 281 | + </p> |
| 282 | + <Pagination className="mx-0 w-auto justify-end"> |
| 283 | + <PaginationContent> |
| 284 | + <PaginationItem> |
| 285 | + <PaginationPrevious |
| 286 | + onClick={() => |
| 287 | + setHistoryPage((current) => Math.max(1, current - 1)) |
| 288 | + } |
| 289 | + disabled={(playedHistoryQuery.data.page ?? 1) <= 1} |
| 290 | + /> |
| 291 | + </PaginationItem> |
| 292 | + <PaginationItem> |
| 293 | + <PaginationNext |
| 294 | + onClick={() => setHistoryPage((current) => current + 1)} |
| 295 | + disabled={!playedHistoryQuery.data.hasNextPage} |
| 296 | + /> |
| 297 | + </PaginationItem> |
| 298 | + </PaginationContent> |
| 299 | + </Pagination> |
| 300 | + </div> |
| 301 | + ) : null} |
| 302 | + </CardContent> |
| 303 | + ) : null} |
| 304 | + </Card> |
| 305 | + |
170 | 306 | <SongSearchPanel |
171 | 307 | title="Search to add a song" |
172 | 308 | description="Copy the request command and use it in Twitch chat." |
|
0 commit comments