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

This commit is contained in:
Matt Bruce 2025-07-18 08:43:22 -05:00
parent 2d5f8fdb8f
commit 97c1b1e030
12 changed files with 197 additions and 286 deletions

View File

@ -1,15 +1,14 @@
import React, { useState, useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { IonApp, IonHeader, IonToolbar, IonTitle, IonContent, IonChip, IonMenuButton } from '@ionic/react';
import { selectCurrentSinger, selectIsAdmin, selectControllerName } from '../../redux/authSlice';
import { IonApp, IonHeader, IonToolbar, IonTitle, IonContent, IonMenuButton, IonIcon } from '@ionic/react';
import { logOut } from 'ionicons/icons';
import { selectControllerName } from '../../redux/authSlice';
import { logout } from '../../redux/authSlice';
import { ActionButton } from '../common';
import Navigation from '../Navigation/Navigation';
import type { LayoutProps } from '../../types';
const Layout: React.FC<LayoutProps> = ({ children }) => {
const currentSinger = useSelector(selectCurrentSinger);
const isAdmin = useSelector(selectIsAdmin);
const controllerName = useSelector(selectControllerName);
const dispatch = useDispatch();
const [isLargeScreen, setIsLargeScreen] = useState(false);
@ -52,35 +51,25 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
<IonTitle>
<div className="flex items-center">
<span>🎤 Karaoke App</span>
<span>Sings A Lot</span>
{controllerName && (
<span className="ml-4 text-sm text-gray-500">
Party: {controllerName}
: {controllerName}
</span>
)}
</div>
</IonTitle>
{/* User Info & Logout */}
{currentSinger && (
<div slot="end" className="flex items-center space-x-3">
<div className="text-sm text-gray-600">
<span className="font-medium">{currentSinger}</span>
{isAdmin && (
<IonChip color="primary">
Admin
</IonChip>
)}
</div>
<ActionButton
onClick={handleLogout}
variant="secondary"
size="sm"
>
Logout
</ActionButton>
</div>
)}
{/* Logout Button */}
<div slot="end">
<ActionButton
onClick={handleLogout}
variant="secondary"
size="sm"
>
<IonIcon icon={logOut} />
</ActionButton>
</div>
</IonToolbar>
</IonHeader>

View File

@ -7,28 +7,22 @@ interface InfiniteScrollListProps<T> {
hasMore: boolean;
onLoadMore: () => void;
renderItem: (item: T, index: number) => React.ReactNode;
title: string;
subtitle?: string;
emptyTitle: string;
emptyMessage: string;
loadingTitle?: string;
loadingMessage?: string;
debugInfo?: string;
}
const InfiniteScrollList = <T extends { key?: string }>({
const InfiniteScrollList = <T extends string | { key?: string }>({
items,
isLoading,
hasMore,
onLoadMore,
renderItem,
title,
subtitle,
emptyTitle,
emptyMessage,
loadingTitle = "Loading...",
loadingMessage = "Please wait while data is being loaded",
debugInfo,
}: InfiniteScrollListProps<T>) => {
const observerRef = useRef<HTMLDivElement>(null);
@ -59,22 +53,16 @@ const InfiniteScrollList = <T extends { key?: string }>({
return () => observer.disconnect();
}, [onLoadMore, hasMore, isLoading, items.length]);
// Generate key for item
const getItemKey = (item: T, index: number): string => {
if (typeof item === 'string') {
return item;
}
return item.key || `item-${index}`;
};
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 ? (
@ -98,9 +86,9 @@ const InfiniteScrollList = <T extends { key?: string }>({
}
/>
) : (
<div className="divide-y divide-gray-200">
<div>
{items.map((item, index) => (
<div key={item.key}>
<div key={getItemKey(item, index)} className="px-4">
{renderItem(item, index)}
</div>
))}

View File

@ -0,0 +1,21 @@
import React from 'react';
interface PageHeaderProps {
title: string;
subtitle?: string;
}
const PageHeader: React.FC<PageHeaderProps> = ({ title, subtitle }) => {
return (
<div className="max-w-4xl mx-auto p-6">
<div style={{ marginBottom: '24px', paddingLeft: '16px' }}>
<h1 className="text-2xl font-bold text-gray-900 mb-2">{title}</h1>
{subtitle && (
<p className="text-sm text-gray-600">{subtitle}</p>
)}
</div>
</div>
);
};
export default PageHeader;

View File

@ -1,6 +1,9 @@
import React from 'react';
import { IonItem, IonLabel } from '@ionic/react';
import { IonItem, IonLabel, IonIcon } from '@ionic/react';
import { add, heart, heartOutline, trash } from 'ionicons/icons';
import ActionButton from './ActionButton';
import { useAppSelector } from '../../redux';
import { selectQueue, selectFavorites } from '../../redux';
import type { SongItemProps } from '../../types';
// Utility function to extract filename from path
@ -23,127 +26,91 @@ const SongItem: React.FC<SongItemProps> = ({
isAdmin = false,
className = ''
}) => {
// Get current state from Redux
const queue = useAppSelector(selectQueue);
const favorites = useAppSelector(selectFavorites);
// Check if song is in queue or favorites based on path
const isInQueue = Object.values(queue).some(item => item.song.path === song.path);
const isInFavorites = Object.values(favorites).some(favSong => favSong.path === song.path);
const renderActionPanel = () => {
switch (context) {
case 'search':
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>
);
case 'queue':
return (
<div className="flex gap-2">
{isAdmin && onRemoveFromQueue && (
<ActionButton
onClick={onRemoveFromQueue}
variant="danger"
size="sm"
>
Remove
</ActionButton>
)}
<ActionButton
onClick={onToggleFavorite || (() => {})}
variant={song.favorite ? 'danger' : 'secondary'}
size="sm"
>
{song.favorite ? '❤️' : '🤍'}
</ActionButton>
</div>
);
case 'history':
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>
);
case 'favorites':
return (
<div className="flex gap-2">
<ActionButton
onClick={onAddToQueue || (() => {})}
variant="primary"
size="sm"
>
Add to Queue
</ActionButton>
<ActionButton
onClick={onDelete || (() => {})}
variant="danger"
size="sm"
>
Remove
</ActionButton>
</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;
const buttons = [];
// Add to Queue button (for all contexts except queue, only if not already in queue)
if (context !== 'queue' && !isInQueue) {
buttons.push(
<ActionButton
key="add"
onClick={onAddToQueue || (() => {})}
variant="primary"
size="sm"
>
<IonIcon icon={add} />
</ActionButton>
);
}
// Remove from Queue button (only for queue context, admin only)
if (context === 'queue' && isAdmin && onRemoveFromQueue) {
buttons.push(
<ActionButton
key="remove"
onClick={onRemoveFromQueue}
variant="danger"
size="sm"
>
<IonIcon icon={trash} />
</ActionButton>
);
}
// Delete from Favorites button (only for favorites context)
if (context === 'favorites' && onDelete) {
buttons.push(
<ActionButton
key="delete"
onClick={onDelete}
variant="danger"
size="sm"
>
<IonIcon icon={trash} />
</ActionButton>
);
}
// Toggle Favorite button (for all contexts except favorites)
if (context !== 'favorites') {
buttons.push(
<ActionButton
key="favorite"
onClick={onToggleFavorite || (() => {})}
variant={isInFavorites ? 'danger' : 'secondary'}
size="sm"
>
<IonIcon icon={isInFavorites ? heart : heartOutline} />
</ActionButton>
);
}
return buttons.length > 0 ? (
<div className="flex gap-2">
{buttons}
</div>
) : null;
};
return (
<IonItem className={className}>
<IonLabel>
<h3 className="text-sm font-medium text-gray-900 truncate">
<IonLabel className="flex-1 min-w-0">
<h3 className="text-base font-extrabold text-gray-900 break-words">
{song.title}
</h3>
<p className="text-sm text-gray-500 truncate">
<p className="text-sm italic text-gray-500 break-words">
{song.artist}
</p>
{/* Show filename for all contexts except queue */}
{context !== 'queue' && song.path && (
<p className="text-xs text-gray-400 truncate">
<p className="text-xs text-gray-400 break-words">
{extractFilename(song.path)}
</p>
)}
@ -154,7 +121,7 @@ const SongItem: React.FC<SongItemProps> = ({
)}
</IonLabel>
<div slot="end" className="flex gap-2">
<div slot="end" className="flex gap-2 flex-shrink-0 ml-2">
{renderActionPanel()}
</div>
</IonItem>

View File

@ -3,5 +3,6 @@ export { default as EmptyState } from './EmptyState';
export { default as Toast } from './Toast';
export { default as ErrorBoundary } from './ErrorBoundary';
export { default as InfiniteScrollList } from './InfiniteScrollList';
export { default as PageHeader } from './PageHeader';
export { default as SongItem } from './SongItem';
export { default as PlayerControls } from './PlayerControls';

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef } from 'react';
import { IonSearchbar, IonList, IonItem, IonLabel, IonModal, IonHeader, IonToolbar, IonTitle, IonButton, IonIcon, IonChip } from '@ionic/react';
import { close, add, heart, heartOutline } from 'ionicons/icons';
import React, { useState } from 'react';
import { IonSearchbar, IonList, IonItem, IonLabel, IonModal, IonHeader, IonToolbar, IonTitle, IonButton, IonIcon } from '@ionic/react';
import { close, add, heart, heartOutline, list } from 'ionicons/icons';
import { InfiniteScrollList, PageHeader } from '../../components/common';
import { useArtists } from '../../hooks';
import { useAppSelector } from '../../redux';
import { selectSongs } from '../../redux';
@ -8,7 +9,6 @@ import { selectSongs } from '../../redux';
const Artists: React.FC = () => {
const {
artists,
allArtists,
searchTerm,
hasMore,
loadMore,
@ -22,40 +22,12 @@ const Artists: React.FC = () => {
const songs = useAppSelector(selectSongs);
const songsCount = Object.keys(songs).length;
const [selectedArtist, setSelectedArtist] = useState<string | null>(null);
const observerRef = useRef<HTMLDivElement>(null);
// Intersection Observer for infinite scrolling
useEffect(() => {
console.log('Artists - Setting up observer:', { hasMore, songsCount, itemsLength: artists.length });
const observer = new IntersectionObserver(
(entries) => {
console.log('Artists - Intersection detected:', {
isIntersecting: entries[0].isIntersecting,
hasMore,
songsCount
});
if (entries[0].isIntersecting && hasMore && songsCount > 0) {
console.log('Artists - Loading more items');
loadMore();
}
},
{ threshold: 0.1 }
);
if (observerRef.current) {
observer.observe(observerRef.current);
}
return () => observer.disconnect();
}, [loadMore, hasMore, songsCount, artists.length]);
// Debug logging
useEffect(() => {
console.log('Artists component - artists count:', artists.length);
console.log('Artists component - selected artist:', selectedArtist);
}, [artists.length, selectedArtist]);
console.log('Artists component - artists count:', artists.length);
console.log('Artists component - selected artist:', selectedArtist);
console.log('Artists component - songs count:', songsCount);
console.log('Artists component - search term:', searchTerm);
const handleArtistClick = (artist: string) => {
setSelectedArtist(artist);
@ -67,6 +39,21 @@ const Artists: React.FC = () => {
const selectedArtistSongs = selectedArtist ? getSongsByArtist(selectedArtist) : [];
// Render artist item for InfiniteScrollList
const renderArtistItem = (artist: string) => (
<IonItem button onClick={() => handleArtistClick(artist)} detail={false}>
<IonLabel>
<h3 className="text-sm font-medium text-gray-900">
{artist}
</h3>
<p className="text-sm text-gray-500">
{getSongCountByArtist(artist)} song{getSongCountByArtist(artist) !== 1 ? 's' : ''}
</p>
</IonLabel>
<IonIcon icon={list} slot="end" color="primary" />
</IonItem>
);
return (
<>
<IonHeader>
@ -74,10 +61,14 @@ const Artists: React.FC = () => {
<IonTitle>Artists</IonTitle>
</IonToolbar>
</IonHeader>
<PageHeader
title="Artists"
subtitle="Browse songs by artist"
/>
<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 */}
<IonSearchbar
placeholder="Search artists..."
@ -86,75 +77,20 @@ const Artists: React.FC = () => {
debounce={300}
showClearButton="focus"
/>
{/* Debug info */}
<div className="mt-2 text-sm text-gray-500">
Total songs loaded: {songsCount} | Showing: {artists.length} of {allArtists.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>
) : (
<IonList>
{artists.map((artist) => (
<IonItem key={artist} button onClick={() => handleArtistClick(artist)}>
<IonLabel>
<h3 className="text-sm font-medium text-gray-900">
{artist}
</h3>
<p className="text-sm text-gray-500">
{getSongCountByArtist(artist)} song{getSongCountByArtist(artist) !== 1 ? 's' : ''}
</p>
</IonLabel>
<IonChip slot="end" color="primary">
View Songs
</IonChip>
</IonItem>
))}
{/* Infinite scroll trigger */}
{hasMore && (
<div
ref={observerRef}
className="py-4 text-center text-gray-500"
>
<div className="inline-flex items-center">
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Loading more artists...
</div>
</div>
)}
</IonList>
)}
</div>
<InfiniteScrollList<string>
items={artists}
isLoading={songsCount === 0}
hasMore={hasMore}
onLoadMore={loadMore}
renderItem={renderArtistItem}
emptyTitle={searchTerm ? "No artists found" : "No artists available"}
emptyMessage={searchTerm ? "Try adjusting your search terms" : "Artists will appear here once songs are loaded"}
loadingTitle="Loading artists..."
loadingMessage="Please wait while songs are being loaded from the database"
/>
{/* Artist Songs Modal */}
<IonModal isOpen={!!selectedArtist} onDidDismiss={handleCloseArtistSongs}>

View File

@ -1,6 +1,6 @@
import React from 'react';
import { IonHeader, IonToolbar, IonTitle, IonChip } from '@ionic/react';
import { InfiniteScrollList, SongItem } from '../../components/common';
import { InfiniteScrollList, PageHeader, SongItem } from '../../components/common';
import { useFavorites } from '../../hooks';
import { useAppSelector } from '../../redux';
import { selectFavorites } from '../../redux';
@ -35,6 +35,11 @@ const Favorites: React.FC = () => {
</IonToolbar>
</IonHeader>
<PageHeader
title="Favorites"
subtitle={`${favoritesCount} items loaded`}
/>
<InfiniteScrollList<Song>
items={favoritesItems}
isLoading={favoritesCount === 0}
@ -48,13 +53,10 @@ const Favorites: React.FC = () => {
onToggleFavorite={() => handleToggleFavorite(song)}
/>
)}
title="Favorites"
subtitle={`${favoritesCount} items loaded`}
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}`}
/>
</>
);

View File

@ -1,7 +1,7 @@
import React from 'react';
import { IonHeader, IonToolbar, IonTitle, IonChip, IonIcon } from '@ionic/react';
import { time } from 'ionicons/icons';
import { InfiniteScrollList, SongItem } from '../../components/common';
import { InfiniteScrollList, PageHeader, SongItem } from '../../components/common';
import { useHistory } from '../../hooks';
import { useAppSelector } from '../../redux';
import { selectHistory } from '../../redux';
@ -50,6 +50,11 @@ const History: React.FC = () => {
</IonToolbar>
</IonHeader>
<PageHeader
title="Recently Played"
subtitle={`${historyCount} items loaded`}
/>
<div style={{ height: '100%', overflowY: 'auto' }}>
<InfiniteScrollList<Song>
items={historyItems}
@ -69,13 +74,10 @@ const History: React.FC = () => {
{renderExtraContent(song)}
</div>
)}
title="Recently Played"
subtitle={`${historyCount} items loaded`}
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}`}
/>
</div>
</>

View File

@ -1,6 +1,6 @@
import React from 'react';
import { IonHeader, IonToolbar, IonTitle, IonChip } from '@ionic/react';
import { InfiniteScrollList, SongItem } from '../../components/common';
import { InfiniteScrollList, PageHeader, SongItem } from '../../components/common';
import { useNewSongs } from '../../hooks';
import { useAppSelector } from '../../redux';
import { selectNewSongs } from '../../redux';
@ -35,6 +35,11 @@ const NewSongs: React.FC = () => {
</IonToolbar>
</IonHeader>
<PageHeader
title="New Songs"
subtitle={`${newSongsCount} items loaded`}
/>
<InfiniteScrollList<Song>
items={newSongsItems}
isLoading={newSongsCount === 0}
@ -48,13 +53,10 @@ const NewSongs: React.FC = () => {
onToggleFavorite={() => handleToggleFavorite(song)}
/>
)}
title="New Songs"
subtitle={`${newSongsCount} items loaded`}
emptyTitle="No new songs"
emptyMessage="Check back later for new additions"
loadingTitle="Loading new songs..."
loadingMessage="Please wait while new songs data is being loaded"
debugInfo={`New songs loaded: ${newSongsCount}`}
/>
</>
);

View File

@ -74,7 +74,7 @@ const Queue: React.FC = () => {
}
/>
) : (
<IonList>
<IonList className="px-4">
{queueItems.map((queueItem, index) => {
console.log(`Queue item ${index}: order=${queueItem.order}, key=${queueItem.key}`);
const canDelete = index === 0 ? canDeleteFirstItem : true;

View File

@ -1,6 +1,6 @@
import React from 'react';
import { IonSearchbar } from '@ionic/react';
import { InfiniteScrollList, SongItem } from '../../components/common';
import { InfiniteScrollList, PageHeader, SongItem } from '../../components/common';
import { useSearch } from '../../hooks';
import { useAppSelector } from '../../redux';
import { selectIsAdmin, selectSongs } from '../../redux';
@ -37,9 +37,12 @@ const Search: React.FC = () => {
return (
<div className="max-w-4xl mx-auto p-6">
<PageHeader
title="Search Songs"
subtitle="Search by title or artist"
/>
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 mb-4">Search Songs</h1>
{/* Search Input */}
<IonSearchbar
placeholder="Search by title or artist..."
@ -70,12 +73,10 @@ const Search: React.FC = () => {
isAdmin={isAdmin}
/>
)}
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"
debugInfo=""
/>
{/* Search Stats */}

View File

@ -3,7 +3,7 @@ import { IonHeader, IonToolbar, IonTitle, IonChip } from '@ionic/react';
import { useTopPlayed } from '../../hooks';
import { useAppSelector } from '../../redux';
import { selectTopPlayed } from '../../redux';
import { InfiniteScrollList } from '../../components/common';
import { InfiniteScrollList, PageHeader } from '../../components/common';
import type { TopPlayed } from '../../types';
const Top100: React.FC = () => {
@ -110,6 +110,11 @@ const Top100: React.FC = () => {
</IonToolbar>
</IonHeader>
<PageHeader
title="Top 100 Played"
subtitle={`${displayCount} items loaded (Mock Data)`}
/>
<InfiniteScrollList<TopPlayed>
items={displayItems}
isLoading={false}
@ -133,11 +138,8 @@ const Top100: React.FC = () => {
</div>
</div>
)}
title="Top 100 Played"
subtitle={`${displayCount} items loaded (Mock Data)`}
emptyTitle="No top played songs"
emptyMessage="Play some songs to see the top played list"
debugInfo={`Top played items loaded: ${displayCount} (Mock Data)`}
/>
</>
);