Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
BIN
docs/design/00-web-layout.JPG
Normal file
|
After Width: | Height: | Size: 204 KiB |
BIN
docs/design/01-Login.png
Normal file
|
After Width: | Height: | Size: 236 KiB |
BIN
docs/design/02-menu.jpeg
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
docs/design/02-queue-delete.png
Normal file
|
After Width: | Height: | Size: 246 KiB |
BIN
docs/design/02-queue-drag.png
Normal file
|
After Width: | Height: | Size: 247 KiB |
BIN
docs/design/02-queue-sorting.png
Normal file
|
After Width: | Height: | Size: 243 KiB |
BIN
docs/design/02-queue.png
Normal file
|
After Width: | Height: | Size: 245 KiB |
BIN
docs/design/03-menu current page and non-admin.png
Normal file
|
After Width: | Height: | Size: 293 KiB |
BIN
docs/design/03-menu playing (admin).png
Normal file
|
After Width: | Height: | Size: 270 KiB |
BIN
docs/design/03-menu.png
Normal file
|
After Width: | Height: | Size: 274 KiB |
BIN
docs/design/04-search typing .png
Normal file
|
After Width: | Height: | Size: 658 KiB |
BIN
docs/design/04-search-song info.png
Normal file
|
After Width: | Height: | Size: 275 KiB |
BIN
docs/design/04-search.png
Normal file
|
After Width: | Height: | Size: 500 KiB |
BIN
docs/design/05-singers add.png
Normal file
|
After Width: | Height: | Size: 206 KiB |
BIN
docs/design/05-singers.png
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
docs/design/06-artists (not admin).png
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
docs/design/06-artists .png
Normal file
|
After Width: | Height: | Size: 268 KiB |
BIN
docs/design/06-artists search.png
Normal file
|
After Width: | Height: | Size: 498 KiB |
BIN
docs/design/06-artists songs.png
Normal file
|
After Width: | Height: | Size: 521 KiB |
BIN
docs/design/07-favorites.png
Normal file
|
After Width: | Height: | Size: 459 KiB |
BIN
docs/design/08-history.png
Normal file
|
After Width: | Height: | Size: 394 KiB |
BIN
docs/design/09- song lists songs expand.png
Normal file
|
After Width: | Height: | Size: 472 KiB |
BIN
docs/design/09-song lists - songs.png
Normal file
|
After Width: | Height: | Size: 444 KiB |
BIN
docs/design/09-songs list.png
Normal file
|
After Width: | Height: | Size: 312 KiB |
BIN
docs/design/10-Settings.png
Normal file
|
After Width: | Height: | Size: 196 KiB |
BIN
docs/design/11-top 100 songs.png
Normal file
|
After Width: | Height: | Size: 397 KiB |
BIN
docs/design/12-favorite .png
Normal file
|
After Width: | Height: | Size: 239 KiB |
BIN
docs/design/12-favorite lists.png
Normal file
|
After Width: | Height: | Size: 481 KiB |
12
env.template
Normal 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
@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@reduxjs/toolkit": "^2.8.2",
|
"@reduxjs/toolkit": "^2.8.2",
|
||||||
|
"@tailwindcss/postcss": "^4.1.11",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"firebase": "^11.10.0",
|
"firebase": "^11.10.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
@ -32,6 +33,6 @@
|
|||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.35.1",
|
"typescript-eslint": "^8.35.1",
|
||||||
"vite": "^7.0.4"
|
"vite": "^5.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
'@tailwindcss/postcss': {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
42
src/App.tsx
@ -1,22 +1,38 @@
|
|||||||
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 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() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<Router>
|
<ErrorBoundary>
|
||||||
<Layout>
|
<FirebaseProvider>
|
||||||
<Navigation />
|
<Router>
|
||||||
<Routes>
|
<AuthInitializer>
|
||||||
<Route path="/" element={<Search />} />
|
<Layout>
|
||||||
<Route path="/queue" element={<Queue />} />
|
<Navigation />
|
||||||
<Route path="/history" element={<History />} />
|
<Routes>
|
||||||
<Route path="/top-played" element={<TopPlayed />} />
|
<Route path="/" element={<Navigate to="/queue" replace />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="/search" element={<Search />} />
|
||||||
</Routes>
|
<Route path="/queue" element={<Queue />} />
|
||||||
</Layout>
|
<Route path="/favorites" element={<Favorites />} />
|
||||||
</Router>
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
59
src/components/Auth/AuthInitializer.tsx
Normal 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;
|
||||||
86
src/components/Auth/LoginPrompt.tsx
Normal 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;
|
||||||
2
src/components/Auth/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { default as AuthInitializer } from './AuthInitializer';
|
||||||
|
export { default as LoginPrompt } from './LoginPrompt';
|
||||||
@ -1,13 +1,21 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
import type { RootState } from '../../redux/store';
|
import { selectCurrentSinger, selectIsAdmin, selectControllerName } from '../../redux/authSlice';
|
||||||
|
import { logout } from '../../redux/authSlice';
|
||||||
|
import { ActionButton } from '../common';
|
||||||
import type { LayoutProps } from '../../types';
|
import type { LayoutProps } from '../../types';
|
||||||
|
|
||||||
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||||
// TODO: Replace with actual Redux selectors
|
const currentSinger = useSelector(selectCurrentSinger);
|
||||||
const currentSinger = useSelector((state: RootState) => state.auth?.singer || '');
|
const isAdmin = useSelector(selectIsAdmin);
|
||||||
const isAdmin = useSelector((state: RootState) => state.auth?.isAdmin || false);
|
const controllerName = useSelector(selectControllerName);
|
||||||
const controllerName = useSelector((state: RootState) => state.auth?.controller || '');
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
dispatch(logout());
|
||||||
|
// Reload the page to return to login screen
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
@ -22,21 +30,30 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
</h1>
|
</h1>
|
||||||
{controllerName && (
|
{controllerName && (
|
||||||
<span className="ml-4 text-sm text-gray-500">
|
<span className="ml-4 text-sm text-gray-500">
|
||||||
Controller: {controllerName}
|
Party: {controllerName}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User Info */}
|
{/* User Info & Logout */}
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
{currentSinger && (
|
{currentSinger && (
|
||||||
<div className="text-sm text-gray-600">
|
<div className="flex items-center space-x-3">
|
||||||
<span className="font-medium">{currentSinger}</span>
|
<div className="text-sm text-gray-600">
|
||||||
{isAdmin && (
|
<span className="font-medium">{currentSinger}</span>
|
||||||
<span className="ml-2 px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded-full">
|
{isAdmin && (
|
||||||
Admin
|
<span className="ml-2 px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded-full">
|
||||||
</span>
|
Admin
|
||||||
)}
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ActionButton
|
||||||
|
onClick={handleLogout}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,10 +3,15 @@ import { NavLink } from 'react-router-dom';
|
|||||||
|
|
||||||
const Navigation: React.FC = () => {
|
const Navigation: React.FC = () => {
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ path: '/', label: 'Search', icon: '🔍' },
|
|
||||||
{ path: '/queue', label: 'Queue', 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: '/history', label: 'History', icon: '⏰' },
|
||||||
{ path: '/top-played', label: 'Top Played', icon: '🏆' },
|
{ path: '/top-played', label: 'Top 100', icon: '🏆' },
|
||||||
|
{ path: '/singers', label: 'Singers', icon: '👥' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
60
src/components/common/ErrorBoundary.tsx
Normal 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;
|
||||||
162
src/components/common/InfiniteScrollList.tsx
Normal 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;
|
||||||
@ -96,6 +96,26 @@ const SongItem: React.FC<SongItemProps> = ({
|
|||||||
</div>
|
</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:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
export { default as EmptyState } from './EmptyState';
|
export { default as EmptyState } from './EmptyState';
|
||||||
export { default as Toast } from './Toast';
|
export { default as Toast } from './Toast';
|
||||||
export { default as ActionButton } from './ActionButton';
|
export { default as ActionButton } from './ActionButton';
|
||||||
export { default as SongItem } from './SongItem';
|
export { default as InfiniteScrollList } from './InfiniteScrollList';
|
||||||
|
export { default as SongItem } from './SongItem';
|
||||||
|
export { default as ErrorBoundary } from './ErrorBoundary';
|
||||||
@ -1,6 +1,7 @@
|
|||||||
// App constants
|
// App constants
|
||||||
export const APP_NAME = '🎤 Karaoke App';
|
export const APP_NAME = '🎤 Karaoke App';
|
||||||
export const APP_VERSION = '1.0.0';
|
export const APP_VERSION = '1.0.0';
|
||||||
|
export const CONTROLLER_NAME = import.meta.env.VITE_CONTROLLER_NAME || 'default';
|
||||||
|
|
||||||
// Firebase configuration
|
// Firebase configuration
|
||||||
export const FIREBASE_CONFIG = {
|
export const FIREBASE_CONFIG = {
|
||||||
|
|||||||
157
src/features/Artists/Artists.tsx
Normal 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;
|
||||||
43
src/features/Favorites/Favorites.tsx
Normal 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;
|
||||||
@ -1,62 +1,57 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { SongItem, EmptyState } from '../../components/common';
|
import { InfiniteScrollList } from '../../components/common';
|
||||||
import { useHistory } from '../../hooks';
|
import { useHistory } from '../../hooks';
|
||||||
|
import { useAppSelector } from '../../redux';
|
||||||
|
import { selectHistory } from '../../redux';
|
||||||
import { formatDate } from '../../utils/dataProcessing';
|
import { formatDate } from '../../utils/dataProcessing';
|
||||||
|
import type { Song } from '../../types';
|
||||||
|
|
||||||
const History: React.FC = () => {
|
const History: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
historyItems,
|
historyItems,
|
||||||
|
hasMore,
|
||||||
|
loadMore,
|
||||||
handleAddToQueue,
|
handleAddToQueue,
|
||||||
handleToggleFavorite,
|
handleToggleFavorite,
|
||||||
} = useHistory();
|
} = 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 (
|
return (
|
||||||
<div className="max-w-4xl mx-auto p-6">
|
<InfiniteScrollList
|
||||||
<div className="mb-6">
|
items={historyItems}
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Recently Played</h1>
|
isLoading={historyCount === 0}
|
||||||
<p className="text-sm text-gray-600">
|
hasMore={hasMore}
|
||||||
{historyItems.length} song{historyItems.length !== 1 ? 's' : ''} in history
|
onLoadMore={loadMore}
|
||||||
</p>
|
onAddToQueue={handleAddToQueue}
|
||||||
</div>
|
onToggleFavorite={handleToggleFavorite}
|
||||||
|
context="history"
|
||||||
{/* History List */}
|
title="Recently Played"
|
||||||
<div className="bg-white rounded-lg shadow">
|
subtitle={`${historyItems.length} song${historyItems.length !== 1 ? 's' : ''} in history`}
|
||||||
{historyItems.length === 0 ? (
|
emptyTitle="No history yet"
|
||||||
<EmptyState
|
emptyMessage="Songs will appear here after they've been played"
|
||||||
title="No history yet"
|
loadingTitle="Loading history..."
|
||||||
message="Songs will appear here after they've been played"
|
loadingMessage="Please wait while history data is being loaded"
|
||||||
icon={
|
debugInfo={`History items loaded: ${historyCount}`}
|
||||||
<svg className="h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
renderExtraContent={renderExtraContent}
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
43
src/features/NewSongs/NewSongs.tsx
Normal 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;
|
||||||
@ -1,6 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { SongItem, EmptyState, ActionButton } from '../../components/common';
|
import { SongItem, EmptyState, ActionButton } from '../../components/common';
|
||||||
import { useQueue } from '../../hooks';
|
import { useQueue } from '../../hooks';
|
||||||
|
import { useAppSelector } from '../../redux';
|
||||||
|
import { selectQueue } from '../../redux';
|
||||||
|
|
||||||
const Queue: React.FC = () => {
|
const Queue: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
@ -13,6 +15,13 @@ const Queue: React.FC = () => {
|
|||||||
handleMoveDown,
|
handleMoveDown,
|
||||||
} = useQueue();
|
} = 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 (
|
return (
|
||||||
<div className="max-w-4xl mx-auto p-6">
|
<div className="max-w-4xl mx-auto p-6">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
@ -20,11 +29,16 @@ const Queue: React.FC = () => {
|
|||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
{queueStats.totalSongs} song{queueStats.totalSongs !== 1 ? 's' : ''} in queue
|
{queueStats.totalSongs} song{queueStats.totalSongs !== 1 ? 's' : ''} in queue
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Debug info */}
|
||||||
|
<div className="mt-2 text-sm text-gray-500">
|
||||||
|
Queue items loaded: {queueCount}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Queue List */}
|
{/* Queue List */}
|
||||||
<div className="bg-white rounded-lg shadow">
|
<div className="bg-white rounded-lg shadow">
|
||||||
{queueItems.length === 0 ? (
|
{queueCount === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="Queue is empty"
|
title="Queue is empty"
|
||||||
message="Add songs from search, history, or favorites to get started"
|
message="Add songs from search, history, or favorites to get started"
|
||||||
@ -34,6 +48,16 @@ const Queue: React.FC = () => {
|
|||||||
</svg>
|
</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">
|
<div className="divide-y divide-gray-200">
|
||||||
{queueItems.map((queueItem, index) => (
|
{queueItems.map((queueItem, index) => (
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useAppSelector } from '../../redux';
|
import { InfiniteScrollList } from '../../components/common';
|
||||||
import { SongItem, EmptyState } from '../../components/common';
|
|
||||||
import { useSearch } from '../../hooks';
|
import { useSearch } from '../../hooks';
|
||||||
import { selectIsAdmin } from '../../redux';
|
import { useAppSelector } from '../../redux';
|
||||||
|
import { selectIsAdmin, selectSongs } from '../../redux';
|
||||||
|
|
||||||
const Search: React.FC = () => {
|
const Search: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
@ -11,9 +11,27 @@ const Search: React.FC = () => {
|
|||||||
handleSearchChange,
|
handleSearchChange,
|
||||||
handleAddToQueue,
|
handleAddToQueue,
|
||||||
handleToggleFavorite,
|
handleToggleFavorite,
|
||||||
|
loadMore,
|
||||||
} = useSearch();
|
} = useSearch();
|
||||||
|
|
||||||
const isAdmin = useAppSelector(selectIsAdmin);
|
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 (
|
return (
|
||||||
<div className="max-w-4xl mx-auto p-6">
|
<div className="max-w-4xl mx-auto p-6">
|
||||||
@ -35,40 +53,36 @@ const Search: React.FC = () => {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Search Results */}
|
{/* Search Results */}
|
||||||
<div className="bg-white rounded-lg shadow">
|
<InfiniteScrollList
|
||||||
{searchResults.songs.length === 0 ? (
|
items={searchResults.songs}
|
||||||
<EmptyState
|
isLoading={songsCount === 0}
|
||||||
title={searchTerm ? "No songs found" : "No songs available"}
|
hasMore={searchResults.hasMore}
|
||||||
message={searchTerm ? "Try adjusting your search terms" : "Songs will appear here once loaded"}
|
onLoadMore={loadMore}
|
||||||
icon={
|
onAddToQueue={handleAddToQueue}
|
||||||
<svg className="h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
onToggleFavorite={handleToggleFavorite}
|
||||||
<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" />
|
context="search"
|
||||||
</svg>
|
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..."
|
||||||
<div className="divide-y divide-gray-200">
|
loadingMessage="Please wait while songs are being loaded from the database"
|
||||||
{searchResults.songs.map((song) => (
|
isAdmin={isAdmin}
|
||||||
<SongItem
|
debugInfo=""
|
||||||
key={song.key}
|
/>
|
||||||
song={song}
|
|
||||||
context="search"
|
|
||||||
onAddToQueue={() => handleAddToQueue(song)}
|
|
||||||
onToggleFavorite={() => handleToggleFavorite(song)}
|
|
||||||
isAdmin={isAdmin}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search Stats */}
|
{/* Search Stats */}
|
||||||
{searchTerm && (
|
{searchTerm && (
|
||||||
<div className="mt-4 text-sm text-gray-500 text-center">
|
<div className="mt-4 text-sm text-gray-500 text-center">
|
||||||
Found {searchResults.count} song{searchResults.count !== 1 ? 's' : ''}
|
Found {searchResults.count} song{searchResults.count !== 1 ? 's' : ''}
|
||||||
|
{searchResults.hasMore && ` • Scroll down to load more`}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
93
src/features/Singers/Singers.tsx
Normal 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;
|
||||||
180
src/features/SongLists/SongLists.tsx
Normal 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;
|
||||||
96
src/features/TopPlayed/Top100.tsx
Normal 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;
|
||||||
@ -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;
|
|
||||||
@ -1,4 +1,9 @@
|
|||||||
export { default as Search } from './Search/Search';
|
export { default as Search } from './Search/Search';
|
||||||
export { default as Queue } from './Queue/Queue';
|
export { default as Queue } from './Queue/Queue';
|
||||||
export { default as History } from './History/History';
|
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';
|
||||||
8
src/firebase/FirebaseContext.tsx
Normal 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);
|
||||||
85
src/firebase/FirebaseProvider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
10
src/firebase/useFirebase.ts
Normal 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;
|
||||||
|
};
|
||||||
@ -4,4 +4,9 @@ export { useToast } from './useToast';
|
|||||||
export { useSearch } from './useSearch';
|
export { useSearch } from './useSearch';
|
||||||
export { useQueue } from './useQueue';
|
export { useQueue } from './useQueue';
|
||||||
export { useHistory } from './useHistory';
|
export { useHistory } from './useHistory';
|
||||||
export { useTopPlayed } from './useTopPlayed';
|
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
@ -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
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -1,14 +1,35 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { useAppSelector } from '../redux';
|
import { useAppSelector, selectHistoryArray } from '../redux';
|
||||||
import { selectHistoryArray } from '../redux/selectors';
|
|
||||||
import { useSongOperations } from './useSongOperations';
|
import { useSongOperations } from './useSongOperations';
|
||||||
import { useToast } from './useToast';
|
import { useToast } from './useToast';
|
||||||
import type { Song } from '../types';
|
import type { Song } from '../types';
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 20;
|
||||||
|
|
||||||
export const useHistory = () => {
|
export const useHistory = () => {
|
||||||
const historyItems = useAppSelector(selectHistoryArray);
|
const allHistoryItems = useAppSelector(selectHistoryArray);
|
||||||
const { addToQueue, toggleFavorite } = useSongOperations();
|
const { addToQueue, toggleFavorite } = useSongOperations();
|
||||||
const { showSuccess, showError } = useToast();
|
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) => {
|
const handleAddToQueue = useCallback(async (song: Song) => {
|
||||||
try {
|
try {
|
||||||
@ -30,6 +51,11 @@ export const useHistory = () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
historyItems,
|
historyItems,
|
||||||
|
allHistoryItems,
|
||||||
|
hasMore,
|
||||||
|
loadMore,
|
||||||
|
currentPage,
|
||||||
|
totalPages: Math.ceil(allHistoryItems.length / ITEMS_PER_PAGE),
|
||||||
handleAddToQueue,
|
handleAddToQueue,
|
||||||
handleToggleFavorite,
|
handleToggleFavorite,
|
||||||
};
|
};
|
||||||
|
|||||||
62
src/hooks/useNewSongs.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -1,6 +1,5 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useAppSelector } from '../redux';
|
import { useAppSelector, selectQueueWithUserInfo, selectQueueStats, selectCanReorderQueue } from '../redux';
|
||||||
import { selectQueueWithUserInfo, selectQueueStats, selectCanReorderQueue } from '../redux/selectors';
|
|
||||||
import { useSongOperations } from './useSongOperations';
|
import { useSongOperations } from './useSongOperations';
|
||||||
import { useToast } from './useToast';
|
import { useToast } from './useToast';
|
||||||
import type { QueueItem } from '../types';
|
import type { QueueItem } from '../types';
|
||||||
|
|||||||
@ -1,33 +1,62 @@
|
|||||||
import { useState, useCallback, useMemo } from 'react';
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
import { useAppSelector } from '../redux';
|
import { useAppSelector, selectSongsArray } from '../redux';
|
||||||
import { selectSearchResults } from '../redux/selectors';
|
|
||||||
import { useSongOperations } from './useSongOperations';
|
import { useSongOperations } from './useSongOperations';
|
||||||
import { useToast } from './useToast';
|
import { useToast } from './useToast';
|
||||||
import { UI_CONSTANTS } from '../constants';
|
import { UI_CONSTANTS } from '../constants';
|
||||||
|
import { filterSongs } from '../utils/dataProcessing';
|
||||||
import type { Song } from '../types';
|
import type { Song } from '../types';
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 20;
|
||||||
|
|
||||||
export const useSearch = () => {
|
export const useSearch = () => {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const { addToQueue, toggleFavorite } = useSongOperations();
|
const { addToQueue, toggleFavorite } = useSongOperations();
|
||||||
const { showSuccess, showError } = useToast();
|
const { showSuccess, showError } = useToast();
|
||||||
|
|
||||||
// Get filtered search results using selector
|
// Get all songs from Redux (this is memoized)
|
||||||
const searchResults = useAppSelector(state =>
|
const allSongs = useAppSelector(selectSongsArray);
|
||||||
selectSearchResults(state, searchTerm)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Debounced search term for performance
|
// Memoize filtered results to prevent unnecessary re-computations
|
||||||
const debouncedSearchTerm = useMemo(() => {
|
const filteredSongs = useMemo(() => {
|
||||||
if (searchTerm.length < UI_CONSTANTS.SEARCH.MIN_SEARCH_LENGTH) {
|
if (!searchTerm.trim() || searchTerm.length < UI_CONSTANTS.SEARCH.MIN_SEARCH_LENGTH) {
|
||||||
return '';
|
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) => {
|
const handleSearchChange = useCallback((value: string) => {
|
||||||
setSearchTerm(value);
|
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) => {
|
const handleAddToQueue = useCallback(async (song: Song) => {
|
||||||
try {
|
try {
|
||||||
await addToQueue(song);
|
await addToQueue(song);
|
||||||
@ -48,10 +77,10 @@ export const useSearch = () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
searchTerm,
|
searchTerm,
|
||||||
debouncedSearchTerm,
|
|
||||||
searchResults,
|
searchResults,
|
||||||
handleSearchChange,
|
handleSearchChange,
|
||||||
handleAddToQueue,
|
handleAddToQueue,
|
||||||
handleToggleFavorite,
|
handleToggleFavorite,
|
||||||
|
loadMore,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
30
src/hooks/useSingers.ts
Normal 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
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -1,14 +1,35 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { useAppSelector } from '../redux';
|
import { useAppSelector, selectTopPlayedArray } from '../redux';
|
||||||
import { selectTopPlayedArray } from '../redux/selectors';
|
|
||||||
import { useSongOperations } from './useSongOperations';
|
import { useSongOperations } from './useSongOperations';
|
||||||
import { useToast } from './useToast';
|
import { useToast } from './useToast';
|
||||||
import type { TopPlayed } from '../types';
|
import type { TopPlayed } from '../types';
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 20;
|
||||||
|
|
||||||
export const useTopPlayed = () => {
|
export const useTopPlayed = () => {
|
||||||
const topPlayedItems = useAppSelector(selectTopPlayedArray);
|
const allTopPlayedItems = useAppSelector(selectTopPlayedArray);
|
||||||
const { addToQueue, toggleFavorite } = useSongOperations();
|
const { addToQueue, toggleFavorite } = useSongOperations();
|
||||||
const { showSuccess, showError } = useToast();
|
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) => {
|
const handleAddToQueue = useCallback(async (song: TopPlayed) => {
|
||||||
try {
|
try {
|
||||||
@ -44,6 +65,11 @@ export const useTopPlayed = () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
topPlayedItems,
|
topPlayedItems,
|
||||||
|
allTopPlayedItems,
|
||||||
|
hasMore,
|
||||||
|
loadMore,
|
||||||
|
currentPage,
|
||||||
|
totalPages: Math.ceil(allTopPlayedItems.length / ITEMS_PER_PAGE),
|
||||||
handleAddToQueue,
|
handleAddToQueue,
|
||||||
handleToggleFavorite,
|
handleToggleFavorite,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -157,6 +157,8 @@ export const selectQueue = (state: { controller: ControllerState }) => state.con
|
|||||||
export const selectFavorites = (state: { controller: ControllerState }) => state.controller.data?.favorites || {};
|
export const selectFavorites = (state: { controller: ControllerState }) => state.controller.data?.favorites || {};
|
||||||
export const selectHistory = (state: { controller: ControllerState }) => state.controller.data?.history || {};
|
export const selectHistory = (state: { controller: ControllerState }) => state.controller.data?.history || {};
|
||||||
export const selectTopPlayed = (state: { controller: ControllerState }) => state.controller.data?.topPlayed || {};
|
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 selectPlayerState = (state: { controller: ControllerState }) => state.controller.data?.player?.state;
|
||||||
export const selectSettings = (state: { controller: ControllerState }) => state.controller.data?.player?.settings;
|
export const selectSettings = (state: { controller: ControllerState }) => state.controller.data?.player?.settings;
|
||||||
export const selectSingers = (state: { controller: ControllerState }) => state.controller.data?.player?.singers || {};
|
export const selectSingers = (state: { controller: ControllerState }) => state.controller.data?.player?.singers || {};
|
||||||
|
|||||||
@ -25,6 +25,8 @@ export {
|
|||||||
selectFavorites,
|
selectFavorites,
|
||||||
selectHistory,
|
selectHistory,
|
||||||
selectTopPlayed,
|
selectTopPlayed,
|
||||||
|
selectNewSongs,
|
||||||
|
selectSongList,
|
||||||
selectPlayerState,
|
selectPlayerState,
|
||||||
selectSettings,
|
selectSettings,
|
||||||
selectSingers,
|
selectSingers,
|
||||||
|
|||||||
@ -6,6 +6,9 @@ import {
|
|||||||
selectFavorites,
|
selectFavorites,
|
||||||
selectHistory,
|
selectHistory,
|
||||||
selectTopPlayed,
|
selectTopPlayed,
|
||||||
|
selectNewSongs,
|
||||||
|
selectSongList,
|
||||||
|
selectSingers,
|
||||||
selectIsAdmin,
|
selectIsAdmin,
|
||||||
selectCurrentSinger
|
selectCurrentSinger
|
||||||
} from './index';
|
} from './index';
|
||||||
@ -15,6 +18,7 @@ import {
|
|||||||
sortQueueByOrder,
|
sortQueueByOrder,
|
||||||
sortHistoryByDate,
|
sortHistoryByDate,
|
||||||
sortTopPlayedByCount,
|
sortTopPlayedByCount,
|
||||||
|
sortSongsByArtistAndTitle,
|
||||||
limitArray,
|
limitArray,
|
||||||
getQueueStats
|
getQueueStats
|
||||||
} from '../utils/dataProcessing';
|
} from '../utils/dataProcessing';
|
||||||
@ -23,11 +27,11 @@ import { UI_CONSTANTS } from '../constants';
|
|||||||
// Enhanced selectors with data processing
|
// Enhanced selectors with data processing
|
||||||
export const selectSongsArray = createSelector(
|
export const selectSongsArray = createSelector(
|
||||||
[selectSongs],
|
[selectSongs],
|
||||||
(songs) => objectToArray(songs)
|
(songs) => sortSongsByArtistAndTitle(objectToArray(songs))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const selectFilteredSongs = createSelector(
|
export const selectFilteredSongs = createSelector(
|
||||||
[selectSongsArray, (state: RootState, searchTerm: string) => searchTerm],
|
[selectSongsArray, (_state: RootState, searchTerm: string) => searchTerm],
|
||||||
(songs, searchTerm) => filterSongs(songs, searchTerm)
|
(songs, searchTerm) => filterSongs(songs, searchTerm)
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -48,7 +52,35 @@ export const selectHistoryArray = createSelector(
|
|||||||
|
|
||||||
export const selectFavoritesArray = createSelector(
|
export const selectFavoritesArray = createSelector(
|
||||||
[selectFavorites],
|
[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(
|
export const selectTopPlayedArray = createSelector(
|
||||||
|
|||||||
@ -90,7 +90,7 @@ export interface Controller {
|
|||||||
singers: Record<string, Singer>;
|
singers: Record<string, Singer>;
|
||||||
state: Player;
|
state: Player;
|
||||||
};
|
};
|
||||||
songList: Record<string, unknown>;
|
songList: Record<string, SongList>;
|
||||||
songs: Record<string, Song>;
|
songs: Record<string, Song>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -120,7 +120,7 @@ export interface ActionButtonProps {
|
|||||||
|
|
||||||
export interface SongItemProps {
|
export interface SongItemProps {
|
||||||
song: Song;
|
song: Song;
|
||||||
context: 'search' | 'queue' | 'history' | 'favorites';
|
context: 'search' | 'queue' | 'history' | 'favorites' | 'topPlayed';
|
||||||
onAddToQueue?: () => void;
|
onAddToQueue?: () => void;
|
||||||
onRemoveFromQueue?: () => void;
|
onRemoveFromQueue?: () => void;
|
||||||
onToggleFavorite?: () => void;
|
onToggleFavorite?: () => void;
|
||||||
|
|||||||
@ -39,6 +39,33 @@ export const sortTopPlayedByCount = (songs: TopPlayed[]): TopPlayed[] => {
|
|||||||
return [...songs].sort((a, b) => b.count - a.count);
|
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
|
// Limit array to specified length
|
||||||
export const limitArray = <T>(array: T[], limit: number): T[] => {
|
export const limitArray = <T>(array: T[], limit: number): T[] => {
|
||||||
return array.slice(0, limit);
|
return array.slice(0, limit);
|
||||||
|
|||||||