From 87e017bac66f9b49a8e0121fd5d5752fa08334e7 Mon Sep 17 00:00:00 2001 From: Daniel Amaral Date: Mon, 11 May 2026 01:40:32 +0200 Subject: [PATCH] feat(deck): search bar to filter cards by artist or title --- app/deck/[id].tsx | 245 ++++++++++++++++++++++++++++------------------ 1 file changed, 148 insertions(+), 97 deletions(-) diff --git a/app/deck/[id].tsx b/app/deck/[id].tsx index 71c8aad..fd4bd10 100644 --- a/app/deck/[id].tsx +++ b/app/deck/[id].tsx @@ -8,6 +8,7 @@ import { Keyboard, Pressable, Text as RNText, + TextInput, View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -39,6 +40,8 @@ const SUBTITLE_DATE_OPTIONS: Intl.DateTimeFormatOptions = { year: 'numeric', }; +const SEARCH_PLACEHOLDER_COLOR = 'rgba(196, 204, 223, 0.4)'; + function rowKey(row: DeckRow, index: number): string { if (row.kind === 'kept') return `k:${row.card.isrc}`; return `d:${index}:${row.track.artist}:${row.track.title}`; @@ -50,6 +53,15 @@ function buildRows(deck: Deck, drops: DroppedTrack[]): DeckRow[] { return rows; } +function rowMatchesQuery(row: DeckRow, needle: string): boolean { + if (needle === '') return true; + const haystack = row.kind === 'kept' ? row.card : row.track; + return ( + haystack.artist.toLowerCase().includes(needle) || + haystack.title.toLowerCase().includes(needle) + ); +} + function dropPercent(kept: number, dropped: number): number { const total = kept + dropped; if (total === 0) return 0; @@ -215,10 +227,13 @@ function LoadedDeck({ const firstDrop = drops[0]; const subtitle = buildSubtitle(keptCount, droppedCount, deck.createdAt); const [filter, setFilter] = useState('all'); - const visibleRows = useMemo( - () => (filter === 'all' ? rows : rows.filter((r) => r.kind === filter)), - [rows, filter], - ); + const [query, setQuery] = useState(''); + const needle = query.trim().toLowerCase(); + const visibleRows = useMemo(() => { + const pillFiltered = + filter === 'all' ? rows : rows.filter((r) => r.kind === filter); + return pillFiltered.filter((r) => rowMatchesQuery(r, needle)); + }, [rows, filter, needle]); // The min-height: 0 flexbox fix referenced by #5: parent flex containers // must allow children to shrink below their content. On RN, putting a @@ -276,116 +291,152 @@ function LoadedDeck({ contentContainerClassName="px-5 pt-2 pb-12 gap-2" keyboardShouldPersistTaps="handled" ListHeaderComponent={ - hasDrops ? ( - - {isSparse && ( - - + + {hasDrops && ( + <> + {isSparse && ( + - {percent}% of tracks dropped. + + {percent}% of tracks dropped. + + {' '}That's a sparse deck. Consider a different playlist. - {' '}That's a sparse deck. Consider a different playlist. - - - )} - {hasMinorDrops && firstDrop && ( - - + + )} + {hasMinorDrops && firstDrop && ( + - {droppedCount === 1 ? '1 skipped.' : `${droppedCount} skipped.`} + + {droppedCount === 1 ? '1 skipped.' : `${droppedCount} skipped.`} + + {' '}Example: “{firstDrop.artist} — {firstDrop.title}”. - {' '}Example: “{firstDrop.artist} — {firstDrop.title}”. - - - )} + + )} - - - setFilter((prev) => (prev === 'kept' ? 'all' : 'kept')) - } - role="button" - accessibilityLabel={ - filter === 'kept' ? 'Show all' : 'Filter to kept cards' - } - accessibilityState={{ selected: filter === 'kept' }} - style={{ opacity: filter === 'dropped' ? 0.4 : 1 }} - > - - - - {`${keptCount} ${keptCount === 1 ? 'card' : 'cards'}`} - - - - - setFilter((prev) => - prev === 'dropped' ? 'all' : 'dropped', - ) - } - role="button" - accessibilityLabel={ - filter === 'dropped' - ? 'Show all' - : 'Filter to skipped tracks' - } - accessibilityState={{ selected: filter === 'dropped' }} - style={{ opacity: filter === 'kept' ? 0.4 : 1 }} - > - - - - {`${droppedCount} skipped`} - - - - - + + + setFilter((prev) => (prev === 'kept' ? 'all' : 'kept')) + } + role="button" + accessibilityLabel={ + filter === 'kept' ? 'Show all' : 'Filter to kept cards' + } + accessibilityState={{ selected: filter === 'kept' }} + style={{ opacity: filter === 'dropped' ? 0.4 : 1 }} + > + + + + {`${keptCount} ${keptCount === 1 ? 'card' : 'cards'}`} + + + + + setFilter((prev) => + prev === 'dropped' ? 'all' : 'dropped', + ) + } + role="button" + accessibilityLabel={ + filter === 'dropped' + ? 'Show all' + : 'Filter to skipped tracks' + } + accessibilityState={{ selected: filter === 'dropped' }} + style={{ opacity: filter === 'kept' ? 0.4 : 1 }} + > + + + + {`${droppedCount} skipped`} + + + + + + )} + + + + } + ListEmptyComponent={ + needle !== '' ? ( + + {`No cards match "${query.trim()}"`} + ) : null } renderItem={({ item }) => {