Skip to content

Commit 18f97d3

Browse files
Improve mobile playlist and search UX (#31)
1 parent d56c909 commit 18f97d3

File tree

9 files changed

+455
-127
lines changed

9 files changed

+455
-127
lines changed

src/app.css

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -644,7 +644,8 @@ h6 {
644644
.search-panel__row {
645645
grid-template-columns: minmax(0, 1fr) auto;
646646
grid-template-areas:
647-
"main copy";
647+
"main copy"
648+
"main stats";
648649
gap: 0.75rem 1rem;
649650
padding-inline: 1rem;
650651
padding-block: 0.9rem;
@@ -655,8 +656,7 @@ h6 {
655656
grid-template-columns: minmax(0, 1fr);
656657
grid-template-areas:
657658
"song"
658-
"paths"
659-
"stats";
659+
"paths";
660660
gap: 0.75rem;
661661
}
662662

@@ -666,10 +666,12 @@ h6 {
666666

667667
.search-panel__copy {
668668
grid-area: copy;
669+
align-items: start;
669670
}
670671

671672
.search-panel__copy > div {
672673
justify-content: flex-end;
674+
min-width: max-content;
673675
}
674676

675677
.search-panel__paths {
@@ -679,6 +681,7 @@ h6 {
679681
.search-panel__stats {
680682
grid-area: stats;
681683
text-align: right;
684+
align-self: start;
682685
}
683686

684687
.search-panel__command,

src/components/blacklist-panel.tsx

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export function BlacklistPanel(props: {
2727
defaultOpen?: boolean;
2828
}) {
2929
const content = (
30-
<CardContent className="grid items-start gap-6 lg:grid-cols-3">
30+
<CardContent className="grid items-start gap-6 lg:grid-cols-2">
3131
<div className="grid content-start gap-3 self-start">
3232
{props.artists.length > 0 ? (
3333
<div className="overflow-hidden rounded-[20px] border border-(--border)">
@@ -49,27 +49,6 @@ export function BlacklistPanel(props: {
4949
)}
5050
</div>
5151

52-
<div className="grid content-start gap-3 self-start">
53-
{(props.charters ?? []).length > 0 ? (
54-
<div className="overflow-hidden rounded-[20px] border border-(--border)">
55-
{(props.charters ?? []).map((charter, index) => (
56-
<div
57-
key={charter.charterId}
58-
className={`px-4 py-2.5 text-sm ${
59-
index % 2 === 0 ? "bg-(--panel-soft)" : "bg-(--panel-muted)"
60-
}`}
61-
>
62-
<p className="truncate text-(--text)">
63-
{charter.charterName} ({charter.charterId})
64-
</p>
65-
</div>
66-
))}
67-
</div>
68-
) : (
69-
<p className="text-sm text-(--muted)">No blacklisted charters.</p>
70-
)}
71-
</div>
72-
7352
<div className="grid content-start gap-3 self-start">
7453
{props.songs.length > 0 ? (
7554
<div className="overflow-hidden rounded-[20px] border border-(--border)">

src/components/song-search-panel.tsx

Lines changed: 111 additions & 57 deletions
Large diffs are not rendered by default.

src/lib/eventsub/chat-message.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -229,8 +229,11 @@ function getRejectedSongMessage(input: {
229229
reason?: string;
230230
reasonCode?: string;
231231
}) {
232-
if (input.reasonCode === "charter_blacklist") {
233-
return `${mention(input.login)} this song cannot be played in this channel.`;
232+
if (
233+
input.reasonCode === "charter_blacklist" ||
234+
input.reasonCode === "song_blacklist"
235+
) {
236+
return `${mention(input.login)} I cannot add that song to the playlist.`;
234237
}
235238

236239
return `${mention(input.login)} ${

src/routes/$slug/index.tsx

Lines changed: 192 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,26 @@
22
import { useQuery, useQueryClient } from "@tanstack/react-query";
33
import { createFileRoute } from "@tanstack/react-router";
44
import { AnimatePresence, motion } from "motion/react";
5-
import { useEffect } from "react";
5+
import { useEffect, useMemo, useState } from "react";
66
import { BlacklistPanel } from "~/components/blacklist-panel";
77
import { PublicPlayedHistoryCard } from "~/components/public-played-history-card";
8+
import type {
9+
SearchSong,
10+
SearchSongResultState,
11+
} from "~/components/song-search-panel";
812
import { SongSearchPanel } from "~/components/song-search-panel";
13+
import { Checkbox } from "~/components/ui/checkbox";
14+
import { Label } from "~/components/ui/label";
915
import { formatSlugTitle, pageTitle } from "~/lib/page-title";
1016
import { getPickNumbersForQueuedItems } from "~/lib/pick-order";
11-
import { decodeHtmlEntities } from "~/lib/utils";
17+
import { cn, decodeHtmlEntities } from "~/lib/utils";
1218

1319
type PublicPlaylistItem = {
1420
id: string;
1521
songTitle: string;
1622
songArtist?: string | null;
23+
songCreator?: string | null;
24+
songCatalogSourceId?: number | null;
1725
requestedByTwitchUserId?: string | null;
1826
requestedByLogin?: string | null;
1927
requestedByDisplayName?: string | null;
@@ -87,6 +95,7 @@ function toPlaylistItems(
8795
function PublicChannelPage() {
8896
const { slug } = Route.useParams();
8997
const queryClient = useQueryClient();
98+
const [showBlacklisted, setShowBlacklisted] = useState(false);
9099
const { data, isLoading } = useQuery({
91100
queryKey: ["public-channel-page", slug],
92101
queryFn: async (): Promise<PublicChannelPageData> => {
@@ -145,6 +154,75 @@ function PublicChannelPage() {
145154
}, [queryClient, slug]);
146155

147156
const channelDisplayName = data?.playlist?.channel?.displayName ?? slug;
157+
const publicSearchResultFilter = useMemo(
158+
() => (song: SearchSong) =>
159+
showBlacklisted ||
160+
getBlacklistReasons(
161+
{
162+
songCatalogSourceId: song.sourceId ?? null,
163+
songArtist: song.artist ?? null,
164+
songCreator: song.creator ?? null,
165+
},
166+
{
167+
artists: data?.playlist.blacklistArtists ?? [],
168+
charters: data?.playlist.blacklistCharters ?? [],
169+
songs: data?.playlist.blacklistSongs ?? [],
170+
}
171+
).length === 0,
172+
[
173+
data?.playlist.blacklistArtists,
174+
data?.playlist.blacklistCharters,
175+
data?.playlist.blacklistSongs,
176+
showBlacklisted,
177+
]
178+
);
179+
const publicSearchResultState = useMemo(
180+
() =>
181+
(song: SearchSong): SearchSongResultState => {
182+
const reasons = getBlacklistReasons(
183+
{
184+
songCatalogSourceId: song.sourceId ?? null,
185+
songArtist: song.artist ?? null,
186+
songCreator: song.creator ?? null,
187+
},
188+
{
189+
artists: data?.playlist.blacklistArtists ?? [],
190+
charters: data?.playlist.blacklistCharters ?? [],
191+
songs: data?.playlist.blacklistSongs ?? [],
192+
}
193+
);
194+
195+
return {
196+
disabled: reasons.length > 0,
197+
reasons,
198+
};
199+
},
200+
[
201+
data?.playlist.blacklistArtists,
202+
data?.playlist.blacklistCharters,
203+
data?.playlist.blacklistSongs,
204+
]
205+
);
206+
const playlistItems = data?.playlist?.items ?? [];
207+
const filteredItems = useMemo(
208+
() =>
209+
playlistItems.filter((item) => {
210+
const blacklistReasons = getBlacklistReasons(item, {
211+
artists: data?.playlist.blacklistArtists ?? [],
212+
charters: data?.playlist.blacklistCharters ?? [],
213+
songs: data?.playlist.blacklistSongs ?? [],
214+
});
215+
return showBlacklisted || blacklistReasons.length === 0;
216+
}),
217+
[
218+
data?.playlist.blacklistArtists,
219+
data?.playlist.blacklistCharters,
220+
data?.playlist.blacklistSongs,
221+
playlistItems,
222+
showBlacklisted,
223+
]
224+
);
225+
const hiddenBlacklistedCount = playlistItems.length - filteredItems.length;
148226

149227
return (
150228
<section className="grid gap-6">
@@ -161,13 +239,23 @@ function PublicChannelPage() {
161239
{isLoading ? <p className="mt-4 px-8">Loading playlist...</p> : null}
162240
<div className="mt-6 grid gap-3 px-8">
163241
<AnimatePresence initial={false} mode="popLayout">
164-
{data?.playlist?.items?.map((item) => (
165-
<PublicPlaylistRow key={item.id} item={item} />
242+
{filteredItems.map((item) => (
243+
<PublicPlaylistRow
244+
key={item.id}
245+
item={item}
246+
blacklistReasons={getBlacklistReasons(item, {
247+
artists: data?.playlist.blacklistArtists ?? [],
248+
charters: data?.playlist.blacklistCharters ?? [],
249+
songs: data?.playlist.blacklistSongs ?? [],
250+
})}
251+
/>
166252
))}
167253
</AnimatePresence>
168-
{!isLoading && !data?.playlist?.items?.length ? (
254+
{!isLoading && !filteredItems.length ? (
169255
<p className="text-sm text-(--muted)">
170-
This playlist is empty right now.
256+
{playlistItems.length > 0 && !showBlacklisted
257+
? "Only blacklisted songs are in the queue right now."
258+
: "This playlist is empty right now."}
171259
</p>
172260
) : null}
173261
</div>
@@ -177,21 +265,48 @@ function PublicChannelPage() {
177265
title="Search to add a song"
178266
description="Copy the request command and use it in Twitch chat."
179267
placeholder={`Search songs for ${channelDisplayName}`}
268+
resultFilter={publicSearchResultFilter}
269+
resultState={publicSearchResultState}
270+
advancedFiltersContent={
271+
<div className="inline-flex flex-wrap items-center gap-3 rounded-full border border-(--border) bg-(--panel) px-4 py-2.5">
272+
<Checkbox
273+
id="show-blacklisted-public-playlist"
274+
checked={showBlacklisted}
275+
onCheckedChange={(checked) =>
276+
setShowBlacklisted(checked === true)
277+
}
278+
/>
279+
<Label
280+
htmlFor="show-blacklisted-public-playlist"
281+
className="cursor-pointer text-sm font-medium text-(--text)"
282+
>
283+
Show blacklisted songs
284+
</Label>
285+
{!showBlacklisted && hiddenBlacklistedCount > 0 ? (
286+
<span className="text-xs text-(--muted)">
287+
Hiding {hiddenBlacklistedCount}
288+
</span>
289+
) : null}
290+
</div>
291+
}
180292
/>
181293

182294
<BlacklistPanel
183295
artists={data?.playlist.blacklistArtists ?? []}
184296
charters={data?.playlist.blacklistCharters ?? []}
185297
songs={data?.playlist.blacklistSongs ?? []}
186-
description="These exact artist IDs, charter IDs, and track IDs are blocked for requests in this channel."
298+
description="These exact artist IDs and track IDs are blocked for requests in this channel."
187299
/>
188300

189301
<PublicPlayedHistoryCard slug={slug} />
190302
</section>
191303
);
192304
}
193305

194-
function PublicPlaylistRow(props: { item: EnrichedPublicPlaylistItem }) {
306+
function PublicPlaylistRow(props: {
307+
item: EnrichedPublicPlaylistItem;
308+
blacklistReasons: string[];
309+
}) {
195310
const requesterName =
196311
props.item.requestedByDisplayName ??
197312
props.item.requestedByLogin ??
@@ -210,7 +325,12 @@ function PublicPlaylistRow(props: { item: EnrichedPublicPlaylistItem }) {
210325
animate={{ opacity: 1, y: 0, scale: 1 }}
211326
exit={{ opacity: 0, y: -12, scale: 0.985 }}
212327
transition={publicPlaylistItemTransition}
213-
className="rounded-[24px] border border-(--border) bg-(--panel-soft) px-5 py-4"
328+
className={cn(
329+
"rounded-[24px] border bg-(--panel-soft) px-5 py-4",
330+
props.blacklistReasons.length > 0
331+
? "border-amber-400/35 bg-amber-500/8"
332+
: "border-(--border)"
333+
)}
214334
>
215335
<div className="flex items-start gap-4">
216336
<StatusColumn
@@ -228,6 +348,9 @@ function PublicPlaylistRow(props: { item: EnrichedPublicPlaylistItem }) {
228348
{props.item.pickNumber && props.item.pickNumber <= 3 ? (
229349
<PickBadge pickNumber={props.item.pickNumber} />
230350
) : null}
351+
{props.blacklistReasons.map((reason) => (
352+
<BlacklistReasonBadge key={reason} reason={reason} />
353+
))}
231354
</div>
232355
</div>
233356
</div>
@@ -308,3 +431,63 @@ function PickBadge(props: { pickNumber: number }) {
308431
</span>
309432
);
310433
}
434+
435+
function BlacklistReasonBadge(props: { reason: string }) {
436+
return (
437+
<span className="inline-flex items-center rounded-full border border-amber-400/35 bg-amber-500/12 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-100">
438+
{props.reason}
439+
</span>
440+
);
441+
}
442+
443+
function normalizeBlacklistValue(value?: string | null) {
444+
return value?.trim().toLowerCase() ?? "";
445+
}
446+
447+
function getBlacklistReasons(
448+
item: {
449+
songCatalogSourceId?: number | null;
450+
songArtist?: string | null;
451+
songCreator?: string | null;
452+
},
453+
blacklist: {
454+
artists: Array<{ artistId: number; artistName: string }>;
455+
charters: Array<{ charterId: number; charterName: string }>;
456+
songs: Array<{
457+
songId: number;
458+
songTitle: string;
459+
artistName?: string | null;
460+
}>;
461+
}
462+
) {
463+
const reasons: string[] = [];
464+
const artistName = normalizeBlacklistValue(item.songArtist);
465+
const creatorName = normalizeBlacklistValue(item.songCreator);
466+
467+
if (
468+
item.songCatalogSourceId != null &&
469+
blacklist.songs.some((song) => song.songId === item.songCatalogSourceId)
470+
) {
471+
reasons.push("Song blacklisted");
472+
}
473+
474+
if (
475+
artistName &&
476+
blacklist.artists.some(
477+
(artist) => normalizeBlacklistValue(artist.artistName) === artistName
478+
)
479+
) {
480+
reasons.push("Artist blacklisted");
481+
}
482+
483+
if (
484+
creatorName &&
485+
blacklist.charters.some(
486+
(charter) => normalizeBlacklistValue(charter.charterName) === creatorName
487+
)
488+
) {
489+
reasons.push("Creator blacklisted");
490+
}
491+
492+
return reasons;
493+
}

0 commit comments

Comments
 (0)