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