Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
3f70aed96a
commit
6272dcdada
@ -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})
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user