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

This commit is contained in:
Matt Bruce 2025-07-17 11:55:41 -05:00
parent d7cf384429
commit e2d9297ffe
72 changed files with 3005 additions and 666 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

BIN
docs/design/01-Login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

BIN
docs/design/02-menu.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

BIN
docs/design/02-queue.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

BIN
docs/design/03-menu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 658 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

BIN
docs/design/04-search.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

BIN
docs/design/05-singers.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

BIN
docs/design/06-artists .png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

BIN
docs/design/08-history.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

BIN
docs/design/10-Settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 KiB

12
env.template Normal file
View File

@ -0,0 +1,12 @@
# Firebase Configuration
VITE_FIREBASE_API_KEY=your-api-key
VITE_FIREBASE_AUTH_DOMAIN=your-project-id.firebaseapp.com
VITE_FIREBASE_DATABASE_URL=https://your-project-id-default-rtdb.firebaseio.com
VITE_FIREBASE_PROJECT_ID=your-project-id
VITE_FIREBASE_STORAGE_BUCKET=your-project-id.appspot.com
VITE_FIREBASE_MESSAGING_SENDER_ID=123456789
VITE_FIREBASE_APP_ID=your-app-id
# App Configuration
VITE_CONTROLLER_NAME=default
VITE_APP_TITLE=SingSalot AI

1653
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,6 +11,7 @@
},
"dependencies": {
"@reduxjs/toolkit": "^2.8.2",
"@tailwindcss/postcss": "^4.1.11",
"@types/react-router-dom": "^5.3.3",
"firebase": "^11.10.0",
"react": "^19.1.0",
@ -32,6 +33,6 @@
"tailwindcss": "^4.1.11",
"typescript": "~5.8.3",
"typescript-eslint": "^8.35.1",
"vite": "^7.0.4"
"vite": "^5.4.0"
}
}

View File

@ -1,6 +1,6 @@
export default {
plugins: {
tailwindcss: {},
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

View File

@ -1,22 +1,38 @@
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import Layout from './components/Layout/Layout';
import Navigation from './components/Navigation/Navigation';
import { Search, Queue, History, TopPlayed } from './features';
import { Search, Queue, History, Favorites, NewSongs, Artists, Singers, SongLists } from './features';
import TopPlayed from './features/TopPlayed/Top100';
import { FirebaseProvider } from './firebase/FirebaseProvider';
import { ErrorBoundary } from './components/common';
import { AuthInitializer } from './components/Auth';
function App() {
return (
<Router>
<Layout>
<Navigation />
<Routes>
<Route path="/" element={<Search />} />
<Route path="/queue" element={<Queue />} />
<Route path="/history" element={<History />} />
<Route path="/top-played" element={<TopPlayed />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
</Router>
<ErrorBoundary>
<FirebaseProvider>
<Router>
<AuthInitializer>
<Layout>
<Navigation />
<Routes>
<Route path="/" element={<Navigate to="/queue" replace />} />
<Route path="/search" element={<Search />} />
<Route path="/queue" element={<Queue />} />
<Route path="/favorites" element={<Favorites />} />
<Route path="/new-songs" element={<NewSongs />} />
<Route path="/artists" element={<Artists />} />
<Route path="/song-lists" element={<SongLists />} />
<Route path="/history" element={<History />} />
<Route path="/top-played" element={<TopPlayed />} />
<Route path="/singers" element={<Singers />} />
<Route path="*" element={<Navigate to="/queue" replace />} />
</Routes>
</Layout>
</AuthInitializer>
</Router>
</FirebaseProvider>
</ErrorBoundary>
);
}

View File

@ -0,0 +1,59 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useAppDispatch, useAppSelector } from '../../redux/hooks';
import { setAuth } from '../../redux/authSlice';
import { selectIsAuthenticated } from '../../redux/authSlice';
import { CONTROLLER_NAME } from '../../constants';
import { LoginPrompt } from './index';
import type { Authentication } from '../../types';
interface AuthInitializerProps {
children: React.ReactNode;
}
const AuthInitializer: React.FC<AuthInitializerProps> = ({ children }) => {
const [searchParams] = useSearchParams();
const [showLogin, setShowLogin] = useState(false);
const dispatch = useAppDispatch();
const isAuthenticated = useAppSelector(selectIsAuthenticated);
useEffect(() => {
// Check for admin parameter in URL
const isAdmin = searchParams.get('admin') === 'true';
// If admin parameter is present, auto-authenticate
if (isAdmin) {
const auth: Authentication = {
authenticated: true,
singer: 'Admin',
isAdmin: true,
controller: CONTROLLER_NAME,
};
dispatch(setAuth(auth));
// Clean up URL
if (window.history.replaceState) {
const newUrl = new URL(window.location.href);
newUrl.searchParams.delete('admin');
window.history.replaceState({}, '', newUrl.toString());
}
} else if (!isAuthenticated) {
// Show login prompt for regular users
setShowLogin(true);
}
}, [searchParams, dispatch, isAuthenticated]);
// Show login prompt if not authenticated
if (showLogin && !isAuthenticated) {
return (
<LoginPrompt
isAdmin={false}
onComplete={() => setShowLogin(false)}
/>
);
}
return <>{children}</>;
};
export default AuthInitializer;

View File

@ -0,0 +1,86 @@
import { useState } from 'react';
import { useAppDispatch } from '../../redux/hooks';
import { setAuth } from '../../redux/authSlice';
import type { Authentication } from '../../types';
interface LoginPromptProps {
isAdmin: boolean;
onComplete: () => void;
}
const LoginPrompt: React.FC<LoginPromptProps> = ({ isAdmin, onComplete }) => {
const [singerName, setSingerName] = useState(isAdmin ? 'Admin' : '');
const [partyId, setPartyId] = useState('');
const [error, setError] = useState('');
const dispatch = useAppDispatch();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!partyId.trim() || !singerName.trim()) {
setError('Please enter both Party Id and your name.');
return;
}
setError('');
const auth: Authentication = {
authenticated: true,
singer: singerName.trim(),
isAdmin: isAdmin,
controller: partyId.trim(),
};
dispatch(setAuth(auth));
onComplete();
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full bg-white shadow-lg rounded-lg p-6">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-gray-900 mb-2">
Welcome to Karaoke! 🎤
</h1>
<p className="text-gray-600">
{isAdmin ? 'You have admin privileges' : 'Enter your Party Id and name to get started'}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="partyId" className="block text-sm font-medium text-gray-700 mb-1">
Party Id
</label>
<input
type="text"
id="partyId"
value={partyId}
onChange={(e) => setPartyId(e.target.value)}
placeholder="Enter your Party Id"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
autoFocus
/>
</div>
<div>
<label htmlFor="singerName" className="block text-sm font-medium text-gray-700 mb-1">
Your Name
</label>
<input
type="text"
id="singerName"
value={singerName}
onChange={(e) => setSingerName(e.target.value)}
placeholder={isAdmin ? 'Admin' : 'Enter your name'}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{error && <div className="text-red-500 text-sm text-center">{error}</div>}
<button
type="submit"
className="w-full bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded-md transition-colors"
>
{isAdmin ? 'Start as Admin' : 'Join Session'}
</button>
</form>
</div>
</div>
);
};
export default LoginPrompt;

View File

@ -0,0 +1,2 @@
export { default as AuthInitializer } from './AuthInitializer';
export { default as LoginPrompt } from './LoginPrompt';

View File

@ -1,13 +1,21 @@
import React from 'react';
import { useSelector } from 'react-redux';
import type { RootState } from '../../redux/store';
import { useSelector, useDispatch } from 'react-redux';
import { selectCurrentSinger, selectIsAdmin, selectControllerName } from '../../redux/authSlice';
import { logout } from '../../redux/authSlice';
import { ActionButton } from '../common';
import type { LayoutProps } from '../../types';
const Layout: React.FC<LayoutProps> = ({ children }) => {
// TODO: Replace with actual Redux selectors
const currentSinger = useSelector((state: RootState) => state.auth?.singer || '');
const isAdmin = useSelector((state: RootState) => state.auth?.isAdmin || false);
const controllerName = useSelector((state: RootState) => state.auth?.controller || '');
const currentSinger = useSelector(selectCurrentSinger);
const isAdmin = useSelector(selectIsAdmin);
const controllerName = useSelector(selectControllerName);
const dispatch = useDispatch();
const handleLogout = () => {
dispatch(logout());
// Reload the page to return to login screen
window.location.reload();
};
return (
<div className="min-h-screen bg-gray-50">
@ -22,21 +30,30 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
</h1>
{controllerName && (
<span className="ml-4 text-sm text-gray-500">
Controller: {controllerName}
Party: {controllerName}
</span>
)}
</div>
{/* User Info */}
{/* User Info & Logout */}
<div className="flex items-center space-x-4">
{currentSinger && (
<div className="text-sm text-gray-600">
<span className="font-medium">{currentSinger}</span>
{isAdmin && (
<span className="ml-2 px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded-full">
Admin
</span>
)}
<div className="flex items-center space-x-3">
<div className="text-sm text-gray-600">
<span className="font-medium">{currentSinger}</span>
{isAdmin && (
<span className="ml-2 px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded-full">
Admin
</span>
)}
</div>
<ActionButton
onClick={handleLogout}
variant="secondary"
size="sm"
>
Logout
</ActionButton>
</div>
)}
</div>

View File

@ -3,10 +3,15 @@ import { NavLink } from 'react-router-dom';
const Navigation: React.FC = () => {
const navItems = [
{ path: '/', label: 'Search', icon: '🔍' },
{ path: '/queue', label: 'Queue', icon: '📋' },
{ path: '/search', label: 'Search', icon: '🔍' },
{ path: '/favorites', label: 'Favorites', icon: '❤️' },
{ path: '/new-songs', label: 'New Songs', icon: '🆕' },
{ path: '/artists', label: 'Artists', icon: '🎤' },
{ path: '/song-lists', label: 'Song Lists', icon: '📝' },
{ path: '/history', label: 'History', icon: '⏰' },
{ path: '/top-played', label: 'Top Played', icon: '🏆' },
{ path: '/top-played', label: 'Top 100', icon: '🏆' },
{ path: '/singers', label: 'Singers', icon: '👥' },
];
return (

View File

@ -0,0 +1,60 @@
import React, { Component, type ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full bg-white shadow-lg rounded-lg p-6">
<div className="text-center">
<div className="text-red-500 text-6xl mb-4"></div>
<h1 className="text-xl font-semibold text-gray-900 mb-2">
Something went wrong
</h1>
<p className="text-gray-600 mb-4">
We're sorry, but something unexpected happened. Please try refreshing the page.
</p>
<button
onClick={() => window.location.reload()}
className="bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
Refresh Page
</button>
</div>
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@ -0,0 +1,162 @@
import React, { useEffect, useRef } from 'react';
import { SongItem, EmptyState } from './index';
import type { Song } from '../../types';
interface InfiniteScrollListProps {
items: Song[];
isLoading: boolean;
hasMore: boolean;
onLoadMore: () => void;
onAddToQueue: (song: Song) => void;
onToggleFavorite: (song: Song) => void;
onRemoveFromQueue?: (song: Song) => void;
context: 'search' | 'queue' | 'history' | 'topPlayed' | 'favorites';
title: string;
subtitle?: string;
emptyTitle: string;
emptyMessage: string;
loadingTitle?: string;
loadingMessage?: string;
isAdmin?: boolean;
renderExtraContent?: (item: Song, index: number) => React.ReactNode;
debugInfo?: string;
}
const InfiniteScrollList: React.FC<InfiniteScrollListProps> = ({
items,
isLoading,
hasMore,
onLoadMore,
onAddToQueue,
onToggleFavorite,
onRemoveFromQueue,
context,
title,
subtitle,
emptyTitle,
emptyMessage,
loadingTitle = "Loading...",
loadingMessage = "Please wait while data is being loaded",
isAdmin = false,
renderExtraContent,
debugInfo,
}) => {
const observerRef = useRef<HTMLDivElement>(null);
// Intersection Observer for infinite scrolling
useEffect(() => {
console.log('InfiniteScrollList - Setting up observer:', { hasMore, isLoading, itemsLength: items.length });
const observer = new IntersectionObserver(
(entries) => {
console.log('InfiniteScrollList - Intersection detected:', {
isIntersecting: entries[0].isIntersecting,
hasMore,
isLoading
});
if (entries[0].isIntersecting && hasMore && !isLoading) {
console.log('InfiniteScrollList - Loading more items');
onLoadMore();
}
},
{ threshold: 0.1 }
);
if (observerRef.current) {
observer.observe(observerRef.current);
}
return () => observer.disconnect();
}, [onLoadMore, hasMore, isLoading, items.length]);
return (
<div className="max-w-4xl mx-auto p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 mb-2">{title}</h1>
{subtitle && (
<p className="text-sm text-gray-600">{subtitle}</p>
)}
{/* Debug info */}
{debugInfo && (
<div className="mt-2 text-sm text-gray-500">
{debugInfo}
</div>
)}
</div>
{/* List */}
<div className="bg-white rounded-lg shadow">
{items.length === 0 && !isLoading ? (
<EmptyState
title={emptyTitle}
message={emptyMessage}
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="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>
}
/>
) : isLoading && items.length === 0 ? (
<EmptyState
title={loadingTitle}
message={loadingMessage}
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">
{items.map((item, index) => (
<div key={item.key} className="flex items-center">
{/* Song Info */}
<div className="flex-1">
<SongItem
song={item}
context={context}
onAddToQueue={() => onAddToQueue(item)}
onToggleFavorite={() => onToggleFavorite(item)}
onRemoveFromQueue={onRemoveFromQueue ? () => onRemoveFromQueue(item) : undefined}
isAdmin={isAdmin}
/>
</div>
{/* Extra Content */}
{renderExtraContent && renderExtraContent(item, index)}
</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>
Loading more items...
</div>
</div>
)}
</div>
)}
</div>
{/* Stats */}
{items.length > 0 && (
<div className="mt-4 text-sm text-gray-500 text-center">
Showing {items.length} item{items.length !== 1 ? 's' : ''}
{hasMore && ` • Scroll down to load more`}
</div>
)}
</div>
);
};
export default InfiniteScrollList;

View File

@ -96,6 +96,26 @@ const SongItem: React.FC<SongItemProps> = ({
</div>
);
case 'topPlayed':
return (
<div className="flex gap-2">
<ActionButton
onClick={onAddToQueue || (() => {})}
variant="primary"
size="sm"
>
Add to Queue
</ActionButton>
<ActionButton
onClick={onToggleFavorite || (() => {})}
variant={song.favorite ? 'danger' : 'secondary'}
size="sm"
>
{song.favorite ? '❤️' : '🤍'}
</ActionButton>
</div>
);
default:
return null;
}

View File

@ -1,4 +1,6 @@
export { default as EmptyState } from './EmptyState';
export { default as Toast } from './Toast';
export { default as ActionButton } from './ActionButton';
export { default as InfiniteScrollList } from './InfiniteScrollList';
export { default as SongItem } from './SongItem';
export { default as ErrorBoundary } from './ErrorBoundary';

View File

@ -1,6 +1,7 @@
// App constants
export const APP_NAME = '🎤 Karaoke App';
export const APP_VERSION = '1.0.0';
export const CONTROLLER_NAME = import.meta.env.VITE_CONTROLLER_NAME || 'default';
// Firebase configuration
export const FIREBASE_CONFIG = {

View File

@ -0,0 +1,157 @@
import React, { useState } from 'react';
import { InfiniteScrollList, ActionButton } from '../../components/common';
import { useArtists } from '../../hooks';
import { useAppSelector } from '../../redux';
import { selectSongs } from '../../redux';
const Artists: React.FC = () => {
const {
artists,
searchTerm,
handleSearchChange,
getSongsByArtist,
handleAddToQueue,
handleToggleFavorite,
} = useArtists();
const songs = useAppSelector(selectSongs);
const songsCount = Object.keys(songs).length;
const [selectedArtist, setSelectedArtist] = useState<string | null>(null);
// Debug logging
console.log('Artists component - artists count:', artists.length);
console.log('Artists component - selected artist:', selectedArtist);
const handleArtistClick = (artist: string) => {
setSelectedArtist(artist);
};
const handleCloseArtistSongs = () => {
setSelectedArtist(null);
};
const selectedArtistSongs = selectedArtist ? getSongsByArtist(selectedArtist) : [];
return (
<div className="max-w-4xl mx-auto p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 mb-4">Artists</h1>
{/* Search Input */}
<div className="relative">
<input
type="text"
placeholder="Search artists..."
value={searchTerm}
onChange={(e) => handleSearchChange(e.target.value)}
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 */}
<div className="mt-2 text-sm text-gray-500">
Total songs loaded: {songsCount} | Showing: {artists.length} artists | Search term: "{searchTerm}"
</div>
</div>
{/* Artists List */}
<div className="bg-white rounded-lg shadow">
{songsCount === 0 ? (
<div className="p-8 text-center">
<div className="text-gray-400 mb-4">
<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" />
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Loading artists...</h3>
<p className="text-sm text-gray-500">Please wait while songs are being loaded from the database</p>
</div>
) : artists.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">
{searchTerm ? "No artists found" : "No artists available"}
</h3>
<p className="text-sm text-gray-500">
{searchTerm ? "Try adjusting your search terms" : "Artists will appear here once songs are loaded"}
</p>
</div>
) : (
<div className="divide-y divide-gray-200">
{artists.map((artist) => (
<div key={artist} 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">
{artist}
</h3>
<p className="text-sm text-gray-500">
{getSongsByArtist(artist).length} song{getSongsByArtist(artist).length !== 1 ? 's' : ''}
</p>
</div>
<div className="flex-shrink-0 ml-4">
<ActionButton
onClick={() => handleArtistClick(artist)}
variant="primary"
size="sm"
>
View Songs
</ActionButton>
</div>
</div>
))}
</div>
)}
</div>
{/* Artist Songs Modal */}
{selectedArtist && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[80vh] overflow-hidden">
<div className="p-6 border-b border-gray-200">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold text-gray-900">
Songs by {selectedArtist}
</h2>
<ActionButton
onClick={handleCloseArtistSongs}
variant="secondary"
size="sm"
>
Close
</ActionButton>
</div>
</div>
<div className="overflow-y-auto max-h-[60vh]">
<InfiniteScrollList
items={selectedArtistSongs}
isLoading={false}
hasMore={false}
onLoadMore={() => {}}
onAddToQueue={handleAddToQueue}
onToggleFavorite={handleToggleFavorite}
context="search"
title=""
emptyTitle="No songs found"
emptyMessage="No songs found for this artist"
debugInfo=""
/>
</div>
</div>
</div>
)}
</div>
);
};
export default Artists;

View File

@ -0,0 +1,43 @@
import React from 'react';
import { InfiniteScrollList } from '../../components/common';
import { useFavorites } from '../../hooks';
import { useAppSelector } from '../../redux';
import { selectFavorites } from '../../redux';
const Favorites: React.FC = () => {
const {
favoritesItems,
hasMore,
loadMore,
handleAddToQueue,
handleToggleFavorite,
} = useFavorites();
const favorites = useAppSelector(selectFavorites);
const favoritesCount = Object.keys(favorites).length;
// Debug logging
console.log('Favorites component - favorites count:', favoritesCount);
console.log('Favorites component - favorites items:', favoritesItems);
return (
<InfiniteScrollList
items={favoritesItems}
isLoading={favoritesCount === 0}
hasMore={hasMore}
onLoadMore={loadMore}
onAddToQueue={handleAddToQueue}
onToggleFavorite={handleToggleFavorite}
context="favorites"
title="Favorites"
subtitle={`${favoritesItems.length} song${favoritesItems.length !== 1 ? 's' : ''} in favorites`}
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}`}
/>
);
};
export default Favorites;

View File

@ -1,62 +1,57 @@
import React from 'react';
import { SongItem, EmptyState } from '../../components/common';
import { InfiniteScrollList } from '../../components/common';
import { useHistory } from '../../hooks';
import { useAppSelector } from '../../redux';
import { selectHistory } from '../../redux';
import { formatDate } from '../../utils/dataProcessing';
import type { Song } from '../../types';
const History: React.FC = () => {
const {
historyItems,
hasMore,
loadMore,
handleAddToQueue,
handleToggleFavorite,
} = useHistory();
const history = useAppSelector(selectHistory);
const historyCount = Object.keys(history).length;
// Debug logging
console.log('History component - history count:', historyCount);
console.log('History component - history items:', historyItems);
// Render extra content for history items (play date)
const renderExtraContent = (item: Song) => {
if (item.date) {
return (
<div className="flex-shrink-0 px-4 py-2 text-sm text-gray-500">
{formatDate(item.date)}
</div>
);
}
return null;
};
return (
<div className="max-w-4xl mx-auto p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 mb-2">Recently Played</h1>
<p className="text-sm text-gray-600">
{historyItems.length} song{historyItems.length !== 1 ? 's' : ''} in history
</p>
</div>
{/* History List */}
<div className="bg-white rounded-lg shadow">
{historyItems.length === 0 ? (
<EmptyState
title="No history yet"
message="Songs will appear here after they've been played"
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 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
}
/>
) : (
<div className="divide-y divide-gray-200">
{historyItems.map((song) => (
<div key={song.key} className="flex items-center">
{/* Song Info */}
<div className="flex-1">
<SongItem
song={song}
context="history"
onAddToQueue={() => handleAddToQueue(song)}
onToggleFavorite={() => handleToggleFavorite(song)}
/>
</div>
{/* Play Date */}
{song.date && (
<div className="flex-shrink-0 px-4 py-2 text-sm text-gray-500">
{formatDate(song.date)}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
<InfiniteScrollList
items={historyItems}
isLoading={historyCount === 0}
hasMore={hasMore}
onLoadMore={loadMore}
onAddToQueue={handleAddToQueue}
onToggleFavorite={handleToggleFavorite}
context="history"
title="Recently Played"
subtitle={`${historyItems.length} song${historyItems.length !== 1 ? 's' : ''} in history`}
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}
/>
);
};

View File

@ -0,0 +1,43 @@
import React from 'react';
import { InfiniteScrollList } from '../../components/common';
import { useNewSongs } from '../../hooks';
import { useAppSelector } from '../../redux';
import { selectNewSongs } from '../../redux';
const NewSongs: React.FC = () => {
const {
newSongsItems,
hasMore,
loadMore,
handleAddToQueue,
handleToggleFavorite,
} = useNewSongs();
const newSongs = useAppSelector(selectNewSongs);
const newSongsCount = Object.keys(newSongs).length;
// Debug logging
console.log('NewSongs component - newSongs count:', newSongsCount);
console.log('NewSongs component - newSongs items:', newSongsItems);
return (
<InfiniteScrollList
items={newSongsItems}
isLoading={newSongsCount === 0}
hasMore={hasMore}
onLoadMore={loadMore}
onAddToQueue={handleAddToQueue}
onToggleFavorite={handleToggleFavorite}
context="search"
title="New Songs"
subtitle={`${newSongsItems.length} new song${newSongsItems.length !== 1 ? 's' : ''} added recently`}
emptyTitle="No new songs"
emptyMessage="New songs will appear here when they're added to the catalog"
loadingTitle="Loading new songs..."
loadingMessage="Please wait while new songs data is being loaded"
debugInfo={`New songs items loaded: ${newSongsCount}`}
/>
);
};
export default NewSongs;

View File

@ -1,6 +1,8 @@
import React from 'react';
import { SongItem, EmptyState, ActionButton } from '../../components/common';
import { useQueue } from '../../hooks';
import { useAppSelector } from '../../redux';
import { selectQueue } from '../../redux';
const Queue: React.FC = () => {
const {
@ -13,6 +15,13 @@ const Queue: React.FC = () => {
handleMoveDown,
} = useQueue();
const queue = useAppSelector(selectQueue);
const queueCount = Object.keys(queue).length;
// Debug logging
console.log('Queue component - queue count:', queueCount);
console.log('Queue component - queue items:', queueItems);
return (
<div className="max-w-4xl mx-auto p-6">
<div className="mb-6">
@ -20,11 +29,16 @@ const Queue: React.FC = () => {
<p className="text-sm text-gray-600">
{queueStats.totalSongs} song{queueStats.totalSongs !== 1 ? 's' : ''} in queue
</p>
{/* Debug info */}
<div className="mt-2 text-sm text-gray-500">
Queue items loaded: {queueCount}
</div>
</div>
{/* Queue List */}
<div className="bg-white rounded-lg shadow">
{queueItems.length === 0 ? (
{queueCount === 0 ? (
<EmptyState
title="Queue is empty"
message="Add songs from search, history, or favorites to get started"
@ -34,6 +48,16 @@ const Queue: React.FC = () => {
</svg>
}
/>
) : queueItems.length === 0 ? (
<EmptyState
title="Loading queue..."
message="Please wait while queue 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">
{queueItems.map((queueItem, index) => (

View File

@ -1,8 +1,8 @@
import React from 'react';
import { useAppSelector } from '../../redux';
import { SongItem, EmptyState } from '../../components/common';
import { InfiniteScrollList } from '../../components/common';
import { useSearch } from '../../hooks';
import { selectIsAdmin } from '../../redux';
import { useAppSelector } from '../../redux';
import { selectIsAdmin, selectSongs } from '../../redux';
const Search: React.FC = () => {
const {
@ -11,9 +11,27 @@ const Search: React.FC = () => {
handleSearchChange,
handleAddToQueue,
handleToggleFavorite,
loadMore,
} = useSearch();
const isAdmin = useAppSelector(selectIsAdmin);
const songs = useAppSelector(selectSongs);
const songsCount = Object.keys(songs).length;
// Performance monitoring
React.useEffect(() => {
const startTime = performance.now();
return () => {
const endTime = performance.now();
const renderTime = endTime - startTime;
console.log(`Search component render time: ${renderTime.toFixed(2)}ms`);
};
});
// Debug logging
console.log('Search component - songs count:', songsCount);
console.log('Search component - search results:', searchResults);
return (
<div className="max-w-4xl mx-auto p-6">
@ -35,40 +53,36 @@ const Search: React.FC = () => {
</svg>
</div>
</div>
{/* Debug info */}
<div className="mt-2 text-sm text-gray-500">
Total songs loaded: {songsCount} | Showing: {searchResults.songs.length} of {searchResults.count} | Page: {searchResults.currentPage}/{searchResults.totalPages} | Search term: "{searchTerm}"
</div>
</div>
{/* Search Results */}
<div className="bg-white rounded-lg shadow">
{searchResults.songs.length === 0 ? (
<EmptyState
title={searchTerm ? "No songs found" : "No songs available"}
message={searchTerm ? "Try adjusting your search terms" : "Songs will appear here once loaded"}
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="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 className="divide-y divide-gray-200">
{searchResults.songs.map((song) => (
<SongItem
key={song.key}
song={song}
context="search"
onAddToQueue={() => handleAddToQueue(song)}
onToggleFavorite={() => handleToggleFavorite(song)}
isAdmin={isAdmin}
/>
))}
</div>
)}
</div>
<InfiniteScrollList
items={searchResults.songs}
isLoading={songsCount === 0}
hasMore={searchResults.hasMore}
onLoadMore={loadMore}
onAddToQueue={handleAddToQueue}
onToggleFavorite={handleToggleFavorite}
context="search"
title=""
emptyTitle={searchTerm ? "No songs found" : "No songs available"}
emptyMessage={searchTerm ? "Try adjusting your search terms" : "Songs will appear here once loaded"}
loadingTitle="Loading songs..."
loadingMessage="Please wait while songs are being loaded from the database"
isAdmin={isAdmin}
debugInfo=""
/>
{/* Search Stats */}
{searchTerm && (
<div className="mt-4 text-sm text-gray-500 text-center">
Found {searchResults.count} song{searchResults.count !== 1 ? 's' : ''}
{searchResults.hasMore && ` • Scroll down to load more`}
</div>
)}
</div>

View File

@ -0,0 +1,93 @@
import React from 'react';
import { ActionButton, EmptyState } from '../../components/common';
import { useSingers } from '../../hooks';
import { useAppSelector } from '../../redux';
import { selectSingers } from '../../redux';
import { formatDate } from '../../utils/dataProcessing';
const Singers: React.FC = () => {
const {
singers,
isAdmin,
handleRemoveSinger,
} = useSingers();
const singersData = useAppSelector(selectSingers);
const singersCount = Object.keys(singersData).length;
// Debug logging
console.log('Singers component - singers count:', singersCount);
console.log('Singers component - singers:', singers);
return (
<div className="max-w-4xl mx-auto p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 mb-2">Singers</h1>
<p className="text-sm text-gray-600">
{singers.length} singer{singers.length !== 1 ? 's' : ''} in the party
</p>
{/* Debug info */}
<div className="mt-2 text-sm text-gray-500">
Singers loaded: {singersCount}
</div>
</div>
{/* Singers List */}
<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>
}
/>
) : (
<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>
);
};
export default Singers;

View File

@ -0,0 +1,180 @@
import React, { useState } from 'react';
import { ActionButton, SongItem, InfiniteScrollList } from '../../components/common';
import { useSongLists } from '../../hooks';
import { useAppSelector } from '../../redux';
import { selectSongList } from '../../redux';
import type { SongListSong, Song } from '../../types';
const SongLists: React.FC = () => {
const {
songLists,
hasMore,
loadMore,
checkSongAvailability,
handleAddToQueue,
handleToggleFavorite,
} = useSongLists();
const songListData = useAppSelector(selectSongList);
const songListCount = Object.keys(songListData).length;
const [selectedSongList, setSelectedSongList] = useState<string | null>(null);
const [expandedSongs, setExpandedSongs] = useState<Set<string>>(new Set());
// Debug logging
console.log('SongLists component - songList count:', songListCount);
console.log('SongLists component - songLists:', songLists);
const handleSongListClick = (songListKey: string) => {
setSelectedSongList(songListKey);
};
const handleCloseSongList = () => {
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 selectedList = selectedSongList ? songLists.find(list => list.key === selectedSongList) : null;
return (
<div className="max-w-4xl mx-auto p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 mb-2">Song Lists</h1>
<p className="text-sm text-gray-600">
{songLists.length} song list{songLists.length !== 1 ? 's' : ''} available
</p>
{/* Debug info */}
<div className="mt-2 text-sm text-gray-500">
Song lists loaded: {songListCount}
</div>
</div>
{/* Song Lists */}
<InfiniteScrollList
items={songLists.map(songList => ({
...songList,
title: songList.title,
artist: `${songList.songs.length} song${songList.songs.length !== 1 ? 's' : ''}`,
path: '',
disabled: false,
favorite: false,
}))}
isLoading={songListCount === 0}
hasMore={hasMore}
onLoadMore={loadMore}
onAddToQueue={() => {}} // Not applicable for song lists
onToggleFavorite={() => {}} // Not applicable for song lists
context="search"
title="Song Lists"
subtitle={`${songLists.length} song list${songLists.length !== 1 ? 's' : ''} available`}
emptyTitle="No song lists available"
emptyMessage="Song lists will appear here when they're available"
loadingTitle="Loading song lists..."
loadingMessage="Please wait while song lists are being loaded"
debugInfo={`Song lists loaded: ${songListCount}`}
renderExtraContent={(item) => (
<div className="flex-shrink-0 ml-4">
<ActionButton
onClick={() => handleSongListClick(item.key!)}
variant="primary"
size="sm"
>
View Songs
</ActionButton>
</div>
)}
/>
{/* Song List Modal */}
{selectedList && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[80vh] overflow-hidden">
<div className="p-6 border-b border-gray-200">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold text-gray-900">
{selectedList.title}
</h2>
<ActionButton
onClick={handleCloseSongList}
variant="secondary"
size="sm"
>
Close
</ActionButton>
</div>
</div>
<div className="overflow-y-auto max-h-[60vh]">
<div className="divide-y divide-gray-200">
{selectedList.songs.map((songListSong: SongListSong) => {
const availableSongs = checkSongAvailability(songListSong);
const isExpanded = expandedSongs.has(songListSong.key!);
const isAvailable = availableSongs.length > 0;
return (
<div key={songListSong.key}>
{/* Song List Song Row */}
<div className={`flex items-center justify-between p-4 ${!isAvailable ? 'opacity-50' : ''}`}>
<div className="flex-1">
<h3 className="text-sm font-medium text-gray-900">
{songListSong.title}
</h3>
<p className="text-sm text-gray-500">
{songListSong.artist} Position {songListSong.position}
</p>
{!isAvailable && (
<p className="text-xs text-red-500 mt-1">
Not available in catalog
</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) => (
<div key={song.key} 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>
);
})}
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default SongLists;

View File

@ -0,0 +1,96 @@
import React from 'react';
import { InfiniteScrollList } from '../../components/common';
import { useTopPlayed } from '../../hooks';
import { useAppSelector } from '../../redux';
import { selectTopPlayed } from '../../redux';
import type { TopPlayed, Song } from '../../types';
const Top100: React.FC = () => {
const {
topPlayedItems,
hasMore,
loadMore,
handleAddToQueue,
handleToggleFavorite,
} = useTopPlayed();
const topPlayed = useAppSelector(selectTopPlayed);
const topPlayedCount = Object.keys(topPlayed).length;
// Debug logging
console.log('TopPlayed component - topPlayed count:', topPlayedCount);
console.log('TopPlayed component - topPlayed items:', topPlayedItems);
// Convert TopPlayed items to Song format for the InfiniteScrollList
const songItems = topPlayedItems.map((item: TopPlayed) => ({
...item,
path: '', // TopPlayed doesn't have path
disabled: false,
favorite: false,
}));
// 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 (
<InfiniteScrollList
items={songItems}
isLoading={topPlayedCount === 0}
hasMore={hasMore}
onLoadMore={loadMore}
onAddToQueue={handleAddToQueueWrapper}
onToggleFavorite={handleToggleFavoriteWrapper}
context="topPlayed"
title="Most Played"
subtitle={`Top ${topPlayedItems.length} song${topPlayedItems.length !== 1 ? 's' : ''} by play count`}
emptyTitle="No play data yet"
emptyMessage="Song play counts will appear here after songs have been played"
loadingTitle="Loading top played..."
loadingMessage="Please wait while top played data is being loaded"
debugInfo={`Top played items loaded: ${topPlayedCount}`}
renderExtraContent={renderExtraContent}
/>
);
};
export default Top100;

View File

@ -1,81 +0,0 @@
import React from 'react';
import { SongItem, EmptyState } from '../../components/common';
import { useTopPlayed } from '../../hooks';
const TopPlayed: React.FC = () => {
const {
topPlayedItems,
handleAddToQueue,
handleToggleFavorite,
} = useTopPlayed();
return (
<div className="max-w-4xl mx-auto p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 mb-2">Most Played</h1>
<p className="text-sm text-gray-600">
Top {topPlayedItems.length} song{topPlayedItems.length !== 1 ? 's' : ''} by play count
</p>
</div>
{/* Top Played List */}
<div className="bg-white rounded-lg shadow">
{topPlayedItems.length === 0 ? (
<EmptyState
title="No play data yet"
message="Song play counts will appear here after songs have been played"
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="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
}
/>
) : (
<div className="divide-y divide-gray-200">
{topPlayedItems.map((song, index) => (
<div key={song.key} className="flex items-center">
{/* 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>
{/* Song Info */}
<div className="flex-1">
<SongItem
song={{
...song,
path: '', // TopPlayed doesn't have path
disabled: false,
favorite: false
}}
context="search"
onAddToQueue={() => handleAddToQueue(song)}
onToggleFavorite={() => handleToggleFavorite(song)}
/>
</div>
{/* Play Count */}
<div className="flex-shrink-0 px-4 py-2 text-sm text-gray-600">
<div className="font-medium">{song.count}</div>
<div className="text-xs text-gray-400">
play{song.count !== 1 ? 's' : ''}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default TopPlayed;

View File

@ -1,4 +1,9 @@
export { default as Search } from './Search/Search';
export { default as Queue } from './Queue/Queue';
export { default as History } from './History/History';
export { default as TopPlayed } from './TopPlayed/TopPlayed';
export { default as TopPlayed } from './TopPlayed/Top100';
export { default as Favorites } from './Favorites/Favorites';
export { default as NewSongs } from './NewSongs/NewSongs';
export { default as Artists } from './Artists/Artists';
export { default as Singers } from './Singers/Singers';
export { default as SongLists } from './SongLists/SongLists';

View File

@ -0,0 +1,8 @@
import { createContext } from 'react';
interface FirebaseContextType {
isConnected: boolean;
syncStatus: 'idle' | 'loading' | 'success' | 'error';
}
export const FirebaseContext = createContext<FirebaseContextType | undefined>(undefined);

View File

@ -0,0 +1,85 @@
import { useEffect, useState, type ReactNode } from 'react';
import { database } from './config';
import { ref, onValue, off } from 'firebase/database';
import { useAppDispatch, useAppSelector } from '../redux/hooks';
import { setController } from '../redux/controllerSlice';
import { type Controller, PlayerState } from '../types';
import { FirebaseContext } from './FirebaseContext';
import { selectAuth } from '../redux/authSlice';
interface FirebaseProviderProps {
children: ReactNode;
}
export const FirebaseProvider = ({ children }: FirebaseProviderProps) => {
const [isConnected, setIsConnected] = useState(false);
const [syncStatus, setLocalSyncStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const dispatch = useAppDispatch();
const auth = useAppSelector(selectAuth);
const controllerName = auth?.controller;
const isAuthenticated = auth?.authenticated;
useEffect(() => {
if (!isAuthenticated || !controllerName) {
setIsConnected(false);
setLocalSyncStatus('idle');
return;
}
const controllerRef = ref(database, `controllers/${controllerName}`);
setLocalSyncStatus('loading');
onValue(
controllerRef,
(snapshot) => {
if (snapshot.exists()) {
const controllerData = snapshot.val() as Controller;
dispatch(setController(controllerData));
setLocalSyncStatus('success');
setIsConnected(true);
} else {
// Initialize empty controller if it doesn't exist
const emptyController: Controller = {
favorites: {},
history: {},
topPlayed: {},
newSongs: {},
player: {
queue: {},
settings: {
autoadvance: false,
userpick: false
},
singers: {},
state: {
state: PlayerState.stopped
}
},
songList: {},
songs: {}
};
dispatch(setController(emptyController));
setLocalSyncStatus('success');
setIsConnected(true);
}
},
(error) => {
console.error('Firebase sync error:', error);
setLocalSyncStatus('error');
setIsConnected(false);
}
);
return () => {
off(controllerRef);
};
}, [dispatch, isAuthenticated, controllerName]);
const value = {
isConnected,
syncStatus
};
return (
<FirebaseContext.Provider value={value}>
{children}
</FirebaseContext.Provider>
);
};

View File

@ -0,0 +1,10 @@
import { useContext } from 'react';
import { FirebaseContext } from './FirebaseContext';
export const useFirebase = () => {
const context = useContext(FirebaseContext);
if (!context) {
throw new Error('useFirebase must be used within a FirebaseProvider');
}
return context;
};

View File

@ -5,3 +5,8 @@ export { useSearch } from './useSearch';
export { useQueue } from './useQueue';
export { useHistory } from './useHistory';
export { useTopPlayed } from './useTopPlayed';
export { useFavorites } from './useFavorites';
export { useNewSongs } from './useNewSongs';
export { useArtists } from './useArtists';
export { useSingers } from './useSingers';
export { useSongLists } from './useSongLists';

63
src/hooks/useArtists.ts Normal file
View File

@ -0,0 +1,63 @@
import { useCallback, useMemo, useState } from 'react';
import { useAppSelector, selectArtistsArray, selectSongsArray } from '../redux';
import { useSongOperations } from './useSongOperations';
import { useToast } from './useToast';
import type { Song } from '../types';
export const useArtists = () => {
const allArtists = useAppSelector(selectArtistsArray);
const allSongs = useAppSelector(selectSongsArray);
const { addToQueue, toggleFavorite } = useSongOperations();
const { showSuccess, showError } = useToast();
const [searchTerm, setSearchTerm] = useState('');
// Filter artists by search term
const filteredArtists = useMemo(() => {
if (!searchTerm.trim()) return allArtists;
const term = searchTerm.toLowerCase();
return allArtists.filter(artist =>
artist.toLowerCase().includes(term)
);
}, [allArtists, searchTerm]);
// Get songs by artist
const getSongsByArtist = useCallback((artistName: string) => {
return allSongs.filter(song =>
song.artist.toLowerCase() === artistName.toLowerCase()
);
}, [allSongs]);
const handleSearchChange = useCallback((value: string) => {
setSearchTerm(value);
}, []);
const handleAddToQueue = useCallback(async (song: Song) => {
try {
await addToQueue(song);
showSuccess('Song added to queue');
} catch {
showError('Failed to add song to queue');
}
}, [addToQueue, showSuccess, showError]);
const handleToggleFavorite = useCallback(async (song: Song) => {
try {
await toggleFavorite(song);
showSuccess(song.favorite ? 'Removed from favorites' : 'Added to favorites');
} catch {
showError('Failed to update favorites');
}
}, [toggleFavorite, showSuccess, showError]);
return {
artists: filteredArtists,
allArtists,
searchTerm,
handleSearchChange,
getSongsByArtist,
handleAddToQueue,
handleToggleFavorite,
};
};

62
src/hooks/useFavorites.ts Normal file
View File

@ -0,0 +1,62 @@
import { useCallback, useMemo, useState } from 'react';
import { useAppSelector, selectFavoritesArray } from '../redux';
import { useSongOperations } from './useSongOperations';
import { useToast } from './useToast';
import type { Song } from '../types';
const ITEMS_PER_PAGE = 20;
export const useFavorites = () => {
const allFavoritesItems = useAppSelector(selectFavoritesArray);
const { addToQueue, toggleFavorite } = useSongOperations();
const { showSuccess, showError } = useToast();
const [currentPage, setCurrentPage] = useState(1);
// Paginate the favorites items - show all items up to current page
const favoritesItems = useMemo(() => {
const endIndex = currentPage * ITEMS_PER_PAGE;
return allFavoritesItems.slice(0, endIndex);
}, [allFavoritesItems, currentPage]);
const hasMore = useMemo(() => {
// Only show "hasMore" if there are more items than currently loaded
return allFavoritesItems.length > ITEMS_PER_PAGE && favoritesItems.length < allFavoritesItems.length;
}, [favoritesItems.length, allFavoritesItems.length]);
const loadMore = useCallback(() => {
console.log('useFavorites - loadMore called:', { hasMore, currentPage, allFavoritesItemsLength: allFavoritesItems.length });
if (hasMore) {
setCurrentPage(prev => prev + 1);
}
}, [hasMore, currentPage, allFavoritesItems.length]);
const handleAddToQueue = useCallback(async (song: Song) => {
try {
await addToQueue(song);
showSuccess('Song added to queue');
} catch {
showError('Failed to add song to queue');
}
}, [addToQueue, showSuccess, showError]);
const handleToggleFavorite = useCallback(async (song: Song) => {
try {
await toggleFavorite(song);
showSuccess('Removed from favorites');
} catch {
showError('Failed to remove from favorites');
}
}, [toggleFavorite, showSuccess, showError]);
return {
favoritesItems,
allFavoritesItems,
hasMore,
loadMore,
currentPage,
totalPages: Math.ceil(allFavoritesItems.length / ITEMS_PER_PAGE),
handleAddToQueue,
handleToggleFavorite,
};
};

View File

@ -1,15 +1,36 @@
import { useCallback } from 'react';
import { useAppSelector } from '../redux';
import { selectHistoryArray } from '../redux/selectors';
import { useCallback, useMemo, useState } from 'react';
import { useAppSelector, selectHistoryArray } from '../redux';
import { useSongOperations } from './useSongOperations';
import { useToast } from './useToast';
import type { Song } from '../types';
const ITEMS_PER_PAGE = 20;
export const useHistory = () => {
const historyItems = useAppSelector(selectHistoryArray);
const allHistoryItems = useAppSelector(selectHistoryArray);
const { addToQueue, toggleFavorite } = useSongOperations();
const { showSuccess, showError } = useToast();
const [currentPage, setCurrentPage] = useState(1);
// Paginate the history items - show all items up to current page
const historyItems = useMemo(() => {
const endIndex = currentPage * ITEMS_PER_PAGE;
return allHistoryItems.slice(0, endIndex);
}, [allHistoryItems, currentPage]);
const hasMore = useMemo(() => {
// Only show "hasMore" if there are more items than currently loaded
return allHistoryItems.length > ITEMS_PER_PAGE && historyItems.length < allHistoryItems.length;
}, [historyItems.length, allHistoryItems.length]);
const loadMore = useCallback(() => {
console.log('useHistory - loadMore called:', { hasMore, currentPage, allHistoryItemsLength: allHistoryItems.length });
if (hasMore) {
setCurrentPage(prev => prev + 1);
}
}, [hasMore, currentPage, allHistoryItems.length]);
const handleAddToQueue = useCallback(async (song: Song) => {
try {
await addToQueue(song);
@ -30,6 +51,11 @@ export const useHistory = () => {
return {
historyItems,
allHistoryItems,
hasMore,
loadMore,
currentPage,
totalPages: Math.ceil(allHistoryItems.length / ITEMS_PER_PAGE),
handleAddToQueue,
handleToggleFavorite,
};

62
src/hooks/useNewSongs.ts Normal file
View File

@ -0,0 +1,62 @@
import { useCallback, useMemo, useState } from 'react';
import { useAppSelector, selectNewSongsArray } from '../redux';
import { useSongOperations } from './useSongOperations';
import { useToast } from './useToast';
import type { Song } from '../types';
const ITEMS_PER_PAGE = 20;
export const useNewSongs = () => {
const allNewSongsItems = useAppSelector(selectNewSongsArray);
const { addToQueue, toggleFavorite } = useSongOperations();
const { showSuccess, showError } = useToast();
const [currentPage, setCurrentPage] = useState(1);
// Paginate the new songs items - show all items up to current page
const newSongsItems = useMemo(() => {
const endIndex = currentPage * ITEMS_PER_PAGE;
return allNewSongsItems.slice(0, endIndex);
}, [allNewSongsItems, currentPage]);
const hasMore = useMemo(() => {
// Only show "hasMore" if there are more items than currently loaded
return allNewSongsItems.length > ITEMS_PER_PAGE && newSongsItems.length < allNewSongsItems.length;
}, [newSongsItems.length, allNewSongsItems.length]);
const loadMore = useCallback(() => {
console.log('useNewSongs - loadMore called:', { hasMore, currentPage, allNewSongsItemsLength: allNewSongsItems.length });
if (hasMore) {
setCurrentPage(prev => prev + 1);
}
}, [hasMore, currentPage, allNewSongsItems.length]);
const handleAddToQueue = useCallback(async (song: Song) => {
try {
await addToQueue(song);
showSuccess('Song added to queue');
} catch {
showError('Failed to add song to queue');
}
}, [addToQueue, showSuccess, showError]);
const handleToggleFavorite = useCallback(async (song: Song) => {
try {
await toggleFavorite(song);
showSuccess(song.favorite ? 'Removed from favorites' : 'Added to favorites');
} catch {
showError('Failed to update favorites');
}
}, [toggleFavorite, showSuccess, showError]);
return {
newSongsItems,
allNewSongsItems,
hasMore,
loadMore,
currentPage,
totalPages: Math.ceil(allNewSongsItems.length / ITEMS_PER_PAGE),
handleAddToQueue,
handleToggleFavorite,
};
};

View File

@ -1,6 +1,5 @@
import { useCallback } from 'react';
import { useAppSelector } from '../redux';
import { selectQueueWithUserInfo, selectQueueStats, selectCanReorderQueue } from '../redux/selectors';
import { useAppSelector, selectQueueWithUserInfo, selectQueueStats, selectCanReorderQueue } from '../redux';
import { useSongOperations } from './useSongOperations';
import { useToast } from './useToast';
import type { QueueItem } from '../types';

View File

@ -1,33 +1,62 @@
import { useState, useCallback, useMemo } from 'react';
import { useAppSelector } from '../redux';
import { selectSearchResults } from '../redux/selectors';
import { useAppSelector, selectSongsArray } from '../redux';
import { useSongOperations } from './useSongOperations';
import { useToast } from './useToast';
import { UI_CONSTANTS } from '../constants';
import { filterSongs } from '../utils/dataProcessing';
import type { Song } from '../types';
const ITEMS_PER_PAGE = 20;
export const useSearch = () => {
const [searchTerm, setSearchTerm] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const { addToQueue, toggleFavorite } = useSongOperations();
const { showSuccess, showError } = useToast();
// Get filtered search results using selector
const searchResults = useAppSelector(state =>
selectSearchResults(state, searchTerm)
);
// Get all songs from Redux (this is memoized)
const allSongs = useAppSelector(selectSongsArray);
// Debounced search term for performance
const debouncedSearchTerm = useMemo(() => {
if (searchTerm.length < UI_CONSTANTS.SEARCH.MIN_SEARCH_LENGTH) {
return '';
// Memoize filtered results to prevent unnecessary re-computations
const filteredSongs = useMemo(() => {
if (!searchTerm.trim() || searchTerm.length < UI_CONSTANTS.SEARCH.MIN_SEARCH_LENGTH) {
return allSongs;
}
return searchTerm;
}, [searchTerm]);
return filterSongs(allSongs, searchTerm);
}, [allSongs, searchTerm]);
// Paginate the filtered results - show all items up to current page
const searchResults = useMemo(() => {
const endIndex = currentPage * ITEMS_PER_PAGE;
const paginatedSongs = filteredSongs.slice(0, endIndex);
return {
songs: paginatedSongs,
count: filteredSongs.length,
hasMore: endIndex < filteredSongs.length,
currentPage,
totalPages: Math.ceil(filteredSongs.length / ITEMS_PER_PAGE),
};
}, [filteredSongs, currentPage]);
const handleSearchChange = useCallback((value: string) => {
setSearchTerm(value);
setCurrentPage(1); // Reset to first page when searching
}, []);
const loadMore = useCallback(() => {
console.log('useSearch - loadMore called:', {
hasMore: searchResults.hasMore,
currentPage,
filteredSongsLength: filteredSongs.length,
searchResultsCount: searchResults.count
});
if (searchResults.hasMore) {
setCurrentPage(prev => prev + 1);
}
}, [searchResults.hasMore, currentPage, filteredSongs.length, searchResults.count]);
const handleAddToQueue = useCallback(async (song: Song) => {
try {
await addToQueue(song);
@ -48,10 +77,10 @@ export const useSearch = () => {
return {
searchTerm,
debouncedSearchTerm,
searchResults,
handleSearchChange,
handleAddToQueue,
handleToggleFavorite,
loadMore,
};
};

30
src/hooks/useSingers.ts Normal file
View File

@ -0,0 +1,30 @@
import { useCallback } from 'react';
import { useAppSelector, selectSingersArray, selectIsAdmin } from '../redux';
import { useToast } from './useToast';
import type { Singer } from '../types';
export const useSingers = () => {
const singers = useAppSelector(selectSingersArray);
const isAdmin = useAppSelector(selectIsAdmin);
const { showSuccess, showError } = useToast();
const handleRemoveSinger = useCallback(async (singer: Singer) => {
if (!isAdmin) {
showError('Only admins can remove singers');
return;
}
try {
// TODO: Implement remove singer functionality
showSuccess(`${singer.name} removed from singers list`);
} catch {
showError('Failed to remove singer');
}
}, [isAdmin, showSuccess, showError]);
return {
singers,
isAdmin,
handleRemoveSinger,
};
};

94
src/hooks/useSongLists.ts Normal file
View File

@ -0,0 +1,94 @@
import { useCallback, useMemo, useState } from 'react';
import { useAppSelector, selectSongListArray, selectSongsArray } from '../redux';
import { useSongOperations } from './useSongOperations';
import { useToast } from './useToast';
import type { SongListSong, Song } from '../types';
const ITEMS_PER_PAGE = 20;
export const useSongLists = () => {
const allSongLists = useAppSelector(selectSongListArray);
const allSongs = useAppSelector(selectSongsArray);
const { addToQueue, toggleFavorite } = useSongOperations();
const { showSuccess, showError } = useToast();
const [currentPage, setCurrentPage] = useState(1);
// Paginate the song lists - show all items up to current page
const songLists = useMemo(() => {
const endIndex = currentPage * ITEMS_PER_PAGE;
return allSongLists.slice(0, endIndex);
}, [allSongLists, currentPage]);
const hasMore = useMemo(() => {
// Show "hasMore" if there are more items than currently loaded
const hasMoreItems = songLists.length < allSongLists.length;
console.log('useSongLists - hasMore calculation:', {
songListsLength: songLists.length,
allSongListsLength: allSongLists.length,
hasMore: hasMoreItems,
currentPage
});
return hasMoreItems;
}, [songLists.length, allSongLists.length, currentPage]);
const loadMore = useCallback(() => {
console.log('useSongLists - loadMore called:', {
hasMore,
currentPage,
allSongListsLength: allSongLists.length,
songListsLength: songLists.length
});
if (hasMore) {
console.log('useSongLists - Incrementing page from', currentPage, 'to', currentPage + 1);
setCurrentPage(prev => prev + 1);
} else {
console.log('useSongLists - Not loading more because hasMore is false');
}
}, [hasMore, currentPage, allSongLists.length, songLists.length]);
// Check if a song exists in the catalog
const checkSongAvailability = useCallback((songListSong: SongListSong) => {
if (songListSong.foundSongs && songListSong.foundSongs.length > 0) {
return songListSong.foundSongs;
}
// Search for songs by artist and title
const matchingSongs = allSongs.filter(song =>
song.artist.toLowerCase() === songListSong.artist.toLowerCase() &&
song.title.toLowerCase() === songListSong.title.toLowerCase()
);
return matchingSongs;
}, [allSongs]);
const handleAddToQueue = useCallback(async (song: Song) => {
try {
await addToQueue(song);
showSuccess('Song added to queue');
} catch {
showError('Failed to add song to queue');
}
}, [addToQueue, showSuccess, showError]);
const handleToggleFavorite = useCallback(async (song: Song) => {
try {
await toggleFavorite(song);
showSuccess(song.favorite ? 'Removed from favorites' : 'Added to favorites');
} catch {
showError('Failed to update favorites');
}
}, [toggleFavorite, showSuccess, showError]);
return {
songLists,
allSongLists,
hasMore,
loadMore,
currentPage,
totalPages: Math.ceil(allSongLists.length / ITEMS_PER_PAGE),
checkSongAvailability,
handleAddToQueue,
handleToggleFavorite,
};
};

View File

@ -1,15 +1,36 @@
import { useCallback } from 'react';
import { useAppSelector } from '../redux';
import { selectTopPlayedArray } from '../redux/selectors';
import { useCallback, useMemo, useState } from 'react';
import { useAppSelector, selectTopPlayedArray } from '../redux';
import { useSongOperations } from './useSongOperations';
import { useToast } from './useToast';
import type { TopPlayed } from '../types';
const ITEMS_PER_PAGE = 20;
export const useTopPlayed = () => {
const topPlayedItems = useAppSelector(selectTopPlayedArray);
const allTopPlayedItems = useAppSelector(selectTopPlayedArray);
const { addToQueue, toggleFavorite } = useSongOperations();
const { showSuccess, showError } = useToast();
const [currentPage, setCurrentPage] = useState(1);
// Paginate the top played items - show all items up to current page
const topPlayedItems = useMemo(() => {
const endIndex = currentPage * ITEMS_PER_PAGE;
return allTopPlayedItems.slice(0, endIndex);
}, [allTopPlayedItems, currentPage]);
const hasMore = useMemo(() => {
// Only show "hasMore" if there are more items than currently loaded
return allTopPlayedItems.length > ITEMS_PER_PAGE && topPlayedItems.length < allTopPlayedItems.length;
}, [topPlayedItems.length, allTopPlayedItems.length]);
const loadMore = useCallback(() => {
console.log('useTopPlayed - loadMore called:', { hasMore, currentPage, allTopPlayedItemsLength: allTopPlayedItems.length });
if (hasMore) {
setCurrentPage(prev => prev + 1);
}
}, [hasMore, currentPage, allTopPlayedItems.length]);
const handleAddToQueue = useCallback(async (song: TopPlayed) => {
try {
// Convert TopPlayed to Song format for queue
@ -44,6 +65,11 @@ export const useTopPlayed = () => {
return {
topPlayedItems,
allTopPlayedItems,
hasMore,
loadMore,
currentPage,
totalPages: Math.ceil(allTopPlayedItems.length / ITEMS_PER_PAGE),
handleAddToQueue,
handleToggleFavorite,
};

View File

@ -157,6 +157,8 @@ export const selectQueue = (state: { controller: ControllerState }) => state.con
export const selectFavorites = (state: { controller: ControllerState }) => state.controller.data?.favorites || {};
export const selectHistory = (state: { controller: ControllerState }) => state.controller.data?.history || {};
export const selectTopPlayed = (state: { controller: ControllerState }) => state.controller.data?.topPlayed || {};
export const selectNewSongs = (state: { controller: ControllerState }) => state.controller.data?.newSongs || {};
export const selectSongList = (state: { controller: ControllerState }) => state.controller.data?.songList || {};
export const selectPlayerState = (state: { controller: ControllerState }) => state.controller.data?.player?.state;
export const selectSettings = (state: { controller: ControllerState }) => state.controller.data?.player?.settings;
export const selectSingers = (state: { controller: ControllerState }) => state.controller.data?.player?.singers || {};

View File

@ -25,6 +25,8 @@ export {
selectFavorites,
selectHistory,
selectTopPlayed,
selectNewSongs,
selectSongList,
selectPlayerState,
selectSettings,
selectSingers,

View File

@ -6,6 +6,9 @@ import {
selectFavorites,
selectHistory,
selectTopPlayed,
selectNewSongs,
selectSongList,
selectSingers,
selectIsAdmin,
selectCurrentSinger
} from './index';
@ -15,6 +18,7 @@ import {
sortQueueByOrder,
sortHistoryByDate,
sortTopPlayedByCount,
sortSongsByArtistAndTitle,
limitArray,
getQueueStats
} from '../utils/dataProcessing';
@ -23,11 +27,11 @@ import { UI_CONSTANTS } from '../constants';
// Enhanced selectors with data processing
export const selectSongsArray = createSelector(
[selectSongs],
(songs) => objectToArray(songs)
(songs) => sortSongsByArtistAndTitle(objectToArray(songs))
);
export const selectFilteredSongs = createSelector(
[selectSongsArray, (state: RootState, searchTerm: string) => searchTerm],
[selectSongsArray, (_state: RootState, searchTerm: string) => searchTerm],
(songs, searchTerm) => filterSongs(songs, searchTerm)
);
@ -48,7 +52,35 @@ export const selectHistoryArray = createSelector(
export const selectFavoritesArray = createSelector(
[selectFavorites],
(favorites) => objectToArray(favorites)
(favorites) => sortSongsByArtistAndTitle(objectToArray(favorites))
);
export const selectNewSongsArray = createSelector(
[selectNewSongs],
(newSongs) => sortSongsByArtistAndTitle(objectToArray(newSongs))
);
export const selectSingersArray = createSelector(
[selectSingers],
(singers) => objectToArray(singers).sort((a, b) => a.name.localeCompare(b.name))
);
export const selectSongListArray = createSelector(
[selectSongList],
(songList) => objectToArray(songList)
);
export const selectArtistsArray = createSelector(
[selectSongs],
(songs) => {
const artists = new Set<string>();
Object.values(songs).forEach(song => {
if (song.artist) {
artists.add(song.artist);
}
});
return Array.from(artists).sort((a, b) => a.localeCompare(b));
}
);
export const selectTopPlayedArray = createSelector(

View File

@ -90,7 +90,7 @@ export interface Controller {
singers: Record<string, Singer>;
state: Player;
};
songList: Record<string, unknown>;
songList: Record<string, SongList>;
songs: Record<string, Song>;
}
@ -120,7 +120,7 @@ export interface ActionButtonProps {
export interface SongItemProps {
song: Song;
context: 'search' | 'queue' | 'history' | 'favorites';
context: 'search' | 'queue' | 'history' | 'favorites' | 'topPlayed';
onAddToQueue?: () => void;
onRemoveFromQueue?: () => void;
onToggleFavorite?: () => void;

View File

@ -39,6 +39,33 @@ export const sortTopPlayedByCount = (songs: TopPlayed[]): TopPlayed[] => {
return [...songs].sort((a, b) => b.count - a.count);
};
// Sort songs by artist then title (case insensitive)
export const sortSongsByArtistAndTitle = (songs: Song[]): Song[] => {
const sortedSongs = [...songs].sort((a, b) => {
// First sort by artist (case insensitive)
const artistA = a.artist.toLowerCase();
const artistB = b.artist.toLowerCase();
if (artistA !== artistB) {
return artistA.localeCompare(artistB);
}
// If artists are the same, sort by title (case insensitive)
const titleA = a.title.toLowerCase();
const titleB = b.title.toLowerCase();
return titleA.localeCompare(titleB);
});
// Debug logging for first few songs to verify sorting
if (sortedSongs.length > 0) {
console.log('Songs sorted by artist and title. First 5 songs:',
sortedSongs.slice(0, 5).map(s => `${s.artist} - ${s.title}`)
);
}
return sortedSongs;
};
// Limit array to specified length
export const limitArray = <T>(array: T[], limit: number): T[] => {
return array.slice(0, limit);