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

This commit is contained in:
Matt Bruce 2025-07-17 17:21:26 -05:00
parent 445d72c4f8
commit 0bbd36010a
12 changed files with 674 additions and 550 deletions

View File

@ -1,6 +1,5 @@
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import Layout from './components/Layout/Layout'; import Layout from './components/Layout/Layout';
import Navigation from './components/Navigation/Navigation';
import { Search, Queue, History, Favorites, NewSongs, Artists, Singers, SongLists } from './features'; import { Search, Queue, History, Favorites, NewSongs, Artists, Singers, SongLists } from './features';
import TopPlayed from './features/TopPlayed/Top100'; import TopPlayed from './features/TopPlayed/Top100';
import { FirebaseProvider } from './firebase/FirebaseProvider'; import { FirebaseProvider } from './firebase/FirebaseProvider';
@ -14,7 +13,6 @@ function App() {
<Router> <Router>
<AuthInitializer> <AuthInitializer>
<Layout> <Layout>
<Navigation />
<Routes> <Routes>
<Route path="/" element={<Navigate to="/queue" replace />} /> <Route path="/" element={<Navigate to="/queue" replace />} />
<Route path="/search" element={<Search />} /> <Route path="/search" element={<Search />} />

View File

@ -1,9 +1,10 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux'; import { useSelector, useDispatch } from 'react-redux';
import { IonApp, IonHeader, IonToolbar, IonTitle, IonContent, IonFooter, IonChip } from '@ionic/react'; import { IonApp, IonHeader, IonToolbar, IonTitle, IonContent, IonChip, IonMenuButton } from '@ionic/react';
import { selectCurrentSinger, selectIsAdmin, selectControllerName } from '../../redux/authSlice'; import { selectCurrentSinger, selectIsAdmin, selectControllerName } from '../../redux/authSlice';
import { logout } from '../../redux/authSlice'; import { logout } from '../../redux/authSlice';
import { ActionButton } from '../common'; import { ActionButton } from '../common';
import Navigation from '../Navigation/Navigation';
import type { LayoutProps } from '../../types'; import type { LayoutProps } from '../../types';
const Layout: React.FC<LayoutProps> = ({ children }) => { const Layout: React.FC<LayoutProps> = ({ children }) => {
@ -11,6 +12,18 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
const isAdmin = useSelector(selectIsAdmin); const isAdmin = useSelector(selectIsAdmin);
const controllerName = useSelector(selectControllerName); const controllerName = useSelector(selectControllerName);
const dispatch = useDispatch(); const dispatch = useDispatch();
const [isLargeScreen, setIsLargeScreen] = useState(false);
// Check screen size for responsive layout
useEffect(() => {
const checkScreenSize = () => {
setIsLargeScreen(window.innerWidth >= 768);
};
checkScreenSize();
window.addEventListener('resize', checkScreenSize);
return () => window.removeEventListener('resize', checkScreenSize);
}, []);
const handleLogout = () => { const handleLogout = () => {
dispatch(logout()); dispatch(logout());
@ -20,53 +33,70 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
return ( return (
<IonApp> <IonApp>
<IonHeader> {/* Navigation - rendered outside header for proper positioning */}
<IonToolbar> <Navigation />
<IonTitle>
<div className="flex items-center"> {/* Main content wrapper */}
<span>🎤 Karaoke App</span> <div style={{
{controllerName && ( position: 'fixed',
<span className="ml-4 text-sm text-gray-500"> left: isLargeScreen ? '256px' : '0',
Party: {controllerName} top: 0,
</span> right: 0,
)} bottom: 0,
</div> zIndex: 1
</IonTitle> }}>
<IonHeader style={{ position: 'relative', zIndex: 2 }}>
{/* User Info & Logout */} <IonToolbar>
{currentSinger && ( {/* Only show hamburger button on mobile */}
<div slot="end" className="flex items-center space-x-3"> {!isLargeScreen && <IonMenuButton slot="start" />}
<div className="text-sm text-gray-600">
<span className="font-medium">{currentSinger}</span> <IonTitle>
{isAdmin && ( <div className="flex items-center">
<IonChip color="primary"> <span>🎤 Karaoke App</span>
Admin {controllerName && (
</IonChip> <span className="ml-4 text-sm text-gray-500">
Party: {controllerName}
</span>
)} )}
</div> </div>
<ActionButton </IonTitle>
onClick={handleLogout}
variant="secondary" {/* User Info & Logout */}
size="sm" {currentSinger && (
> <div slot="end" className="flex items-center space-x-3">
Logout <div className="text-sm text-gray-600">
</ActionButton> <span className="font-medium">{currentSinger}</span>
</div> {isAdmin && (
)} <IonChip color="primary">
</IonToolbar> Admin
</IonHeader> </IonChip>
)}
</div>
<ActionButton
onClick={handleLogout}
variant="secondary"
size="sm"
>
Logout
</ActionButton>
</div>
)}
</IonToolbar>
</IonHeader>
<IonContent> <IonContent
{children} id="main-content"
</IonContent> className={isLargeScreen ? "ion-padding" : ""}
style={{
<IonFooter> position: 'relative',
<IonToolbar> zIndex: 1,
<div className="text-center text-sm text-gray-500"> height: 'calc(100vh - 56px)', // Subtract header height
<p>🎵 Powered by Firebase Realtime Database</p> overflow: 'hidden' // Prevent main content from scrolling
</div> }}
</IonToolbar> >
</IonFooter> {children}
</IonContent>
</div>
</IonApp> </IonApp>
); );
}; };

View File

@ -1,11 +1,12 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { IonTabs, IonTabBar, IonTabButton, IonLabel, IonIcon } from '@ionic/react'; import { IonMenu, IonHeader, IonToolbar, IonTitle, IonContent, IonList, IonItem, IonLabel, IonIcon } from '@ionic/react';
import { list, search, heart, add, mic, documentText, time, trophy, people } from 'ionicons/icons'; import { list, search, heart, add, mic, documentText, time, trophy, people } from 'ionicons/icons';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
const Navigation: React.FC = () => { const Navigation: React.FC = () => {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const [isLargeScreen, setIsLargeScreen] = useState(false);
const navItems = [ const navItems = [
{ path: '/queue', label: 'Queue', icon: list }, { path: '/queue', label: 'Queue', icon: list },
@ -19,62 +20,126 @@ const Navigation: React.FC = () => {
{ path: '/singers', label: 'Singers', icon: people }, { path: '/singers', label: 'Singers', icon: people },
]; ];
// For mobile, show bottom tabs with main features // Check screen size for responsive menu behavior
const mobileNavItems = [ useEffect(() => {
{ path: '/queue', label: 'Queue', icon: list }, const checkScreenSize = () => {
{ path: '/search', label: 'Search', icon: search }, const large = window.innerWidth >= 768;
{ path: '/favorites', label: 'Favorites', icon: heart }, console.log('Screen width:', window.innerWidth, 'Is large screen:', large);
{ path: '/history', label: 'History', icon: time }, setIsLargeScreen(large);
]; };
// Check if we're on mobile (you can adjust this breakpoint) checkScreenSize();
const isMobile = window.innerWidth < 768; window.addEventListener('resize', checkScreenSize);
return () => window.removeEventListener('resize', checkScreenSize);
}, []);
const currentItems = isMobile ? mobileNavItems : navItems; const handleNavigation = (path: string) => {
navigate(path);
// Close menu on mobile after navigation
if (!isLargeScreen) {
const menu = document.querySelector('ion-menu');
if (menu) {
menu.close();
}
}
};
return ( // For large screens, render a fixed sidebar instead of a menu
<> if (isLargeScreen) {
{isMobile ? ( console.log('Rendering large screen sidebar');
<IonTabs> return (
<IonTabBar slot="bottom"> <div
{currentItems.map((item) => ( style={{
<IonTabButton position: 'fixed',
key={item.path} left: 0,
tab={item.path} top: 0,
selected={location.pathname === item.path} height: '100vh',
onClick={() => navigate(item.path)} width: '256px',
> backgroundColor: 'white',
<IonIcon icon={item.icon} /> boxShadow: '2px 0 8px rgba(0,0,0,0.1)',
<IonLabel>{item.label}</IonLabel> zIndex: 1000,
</IonTabButton> borderRight: '1px solid #e5e7eb',
))} overflowY: 'auto'
</IonTabBar> }}
</IonTabs> >
) : ( <div style={{ padding: '16px', borderBottom: '1px solid #e5e7eb', backgroundColor: '#f9fafb' }}>
<nav className="bg-white border-b border-gray-200"> <h2 style={{ fontSize: '18px', fontWeight: '600', color: '#1f2937', margin: 0 }}>Karaoke</h2>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <p style={{ fontSize: '14px', color: '#6b7280', margin: '4px 0 0 0' }}>Singer: Matt</p>
<div className="flex space-x-8"> </div>
{currentItems.map((item) => ( <nav style={{ marginTop: '16px' }}>
<button {navItems.map((item) => (
key={item.path} <div
onClick={() => navigate(item.path)} key={item.path}
className={` onClick={() => handleNavigation(item.path)}
flex items-center px-3 py-4 text-sm font-medium border-b-2 transition-colors style={{
${location.pathname === item.path display: 'flex',
? 'border-blue-500 text-blue-600' alignItems: 'center',
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' padding: '12px 16px',
} cursor: 'pointer',
`} backgroundColor: location.pathname === item.path ? '#dbeafe' : 'transparent',
> color: location.pathname === item.path ? '#2563eb' : '#374151',
<IonIcon icon={item.icon} className="mr-2" /> borderRight: location.pathname === item.path ? '2px solid #2563eb' : 'none',
{item.label} transition: 'background-color 0.2s, color 0.2s'
</button> }}
))} onMouseEnter={(e) => {
if (location.pathname !== item.path) {
e.currentTarget.style.backgroundColor = '#f3f4f6';
}
}}
onMouseLeave={(e) => {
if (location.pathname !== item.path) {
e.currentTarget.style.backgroundColor = 'transparent';
}
}}
>
<IonIcon
icon={item.icon}
style={{
marginRight: '12px',
fontSize: '20px'
}}
/>
<span style={{ fontWeight: '500' }}>{item.label}</span>
</div> </div>
</div> ))}
</nav> </nav>
)} </div>
</> );
}
// For mobile screens, use the Ionic menu
console.log('Rendering mobile menu');
return (
<IonMenu
contentId="main-content"
type="overlay"
side="start"
swipeGesture={true}
style={{
'--width': '250px'
} as React.CSSProperties}
>
<IonHeader>
<IonToolbar>
<IonTitle>Menu</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<IonList>
{navItems.map((item) => (
<IonItem
key={item.path}
button
onClick={() => handleNavigation(item.path)}
className={location.pathname === item.path ? 'ion-activated' : ''}
>
<IonIcon icon={item.icon} slot="start" />
<IonLabel>{item.label}</IonLabel>
</IonItem>
))}
</IonList>
</IonContent>
</IonMenu>
); );
}; };

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { ActionButton } from '../../components/common'; import { IonSearchbar, IonList, IonItem, IonLabel, IonModal, IonHeader, IonToolbar, IonTitle, IonContent, IonButton, IonIcon, IonChip } from '@ionic/react';
import { close, add, heart, heartOutline } from 'ionicons/icons';
import { useArtists } from '../../hooks'; import { useArtists } from '../../hooks';
import { useAppSelector } from '../../redux'; import { useAppSelector } from '../../redux';
import { selectSongs } from '../../redux'; import { selectSongs } from '../../redux';
@ -72,20 +73,13 @@ const Artists: React.FC = () => {
<h1 className="text-2xl font-bold text-gray-900 mb-4">Artists</h1> <h1 className="text-2xl font-bold text-gray-900 mb-4">Artists</h1>
{/* Search Input */} {/* Search Input */}
<div className="relative"> <IonSearchbar
<input placeholder="Search artists..."
type="text" value={searchTerm}
placeholder="Search artists..." onIonInput={(e) => handleSearchChange(e.detail.value || '')}
value={searchTerm} debounce={300}
onChange={(e) => handleSearchChange(e.target.value)} showClearButton="focus"
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
/>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</div>
{/* Debug info */} {/* Debug info */}
<div className="mt-2 text-sm text-gray-500"> <div className="mt-2 text-sm text-gray-500">
@ -120,27 +114,21 @@ const Artists: React.FC = () => {
</p> </p>
</div> </div>
) : ( ) : (
<div className="divide-y divide-gray-200"> <IonList>
{artists.map((artist) => ( {artists.map((artist) => (
<div key={artist} className="flex items-center justify-between p-4 hover:bg-gray-50"> <IonItem key={artist} button onClick={() => handleArtistClick(artist)}>
<div className="flex-1"> <IonLabel>
<h3 className="text-sm font-medium text-gray-900"> <h3 className="text-sm font-medium text-gray-900">
{artist} {artist}
</h3> </h3>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
{getSongCountByArtist(artist)} song{getSongCountByArtist(artist) !== 1 ? 's' : ''} {getSongCountByArtist(artist)} song{getSongCountByArtist(artist) !== 1 ? 's' : ''}
</p> </p>
</div> </IonLabel>
<div className="flex-shrink-0 ml-4"> <IonChip slot="end" color="primary">
<ActionButton View Songs
onClick={() => handleArtistClick(artist)} </IonChip>
variant="primary" </IonItem>
size="sm"
>
View Songs
</ActionButton>
</div>
</div>
))} ))}
{/* Infinite scroll trigger */} {/* Infinite scroll trigger */}
@ -158,66 +146,54 @@ const Artists: React.FC = () => {
</div> </div>
</div> </div>
)} )}
</div> </IonList>
)} )}
</div> </div>
{/* Artist Songs Modal */} {/* Artist Songs Modal */}
{selectedArtist && ( <IonModal isOpen={!!selectedArtist} onDidDismiss={handleCloseArtistSongs}>
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999]" style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0 }}> <IonHeader>
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[80vh] overflow-hidden" style={{ backgroundColor: 'white', zIndex: 10000 }}> <IonToolbar>
<div className="p-6 border-b border-gray-200"> <IonTitle>Songs by {selectedArtist}</IonTitle>
<div className="flex items-center justify-between"> <IonButton slot="end" fill="clear" onClick={handleCloseArtistSongs}>
<h2 className="text-xl font-bold text-gray-900"> <IonIcon icon={close} />
Songs by {selectedArtist} </IonButton>
</h2> </IonToolbar>
<ActionButton </IonHeader>
onClick={handleCloseArtistSongs}
variant="secondary" <IonContent>
size="sm" <IonList>
> {selectedArtistSongs.map((song) => (
Close <IonItem key={song.key}>
</ActionButton> <IonLabel>
</div> <h3 className="text-sm font-medium text-gray-900">
</div> {song.title}
</h3>
<div className="overflow-y-auto max-h-[60vh]"> <p className="text-sm text-gray-500">
<div className="divide-y divide-gray-200"> {song.artist}
{selectedArtistSongs.map((song) => ( </p>
<div key={song.key} className="p-4"> </IonLabel>
<div className="flex items-center justify-between"> <div slot="end" className="flex gap-2">
<div className="flex-1"> <IonButton
<h3 className="text-sm font-medium text-gray-900"> fill="clear"
{song.title} size="small"
</h3> onClick={() => handleAddToQueue(song)}
<p className="text-sm text-gray-500"> >
{song.artist} <IonIcon icon={add} slot="icon-only" />
</p> </IonButton>
</div> <IonButton
<div className="flex-shrink-0 ml-4 flex items-center space-x-2"> fill="clear"
<ActionButton size="small"
onClick={() => handleAddToQueue(song)} onClick={() => handleToggleFavorite(song)}
variant="primary" >
size="sm" <IonIcon icon={song.favorite ? heart : heartOutline} slot="icon-only" />
> </IonButton>
Add to Queue </div>
</ActionButton> </IonItem>
<ActionButton ))}
onClick={() => handleToggleFavorite(song)} </IonList>
variant="secondary" </IonContent>
size="sm" </IonModal>
>
{song.favorite ? 'Remove from Favorites' : 'Add to Favorites'}
</ActionButton>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
)}
</div> </div>
); );
}; };

View File

@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { IonHeader, IonToolbar, IonTitle, IonChip } from '@ionic/react';
import { InfiniteScrollList } from '../../components/common'; import { InfiniteScrollList } from '../../components/common';
import { useFavorites } from '../../hooks'; import { useFavorites } from '../../hooks';
import { useAppSelector } from '../../redux'; import { useAppSelector } from '../../redux';
@ -21,22 +22,37 @@ const Favorites: React.FC = () => {
console.log('Favorites component - favorites items:', favoritesItems); console.log('Favorites component - favorites items:', favoritesItems);
return ( return (
<InfiniteScrollList <>
items={favoritesItems} <IonHeader>
isLoading={favoritesCount === 0} <IonToolbar>
hasMore={hasMore} <IonTitle>
onLoadMore={loadMore} Favorites
onAddToQueue={handleAddToQueue} <IonChip color="primary" className="ml-2">
onToggleFavorite={handleToggleFavorite} {favoritesItems.length}
context="favorites" </IonChip>
title="Favorites" </IonTitle>
subtitle={`${favoritesItems.length} song${favoritesItems.length !== 1 ? 's' : ''} in favorites`} </IonToolbar>
emptyTitle="No favorites yet" </IonHeader>
emptyMessage="Add songs to your favorites to see them here"
loadingTitle="Loading favorites..." <div style={{ height: '100%', overflowY: 'auto' }}>
loadingMessage="Please wait while favorites data is being loaded" <InfiniteScrollList
debugInfo={`Favorites items loaded: ${favoritesCount}`} items={favoritesItems}
/> isLoading={favoritesCount === 0}
hasMore={hasMore}
onLoadMore={loadMore}
onAddToQueue={handleAddToQueue}
onToggleFavorite={handleToggleFavorite}
context="favorites"
title=""
subtitle=""
emptyTitle="No favorites yet"
emptyMessage="Add songs to your favorites to see them here"
loadingTitle="Loading favorites..."
loadingMessage="Please wait while favorites data is being loaded"
debugInfo={`Favorites items loaded: ${favoritesCount}`}
/>
</div>
</>
); );
}; };

View File

@ -1,4 +1,6 @@
import React from 'react'; import React from 'react';
import { IonHeader, IonToolbar, IonTitle, IonChip, IonIcon } from '@ionic/react';
import { time } from 'ionicons/icons';
import { InfiniteScrollList } from '../../components/common'; import { InfiniteScrollList } from '../../components/common';
import { useHistory } from '../../hooks'; import { useHistory } from '../../hooks';
import { useAppSelector } from '../../redux'; import { useAppSelector } from '../../redux';
@ -26,32 +28,48 @@ const History: React.FC = () => {
const renderExtraContent = (item: Song) => { const renderExtraContent = (item: Song) => {
if (item.date) { if (item.date) {
return ( return (
<div className="flex-shrink-0 px-4 py-2 text-sm text-gray-500"> <IonChip color="medium" className="ml-2">
<IonIcon icon={time} />
{formatDate(item.date)} {formatDate(item.date)}
</div> </IonChip>
); );
} }
return null; return null;
}; };
return ( return (
<InfiniteScrollList <>
items={historyItems} <IonHeader>
isLoading={historyCount === 0} <IonToolbar>
hasMore={hasMore} <IonTitle>
onLoadMore={loadMore} Recently Played
onAddToQueue={handleAddToQueue} <IonChip color="primary" className="ml-2">
onToggleFavorite={handleToggleFavorite} {historyItems.length}
context="history" </IonChip>
title="Recently Played" </IonTitle>
subtitle={`${historyItems.length} song${historyItems.length !== 1 ? 's' : ''} in history`} </IonToolbar>
emptyTitle="No history yet" </IonHeader>
emptyMessage="Songs will appear here after they've been played"
loadingTitle="Loading history..." <div style={{ height: '100%', overflowY: 'auto' }}>
loadingMessage="Please wait while history data is being loaded" <InfiniteScrollList
debugInfo={`History items loaded: ${historyCount}`} items={historyItems}
renderExtraContent={renderExtraContent} isLoading={historyCount === 0}
/> hasMore={hasMore}
onLoadMore={loadMore}
onAddToQueue={handleAddToQueue}
onToggleFavorite={handleToggleFavorite}
context="history"
title=""
subtitle=""
emptyTitle="No history yet"
emptyMessage="Songs will appear here after they've been played"
loadingTitle="Loading history..."
loadingMessage="Please wait while history data is being loaded"
debugInfo={`History items loaded: ${historyCount}`}
renderExtraContent={renderExtraContent}
/>
</div>
</>
); );
}; };

View File

@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { IonHeader, IonToolbar, IonTitle, IonChip } from '@ionic/react';
import { InfiniteScrollList } from '../../components/common'; import { InfiniteScrollList } from '../../components/common';
import { useNewSongs } from '../../hooks'; import { useNewSongs } from '../../hooks';
import { useAppSelector } from '../../redux'; import { useAppSelector } from '../../redux';
@ -17,26 +18,41 @@ const NewSongs: React.FC = () => {
const newSongsCount = Object.keys(newSongs).length; const newSongsCount = Object.keys(newSongs).length;
// Debug logging // Debug logging
console.log('NewSongs component - newSongs count:', newSongsCount); console.log('NewSongs component - new songs count:', newSongsCount);
console.log('NewSongs component - newSongs items:', newSongsItems); console.log('NewSongs component - new songs items:', newSongsItems);
return ( return (
<InfiniteScrollList <>
items={newSongsItems} <IonHeader>
isLoading={newSongsCount === 0} <IonToolbar>
hasMore={hasMore} <IonTitle>
onLoadMore={loadMore} New Songs
onAddToQueue={handleAddToQueue} <IonChip color="primary" className="ml-2">
onToggleFavorite={handleToggleFavorite} {newSongsItems.length}
context="search" </IonChip>
title="New Songs" </IonTitle>
subtitle={`${newSongsItems.length} new song${newSongsItems.length !== 1 ? 's' : ''} added recently`} </IonToolbar>
emptyTitle="No new songs" </IonHeader>
emptyMessage="New songs will appear here when they're added to the catalog"
loadingTitle="Loading new songs..." <div style={{ height: '100%', overflowY: 'auto' }}>
loadingMessage="Please wait while new songs data is being loaded" <InfiniteScrollList
debugInfo={`New songs items loaded: ${newSongsCount}`} items={newSongsItems}
/> isLoading={newSongsCount === 0}
hasMore={hasMore}
onLoadMore={loadMore}
onAddToQueue={handleAddToQueue}
onToggleFavorite={handleToggleFavorite}
context="search"
title=""
subtitle=""
emptyTitle="No new songs"
emptyMessage="Check back later for new additions"
loadingTitle="Loading new songs..."
loadingMessage="Please wait while new songs data is being loaded"
debugInfo={`New songs loaded: ${newSongsCount}`}
/>
</div>
</>
); );
}; };

View File

@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { IonSearchbar } from '@ionic/react';
import { InfiniteScrollList } from '../../components/common'; import { InfiniteScrollList } from '../../components/common';
import { useSearch } from '../../hooks'; import { useSearch } from '../../hooks';
import { useAppSelector } from '../../redux'; import { useAppSelector } from '../../redux';
@ -39,20 +40,13 @@ const Search: React.FC = () => {
<h1 className="text-2xl font-bold text-gray-900 mb-4">Search Songs</h1> <h1 className="text-2xl font-bold text-gray-900 mb-4">Search Songs</h1>
{/* Search Input */} {/* Search Input */}
<div className="relative"> <IonSearchbar
<input placeholder="Search by title or artist..."
type="text" value={searchTerm}
placeholder="Search by title or artist..." onIonInput={(e) => handleSearchChange(e.detail.value || '')}
value={searchTerm} debounce={300}
onChange={(e) => handleSearchChange(e.target.value)} showClearButton="focus"
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
/>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</div>
{/* Debug info */} {/* Debug info */}
<div className="mt-2 text-sm text-gray-500"> <div className="mt-2 text-sm text-gray-500">

View File

@ -1,5 +1,7 @@
import React from 'react'; import React from 'react';
import { ActionButton, EmptyState } from '../../components/common'; import { IonHeader, IonToolbar, IonTitle, IonContent, IonList, IonItem, IonLabel, IonItemSliding, IonItemOptions, IonItemOption, IonIcon, IonChip } from '@ionic/react';
import { people, trash, time } from 'ionicons/icons';
import { EmptyState } from '../../components/common';
import { useSingers } from '../../hooks'; import { useSingers } from '../../hooks';
import { useAppSelector } from '../../redux'; import { useAppSelector } from '../../redux';
import { selectSingers } from '../../redux'; import { selectSingers } from '../../redux';
@ -20,73 +22,89 @@ const Singers: React.FC = () => {
console.log('Singers component - singers:', singers); console.log('Singers component - singers:', singers);
return ( return (
<div className="max-w-4xl mx-auto p-6"> <>
<div className="mb-6"> <IonHeader>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Singers</h1> <IonToolbar>
<p className="text-sm text-gray-600"> <IonTitle>
{singers.length} singer{singers.length !== 1 ? 's' : ''} in the party Singers
</p> <IonChip color="primary" className="ml-2">
{singers.length}
{/* Debug info */} </IonChip>
<div className="mt-2 text-sm text-gray-500"> </IonTitle>
Singers loaded: {singersCount} </IonToolbar>
</div> </IonHeader>
</div>
{/* Singers List */} <IonContent>
<div className="bg-white rounded-lg shadow"> <div className="p-4">
{singersCount === 0 ? ( <p className="text-sm text-gray-600 mb-4">
<EmptyState {singers.length} singer{singers.length !== 1 ? 's' : ''} in the party
title="No singers yet" </p>
message="Singers will appear here when they join the party"
icon={ {/* Debug info */}
<svg className="h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <div className="mb-4 text-sm text-gray-500">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" /> Singers loaded: {singersCount}
</svg>
}
/>
) : singers.length === 0 ? (
<EmptyState
title="Loading singers..."
message="Please wait while singers data is being loaded"
icon={
<svg className="h-12 w-12 text-gray-400 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
}
/>
) : (
<div className="divide-y divide-gray-200">
{singers.map((singer) => (
<div key={singer.key} className="flex items-center justify-between p-4">
{/* Singer Info */}
<div className="flex-1">
<h3 className="text-sm font-medium text-gray-900">
{singer.name}
</h3>
<p className="text-sm text-gray-500">
Last login: {formatDate(singer.lastLogin)}
</p>
</div>
{/* Admin Controls */}
{isAdmin && (
<div className="flex-shrink-0 ml-4">
<ActionButton
onClick={() => handleRemoveSinger(singer)}
variant="danger"
size="sm"
>
Remove
</ActionButton>
</div>
)}
</div>
))}
</div> </div>
)}
</div> {/* Singers List */}
</div> <div className="bg-white rounded-lg shadow">
{singersCount === 0 ? (
<EmptyState
title="No singers yet"
message="Singers will appear here when they join the party"
icon={
<svg className="h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
</svg>
}
/>
) : singers.length === 0 ? (
<EmptyState
title="Loading singers..."
message="Please wait while singers data is being loaded"
icon={
<svg className="h-12 w-12 text-gray-400 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
}
/>
) : (
<IonList>
{singers.map((singer) => (
<IonItemSliding key={singer.key}>
<IonItem>
<IonIcon icon={people} slot="start" color="primary" />
<IonLabel>
<h3 className="text-sm font-medium text-gray-900">
{singer.name}
</h3>
<div className="flex items-center mt-1">
<IonChip color="medium">
<IonIcon icon={time} />
{formatDate(singer.lastLogin)}
</IonChip>
</div>
</IonLabel>
</IonItem>
{/* Swipe to Remove (Admin Only) */}
{isAdmin && (
<IonItemOptions side="end">
<IonItemOption
color="danger"
onClick={() => handleRemoveSinger(singer)}
>
<IonIcon icon={trash} slot="icon-only" />
</IonItemOption>
</IonItemOptions>
)}
</IonItemSliding>
))}
</IonList>
)}
</div>
</div>
</IonContent>
</>
); );
}; };

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { ActionButton, SongItem } from '../../components/common'; import { IonHeader, IonToolbar, IonTitle, IonContent, IonList, IonItem, IonLabel, IonModal, IonButton, IonIcon, IonChip, IonAccordion, IonAccordionGroup } from '@ionic/react';
import { close, documentText, add, heart, heartOutline } from 'ionicons/icons';
import { useSongLists } from '../../hooks'; import { useSongLists } from '../../hooks';
import { useAppSelector } from '../../redux'; import { useAppSelector } from '../../redux';
import { selectSongList } from '../../redux'; import { selectSongList } from '../../redux';
@ -47,7 +48,6 @@ const SongLists: React.FC = () => {
return () => observer.disconnect(); return () => observer.disconnect();
}, [loadMore, hasMore, songListCount]); }, [loadMore, hasMore, songListCount]);
const [selectedSongList, setSelectedSongList] = useState<string | null>(null); const [selectedSongList, setSelectedSongList] = useState<string | null>(null);
const [expandedSongs, setExpandedSongs] = useState<Set<string>>(new Set());
// Debug logging - only log when data changes // Debug logging - only log when data changes
useEffect(() => { useEffect(() => {
@ -64,16 +64,6 @@ const SongLists: React.FC = () => {
setSelectedSongList(null); setSelectedSongList(null);
}; };
const handleToggleExpanded = (songKey: string) => {
const newExpanded = new Set(expandedSongs);
if (newExpanded.has(songKey)) {
newExpanded.delete(songKey);
} else {
newExpanded.add(songKey);
}
setExpandedSongs(newExpanded);
};
const finalSelectedList = selectedSongList const finalSelectedList = selectedSongList
? allSongLists.find(list => list.key === selectedSongList) ? allSongLists.find(list => list.key === selectedSongList)
: null; : null;
@ -93,164 +83,176 @@ const SongLists: React.FC = () => {
}, [selectedSongList, finalSelectedList, songLists.length]); }, [selectedSongList, finalSelectedList, songLists.length]);
return ( return (
<div className="max-w-4xl mx-auto p-6"> <>
<div className="mb-6"> <IonHeader>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Song Lists</h1> <IonToolbar>
<p className="text-sm text-gray-600"> <IonTitle>
{songLists.length} song list{songLists.length !== 1 ? 's' : ''} available Song Lists
</p> <IonChip color="primary" className="ml-2">
{songLists.length}
{/* Debug info */} </IonChip>
<div className="mt-2 text-sm text-gray-500"> </IonTitle>
Song lists loaded: {songListCount} </IonToolbar>
</div> </IonHeader>
</div>
{/* Song Lists */} <IonContent>
<div className="bg-white rounded-lg shadow"> <div className="p-4">
{songListCount === 0 ? ( <p className="text-sm text-gray-600 mb-4">
<div className="p-8 text-center"> {songLists.length} song list{songLists.length !== 1 ? 's' : ''} available
<div className="text-gray-400 mb-4"> </p>
<svg className="h-12 w-12 mx-auto animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> {/* Debug info */}
</svg> <div className="mb-4 text-sm text-gray-500">
</div> Song lists loaded: {songListCount}
<h3 className="text-lg font-medium text-gray-900 mb-2">Loading song lists...</h3>
<p className="text-sm text-gray-500">Please wait while song lists are being loaded</p>
</div> </div>
) : songLists.length === 0 ? (
<div className="p-8 text-center"> {/* Song Lists */}
<div className="text-gray-400 mb-4"> <div className="bg-white rounded-lg shadow">
<svg className="h-12 w-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24"> {songListCount === 0 ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" /> <div className="p-8 text-center">
</svg> <div className="text-gray-400 mb-4">
</div> <svg className="h-12 w-12 mx-auto animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<h3 className="text-lg font-medium text-gray-900 mb-2">No song lists available</h3> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
<p className="text-sm text-gray-500">Song lists will appear here when they're available</p>
</div>
) : (
<div className="divide-y divide-gray-200">
{songLists.map((songList) => (
<div key={songList.key} className="flex items-center justify-between p-4 hover:bg-gray-50">
<div className="flex-1">
<h3 className="text-sm font-medium text-gray-900">
{songList.title}
</h3>
<p className="text-sm text-gray-500">
{songList.songs.length} song{songList.songs.length !== 1 ? 's' : ''}
</p>
</div>
<div className="flex-shrink-0 ml-4">
<ActionButton
onClick={() => handleSongListClick(songList.key!)}
variant="primary"
size="sm"
>
View Songs
</ActionButton>
</div>
</div>
))}
{/* Infinite scroll trigger */}
{hasMore && (
<div
ref={observerRef}
className="py-4 text-center text-gray-500"
>
<div className="inline-flex items-center">
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg> </svg>
Loading more song lists...
</div> </div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Loading song lists...</h3>
<p className="text-sm text-gray-500">Please wait while song lists are being loaded</p>
</div> </div>
) : songLists.length === 0 ? (
<div className="p-8 text-center">
<div className="text-gray-400 mb-4">
<svg className="h-12 w-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">No song lists available</h3>
<p className="text-sm text-gray-500">Song lists will appear here when they're available</p>
</div>
) : (
<IonList>
{songLists.map((songList) => (
<IonItem key={songList.key} button onClick={() => handleSongListClick(songList.key!)}>
<IonIcon icon={documentText} slot="start" color="primary" />
<IonLabel>
<h3 className="text-sm font-medium text-gray-900">
{songList.title}
</h3>
<p className="text-sm text-gray-500">
{songList.songs.length} song{songList.songs.length !== 1 ? 's' : ''}
</p>
</IonLabel>
<IonChip slot="end" color="primary">
View Songs
</IonChip>
</IonItem>
))}
{/* Infinite scroll trigger */}
{hasMore && (
<div
ref={observerRef}
className="py-4 text-center text-gray-500"
>
<div className="inline-flex items-center">
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Loading more song lists...
</div>
</div>
)}
</IonList>
)} )}
</div> </div>
)} </div>
</div> </IonContent>
{/* Song List Modal */} {/* Song List Modal */}
{finalSelectedList && ( <IonModal isOpen={!!finalSelectedList} onDidDismiss={handleCloseSongList}>
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999]" style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0 }}> <IonHeader>
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[80vh] overflow-hidden" style={{ backgroundColor: 'white', zIndex: 10000 }}> <IonToolbar>
<div className="p-6 border-b border-gray-200"> <IonTitle>{finalSelectedList?.title}</IonTitle>
<div className="flex items-center justify-between"> <IonButton slot="end" fill="clear" onClick={handleCloseSongList}>
<h2 className="text-xl font-bold text-gray-900"> <IonIcon icon={close} />
{finalSelectedList.title} </IonButton>
</h2> </IonToolbar>
<ActionButton </IonHeader>
onClick={handleCloseSongList}
variant="secondary" <IonContent>
size="sm" <IonAccordionGroup>
> {finalSelectedList?.songs.map((songListSong: SongListSong, idx) => {
Close const availableSongs = checkSongAvailability(songListSong);
</ActionButton> const isAvailable = availableSongs.length > 0;
</div>
</div>
<div className="overflow-y-auto max-h-[60vh]">
<div className="divide-y divide-gray-200">
{finalSelectedList.songs.map((songListSong: SongListSong, idx) => {
const availableSongs = checkSongAvailability(songListSong);
const isExpanded = expandedSongs.has(songListSong.key!);
const isAvailable = availableSongs.length > 0;
return ( return (
<div key={songListSong.key || `${songListSong.title}-${songListSong.position}-${idx}`}> <IonAccordion key={songListSong.key || `${songListSong.title}-${songListSong.position}-${idx}`} value={songListSong.key}>
{/* Song List Song Row */} <IonItem slot="header" className={!isAvailable ? 'opacity-50' : ''}>
<div className={`flex items-center justify-between p-4 ${!isAvailable ? 'opacity-50' : ''}`}> <IonLabel>
<div className="flex-1"> <h3 className="text-sm font-medium text-gray-900">
<h3 className="text-sm font-medium text-gray-900"> {songListSong.title}
{songListSong.title} </h3>
</h3> <p className="text-sm text-gray-500">
<p className="text-sm text-gray-500"> {songListSong.artist} Position {songListSong.position}
{songListSong.artist} Position {songListSong.position} </p>
</p> {!isAvailable && (
{!isAvailable && ( <p className="text-xs text-red-500 mt-1">
<p className="text-xs text-red-500 mt-1"> Not available in catalog
Not available in catalog </p>
</p>
)}
</div>
<div className="flex-shrink-0 ml-4 flex items-center space-x-2">
{isAvailable && (
<ActionButton
onClick={() => handleToggleExpanded(songListSong.key!)}
variant="secondary"
size="sm"
>
{isExpanded ? 'Hide' : 'Show'} ({availableSongs.length})
</ActionButton>
)}
</div>
</div>
{/* Available Songs (when expanded) */}
{isExpanded && isAvailable && (
<div className="bg-gray-50 border-t border-gray-200">
{availableSongs.map((song: Song, sidx) => (
<div key={song.key || `${song.title}-${song.artist}-${sidx}`} className="p-4 border-b border-gray-200 last:border-b-0">
<SongItem
song={song}
context="search"
onAddToQueue={() => handleAddToQueue(song)}
onToggleFavorite={() => handleToggleFavorite(song)}
/>
</div>
))}
</div>
)} )}
</div> </IonLabel>
); {isAvailable && (
})} <IonChip slot="end" color="success">
</div> {availableSongs.length} version{availableSongs.length !== 1 ? 's' : ''}
</div> </IonChip>
</div> )}
</div> </IonItem>
)}
</div> <div slot="content">
{isAvailable ? (
<IonList>
{availableSongs.map((song: Song, sidx) => (
<IonItem key={song.key || `${song.title}-${song.artist}-${sidx}`}>
<IonLabel>
<h3 className="text-sm font-medium text-gray-900">
{song.title}
</h3>
<p className="text-sm text-gray-500">
{song.artist}
</p>
</IonLabel>
<div slot="end" className="flex gap-2">
<IonButton
fill="clear"
size="small"
onClick={() => handleAddToQueue(song)}
>
<IonIcon icon={add} slot="icon-only" />
</IonButton>
<IonButton
fill="clear"
size="small"
onClick={() => handleToggleFavorite(song)}
>
<IonIcon icon={song.favorite ? heart : heartOutline} slot="icon-only" />
</IonButton>
</div>
</IonItem>
))}
</IonList>
) : (
<div className="p-4 text-center text-gray-500">
No matching songs found in catalog
</div>
)}
</div>
</IonAccordion>
);
})}
</IonAccordionGroup>
</IonContent>
</IonModal>
</>
); );
}; };

View File

@ -1,11 +1,11 @@
import React from 'react'; import React from 'react';
import { IonHeader, IonToolbar, IonTitle, IonChip, IonIcon } from '@ionic/react';
import { InfiniteScrollList } from '../../components/common'; import { InfiniteScrollList } from '../../components/common';
import { useTopPlayed } from '../../hooks'; import { useTopPlayed } from '../../hooks';
import { useAppSelector } from '../../redux'; import { useAppSelector } from '../../redux';
import { selectTopPlayed } from '../../redux'; import { selectTopPlayed } from '../../redux';
import type { TopPlayed, Song } from '../../types';
const Top100: React.FC = () => { const TopPlayed: React.FC = () => {
const { const {
topPlayedItems, topPlayedItems,
hasMore, hasMore,
@ -18,79 +18,52 @@ const Top100: React.FC = () => {
const topPlayedCount = Object.keys(topPlayed).length; const topPlayedCount = Object.keys(topPlayed).length;
// Debug logging // Debug logging
console.log('TopPlayed component - topPlayed count:', topPlayedCount); console.log('TopPlayed component - top played count:', topPlayedCount);
console.log('TopPlayed component - topPlayed items:', topPlayedItems); console.log('TopPlayed component - top played items:', topPlayedItems);
// Convert TopPlayed items to Song format for the InfiniteScrollList const renderExtraContent = (item: any, index: number) => (
const songItems = topPlayedItems.map((item: TopPlayed) => ({ <div className="flex items-center space-x-2 px-4 py-2">
...item, <div className="flex items-center text-sm text-gray-500">
path: '', // TopPlayed doesn't have path <IonIcon icon="trophy" className="mr-1" />
disabled: false, <span>#{index + 1}</span>
favorite: false, </div>
})); </div>
);
// Render extra content for top played items (rank and play count)
const renderExtraContent = (item: Song, index: number) => {
return (
<>
{/* Rank */}
<div className="flex-shrink-0 w-12 h-12 flex items-center justify-center">
<div className={`
w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold
${index === 0 ? 'bg-yellow-100 text-yellow-800' : ''}
${index === 1 ? 'bg-gray-100 text-gray-800' : ''}
${index === 2 ? 'bg-orange-100 text-orange-800' : ''}
${index > 2 ? 'bg-gray-50 text-gray-600' : ''}
`}>
{index + 1}
</div>
</div>
{/* Play Count */}
<div className="flex-shrink-0 px-4 py-2 text-sm text-gray-600">
<div className="font-medium">{item.count}</div>
<div className="text-xs text-gray-400">
play{item.count !== 1 ? 's' : ''}
</div>
</div>
</>
);
};
// Wrapper functions to handle type conversion
const handleAddToQueueWrapper = (song: Song) => {
const topPlayedItem = topPlayedItems.find(item => item.key === song.key);
if (topPlayedItem) {
handleAddToQueue(topPlayedItem);
}
};
const handleToggleFavoriteWrapper = (song: Song) => {
const topPlayedItem = topPlayedItems.find(item => item.key === song.key);
if (topPlayedItem) {
handleToggleFavorite(topPlayedItem);
}
};
return ( return (
<InfiniteScrollList <>
items={songItems} <IonHeader>
isLoading={topPlayedCount === 0} <IonToolbar>
hasMore={hasMore} <IonTitle>
onLoadMore={loadMore} Top 100 Played
onAddToQueue={handleAddToQueueWrapper} <IonChip color="primary" className="ml-2">
onToggleFavorite={handleToggleFavoriteWrapper} {topPlayedItems.length}
context="topPlayed" </IonChip>
title="Most Played" </IonTitle>
subtitle={`Top ${topPlayedItems.length} song${topPlayedItems.length !== 1 ? 's' : ''} by play count`} </IonToolbar>
emptyTitle="No play data yet" </IonHeader>
emptyMessage="Song play counts will appear here after songs have been played"
loadingTitle="Loading top played..." <div style={{ height: '100%', overflowY: 'auto' }}>
loadingMessage="Please wait while top played data is being loaded" <InfiniteScrollList
debugInfo={`Top played items loaded: ${topPlayedCount}`} items={topPlayedItems}
renderExtraContent={renderExtraContent} isLoading={topPlayedCount === 0}
/> hasMore={hasMore}
onLoadMore={loadMore}
onAddToQueue={handleAddToQueue}
onToggleFavorite={handleToggleFavorite}
context="topPlayed"
title=""
subtitle=""
emptyTitle="No top played songs"
emptyMessage="Play some songs to see the top played list"
loadingTitle="Loading top played songs..."
loadingMessage="Please wait while top played data is being loaded"
debugInfo={`Top played items loaded: ${topPlayedCount}`}
renderExtraContent={renderExtraContent}
/>
</div>
</>
); );
}; };
export default Top100; export default TopPlayed;

View File

@ -15,3 +15,21 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* Ensure menu button is visible */
ion-menu-button {
--color: var(--ion-color-primary);
--padding-start: 8px;
--padding-end: 8px;
}
/* Menu item styling */
ion-item.ion-activated {
--background: var(--ion-color-primary);
--color: var(--ion-color-primary-contrast);
}
/* Ensure mobile menu appears above other content */
ion-menu {
--z-index: 1000;
}