Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2025-07-18 10:13:03 -05:00
parent 3f70aed96a
commit 6272dcdada
4 changed files with 188 additions and 43 deletions

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useMemo, useCallback } from 'react';
import { IonItem, IonLabel, IonModal, IonHeader, IonToolbar, IonTitle, IonButton, IonIcon, IonChip, IonContent, IonList, IonAccordionGroup, IonAccordion } from '@ionic/react';
import { close, list } from 'ionicons/icons';
import { InfiniteScrollList, PageHeader, SongItem } from '../../components/common';
@ -35,14 +35,27 @@ const SongLists: React.FC = () => {
setExpandedSongKey(null); // Reset expansion when closing
};
const handleSongItemClick = (songKey: string) => {
const handleSongItemClick = useCallback((songKey: string) => {
setExpandedSongKey(expandedSongKey === songKey ? null : songKey);
};
}, [expandedSongKey]);
const finalSelectedList = selectedSongList
? allSongLists.find(list => list.key === selectedSongList)
: null;
// Pre-calculate available songs for the selected list to avoid repeated calculations
const selectedListWithAvailability = useMemo(() => {
if (!finalSelectedList) return null;
return {
...finalSelectedList,
songs: finalSelectedList.songs.map(songListSong => ({
...songListSong,
availableSongs: checkSongAvailability(songListSong)
}))
};
}, [finalSelectedList, checkSongAvailability]);
// Render song list item for InfiniteScrollList
const renderSongListItem = (songList: SongList) => (
<IonItem button onClick={() => handleSongListClick(songList.key!)} detail={false}>
@ -94,26 +107,16 @@ const SongLists: React.FC = () => {
<IonContent>
<IonAccordionGroup value={expandedSongKey}>
{finalSelectedList?.songs.map((songListSong: SongListSong, index) => {
const availableSongs = checkSongAvailability(songListSong);
{selectedListWithAvailability?.songs.map((songListSong: SongListSong & { availableSongs: Song[] }, index) => {
const availableSongs = songListSong.availableSongs;
const isAvailable = availableSongs.length > 0;
const songKey = songListSong.key || `${songListSong.title}-${songListSong.position}-${index}`;
if (isAvailable) {
// Available songs get an accordion that expands
return (
<IonAccordion key={songKey} value={songKey} style={{ '--border-style': 'none' } as React.CSSProperties}>
<IonItem
slot="header"
detail={false}
button
onClick={() => handleSongItemClick(songKey)}
style={{
'--border-style': 'solid',
'--border-width': '0 0 1px 0',
'--border-color': 'rgba(0, 0, 0, 0.13)'
} as React.CSSProperties}
>
<IonAccordion key={songKey} value={songKey}>
<IonItem slot="header" detail={false} button onClick={() => handleSongItemClick(songKey)}>
{/* Number */}
<div slot="start" className="flex-shrink-0 w-12 h-12 flex items-center justify-center text-gray-600 font-medium">
{index + 1})

View File

@ -1,9 +1,14 @@
import React from 'react';
import React, { useState, useMemo, useCallback } from 'react';
import { IonItem, IonLabel, IonChip, IonModal, IonHeader, IonToolbar, IonTitle, IonButton, IonIcon, IonContent, IonList } from '@ionic/react';
import { close, list } from 'ionicons/icons';
import { useTopPlayed } from '../../hooks';
import { useAppSelector } from '../../redux';
import { selectTopPlayed } from '../../redux';
import { InfiniteScrollList, PageHeader } from '../../components/common';
import type { TopPlayed } from '../../types';
import { selectTopPlayed, selectSongsArray } from '../../redux';
import { InfiniteScrollList, PageHeader, SongItem } from '../../components/common';
import { filterSongs } from '../../utils/dataProcessing';
import { useSongOperations } from '../../hooks';
import { useToast } from '../../hooks';
import type { TopPlayed, Song } from '../../types';
const Top100: React.FC = () => {
console.log('Top100 component - RENDERING START');
@ -15,9 +20,62 @@ const Top100: React.FC = () => {
const topPlayed = useAppSelector(selectTopPlayed);
const topPlayedCount = Object.keys(topPlayed).length;
const allSongs = useAppSelector(selectSongsArray);
const { addToQueue, toggleFavorite } = useSongOperations();
const { showSuccess, showError } = useToast();
const [selectedTopPlayed, setSelectedTopPlayed] = useState<TopPlayed | null>(null);
console.log('Top100 component - Redux data:', { topPlayedCount, topPlayedItems: topPlayedItems.length });
const handleTopPlayedClick = useCallback((item: TopPlayed) => {
setSelectedTopPlayed(item);
}, []);
const handleCloseModal = useCallback(() => {
setSelectedTopPlayed(null);
}, []);
// Find songs that match the selected top played item
const selectedSongs = useMemo(() => {
if (!selectedTopPlayed) return [];
// Use the shared search function with title and artist
const searchTerm = `${selectedTopPlayed.title} ${selectedTopPlayed.artist}`;
console.log('Top100 - Search details:', {
selectedTopPlayed,
searchTerm,
allSongsCount: allSongs.length
});
const filteredSongs = filterSongs(allSongs, searchTerm);
console.log('Top100 - Search results:', {
filteredSongsCount: filteredSongs.length,
firstFewResults: filteredSongs.slice(0, 3).map(s => `${s.artist} - ${s.title}`)
});
return filteredSongs;
}, [selectedTopPlayed, allSongs]);
const handleAddToQueue = useCallback(async (song: Song) => {
try {
await addToQueue(song);
showSuccess('Song added to queue');
} catch {
showError('Failed to add song to queue');
}
}, [addToQueue, showSuccess, showError]);
const handleToggleFavorite = useCallback(async (song: Song) => {
try {
await toggleFavorite(song);
showSuccess(song.favorite ? 'Removed from favorites' : 'Added to favorites');
} catch {
showError('Failed to update favorites');
}
}, [toggleFavorite, showSuccess, showError]);
// Mock data for testing - these are artist/title combinations, not individual songs
const mockTopPlayedItems: TopPlayed[] = [
{
@ -109,26 +167,64 @@ const Top100: React.FC = () => {
hasMore={displayHasMore}
onLoadMore={loadMore}
renderItem={(item, index) => (
<div style={{ display: 'flex', alignItems: 'flex-start', padding: '12px 16px', borderBottom: '1px solid #e5e7eb' }}>
<div style={{ width: '80px', textAlign: 'right', paddingRight: '16px', flexShrink: 0 }}>
<span style={{ fontSize: '18px', fontWeight: 'bold', color: '#374151' }}>
{index + 1})
</span>
<IonItem
button
onClick={() => handleTopPlayedClick(item)}
detail={false}
>
{/* Number */}
<div slot="start" className="flex-shrink-0 w-12 h-12 flex items-center justify-center text-gray-600 font-medium">
{index + 1})
</div>
<div style={{ width: '16px', flexShrink: 0 }}></div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '18px', fontWeight: 'bold', color: '#111827', marginBottom: '4px' }}>
{item.title} ({item.count})
</div>
<div style={{ fontSize: '16px', fontStyle: 'italic', color: '#4b5563' }}>
<IonLabel>
<h3 className="text-sm font-medium text-gray-900">
{item.title}
</h3>
<p className="text-sm text-gray-500">
{item.artist}
</div>
</div>
</div>
</p>
</IonLabel>
<IonChip slot="end" color="primary">
{item.count} plays
</IonChip>
<IonIcon icon={list} slot="end" color="primary" />
</IonItem>
)}
emptyTitle="No top played songs"
emptyMessage="Play some songs to see the top played list"
/>
{/* Top Played Songs Modal */}
<IonModal
isOpen={!!selectedTopPlayed}
onDidDismiss={handleCloseModal}
>
<IonHeader>
<IonToolbar>
<IonTitle>{selectedTopPlayed?.artist}</IonTitle>
<IonButton slot="end" fill="clear" onClick={handleCloseModal}>
<IonIcon icon={close} />
</IonButton>
</IonToolbar>
</IonHeader>
<IonContent>
<IonList>
{selectedSongs.map((song) => (
<SongItem
key={song.key || `${song.title}-${song.artist}`}
song={song}
context="search"
onAddToQueue={() => handleAddToQueue(song)}
onToggleFavorite={() => handleToggleFavorite(song)}
/>
))}
</IonList>
</IonContent>
</IonModal>
</>
);
};

View File

@ -1,4 +1,4 @@
/* Ionic CSS imports */
/* Ionic CSS imports - must come first to establish base styles */
@import '@ionic/react/css/core.css';
@import '@ionic/react/css/normalize.css';
@import '@ionic/react/css/structure.css';
@ -11,7 +11,7 @@
@import '@ionic/react/css/flex-utils.css';
@import '@ionic/react/css/display.css';
/* Tailwind CSS */
/* Tailwind CSS - comes after Ionic to avoid overriding essential Ionic styles */
@tailwind base;
@tailwind components;
@tailwind utilities;
@ -33,3 +33,36 @@ ion-item.ion-activated {
ion-menu {
--z-index: 1000;
}
/* Ensure modal content is visible */
ion-modal ion-content {
--background: var(--ion-background-color, #ffffff);
}
ion-modal ion-list {
background: transparent;
}
/* Ensure content is visible and properly styled */
ion-content {
--background: var(--ion-background-color, #ffffff);
}
ion-list {
background: transparent;
}
/* Ensure items are properly displayed */
ion-item {
--background: transparent;
--color: var(--ion-text-color, #000000);
}
/* Ensure accordion content is visible */
ion-accordion {
--background: transparent;
}
ion-accordion ion-item {
--background: transparent;
}

View File

@ -10,15 +10,28 @@ export const objectToArray = <T extends { key?: string }>(
}));
};
// Filter songs by search term
// Filter songs by search term with intelligent multi-word handling
export const filterSongs = (songs: Song[], searchTerm: string): Song[] => {
if (!searchTerm.trim()) return songs;
const term = searchTerm.toLowerCase();
return songs.filter(song =>
song.title.toLowerCase().includes(term) ||
song.artist.toLowerCase().includes(term)
);
const terms = searchTerm.toLowerCase().split(/\s+/).filter(term => term.length > 0);
if (terms.length === 0) return songs;
return songs.filter(song => {
const songTitle = song.title.toLowerCase();
const songArtist = song.artist.toLowerCase();
// If only one term, use OR logic (title OR artist)
if (terms.length === 1) {
return songTitle.includes(terms[0]) || songArtist.includes(terms[0]);
}
// If multiple terms, use AND logic (all terms must match somewhere)
return terms.every(term =>
songTitle.includes(term) || songArtist.includes(term)
);
});
};
// Sort queue items by order