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": {
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"firebase": "^11.10.0",
|
||||
"react": "^19.1.0",
|
||||
@ -32,6 +33,6 @@
|
||||
"tailwindcss": "^4.1.11",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.35.1",
|
||||
"vite": "^7.0.4"
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
42
src/App.tsx
@ -1,22 +1,38 @@
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import Layout from './components/Layout/Layout';
|
||||
import Navigation from './components/Navigation/Navigation';
|
||||
import { Search, Queue, History, TopPlayed } from './features';
|
||||
import { Search, Queue, History, Favorites, NewSongs, Artists, Singers, SongLists } from './features';
|
||||
import TopPlayed from './features/TopPlayed/Top100';
|
||||
import { FirebaseProvider } from './firebase/FirebaseProvider';
|
||||
import { ErrorBoundary } from './components/common';
|
||||
import { AuthInitializer } from './components/Auth';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<Layout>
|
||||
<Navigation />
|
||||
<Routes>
|
||||
<Route path="/" element={<Search />} />
|
||||
<Route path="/queue" element={<Queue />} />
|
||||
<Route path="/history" element={<History />} />
|
||||
<Route path="/top-played" element={<TopPlayed />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</Router>
|
||||
<ErrorBoundary>
|
||||
<FirebaseProvider>
|
||||
<Router>
|
||||
<AuthInitializer>
|
||||
<Layout>
|
||||
<Navigation />
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/queue" replace />} />
|
||||
<Route path="/search" element={<Search />} />
|
||||
<Route path="/queue" element={<Queue />} />
|
||||
<Route path="/favorites" element={<Favorites />} />
|
||||
<Route path="/new-songs" element={<NewSongs />} />
|
||||
<Route path="/artists" element={<Artists />} />
|
||||
<Route path="/song-lists" element={<SongLists />} />
|
||||
<Route path="/history" element={<History />} />
|
||||
<Route path="/top-played" element={<TopPlayed />} />
|
||||
<Route path="/singers" element={<Singers />} />
|
||||
<Route path="*" element={<Navigate to="/queue" replace />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</AuthInitializer>
|
||||
</Router>
|
||||
</FirebaseProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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 { useSelector } from 'react-redux';
|
||||
import type { RootState } from '../../redux/store';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { selectCurrentSinger, selectIsAdmin, selectControllerName } from '../../redux/authSlice';
|
||||
import { logout } from '../../redux/authSlice';
|
||||
import { ActionButton } from '../common';
|
||||
import type { LayoutProps } from '../../types';
|
||||
|
||||
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
// TODO: Replace with actual Redux selectors
|
||||
const currentSinger = useSelector((state: RootState) => state.auth?.singer || '');
|
||||
const isAdmin = useSelector((state: RootState) => state.auth?.isAdmin || false);
|
||||
const controllerName = useSelector((state: RootState) => state.auth?.controller || '');
|
||||
const currentSinger = useSelector(selectCurrentSinger);
|
||||
const isAdmin = useSelector(selectIsAdmin);
|
||||
const controllerName = useSelector(selectControllerName);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleLogout = () => {
|
||||
dispatch(logout());
|
||||
// Reload the page to return to login screen
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
@ -22,21 +30,30 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
</h1>
|
||||
{controllerName && (
|
||||
<span className="ml-4 text-sm text-gray-500">
|
||||
Controller: {controllerName}
|
||||
Party: {controllerName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Info */}
|
||||
{/* User Info & Logout */}
|
||||
<div className="flex items-center space-x-4">
|
||||
{currentSinger && (
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium">{currentSinger}</span>
|
||||
{isAdmin && (
|
||||
<span className="ml-2 px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded-full">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium">{currentSinger}</span>
|
||||
{isAdmin && (
|
||||
<span className="ml-2 px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded-full">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ActionButton
|
||||
onClick={handleLogout}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
Logout
|
||||
</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -3,10 +3,15 @@ import { NavLink } from 'react-router-dom';
|
||||
|
||||
const Navigation: React.FC = () => {
|
||||
const navItems = [
|
||||
{ path: '/', label: 'Search', icon: '🔍' },
|
||||
{ path: '/queue', label: 'Queue', icon: '📋' },
|
||||
{ path: '/search', label: 'Search', icon: '🔍' },
|
||||
{ path: '/favorites', label: 'Favorites', icon: '❤️' },
|
||||
{ path: '/new-songs', label: 'New Songs', icon: '🆕' },
|
||||
{ path: '/artists', label: 'Artists', icon: '🎤' },
|
||||
{ path: '/song-lists', label: 'Song Lists', icon: '📝' },
|
||||
{ path: '/history', label: 'History', icon: '⏰' },
|
||||
{ path: '/top-played', label: 'Top Played', icon: '🏆' },
|
||||
{ path: '/top-played', label: 'Top 100', icon: '🏆' },
|
||||
{ path: '/singers', label: 'Singers', icon: '👥' },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
case 'topPlayed':
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<ActionButton
|
||||
onClick={onAddToQueue || (() => {})}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
Add to Queue
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
onClick={onToggleFavorite || (() => {})}
|
||||
variant={song.favorite ? 'danger' : 'secondary'}
|
||||
size="sm"
|
||||
>
|
||||
{song.favorite ? '❤️' : '🤍'}
|
||||
</ActionButton>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
export { default as EmptyState } from './EmptyState';
|
||||
export { default as Toast } from './Toast';
|
||||
export { default as ActionButton } from './ActionButton';
|
||||
export { default as InfiniteScrollList } from './InfiniteScrollList';
|
||||
export { default as SongItem } from './SongItem';
|
||||
export { default as ErrorBoundary } from './ErrorBoundary';
|
||||
@ -1,6 +1,7 @@
|
||||
// App constants
|
||||
export const APP_NAME = '🎤 Karaoke App';
|
||||
export const APP_VERSION = '1.0.0';
|
||||
export const CONTROLLER_NAME = import.meta.env.VITE_CONTROLLER_NAME || 'default';
|
||||
|
||||
// Firebase configuration
|
||||
export const FIREBASE_CONFIG = {
|
||||
|
||||
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 { SongItem, EmptyState } from '../../components/common';
|
||||
import { InfiniteScrollList } from '../../components/common';
|
||||
import { useHistory } from '../../hooks';
|
||||
import { useAppSelector } from '../../redux';
|
||||
import { selectHistory } from '../../redux';
|
||||
import { formatDate } from '../../utils/dataProcessing';
|
||||
import type { Song } from '../../types';
|
||||
|
||||
const History: React.FC = () => {
|
||||
const {
|
||||
historyItems,
|
||||
hasMore,
|
||||
loadMore,
|
||||
handleAddToQueue,
|
||||
handleToggleFavorite,
|
||||
} = useHistory();
|
||||
|
||||
const history = useAppSelector(selectHistory);
|
||||
const historyCount = Object.keys(history).length;
|
||||
|
||||
// Debug logging
|
||||
console.log('History component - history count:', historyCount);
|
||||
console.log('History component - history items:', historyItems);
|
||||
|
||||
// Render extra content for history items (play date)
|
||||
const renderExtraContent = (item: Song) => {
|
||||
if (item.date) {
|
||||
return (
|
||||
<div className="flex-shrink-0 px-4 py-2 text-sm text-gray-500">
|
||||
{formatDate(item.date)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Recently Played</h1>
|
||||
<p className="text-sm text-gray-600">
|
||||
{historyItems.length} song{historyItems.length !== 1 ? 's' : ''} in history
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* History List */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
{historyItems.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No history yet"
|
||||
message="Songs will appear here after they've been played"
|
||||
icon={
|
||||
<svg className="h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{historyItems.map((song) => (
|
||||
<div key={song.key} className="flex items-center">
|
||||
{/* Song Info */}
|
||||
<div className="flex-1">
|
||||
<SongItem
|
||||
song={song}
|
||||
context="history"
|
||||
onAddToQueue={() => handleAddToQueue(song)}
|
||||
onToggleFavorite={() => handleToggleFavorite(song)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Play Date */}
|
||||
{song.date && (
|
||||
<div className="flex-shrink-0 px-4 py-2 text-sm text-gray-500">
|
||||
{formatDate(song.date)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<InfiniteScrollList
|
||||
items={historyItems}
|
||||
isLoading={historyCount === 0}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={loadMore}
|
||||
onAddToQueue={handleAddToQueue}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
context="history"
|
||||
title="Recently Played"
|
||||
subtitle={`${historyItems.length} song${historyItems.length !== 1 ? 's' : ''} in history`}
|
||||
emptyTitle="No history yet"
|
||||
emptyMessage="Songs will appear here after they've been played"
|
||||
loadingTitle="Loading history..."
|
||||
loadingMessage="Please wait while history data is being loaded"
|
||||
debugInfo={`History items loaded: ${historyCount}`}
|
||||
renderExtraContent={renderExtraContent}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
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 { SongItem, EmptyState, ActionButton } from '../../components/common';
|
||||
import { useQueue } from '../../hooks';
|
||||
import { useAppSelector } from '../../redux';
|
||||
import { selectQueue } from '../../redux';
|
||||
|
||||
const Queue: React.FC = () => {
|
||||
const {
|
||||
@ -13,6 +15,13 @@ const Queue: React.FC = () => {
|
||||
handleMoveDown,
|
||||
} = useQueue();
|
||||
|
||||
const queue = useAppSelector(selectQueue);
|
||||
const queueCount = Object.keys(queue).length;
|
||||
|
||||
// Debug logging
|
||||
console.log('Queue component - queue count:', queueCount);
|
||||
console.log('Queue component - queue items:', queueItems);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<div className="mb-6">
|
||||
@ -20,11 +29,16 @@ const Queue: React.FC = () => {
|
||||
<p className="text-sm text-gray-600">
|
||||
{queueStats.totalSongs} song{queueStats.totalSongs !== 1 ? 's' : ''} in queue
|
||||
</p>
|
||||
|
||||
{/* Debug info */}
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
Queue items loaded: {queueCount}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Queue List */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
{queueItems.length === 0 ? (
|
||||
{queueCount === 0 ? (
|
||||
<EmptyState
|
||||
title="Queue is empty"
|
||||
message="Add songs from search, history, or favorites to get started"
|
||||
@ -34,6 +48,16 @@ const Queue: React.FC = () => {
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
) : queueItems.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Loading queue..."
|
||||
message="Please wait while queue data is being loaded"
|
||||
icon={
|
||||
<svg className="h-12 w-12 text-gray-400 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{queueItems.map((queueItem, index) => (
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useAppSelector } from '../../redux';
|
||||
import { SongItem, EmptyState } from '../../components/common';
|
||||
import { InfiniteScrollList } from '../../components/common';
|
||||
import { useSearch } from '../../hooks';
|
||||
import { selectIsAdmin } from '../../redux';
|
||||
import { useAppSelector } from '../../redux';
|
||||
import { selectIsAdmin, selectSongs } from '../../redux';
|
||||
|
||||
const Search: React.FC = () => {
|
||||
const {
|
||||
@ -11,9 +11,27 @@ const Search: React.FC = () => {
|
||||
handleSearchChange,
|
||||
handleAddToQueue,
|
||||
handleToggleFavorite,
|
||||
loadMore,
|
||||
} = useSearch();
|
||||
|
||||
const isAdmin = useAppSelector(selectIsAdmin);
|
||||
const songs = useAppSelector(selectSongs);
|
||||
const songsCount = Object.keys(songs).length;
|
||||
|
||||
// Performance monitoring
|
||||
React.useEffect(() => {
|
||||
const startTime = performance.now();
|
||||
|
||||
return () => {
|
||||
const endTime = performance.now();
|
||||
const renderTime = endTime - startTime;
|
||||
console.log(`Search component render time: ${renderTime.toFixed(2)}ms`);
|
||||
};
|
||||
});
|
||||
|
||||
// Debug logging
|
||||
console.log('Search component - songs count:', songsCount);
|
||||
console.log('Search component - search results:', searchResults);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
@ -35,40 +53,36 @@ const Search: React.FC = () => {
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Debug info */}
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
Total songs loaded: {songsCount} | Showing: {searchResults.songs.length} of {searchResults.count} | Page: {searchResults.currentPage}/{searchResults.totalPages} | Search term: "{searchTerm}"
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Results */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
{searchResults.songs.length === 0 ? (
|
||||
<EmptyState
|
||||
title={searchTerm ? "No songs found" : "No songs available"}
|
||||
message={searchTerm ? "Try adjusting your search terms" : "Songs will appear here once loaded"}
|
||||
icon={
|
||||
<svg className="h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{searchResults.songs.map((song) => (
|
||||
<SongItem
|
||||
key={song.key}
|
||||
song={song}
|
||||
context="search"
|
||||
onAddToQueue={() => handleAddToQueue(song)}
|
||||
onToggleFavorite={() => handleToggleFavorite(song)}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<InfiniteScrollList
|
||||
items={searchResults.songs}
|
||||
isLoading={songsCount === 0}
|
||||
hasMore={searchResults.hasMore}
|
||||
onLoadMore={loadMore}
|
||||
onAddToQueue={handleAddToQueue}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
context="search"
|
||||
title=""
|
||||
emptyTitle={searchTerm ? "No songs found" : "No songs available"}
|
||||
emptyMessage={searchTerm ? "Try adjusting your search terms" : "Songs will appear here once loaded"}
|
||||
loadingTitle="Loading songs..."
|
||||
loadingMessage="Please wait while songs are being loaded from the database"
|
||||
isAdmin={isAdmin}
|
||||
debugInfo=""
|
||||
/>
|
||||
|
||||
{/* Search Stats */}
|
||||
{searchTerm && (
|
||||
<div className="mt-4 text-sm text-gray-500 text-center">
|
||||
Found {searchResults.count} song{searchResults.count !== 1 ? 's' : ''}
|
||||
{searchResults.hasMore && ` • Scroll down to load more`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
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 Queue } from './Queue/Queue';
|
||||
export { default as History } from './History/History';
|
||||
export { default as TopPlayed } from './TopPlayed/TopPlayed';
|
||||
export { default as TopPlayed } from './TopPlayed/Top100';
|
||||
export { default as Favorites } from './Favorites/Favorites';
|
||||
export { default as NewSongs } from './NewSongs/NewSongs';
|
||||
export { default as Artists } from './Artists/Artists';
|
||||
export { default as Singers } from './Singers/Singers';
|
||||
export { default as SongLists } from './SongLists/SongLists';
|
||||
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;
|
||||
};
|
||||
@ -5,3 +5,8 @@ export { useSearch } from './useSearch';
|
||||
export { useQueue } from './useQueue';
|
||||
export { useHistory } from './useHistory';
|
||||
export { useTopPlayed } from './useTopPlayed';
|
||||
export { useFavorites } from './useFavorites';
|
||||
export { useNewSongs } from './useNewSongs';
|
||||
export { useArtists } from './useArtists';
|
||||
export { useSingers } from './useSingers';
|
||||
export { useSongLists } from './useSongLists';
|
||||
63
src/hooks/useArtists.ts
Normal file
@ -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,15 +1,36 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useAppSelector } from '../redux';
|
||||
import { selectHistoryArray } from '../redux/selectors';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useAppSelector, selectHistoryArray } from '../redux';
|
||||
import { useSongOperations } from './useSongOperations';
|
||||
import { useToast } from './useToast';
|
||||
import type { Song } from '../types';
|
||||
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
|
||||
export const useHistory = () => {
|
||||
const historyItems = useAppSelector(selectHistoryArray);
|
||||
const allHistoryItems = useAppSelector(selectHistoryArray);
|
||||
const { addToQueue, toggleFavorite } = useSongOperations();
|
||||
const { showSuccess, showError } = useToast();
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
// Paginate the history items - show all items up to current page
|
||||
const historyItems = useMemo(() => {
|
||||
const endIndex = currentPage * ITEMS_PER_PAGE;
|
||||
return allHistoryItems.slice(0, endIndex);
|
||||
}, [allHistoryItems, currentPage]);
|
||||
|
||||
const hasMore = useMemo(() => {
|
||||
// Only show "hasMore" if there are more items than currently loaded
|
||||
return allHistoryItems.length > ITEMS_PER_PAGE && historyItems.length < allHistoryItems.length;
|
||||
}, [historyItems.length, allHistoryItems.length]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
console.log('useHistory - loadMore called:', { hasMore, currentPage, allHistoryItemsLength: allHistoryItems.length });
|
||||
if (hasMore) {
|
||||
setCurrentPage(prev => prev + 1);
|
||||
}
|
||||
}, [hasMore, currentPage, allHistoryItems.length]);
|
||||
|
||||
const handleAddToQueue = useCallback(async (song: Song) => {
|
||||
try {
|
||||
await addToQueue(song);
|
||||
@ -30,6 +51,11 @@ export const useHistory = () => {
|
||||
|
||||
return {
|
||||
historyItems,
|
||||
allHistoryItems,
|
||||
hasMore,
|
||||
loadMore,
|
||||
currentPage,
|
||||
totalPages: Math.ceil(allHistoryItems.length / ITEMS_PER_PAGE),
|
||||
handleAddToQueue,
|
||||
handleToggleFavorite,
|
||||
};
|
||||
|
||||
62
src/hooks/useNewSongs.ts
Normal file
@ -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 { useAppSelector } from '../redux';
|
||||
import { selectQueueWithUserInfo, selectQueueStats, selectCanReorderQueue } from '../redux/selectors';
|
||||
import { useAppSelector, selectQueueWithUserInfo, selectQueueStats, selectCanReorderQueue } from '../redux';
|
||||
import { useSongOperations } from './useSongOperations';
|
||||
import { useToast } from './useToast';
|
||||
import type { QueueItem } from '../types';
|
||||
|
||||
@ -1,33 +1,62 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useAppSelector } from '../redux';
|
||||
import { selectSearchResults } from '../redux/selectors';
|
||||
import { useAppSelector, selectSongsArray } from '../redux';
|
||||
import { useSongOperations } from './useSongOperations';
|
||||
import { useToast } from './useToast';
|
||||
import { UI_CONSTANTS } from '../constants';
|
||||
import { filterSongs } from '../utils/dataProcessing';
|
||||
import type { Song } from '../types';
|
||||
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
|
||||
export const useSearch = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const { addToQueue, toggleFavorite } = useSongOperations();
|
||||
const { showSuccess, showError } = useToast();
|
||||
|
||||
// Get filtered search results using selector
|
||||
const searchResults = useAppSelector(state =>
|
||||
selectSearchResults(state, searchTerm)
|
||||
);
|
||||
// Get all songs from Redux (this is memoized)
|
||||
const allSongs = useAppSelector(selectSongsArray);
|
||||
|
||||
// Debounced search term for performance
|
||||
const debouncedSearchTerm = useMemo(() => {
|
||||
if (searchTerm.length < UI_CONSTANTS.SEARCH.MIN_SEARCH_LENGTH) {
|
||||
return '';
|
||||
// Memoize filtered results to prevent unnecessary re-computations
|
||||
const filteredSongs = useMemo(() => {
|
||||
if (!searchTerm.trim() || searchTerm.length < UI_CONSTANTS.SEARCH.MIN_SEARCH_LENGTH) {
|
||||
return allSongs;
|
||||
}
|
||||
return searchTerm;
|
||||
}, [searchTerm]);
|
||||
|
||||
return filterSongs(allSongs, searchTerm);
|
||||
}, [allSongs, searchTerm]);
|
||||
|
||||
// Paginate the filtered results - show all items up to current page
|
||||
const searchResults = useMemo(() => {
|
||||
const endIndex = currentPage * ITEMS_PER_PAGE;
|
||||
const paginatedSongs = filteredSongs.slice(0, endIndex);
|
||||
|
||||
return {
|
||||
songs: paginatedSongs,
|
||||
count: filteredSongs.length,
|
||||
hasMore: endIndex < filteredSongs.length,
|
||||
currentPage,
|
||||
totalPages: Math.ceil(filteredSongs.length / ITEMS_PER_PAGE),
|
||||
};
|
||||
}, [filteredSongs, currentPage]);
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchTerm(value);
|
||||
setCurrentPage(1); // Reset to first page when searching
|
||||
}, []);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
console.log('useSearch - loadMore called:', {
|
||||
hasMore: searchResults.hasMore,
|
||||
currentPage,
|
||||
filteredSongsLength: filteredSongs.length,
|
||||
searchResultsCount: searchResults.count
|
||||
});
|
||||
if (searchResults.hasMore) {
|
||||
setCurrentPage(prev => prev + 1);
|
||||
}
|
||||
}, [searchResults.hasMore, currentPage, filteredSongs.length, searchResults.count]);
|
||||
|
||||
const handleAddToQueue = useCallback(async (song: Song) => {
|
||||
try {
|
||||
await addToQueue(song);
|
||||
@ -48,10 +77,10 @@ export const useSearch = () => {
|
||||
|
||||
return {
|
||||
searchTerm,
|
||||
debouncedSearchTerm,
|
||||
searchResults,
|
||||
handleSearchChange,
|
||||
handleAddToQueue,
|
||||
handleToggleFavorite,
|
||||
loadMore,
|
||||
};
|
||||
};
|
||||
30
src/hooks/useSingers.ts
Normal file
@ -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,15 +1,36 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useAppSelector } from '../redux';
|
||||
import { selectTopPlayedArray } from '../redux/selectors';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useAppSelector, selectTopPlayedArray } from '../redux';
|
||||
import { useSongOperations } from './useSongOperations';
|
||||
import { useToast } from './useToast';
|
||||
import type { TopPlayed } from '../types';
|
||||
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
|
||||
export const useTopPlayed = () => {
|
||||
const topPlayedItems = useAppSelector(selectTopPlayedArray);
|
||||
const allTopPlayedItems = useAppSelector(selectTopPlayedArray);
|
||||
const { addToQueue, toggleFavorite } = useSongOperations();
|
||||
const { showSuccess, showError } = useToast();
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
// Paginate the top played items - show all items up to current page
|
||||
const topPlayedItems = useMemo(() => {
|
||||
const endIndex = currentPage * ITEMS_PER_PAGE;
|
||||
return allTopPlayedItems.slice(0, endIndex);
|
||||
}, [allTopPlayedItems, currentPage]);
|
||||
|
||||
const hasMore = useMemo(() => {
|
||||
// Only show "hasMore" if there are more items than currently loaded
|
||||
return allTopPlayedItems.length > ITEMS_PER_PAGE && topPlayedItems.length < allTopPlayedItems.length;
|
||||
}, [topPlayedItems.length, allTopPlayedItems.length]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
console.log('useTopPlayed - loadMore called:', { hasMore, currentPage, allTopPlayedItemsLength: allTopPlayedItems.length });
|
||||
if (hasMore) {
|
||||
setCurrentPage(prev => prev + 1);
|
||||
}
|
||||
}, [hasMore, currentPage, allTopPlayedItems.length]);
|
||||
|
||||
const handleAddToQueue = useCallback(async (song: TopPlayed) => {
|
||||
try {
|
||||
// Convert TopPlayed to Song format for queue
|
||||
@ -44,6 +65,11 @@ export const useTopPlayed = () => {
|
||||
|
||||
return {
|
||||
topPlayedItems,
|
||||
allTopPlayedItems,
|
||||
hasMore,
|
||||
loadMore,
|
||||
currentPage,
|
||||
totalPages: Math.ceil(allTopPlayedItems.length / ITEMS_PER_PAGE),
|
||||
handleAddToQueue,
|
||||
handleToggleFavorite,
|
||||
};
|
||||
|
||||
@ -157,6 +157,8 @@ export const selectQueue = (state: { controller: ControllerState }) => state.con
|
||||
export const selectFavorites = (state: { controller: ControllerState }) => state.controller.data?.favorites || {};
|
||||
export const selectHistory = (state: { controller: ControllerState }) => state.controller.data?.history || {};
|
||||
export const selectTopPlayed = (state: { controller: ControllerState }) => state.controller.data?.topPlayed || {};
|
||||
export const selectNewSongs = (state: { controller: ControllerState }) => state.controller.data?.newSongs || {};
|
||||
export const selectSongList = (state: { controller: ControllerState }) => state.controller.data?.songList || {};
|
||||
export const selectPlayerState = (state: { controller: ControllerState }) => state.controller.data?.player?.state;
|
||||
export const selectSettings = (state: { controller: ControllerState }) => state.controller.data?.player?.settings;
|
||||
export const selectSingers = (state: { controller: ControllerState }) => state.controller.data?.player?.singers || {};
|
||||
|
||||
@ -25,6 +25,8 @@ export {
|
||||
selectFavorites,
|
||||
selectHistory,
|
||||
selectTopPlayed,
|
||||
selectNewSongs,
|
||||
selectSongList,
|
||||
selectPlayerState,
|
||||
selectSettings,
|
||||
selectSingers,
|
||||
|
||||
@ -6,6 +6,9 @@ import {
|
||||
selectFavorites,
|
||||
selectHistory,
|
||||
selectTopPlayed,
|
||||
selectNewSongs,
|
||||
selectSongList,
|
||||
selectSingers,
|
||||
selectIsAdmin,
|
||||
selectCurrentSinger
|
||||
} from './index';
|
||||
@ -15,6 +18,7 @@ import {
|
||||
sortQueueByOrder,
|
||||
sortHistoryByDate,
|
||||
sortTopPlayedByCount,
|
||||
sortSongsByArtistAndTitle,
|
||||
limitArray,
|
||||
getQueueStats
|
||||
} from '../utils/dataProcessing';
|
||||
@ -23,11 +27,11 @@ import { UI_CONSTANTS } from '../constants';
|
||||
// Enhanced selectors with data processing
|
||||
export const selectSongsArray = createSelector(
|
||||
[selectSongs],
|
||||
(songs) => objectToArray(songs)
|
||||
(songs) => sortSongsByArtistAndTitle(objectToArray(songs))
|
||||
);
|
||||
|
||||
export const selectFilteredSongs = createSelector(
|
||||
[selectSongsArray, (state: RootState, searchTerm: string) => searchTerm],
|
||||
[selectSongsArray, (_state: RootState, searchTerm: string) => searchTerm],
|
||||
(songs, searchTerm) => filterSongs(songs, searchTerm)
|
||||
);
|
||||
|
||||
@ -48,7 +52,35 @@ export const selectHistoryArray = createSelector(
|
||||
|
||||
export const selectFavoritesArray = createSelector(
|
||||
[selectFavorites],
|
||||
(favorites) => objectToArray(favorites)
|
||||
(favorites) => sortSongsByArtistAndTitle(objectToArray(favorites))
|
||||
);
|
||||
|
||||
export const selectNewSongsArray = createSelector(
|
||||
[selectNewSongs],
|
||||
(newSongs) => sortSongsByArtistAndTitle(objectToArray(newSongs))
|
||||
);
|
||||
|
||||
export const selectSingersArray = createSelector(
|
||||
[selectSingers],
|
||||
(singers) => objectToArray(singers).sort((a, b) => a.name.localeCompare(b.name))
|
||||
);
|
||||
|
||||
export const selectSongListArray = createSelector(
|
||||
[selectSongList],
|
||||
(songList) => objectToArray(songList)
|
||||
);
|
||||
|
||||
export const selectArtistsArray = createSelector(
|
||||
[selectSongs],
|
||||
(songs) => {
|
||||
const artists = new Set<string>();
|
||||
Object.values(songs).forEach(song => {
|
||||
if (song.artist) {
|
||||
artists.add(song.artist);
|
||||
}
|
||||
});
|
||||
return Array.from(artists).sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
);
|
||||
|
||||
export const selectTopPlayedArray = createSelector(
|
||||
|
||||
@ -90,7 +90,7 @@ export interface Controller {
|
||||
singers: Record<string, Singer>;
|
||||
state: Player;
|
||||
};
|
||||
songList: Record<string, unknown>;
|
||||
songList: Record<string, SongList>;
|
||||
songs: Record<string, Song>;
|
||||
}
|
||||
|
||||
@ -120,7 +120,7 @@ export interface ActionButtonProps {
|
||||
|
||||
export interface SongItemProps {
|
||||
song: Song;
|
||||
context: 'search' | 'queue' | 'history' | 'favorites';
|
||||
context: 'search' | 'queue' | 'history' | 'favorites' | 'topPlayed';
|
||||
onAddToQueue?: () => void;
|
||||
onRemoveFromQueue?: () => void;
|
||||
onToggleFavorite?: () => void;
|
||||
|
||||
@ -39,6 +39,33 @@ export const sortTopPlayedByCount = (songs: TopPlayed[]): TopPlayed[] => {
|
||||
return [...songs].sort((a, b) => b.count - a.count);
|
||||
};
|
||||
|
||||
// Sort songs by artist then title (case insensitive)
|
||||
export const sortSongsByArtistAndTitle = (songs: Song[]): Song[] => {
|
||||
const sortedSongs = [...songs].sort((a, b) => {
|
||||
// First sort by artist (case insensitive)
|
||||
const artistA = a.artist.toLowerCase();
|
||||
const artistB = b.artist.toLowerCase();
|
||||
|
||||
if (artistA !== artistB) {
|
||||
return artistA.localeCompare(artistB);
|
||||
}
|
||||
|
||||
// If artists are the same, sort by title (case insensitive)
|
||||
const titleA = a.title.toLowerCase();
|
||||
const titleB = b.title.toLowerCase();
|
||||
return titleA.localeCompare(titleB);
|
||||
});
|
||||
|
||||
// Debug logging for first few songs to verify sorting
|
||||
if (sortedSongs.length > 0) {
|
||||
console.log('Songs sorted by artist and title. First 5 songs:',
|
||||
sortedSongs.slice(0, 5).map(s => `${s.artist} - ${s.title}`)
|
||||
);
|
||||
}
|
||||
|
||||
return sortedSongs;
|
||||
};
|
||||
|
||||
// Limit array to specified length
|
||||
export const limitArray = <T>(array: T[], limit: number): T[] => {
|
||||
return array.slice(0, limit);
|
||||
|
||||