Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
2d5f8fdb8f
commit
97c1b1e030
@ -1,15 +1,14 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useSelector, useDispatch } from 'react-redux';
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
import { IonApp, IonHeader, IonToolbar, IonTitle, IonContent, IonChip, IonMenuButton } from '@ionic/react';
|
import { IonApp, IonHeader, IonToolbar, IonTitle, IonContent, IonMenuButton, IonIcon } from '@ionic/react';
|
||||||
import { selectCurrentSinger, selectIsAdmin, selectControllerName } from '../../redux/authSlice';
|
import { logOut } from 'ionicons/icons';
|
||||||
|
import { selectControllerName } from '../../redux/authSlice';
|
||||||
import { logout } from '../../redux/authSlice';
|
import { logout } from '../../redux/authSlice';
|
||||||
import { ActionButton } from '../common';
|
import { ActionButton } from '../common';
|
||||||
import Navigation from '../Navigation/Navigation';
|
import Navigation from '../Navigation/Navigation';
|
||||||
import type { LayoutProps } from '../../types';
|
import type { LayoutProps } from '../../types';
|
||||||
|
|
||||||
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||||
const currentSinger = useSelector(selectCurrentSinger);
|
|
||||||
const isAdmin = useSelector(selectIsAdmin);
|
|
||||||
const controllerName = useSelector(selectControllerName);
|
const controllerName = useSelector(selectControllerName);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const [isLargeScreen, setIsLargeScreen] = useState(false);
|
const [isLargeScreen, setIsLargeScreen] = useState(false);
|
||||||
@ -52,35 +51,25 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
|
|
||||||
<IonTitle>
|
<IonTitle>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span>🎤 Karaoke App</span>
|
<span>Sings A Lot</span>
|
||||||
{controllerName && (
|
{controllerName && (
|
||||||
<span className="ml-4 text-sm text-gray-500">
|
<span className="ml-4 text-sm text-gray-500">
|
||||||
Party: {controllerName}
|
: {controllerName}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</IonTitle>
|
</IonTitle>
|
||||||
|
|
||||||
{/* User Info & Logout */}
|
{/* Logout Button */}
|
||||||
{currentSinger && (
|
<div slot="end">
|
||||||
<div slot="end" className="flex items-center space-x-3">
|
<ActionButton
|
||||||
<div className="text-sm text-gray-600">
|
onClick={handleLogout}
|
||||||
<span className="font-medium">{currentSinger}</span>
|
variant="secondary"
|
||||||
{isAdmin && (
|
size="sm"
|
||||||
<IonChip color="primary">
|
>
|
||||||
Admin
|
<IonIcon icon={logOut} />
|
||||||
</IonChip>
|
</ActionButton>
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
<ActionButton
|
|
||||||
onClick={handleLogout}
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
Logout
|
|
||||||
</ActionButton>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</IonToolbar>
|
</IonToolbar>
|
||||||
</IonHeader>
|
</IonHeader>
|
||||||
|
|
||||||
|
|||||||
@ -7,28 +7,22 @@ interface InfiniteScrollListProps<T> {
|
|||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
onLoadMore: () => void;
|
onLoadMore: () => void;
|
||||||
renderItem: (item: T, index: number) => React.ReactNode;
|
renderItem: (item: T, index: number) => React.ReactNode;
|
||||||
title: string;
|
|
||||||
subtitle?: string;
|
|
||||||
emptyTitle: string;
|
emptyTitle: string;
|
||||||
emptyMessage: string;
|
emptyMessage: string;
|
||||||
loadingTitle?: string;
|
loadingTitle?: string;
|
||||||
loadingMessage?: string;
|
loadingMessage?: string;
|
||||||
debugInfo?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const InfiniteScrollList = <T extends { key?: string }>({
|
const InfiniteScrollList = <T extends string | { key?: string }>({
|
||||||
items,
|
items,
|
||||||
isLoading,
|
isLoading,
|
||||||
hasMore,
|
hasMore,
|
||||||
onLoadMore,
|
onLoadMore,
|
||||||
renderItem,
|
renderItem,
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
emptyTitle,
|
emptyTitle,
|
||||||
emptyMessage,
|
emptyMessage,
|
||||||
loadingTitle = "Loading...",
|
loadingTitle = "Loading...",
|
||||||
loadingMessage = "Please wait while data is being loaded",
|
loadingMessage = "Please wait while data is being loaded",
|
||||||
debugInfo,
|
|
||||||
}: InfiniteScrollListProps<T>) => {
|
}: InfiniteScrollListProps<T>) => {
|
||||||
const observerRef = useRef<HTMLDivElement>(null);
|
const observerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@ -59,22 +53,16 @@ const InfiniteScrollList = <T extends { key?: string }>({
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [onLoadMore, hasMore, isLoading, items.length]);
|
}, [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 (
|
return (
|
||||||
<div className="max-w-4xl mx-auto p-6">
|
<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 */}
|
{/* List */}
|
||||||
<div className="bg-white rounded-lg shadow">
|
<div className="bg-white rounded-lg shadow">
|
||||||
{items.length === 0 && !isLoading ? (
|
{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) => (
|
{items.map((item, index) => (
|
||||||
<div key={item.key}>
|
<div key={getItemKey(item, index)} className="px-4">
|
||||||
{renderItem(item, index)}
|
{renderItem(item, index)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
21
src/components/common/PageHeader.tsx
Normal file
21
src/components/common/PageHeader.tsx
Normal 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;
|
||||||
@ -1,6 +1,9 @@
|
|||||||
import React from 'react';
|
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 ActionButton from './ActionButton';
|
||||||
|
import { useAppSelector } from '../../redux';
|
||||||
|
import { selectQueue, selectFavorites } from '../../redux';
|
||||||
import type { SongItemProps } from '../../types';
|
import type { SongItemProps } from '../../types';
|
||||||
|
|
||||||
// Utility function to extract filename from path
|
// Utility function to extract filename from path
|
||||||
@ -23,127 +26,91 @@ const SongItem: React.FC<SongItemProps> = ({
|
|||||||
isAdmin = false,
|
isAdmin = false,
|
||||||
className = ''
|
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 = () => {
|
const renderActionPanel = () => {
|
||||||
switch (context) {
|
const buttons = [];
|
||||||
case 'search':
|
|
||||||
return (
|
// Add to Queue button (for all contexts except queue, only if not already in queue)
|
||||||
<div className="flex gap-2">
|
if (context !== 'queue' && !isInQueue) {
|
||||||
<ActionButton
|
buttons.push(
|
||||||
onClick={onAddToQueue || (() => {})}
|
<ActionButton
|
||||||
variant="primary"
|
key="add"
|
||||||
size="sm"
|
onClick={onAddToQueue || (() => {})}
|
||||||
>
|
variant="primary"
|
||||||
Add to Queue
|
size="sm"
|
||||||
</ActionButton>
|
>
|
||||||
<ActionButton
|
<IonIcon icon={add} />
|
||||||
onClick={onToggleFavorite || (() => {})}
|
</ActionButton>
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<IonItem className={className}>
|
<IonItem className={className}>
|
||||||
<IonLabel>
|
<IonLabel className="flex-1 min-w-0">
|
||||||
<h3 className="text-sm font-medium text-gray-900 truncate">
|
<h3 className="text-base font-extrabold text-gray-900 break-words">
|
||||||
{song.title}
|
{song.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-500 truncate">
|
<p className="text-sm italic text-gray-500 break-words">
|
||||||
{song.artist}
|
{song.artist}
|
||||||
</p>
|
</p>
|
||||||
{/* Show filename for all contexts except queue */}
|
{/* Show filename for all contexts except queue */}
|
||||||
{context !== 'queue' && song.path && (
|
{context !== 'queue' && song.path && (
|
||||||
<p className="text-xs text-gray-400 truncate">
|
<p className="text-xs text-gray-400 break-words">
|
||||||
{extractFilename(song.path)}
|
{extractFilename(song.path)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -154,7 +121,7 @@ const SongItem: React.FC<SongItemProps> = ({
|
|||||||
)}
|
)}
|
||||||
</IonLabel>
|
</IonLabel>
|
||||||
|
|
||||||
<div slot="end" className="flex gap-2">
|
<div slot="end" className="flex gap-2 flex-shrink-0 ml-2">
|
||||||
{renderActionPanel()}
|
{renderActionPanel()}
|
||||||
</div>
|
</div>
|
||||||
</IonItem>
|
</IonItem>
|
||||||
|
|||||||
@ -3,5 +3,6 @@ export { default as EmptyState } from './EmptyState';
|
|||||||
export { default as Toast } from './Toast';
|
export { default as Toast } from './Toast';
|
||||||
export { default as ErrorBoundary } from './ErrorBoundary';
|
export { default as ErrorBoundary } from './ErrorBoundary';
|
||||||
export { default as InfiniteScrollList } from './InfiniteScrollList';
|
export { default as InfiniteScrollList } from './InfiniteScrollList';
|
||||||
|
export { default as PageHeader } from './PageHeader';
|
||||||
export { default as SongItem } from './SongItem';
|
export { default as SongItem } from './SongItem';
|
||||||
export { default as PlayerControls } from './PlayerControls';
|
export { default as PlayerControls } from './PlayerControls';
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { IonSearchbar, IonList, IonItem, IonLabel, IonModal, IonHeader, IonToolbar, IonTitle, IonButton, IonIcon, IonChip } from '@ionic/react';
|
import { IonSearchbar, IonList, IonItem, IonLabel, IonModal, IonHeader, IonToolbar, IonTitle, IonButton, IonIcon } from '@ionic/react';
|
||||||
import { close, add, heart, heartOutline } from 'ionicons/icons';
|
import { close, add, heart, heartOutline, list } from 'ionicons/icons';
|
||||||
|
import { InfiniteScrollList, PageHeader } from '../../components/common';
|
||||||
import { useArtists } from '../../hooks';
|
import { useArtists } from '../../hooks';
|
||||||
import { useAppSelector } from '../../redux';
|
import { useAppSelector } from '../../redux';
|
||||||
import { selectSongs } from '../../redux';
|
import { selectSongs } from '../../redux';
|
||||||
@ -8,7 +9,6 @@ import { selectSongs } from '../../redux';
|
|||||||
const Artists: React.FC = () => {
|
const Artists: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
artists,
|
artists,
|
||||||
allArtists,
|
|
||||||
searchTerm,
|
searchTerm,
|
||||||
hasMore,
|
hasMore,
|
||||||
loadMore,
|
loadMore,
|
||||||
@ -22,40 +22,12 @@ const Artists: React.FC = () => {
|
|||||||
const songs = useAppSelector(selectSongs);
|
const songs = useAppSelector(selectSongs);
|
||||||
const songsCount = Object.keys(songs).length;
|
const songsCount = Object.keys(songs).length;
|
||||||
const [selectedArtist, setSelectedArtist] = useState<string | null>(null);
|
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
|
// Debug logging
|
||||||
useEffect(() => {
|
console.log('Artists component - artists count:', artists.length);
|
||||||
console.log('Artists component - artists count:', artists.length);
|
console.log('Artists component - selected artist:', selectedArtist);
|
||||||
console.log('Artists component - selected artist:', selectedArtist);
|
console.log('Artists component - songs count:', songsCount);
|
||||||
}, [artists.length, selectedArtist]);
|
console.log('Artists component - search term:', searchTerm);
|
||||||
|
|
||||||
const handleArtistClick = (artist: string) => {
|
const handleArtistClick = (artist: string) => {
|
||||||
setSelectedArtist(artist);
|
setSelectedArtist(artist);
|
||||||
@ -67,6 +39,21 @@ const Artists: React.FC = () => {
|
|||||||
|
|
||||||
const selectedArtistSongs = selectedArtist ? getSongsByArtist(selectedArtist) : [];
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<IonHeader>
|
<IonHeader>
|
||||||
@ -74,10 +61,14 @@ const Artists: React.FC = () => {
|
|||||||
<IonTitle>Artists</IonTitle>
|
<IonTitle>Artists</IonTitle>
|
||||||
</IonToolbar>
|
</IonToolbar>
|
||||||
</IonHeader>
|
</IonHeader>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
title="Artists"
|
||||||
|
subtitle="Browse songs by artist"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="max-w-4xl mx-auto p-6">
|
<div className="max-w-4xl mx-auto p-6">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Artists</h1>
|
|
||||||
|
|
||||||
{/* Search Input */}
|
{/* Search Input */}
|
||||||
<IonSearchbar
|
<IonSearchbar
|
||||||
placeholder="Search artists..."
|
placeholder="Search artists..."
|
||||||
@ -86,75 +77,20 @@ const Artists: React.FC = () => {
|
|||||||
debounce={300}
|
debounce={300}
|
||||||
showClearButton="focus"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Artists List */}
|
{/* Artists List */}
|
||||||
<div className="bg-white rounded-lg shadow">
|
<InfiniteScrollList<string>
|
||||||
{songsCount === 0 ? (
|
items={artists}
|
||||||
<div className="p-8 text-center">
|
isLoading={songsCount === 0}
|
||||||
<div className="text-gray-400 mb-4">
|
hasMore={hasMore}
|
||||||
<svg className="h-12 w-12 mx-auto animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
onLoadMore={loadMore}
|
||||||
<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" />
|
renderItem={renderArtistItem}
|
||||||
</svg>
|
emptyTitle={searchTerm ? "No artists found" : "No artists available"}
|
||||||
</div>
|
emptyMessage={searchTerm ? "Try adjusting your search terms" : "Artists will appear here once songs are loaded"}
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Loading artists...</h3>
|
loadingTitle="Loading artists..."
|
||||||
<p className="text-sm text-gray-500">Please wait while songs are being loaded from the database</p>
|
loadingMessage="Please wait while songs are being loaded from the database"
|
||||||
</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>
|
|
||||||
|
|
||||||
{/* Artist Songs Modal */}
|
{/* Artist Songs Modal */}
|
||||||
<IonModal isOpen={!!selectedArtist} onDidDismiss={handleCloseArtistSongs}>
|
<IonModal isOpen={!!selectedArtist} onDidDismiss={handleCloseArtistSongs}>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { IonHeader, IonToolbar, IonTitle, IonChip } from '@ionic/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 { useFavorites } from '../../hooks';
|
||||||
import { useAppSelector } from '../../redux';
|
import { useAppSelector } from '../../redux';
|
||||||
import { selectFavorites } from '../../redux';
|
import { selectFavorites } from '../../redux';
|
||||||
@ -35,6 +35,11 @@ const Favorites: React.FC = () => {
|
|||||||
</IonToolbar>
|
</IonToolbar>
|
||||||
</IonHeader>
|
</IonHeader>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
title="Favorites"
|
||||||
|
subtitle={`${favoritesCount} items loaded`}
|
||||||
|
/>
|
||||||
|
|
||||||
<InfiniteScrollList<Song>
|
<InfiniteScrollList<Song>
|
||||||
items={favoritesItems}
|
items={favoritesItems}
|
||||||
isLoading={favoritesCount === 0}
|
isLoading={favoritesCount === 0}
|
||||||
@ -48,13 +53,10 @@ const Favorites: React.FC = () => {
|
|||||||
onToggleFavorite={() => handleToggleFavorite(song)}
|
onToggleFavorite={() => handleToggleFavorite(song)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
title="Favorites"
|
|
||||||
subtitle={`${favoritesCount} items loaded`}
|
|
||||||
emptyTitle="No favorites yet"
|
emptyTitle="No favorites yet"
|
||||||
emptyMessage="Add songs to your favorites to see them here"
|
emptyMessage="Add songs to your favorites to see them here"
|
||||||
loadingTitle="Loading favorites..."
|
loadingTitle="Loading favorites..."
|
||||||
loadingMessage="Please wait while favorites data is being loaded"
|
loadingMessage="Please wait while favorites data is being loaded"
|
||||||
debugInfo={`Favorites items loaded: ${favoritesCount}`}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { IonHeader, IonToolbar, IonTitle, IonChip, IonIcon } from '@ionic/react';
|
import { IonHeader, IonToolbar, IonTitle, IonChip, IonIcon } from '@ionic/react';
|
||||||
import { time } from 'ionicons/icons';
|
import { time } from 'ionicons/icons';
|
||||||
import { InfiniteScrollList, SongItem } from '../../components/common';
|
import { InfiniteScrollList, PageHeader, SongItem } from '../../components/common';
|
||||||
import { useHistory } from '../../hooks';
|
import { useHistory } from '../../hooks';
|
||||||
import { useAppSelector } from '../../redux';
|
import { useAppSelector } from '../../redux';
|
||||||
import { selectHistory } from '../../redux';
|
import { selectHistory } from '../../redux';
|
||||||
@ -50,6 +50,11 @@ const History: React.FC = () => {
|
|||||||
</IonToolbar>
|
</IonToolbar>
|
||||||
</IonHeader>
|
</IonHeader>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
title="Recently Played"
|
||||||
|
subtitle={`${historyCount} items loaded`}
|
||||||
|
/>
|
||||||
|
|
||||||
<div style={{ height: '100%', overflowY: 'auto' }}>
|
<div style={{ height: '100%', overflowY: 'auto' }}>
|
||||||
<InfiniteScrollList<Song>
|
<InfiniteScrollList<Song>
|
||||||
items={historyItems}
|
items={historyItems}
|
||||||
@ -69,13 +74,10 @@ const History: React.FC = () => {
|
|||||||
{renderExtraContent(song)}
|
{renderExtraContent(song)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
title="Recently Played"
|
|
||||||
subtitle={`${historyCount} items loaded`}
|
|
||||||
emptyTitle="No history yet"
|
emptyTitle="No history yet"
|
||||||
emptyMessage="Songs will appear here after they've been played"
|
emptyMessage="Songs will appear here after they've been played"
|
||||||
loadingTitle="Loading history..."
|
loadingTitle="Loading history..."
|
||||||
loadingMessage="Please wait while history data is being loaded"
|
loadingMessage="Please wait while history data is being loaded"
|
||||||
debugInfo={`History items loaded: ${historyCount}`}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { IonHeader, IonToolbar, IonTitle, IonChip } from '@ionic/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 { useNewSongs } from '../../hooks';
|
||||||
import { useAppSelector } from '../../redux';
|
import { useAppSelector } from '../../redux';
|
||||||
import { selectNewSongs } from '../../redux';
|
import { selectNewSongs } from '../../redux';
|
||||||
@ -35,6 +35,11 @@ const NewSongs: React.FC = () => {
|
|||||||
</IonToolbar>
|
</IonToolbar>
|
||||||
</IonHeader>
|
</IonHeader>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
title="New Songs"
|
||||||
|
subtitle={`${newSongsCount} items loaded`}
|
||||||
|
/>
|
||||||
|
|
||||||
<InfiniteScrollList<Song>
|
<InfiniteScrollList<Song>
|
||||||
items={newSongsItems}
|
items={newSongsItems}
|
||||||
isLoading={newSongsCount === 0}
|
isLoading={newSongsCount === 0}
|
||||||
@ -48,13 +53,10 @@ const NewSongs: React.FC = () => {
|
|||||||
onToggleFavorite={() => handleToggleFavorite(song)}
|
onToggleFavorite={() => handleToggleFavorite(song)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
title="New Songs"
|
|
||||||
subtitle={`${newSongsCount} items loaded`}
|
|
||||||
emptyTitle="No new songs"
|
emptyTitle="No new songs"
|
||||||
emptyMessage="Check back later for new additions"
|
emptyMessage="Check back later for new additions"
|
||||||
loadingTitle="Loading new songs..."
|
loadingTitle="Loading new songs..."
|
||||||
loadingMessage="Please wait while new songs data is being loaded"
|
loadingMessage="Please wait while new songs data is being loaded"
|
||||||
debugInfo={`New songs loaded: ${newSongsCount}`}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -74,7 +74,7 @@ const Queue: React.FC = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<IonList>
|
<IonList className="px-4">
|
||||||
{queueItems.map((queueItem, index) => {
|
{queueItems.map((queueItem, index) => {
|
||||||
console.log(`Queue item ${index}: order=${queueItem.order}, key=${queueItem.key}`);
|
console.log(`Queue item ${index}: order=${queueItem.order}, key=${queueItem.key}`);
|
||||||
const canDelete = index === 0 ? canDeleteFirstItem : true;
|
const canDelete = index === 0 ? canDeleteFirstItem : true;
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { IonSearchbar } from '@ionic/react';
|
import { IonSearchbar } from '@ionic/react';
|
||||||
import { InfiniteScrollList, SongItem } from '../../components/common';
|
import { InfiniteScrollList, PageHeader, SongItem } from '../../components/common';
|
||||||
import { useSearch } from '../../hooks';
|
import { useSearch } from '../../hooks';
|
||||||
import { useAppSelector } from '../../redux';
|
import { useAppSelector } from '../../redux';
|
||||||
import { selectIsAdmin, selectSongs } from '../../redux';
|
import { selectIsAdmin, selectSongs } from '../../redux';
|
||||||
@ -37,9 +37,12 @@ const Search: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto p-6">
|
<div className="max-w-4xl mx-auto p-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Search Songs"
|
||||||
|
subtitle="Search by title or artist"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Search Songs</h1>
|
|
||||||
|
|
||||||
{/* Search Input */}
|
{/* Search Input */}
|
||||||
<IonSearchbar
|
<IonSearchbar
|
||||||
placeholder="Search by title or artist..."
|
placeholder="Search by title or artist..."
|
||||||
@ -70,12 +73,10 @@ const Search: React.FC = () => {
|
|||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
title=""
|
|
||||||
emptyTitle={searchTerm ? "No songs found" : "No songs available"}
|
emptyTitle={searchTerm ? "No songs found" : "No songs available"}
|
||||||
emptyMessage={searchTerm ? "Try adjusting your search terms" : "Songs will appear here once loaded"}
|
emptyMessage={searchTerm ? "Try adjusting your search terms" : "Songs will appear here once loaded"}
|
||||||
loadingTitle="Loading songs..."
|
loadingTitle="Loading songs..."
|
||||||
loadingMessage="Please wait while songs are being loaded from the database"
|
loadingMessage="Please wait while songs are being loaded from the database"
|
||||||
debugInfo=""
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Search Stats */}
|
{/* Search Stats */}
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { IonHeader, IonToolbar, IonTitle, IonChip } from '@ionic/react';
|
|||||||
import { useTopPlayed } from '../../hooks';
|
import { useTopPlayed } from '../../hooks';
|
||||||
import { useAppSelector } from '../../redux';
|
import { useAppSelector } from '../../redux';
|
||||||
import { selectTopPlayed } from '../../redux';
|
import { selectTopPlayed } from '../../redux';
|
||||||
import { InfiniteScrollList } from '../../components/common';
|
import { InfiniteScrollList, PageHeader } from '../../components/common';
|
||||||
import type { TopPlayed } from '../../types';
|
import type { TopPlayed } from '../../types';
|
||||||
|
|
||||||
const Top100: React.FC = () => {
|
const Top100: React.FC = () => {
|
||||||
@ -110,6 +110,11 @@ const Top100: React.FC = () => {
|
|||||||
</IonToolbar>
|
</IonToolbar>
|
||||||
</IonHeader>
|
</IonHeader>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
title="Top 100 Played"
|
||||||
|
subtitle={`${displayCount} items loaded (Mock Data)`}
|
||||||
|
/>
|
||||||
|
|
||||||
<InfiniteScrollList<TopPlayed>
|
<InfiniteScrollList<TopPlayed>
|
||||||
items={displayItems}
|
items={displayItems}
|
||||||
isLoading={false}
|
isLoading={false}
|
||||||
@ -133,11 +138,8 @@ const Top100: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
title="Top 100 Played"
|
|
||||||
subtitle={`${displayCount} items loaded (Mock Data)`}
|
|
||||||
emptyTitle="No top played songs"
|
emptyTitle="No top played songs"
|
||||||
emptyMessage="Play some songs to see the top played list"
|
emptyMessage="Play some songs to see the top played list"
|
||||||
debugInfo={`Top played items loaded: ${displayCount} (Mock Data)`}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user