Skip to content

Commit 0ad1524

Browse files
Add lifetime played history and restore played requests
1 parent f03e6cb commit 0ad1524

File tree

12 files changed

+491
-31
lines changed

12 files changed

+491
-31
lines changed

drizzle/0002_clever_justice.sql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ALTER TABLE `played_songs`
2+
ADD COLUMN `request_kind` text NOT NULL DEFAULT 'regular';

drizzle/meta/_journal.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@
1515
"when": 1773950400000,
1616
"tag": "0001_true_ares",
1717
"breakpoints": true
18+
},
19+
{
20+
"idx": 2,
21+
"version": "7",
22+
"when": 1774041600000,
23+
"tag": "0002_clever_justice",
24+
"breakpoints": true
1825
}
1926
]
2027
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export const LATEST_MIGRATION_NAME = "0001_true_ares.sql";
1+
export const LATEST_MIGRATION_NAME = "0002_clever_justice.sql";

src/lib/db/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,7 @@ export const playedSongs = sqliteTable(
451451
requestedByTwitchUserId: text("requested_by_twitch_user_id"),
452452
requestedByLogin: text("requested_by_login"),
453453
requestedByDisplayName: text("requested_by_display_name"),
454+
requestKind: text("request_kind").notNull().default("regular"),
454455
requestedAt: integer("requested_at"),
455456
playedAt: integer("played_at").notNull(),
456457
createdAt: integer("created_at")

src/lib/playlist/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ export interface SetCurrentInput {
4545
actorUserId: string;
4646
}
4747

48+
export interface RestorePlayedInput {
49+
channelId: string;
50+
playedSongId: string;
51+
actorUserId: string;
52+
}
53+
4854
export interface SkipItemInput {
4955
channelId: string;
5056
itemId: string;
@@ -126,6 +132,7 @@ export interface PlaylistCoordinator {
126132
addRequest(input: AddRequestInput): Promise<PlaylistMutationResult>;
127133
removeRequests(input: RemoveRequestsInput): Promise<PlaylistMutationResult>;
128134
markPlayed(input: MarkPlayedInput): Promise<PlaylistMutationResult>;
135+
restorePlayed(input: RestorePlayedInput): Promise<PlaylistMutationResult>;
129136
setCurrent(input: SetCurrentInput): Promise<PlaylistMutationResult>;
130137
skipItem(input: SkipItemInput): Promise<PlaylistMutationResult>;
131138
shuffleNext(input: ShuffleNextInput): Promise<PlaylistMutationResult>;

src/lib/validation.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ export const songListItemSchema = z.object({
224224

225225
export const playlistMutationSchema = z.discriminatedUnion("action", [
226226
z.object({ action: z.literal("markPlayed"), itemId: z.string() }),
227+
z.object({ action: z.literal("restorePlayed"), playedSongId: z.string() }),
227228
z.object({ action: z.literal("skipItem"), itemId: z.string() }),
228229
z.object({ action: z.literal("setCurrent"), itemId: z.string() }),
229230
z.object({ action: z.literal("deleteItem"), itemId: z.string() }),

src/routes/$slug/index.tsx

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
11
// Route: Shows the public playlist page for a single channel by slug.
22
import { useQuery, useQueryClient } from "@tanstack/react-query";
33
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";
56
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";
616
import { formatSlugTitle, pageTitle } from "~/lib/page-title";
717
import { decodeHtmlEntities } from "~/lib/utils";
818

@@ -39,6 +49,22 @@ type PublicChannelPageData = {
3949
};
4050
};
4151

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+
4268
export const Route = createFileRoute("/$slug/")({
4369
head: ({ params }) => ({
4470
meta: [{ title: pageTitle(`${formatSlugTitle(params.slug)} Playlist`) }],
@@ -86,6 +112,8 @@ function toPlaylistItems(
86112
function PublicChannelPage() {
87113
const { slug } = Route.useParams();
88114
const queryClient = useQueryClient();
115+
const [historyOpen, setHistoryOpen] = useState(false);
116+
const [historyPage, setHistoryPage] = useState(1);
89117
const { data, isLoading } = useQuery({
90118
queryKey: ["public-channel-page", slug],
91119
queryFn: async (): Promise<PublicChannelPageData> => {
@@ -140,6 +168,17 @@ function PublicChannelPage() {
140168
};
141169
}, [queryClient, slug]);
142170

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+
143182
const channelDisplayName = data?.playlist?.channel?.displayName ?? slug;
144183

145184
return (
@@ -167,6 +206,103 @@ function PublicChannelPage() {
167206
</div>
168207
</div>
169208

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+
170306
<SongSearchPanel
171307
title="Search to add a song"
172308
description="Copy the request command and use it in Twitch chat."
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Route: Returns paginated played-song history for a public channel playlist.
2+
import { env } from "cloudflare:workers";
3+
import { createFileRoute } from "@tanstack/react-router";
4+
import { desc, eq } from "drizzle-orm";
5+
import { getDb } from "~/lib/db/client";
6+
import {
7+
getChannelBySlug,
8+
getChannelSettingsByChannelId,
9+
} from "~/lib/db/repositories";
10+
import { playedSongs } from "~/lib/db/schema";
11+
import type { AppEnv } from "~/lib/env";
12+
import { json } from "~/lib/utils";
13+
14+
export const Route = createFileRoute("/api/channel/$slug/played")({
15+
server: {
16+
handlers: {
17+
GET: async ({ params, request }) => {
18+
const runtimeEnv = env as AppEnv;
19+
const channel = await getChannelBySlug(runtimeEnv, params.slug);
20+
21+
if (!channel) {
22+
return json({ error: "Channel not found" }, { status: 404 });
23+
}
24+
25+
const settings = await getChannelSettingsByChannelId(
26+
runtimeEnv,
27+
channel.id
28+
);
29+
if (settings && !settings.publicPlaylistEnabled) {
30+
return json({ error: "Playlist is private" }, { status: 403 });
31+
}
32+
33+
const url = new URL(request.url);
34+
const page = Math.max(1, Number(url.searchParams.get("page") ?? "1"));
35+
const pageSize = Math.min(
36+
20,
37+
Math.max(1, Number(url.searchParams.get("pageSize") ?? "20"))
38+
);
39+
const offset = (page - 1) * pageSize;
40+
41+
const rows = await getDb(runtimeEnv).query.playedSongs.findMany({
42+
where: eq(playedSongs.channelId, channel.id),
43+
orderBy: [desc(playedSongs.playedAt)],
44+
limit: pageSize + 1,
45+
offset,
46+
});
47+
48+
return json({
49+
results: rows.slice(0, pageSize),
50+
page,
51+
pageSize,
52+
hasNextPage: rows.length > pageSize,
53+
});
54+
},
55+
},
56+
},
57+
});

src/routes/api/dashboard/playlist/route.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
// Route: Reads and mutates playlist state for the active dashboard channel.
22
import { env } from "cloudflare:workers";
33
import { createFileRoute } from "@tanstack/react-router";
4+
import { desc, eq } from "drizzle-orm";
45
import { getSessionUserId } from "~/lib/auth/session.server";
56
import { callBackend } from "~/lib/backend";
7+
import { getDb } from "~/lib/db/client";
68
import {
79
getCatalogSongsByIds,
810
getChannelSettingsByChannelId,
911
getDashboardChannelAccess,
1012
getDashboardState,
1113
getPlaylistByChannelId,
1214
} from "~/lib/db/repositories";
15+
import { playedSongs } from "~/lib/db/schema";
1316
import {
1417
assertDatabaseSchemaCurrent,
1518
DatabaseSchemaOutOfDateError,
@@ -60,6 +63,11 @@ async function requireDashboardState(
6063
runtimeEnv,
6164
access.channel.id
6265
);
66+
const playedRows = await getDb(runtimeEnv).query.playedSongs.findMany({
67+
where: eq(playedSongs.channelId, access.channel.id),
68+
orderBy: [desc(playedSongs.playedAt)],
69+
limit: 100,
70+
});
6371
if (!playlistState) {
6472
return null;
6573
}
@@ -69,7 +77,7 @@ async function requireDashboardState(
6977
settings,
7078
playlist: playlistState.playlist,
7179
items: playlistState.items,
72-
playedSongs: [],
80+
playedSongs: playedRows,
7381
accessRole: access.accessRole,
7482
actorUserId: access.actorUserId,
7583
};

0 commit comments

Comments
 (0)