Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
97c1b1e030
commit
3f70aed96a
34
src/App.css
34
src/App.css
@ -40,3 +40,37 @@
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* Override Ionic accordion separator styling with higher specificity */
|
||||
ion-accordion.accordion-no-full-separator {
|
||||
--border-style: none !important;
|
||||
}
|
||||
|
||||
ion-accordion.accordion-no-full-separator::part(header) {
|
||||
--border-style: none !important;
|
||||
}
|
||||
|
||||
ion-accordion.accordion-no-full-separator ion-item.item-no-full-separator {
|
||||
--border-style: solid !important;
|
||||
--border-width: 0 0 1px 0 !important;
|
||||
--border-color: var(--ion-item-border-color, rgba(0, 0, 0, 0.13)) !important;
|
||||
}
|
||||
|
||||
ion-accordion.accordion-no-full-separator ion-item.item-no-full-separator::part(native) {
|
||||
--border-style: solid !important;
|
||||
--border-width: 0 0 1px 0 !important;
|
||||
--border-color: var(--ion-item-border-color, rgba(0, 0, 0, 0.13)) !important;
|
||||
}
|
||||
|
||||
/* Alternative approach: Target all accordion items globally */
|
||||
ion-accordion ion-item {
|
||||
--border-style: solid !important;
|
||||
--border-width: 0 0 1px 0 !important;
|
||||
--border-color: var(--ion-item-border-color, rgba(0, 0, 0, 0.13)) !important;
|
||||
}
|
||||
|
||||
ion-accordion ion-item::part(native) {
|
||||
--border-style: solid !important;
|
||||
--border-width: 0 0 1px 0 !important;
|
||||
--border-color: var(--ion-item-border-color, rgba(0, 0, 0, 0.13)) !important;
|
||||
}
|
||||
|
||||
@ -114,7 +114,7 @@ const InfiniteScrollList = <T extends string | { key?: string }>({
|
||||
|
||||
{/* Stats */}
|
||||
{items.length > 0 && (
|
||||
<div className="mt-4 text-sm text-gray-500 text-center">
|
||||
<div style={{ marginTop: '16px', marginBottom: '24px' }} className="text-sm text-gray-500 text-center">
|
||||
Showing {items.length} item{items.length !== 1 ? 's' : ''}
|
||||
{hasMore && ` • Scroll down to load more`}
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { IonSearchbar, IonList, IonItem, IonLabel, IonModal, IonHeader, IonToolbar, IonTitle, IonButton, IonIcon } from '@ionic/react';
|
||||
import { IonSearchbar, IonList, IonItem, IonLabel, IonModal, IonHeader, IonToolbar, IonTitle, IonButton, IonIcon, IonContent } from '@ionic/react';
|
||||
import { close, add, heart, heartOutline, list } from 'ionicons/icons';
|
||||
import { InfiniteScrollList, PageHeader } from '../../components/common';
|
||||
import { useArtists } from '../../hooks';
|
||||
@ -56,15 +56,8 @@ const Artists: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<IonHeader>
|
||||
<IonToolbar>
|
||||
<IonTitle>Artists</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<PageHeader
|
||||
title="Artists"
|
||||
subtitle="Browse songs by artist"
|
||||
/>
|
||||
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
@ -103,7 +96,7 @@ const Artists: React.FC = () => {
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<div className="p-4">
|
||||
<IonContent>
|
||||
<IonList>
|
||||
{selectedArtistSongs.map((song) => (
|
||||
<IonItem key={song.key}>
|
||||
@ -134,7 +127,7 @@ const Artists: React.FC = () => {
|
||||
</IonItem>
|
||||
))}
|
||||
</IonList>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonModal>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import { IonHeader, IonToolbar, IonTitle, IonChip } from '@ionic/react';
|
||||
import { InfiniteScrollList, PageHeader, SongItem } from '../../components/common';
|
||||
import { useFavorites } from '../../hooks';
|
||||
import { useAppSelector } from '../../redux';
|
||||
@ -24,17 +23,6 @@ const Favorites: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<IonHeader>
|
||||
<IonToolbar>
|
||||
<IonTitle>
|
||||
Favorites
|
||||
<IonChip color="primary" className="ml-2">
|
||||
{favoritesItems.length}
|
||||
</IonChip>
|
||||
</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<PageHeader
|
||||
title="Favorites"
|
||||
subtitle={`${favoritesCount} items loaded`}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { IonHeader, IonToolbar, IonTitle, IonChip, IonIcon } from '@ionic/react';
|
||||
import { IonChip, IonIcon } from '@ionic/react';
|
||||
import { time } from 'ionicons/icons';
|
||||
import { InfiniteScrollList, PageHeader, SongItem } from '../../components/common';
|
||||
import { useHistory } from '../../hooks';
|
||||
@ -39,17 +39,6 @@ const History: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<IonHeader>
|
||||
<IonToolbar>
|
||||
<IonTitle>
|
||||
Recently Played
|
||||
<IonChip color="primary" className="ml-2">
|
||||
{historyItems.length}
|
||||
</IonChip>
|
||||
</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<PageHeader
|
||||
title="Recently Played"
|
||||
subtitle={`${historyCount} items loaded`}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import { IonHeader, IonToolbar, IonTitle, IonChip } from '@ionic/react';
|
||||
import { InfiniteScrollList, PageHeader, SongItem } from '../../components/common';
|
||||
import { useNewSongs } from '../../hooks';
|
||||
import { useAppSelector } from '../../redux';
|
||||
@ -24,17 +23,6 @@ const NewSongs: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<IonHeader>
|
||||
<IonToolbar>
|
||||
<IonTitle>
|
||||
New Songs
|
||||
<IonChip color="primary" className="ml-2">
|
||||
{newSongsItems.length}
|
||||
</IonChip>
|
||||
</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<PageHeader
|
||||
title="New Songs"
|
||||
subtitle={`${newSongsCount} items loaded`}
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import React from 'react';
|
||||
import { IonList, IonItem, IonItemSliding, IonItemOptions, IonItemOption, IonIcon, IonLabel, IonChip } from '@ionic/react';
|
||||
import { IonItem, IonItemSliding, IonItemOptions, IonItemOption, IonIcon, IonLabel, IonChip } from '@ionic/react';
|
||||
import { trash, arrowUp, arrowDown } from 'ionicons/icons';
|
||||
import { EmptyState, ActionButton, PlayerControls } from '../../components/common';
|
||||
import { ActionButton, PlayerControls, InfiniteScrollList, PageHeader } from '../../components/common';
|
||||
import { useQueue } from '../../hooks';
|
||||
import { useAppSelector } from '../../redux';
|
||||
import { selectQueue, selectPlayerState } from '../../redux';
|
||||
import { PlayerState } from '../../types';
|
||||
import type { QueueItem } from '../../types';
|
||||
|
||||
const Queue: React.FC = () => {
|
||||
const {
|
||||
@ -32,124 +33,101 @@ const Queue: React.FC = () => {
|
||||
console.log('Queue component - canDeleteFirstItem:', canDeleteFirstItem);
|
||||
console.log('Queue component - canReorder:', canReorder);
|
||||
|
||||
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">Queue</h1>
|
||||
<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>
|
||||
// Render queue item for InfiniteScrollList
|
||||
const renderQueueItem = (queueItem: QueueItem, index: number) => {
|
||||
console.log(`Queue item ${index}: order=${queueItem.order}, key=${queueItem.key}`);
|
||||
const canDelete = index === 0 ? canDeleteFirstItem : true;
|
||||
|
||||
return (
|
||||
<IonItemSliding key={queueItem.key}>
|
||||
<IonItem>
|
||||
{/* Order Number */}
|
||||
<div slot="start" className="flex-shrink-0 w-12 h-12 flex items-center justify-center bg-gray-100 text-gray-600 font-medium rounded-full">
|
||||
{queueItem.order}
|
||||
</div>
|
||||
|
||||
{/* Player Controls - Only visible to admin users */}
|
||||
<div className="mb-6">
|
||||
<PlayerControls />
|
||||
</div>
|
||||
{/* Song Info */}
|
||||
<IonLabel>
|
||||
<h3 className="text-sm font-medium text-gray-900 truncate">
|
||||
{queueItem.song.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 truncate">
|
||||
{queueItem.song.artist}
|
||||
</p>
|
||||
</IonLabel>
|
||||
|
||||
{/* Queue List */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
{queueCount === 0 ? (
|
||||
<EmptyState
|
||||
title="Queue is empty"
|
||||
message="Add songs from search, history, or favorites to get started"
|
||||
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>
|
||||
}
|
||||
/>
|
||||
) : 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>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<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;
|
||||
|
||||
return (
|
||||
<IonItemSliding key={queueItem.key}>
|
||||
<IonItem>
|
||||
{/* Order Number */}
|
||||
<div slot="start" className="flex-shrink-0 w-12 h-12 flex items-center justify-center bg-gray-100 text-gray-600 font-medium rounded-full">
|
||||
{queueItem.order}
|
||||
</div>
|
||||
{/* Singer Pill - Moved to far right */}
|
||||
<IonChip slot="end" color="medium">
|
||||
{queueItem.singer.name}
|
||||
</IonChip>
|
||||
|
||||
{/* Song Info */}
|
||||
<IonLabel>
|
||||
<h3 className="text-sm font-medium text-gray-900 truncate">
|
||||
{queueItem.song.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 truncate">
|
||||
{queueItem.song.artist}
|
||||
</p>
|
||||
<div className="flex items-center mt-1">
|
||||
<IonChip color="medium">
|
||||
{queueItem.singer.name}
|
||||
</IonChip>
|
||||
{queueItem.isCurrentUser && (
|
||||
<IonChip color="primary">
|
||||
You
|
||||
</IonChip>
|
||||
)}
|
||||
</div>
|
||||
</IonLabel>
|
||||
{/* Admin Controls */}
|
||||
{canReorder && (
|
||||
<div slot="end" className="flex flex-col gap-1 ml-2">
|
||||
{queueItem.order > 2 && (
|
||||
<ActionButton
|
||||
onClick={() => handleMoveUp(queueItem)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<IonIcon icon={arrowUp} />
|
||||
</ActionButton>
|
||||
)}
|
||||
{queueItem.order > 1 && queueItem.order < queueItems.length && (
|
||||
<ActionButton
|
||||
onClick={() => handleMoveDown(queueItem)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<IonIcon icon={arrowDown} />
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</IonItem>
|
||||
|
||||
{/* Admin Controls */}
|
||||
{canReorder && (
|
||||
<div slot="end" className="flex flex-col gap-1">
|
||||
{queueItem.order > 2 && (
|
||||
<ActionButton
|
||||
onClick={() => handleMoveUp(queueItem)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<IonIcon icon={arrowUp} />
|
||||
</ActionButton>
|
||||
)}
|
||||
{queueItem.order > 1 && queueItem.order < queueItems.length && (
|
||||
<ActionButton
|
||||
onClick={() => handleMoveDown(queueItem)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<IonIcon icon={arrowDown} />
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</IonItem>
|
||||
|
||||
{/* Swipe Actions */}
|
||||
{canDelete && (
|
||||
<IonItemOptions side="end">
|
||||
<IonItemOption
|
||||
color="danger"
|
||||
onClick={() => handleRemoveFromQueue(queueItem)}
|
||||
>
|
||||
<IonIcon icon={trash} slot="icon-only" />
|
||||
</IonItemOption>
|
||||
</IonItemOptions>
|
||||
)}
|
||||
</IonItemSliding>
|
||||
);
|
||||
})}
|
||||
</IonList>
|
||||
{/* Swipe Actions */}
|
||||
{canDelete && (
|
||||
<IonItemOptions side="end">
|
||||
<IonItemOption
|
||||
color="danger"
|
||||
onClick={() => handleRemoveFromQueue(queueItem)}
|
||||
>
|
||||
<IonIcon icon={trash} slot="icon-only" />
|
||||
</IonItemOption>
|
||||
</IonItemOptions>
|
||||
)}
|
||||
</IonItemSliding>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Queue"
|
||||
subtitle={`${queueStats.totalSongs} song${queueStats.totalSongs !== 1 ? 's' : ''} in queue`}
|
||||
/>
|
||||
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
{/* Player Controls - Only visible to admin users */}
|
||||
<div className="mb-6">
|
||||
<PlayerControls />
|
||||
</div>
|
||||
|
||||
{/* Queue List */}
|
||||
<InfiniteScrollList<QueueItem>
|
||||
items={queueItems}
|
||||
isLoading={queueCount === 0}
|
||||
hasMore={false}
|
||||
onLoadMore={() => {}}
|
||||
renderItem={renderQueueItem}
|
||||
emptyTitle="Queue is empty"
|
||||
emptyMessage="Add songs from search, history, or favorites to get started"
|
||||
loadingTitle="Loading queue..."
|
||||
loadingMessage="Please wait while queue data is being loaded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -34,12 +34,13 @@ const Search: React.FC = () => {
|
||||
// Debug logging
|
||||
console.log('Search component - songs count:', songsCount);
|
||||
console.log('Search component - search results:', searchResults);
|
||||
console.log('Search component - search term:', searchTerm);
|
||||
console.log('Search component - showing:', searchResults.songs.length, 'of', searchResults.count);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<PageHeader
|
||||
title="Search Songs"
|
||||
subtitle="Search by title or artist"
|
||||
/>
|
||||
|
||||
<div className="mb-6">
|
||||
@ -51,11 +52,6 @@ const Search: React.FC = () => {
|
||||
debounce={300}
|
||||
showClearButton="focus"
|
||||
/>
|
||||
|
||||
{/* 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 */}
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { IonHeader, IonToolbar, IonTitle, IonList, IonItem, IonLabel, IonModal, IonButton, IonIcon, IonChip, IonAccordion, IonAccordionGroup } from '@ionic/react';
|
||||
import { close, documentText, add, heart, heartOutline } from 'ionicons/icons';
|
||||
import React, { useState } from 'react';
|
||||
import { IonItem, IonLabel, IonModal, IonHeader, IonToolbar, IonTitle, IonButton, IonIcon, IonChip, IonContent, IonList, IonAccordionGroup, IonAccordion } from '@ionic/react';
|
||||
import { close, list } from 'ionicons/icons';
|
||||
import { InfiniteScrollList, PageHeader, SongItem } from '../../components/common';
|
||||
import { useSongLists } from '../../hooks';
|
||||
import { useAppSelector } from '../../redux';
|
||||
import { selectSongList } from '../../redux';
|
||||
import type { SongListSong, Song } from '../../types';
|
||||
import type { SongListSong, SongList, Song } from '../../types';
|
||||
|
||||
const SongLists: React.FC = () => {
|
||||
const {
|
||||
@ -19,237 +20,163 @@ const SongLists: React.FC = () => {
|
||||
|
||||
const songListData = useAppSelector(selectSongList);
|
||||
const songListCount = Object.keys(songListData).length;
|
||||
const observerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Intersection Observer for infinite scrolling
|
||||
useEffect(() => {
|
||||
console.log('SongLists - Setting up observer:', { hasMore, songListCount, itemsLength: songLists.length });
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
console.log('SongLists - Intersection detected:', {
|
||||
isIntersecting: entries[0].isIntersecting,
|
||||
hasMore,
|
||||
songListCount
|
||||
});
|
||||
|
||||
if (entries[0].isIntersecting && hasMore && songListCount > 0) {
|
||||
console.log('SongLists - Loading more items');
|
||||
loadMore();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
if (observerRef.current) {
|
||||
observer.observe(observerRef.current);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [loadMore, hasMore, songListCount]);
|
||||
const [selectedSongList, setSelectedSongList] = useState<string | null>(null);
|
||||
const [expandedSongKey, setExpandedSongKey] = useState<string | null>(null);
|
||||
|
||||
|
||||
// Debug logging - only log when data changes
|
||||
useEffect(() => {
|
||||
console.log('SongLists component - songList count:', songListCount);
|
||||
console.log('SongLists component - songLists:', songLists);
|
||||
}, [songListCount, songLists.length]);
|
||||
|
||||
const handleSongListClick = (songListKey: string) => {
|
||||
console.log('SongLists - handleSongListClick called with key:', songListKey);
|
||||
setSelectedSongList(songListKey);
|
||||
setExpandedSongKey(null); // Reset expansion when opening a new song list
|
||||
};
|
||||
|
||||
const handleCloseSongList = () => {
|
||||
setSelectedSongList(null);
|
||||
setExpandedSongKey(null); // Reset expansion when closing
|
||||
};
|
||||
|
||||
const handleSongItemClick = (songKey: string) => {
|
||||
setExpandedSongKey(expandedSongKey === songKey ? null : songKey);
|
||||
};
|
||||
|
||||
const finalSelectedList = selectedSongList
|
||||
? allSongLists.find(list => list.key === selectedSongList)
|
||||
: null;
|
||||
|
||||
// Debug logging for modal
|
||||
useEffect(() => {
|
||||
console.log('SongLists - Modal state check:', {
|
||||
selectedSongList,
|
||||
finalSelectedList: !!finalSelectedList,
|
||||
songListsLength: songLists.length
|
||||
});
|
||||
if (selectedSongList) {
|
||||
console.log('SongLists - Modal opened for song list:', selectedSongList);
|
||||
console.log('SongLists - Selected list data:', finalSelectedList);
|
||||
console.log('SongLists - About to render modal, finalSelectedList:', !!finalSelectedList);
|
||||
}
|
||||
}, [selectedSongList, finalSelectedList, songLists.length]);
|
||||
|
||||
// Render song list item for InfiniteScrollList
|
||||
const renderSongListItem = (songList: SongList) => (
|
||||
<IonItem button onClick={() => handleSongListClick(songList.key!)} detail={false}>
|
||||
<IonLabel>
|
||||
<h3 className="text-sm font-medium text-gray-900">
|
||||
{songList.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{songList.songs.length} song{songList.songs.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</IonLabel>
|
||||
<IonIcon icon={list} slot="end" color="primary" />
|
||||
</IonItem>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IonHeader>
|
||||
<IonToolbar>
|
||||
<IonTitle>
|
||||
Song Lists
|
||||
<IonChip color="primary" className="ml-2">
|
||||
{songLists.length}
|
||||
</IonChip>
|
||||
</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<PageHeader
|
||||
title="Song Lists"
|
||||
subtitle={`${songListCount} items loaded`}
|
||||
/>
|
||||
|
||||
<div className="p-4">
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
{songLists.length} song list{songLists.length !== 1 ? 's' : ''} available
|
||||
</p>
|
||||
|
||||
{/* Debug info */}
|
||||
<div className="mb-4 text-sm text-gray-500">
|
||||
Song lists loaded: {songListCount}
|
||||
</div>
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<InfiniteScrollList<SongList>
|
||||
items={songLists}
|
||||
isLoading={songListCount === 0}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={loadMore}
|
||||
renderItem={renderSongListItem}
|
||||
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"
|
||||
/>
|
||||
|
||||
{/* Song Lists */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
{songListCount === 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 song lists...</h3>
|
||||
<p className="text-sm text-gray-500">Please wait while song lists are being loaded</p>
|
||||
</div>
|
||||
) : songLists.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">No song lists available</h3>
|
||||
<p className="text-sm text-gray-500">Song lists will appear here when they're available</p>
|
||||
</div>
|
||||
) : (
|
||||
<IonList>
|
||||
{songLists.map((songList) => (
|
||||
<IonItem key={songList.key} button onClick={() => handleSongListClick(songList.key!)}>
|
||||
<IonIcon icon={documentText} slot="start" color="primary" />
|
||||
<IonLabel>
|
||||
<h3 className="text-sm font-medium text-gray-900">
|
||||
{songList.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{songList.songs.length} song{songList.songs.length !== 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 song lists...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</IonList>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Song List Modal */}
|
||||
<IonModal
|
||||
isOpen={!!finalSelectedList}
|
||||
onDidDismiss={handleCloseSongList}
|
||||
>
|
||||
<IonHeader>
|
||||
<IonToolbar>
|
||||
<IonTitle>{finalSelectedList?.title}</IonTitle>
|
||||
<IonButton slot="end" fill="clear" onClick={handleCloseSongList}>
|
||||
<IonIcon icon={close} />
|
||||
</IonButton>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent>
|
||||
<IonAccordionGroup value={expandedSongKey}>
|
||||
{finalSelectedList?.songs.map((songListSong: SongListSong, index) => {
|
||||
const availableSongs = checkSongAvailability(songListSong);
|
||||
const isAvailable = availableSongs.length > 0;
|
||||
const songKey = songListSong.key || `${songListSong.title}-${songListSong.position}-${index}`;
|
||||
|
||||
{/* Song List Modal */}
|
||||
<IonModal isOpen={!!finalSelectedList} onDidDismiss={handleCloseSongList}>
|
||||
<IonHeader>
|
||||
<IonToolbar>
|
||||
<IonTitle>{finalSelectedList?.title}</IonTitle>
|
||||
<IonButton slot="end" fill="clear" onClick={handleCloseSongList}>
|
||||
<IonIcon icon={close} />
|
||||
</IonButton>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
{/* Remove IonContent, use a div instead */}
|
||||
<div>
|
||||
<IonAccordionGroup>
|
||||
{finalSelectedList?.songs.map((songListSong: SongListSong, idx) => {
|
||||
const availableSongs = checkSongAvailability(songListSong);
|
||||
const isAvailable = availableSongs.length > 0;
|
||||
if (isAvailable) {
|
||||
// Available songs get an accordion that expands
|
||||
return (
|
||||
<IonAccordion key={songKey} value={songKey} style={{ '--border-style': 'none' } as React.CSSProperties}>
|
||||
<IonItem
|
||||
slot="header"
|
||||
detail={false}
|
||||
button
|
||||
onClick={() => handleSongItemClick(songKey)}
|
||||
style={{
|
||||
'--border-style': 'solid',
|
||||
'--border-width': '0 0 1px 0',
|
||||
'--border-color': 'rgba(0, 0, 0, 0.13)'
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
{/* Number */}
|
||||
<div slot="start" className="flex-shrink-0 w-12 h-12 flex items-center justify-center text-gray-600 font-medium">
|
||||
{index + 1})
|
||||
</div>
|
||||
|
||||
return (
|
||||
<IonAccordion key={songListSong.key || `${songListSong.title}-${songListSong.position}-${idx}`} value={songListSong.key}>
|
||||
<IonItem slot="header" className={!isAvailable ? 'opacity-50' : ''}>
|
||||
<IonLabel>
|
||||
<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>
|
||||
)}
|
||||
</IonLabel>
|
||||
{isAvailable && (
|
||||
<IonChip slot="end" color="success">
|
||||
{availableSongs.length} version{availableSongs.length !== 1 ? 's' : ''}
|
||||
</IonChip>
|
||||
)}
|
||||
</IonItem>
|
||||
<IonLabel>
|
||||
<h3 className="text-sm font-medium text-gray-900">
|
||||
{songListSong.artist}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{songListSong.title}
|
||||
</p>
|
||||
</IonLabel>
|
||||
|
||||
<div slot="content">
|
||||
{isAvailable ? (
|
||||
<IonList>
|
||||
{availableSongs.map((song: Song, sidx) => (
|
||||
<IonItem key={song.key || `${song.title}-${song.artist}-${sidx}`}>
|
||||
<IonLabel>
|
||||
<h3 className="text-sm font-medium text-gray-900">
|
||||
{song.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{song.artist}
|
||||
</p>
|
||||
</IonLabel>
|
||||
<div slot="end" className="flex gap-2">
|
||||
<IonButton
|
||||
fill="clear"
|
||||
size="small"
|
||||
onClick={() => handleAddToQueue(song)}
|
||||
>
|
||||
<IonIcon icon={add} slot="icon-only" />
|
||||
</IonButton>
|
||||
<IonButton
|
||||
fill="clear"
|
||||
size="small"
|
||||
onClick={() => handleToggleFavorite(song)}
|
||||
>
|
||||
<IonIcon icon={song.favorite ? heart : heartOutline} slot="icon-only" />
|
||||
</IonButton>
|
||||
</div>
|
||||
</IonItem>
|
||||
))}
|
||||
</IonList>
|
||||
) : (
|
||||
<div className="p-4 text-center text-gray-500">
|
||||
No matching songs found in catalog
|
||||
<IonChip slot="end" color="success">
|
||||
{availableSongs.length} version{availableSongs.length !== 1 ? 's' : ''}
|
||||
</IonChip>
|
||||
</IonItem>
|
||||
|
||||
<div slot="content" className="bg-gray-50 border-l-4 border-primary">
|
||||
<IonList>
|
||||
{availableSongs.map((song: Song, sidx) => (
|
||||
<SongItem
|
||||
key={song.key || `${song.title}-${song.artist}-${sidx}`}
|
||||
song={song}
|
||||
context="search"
|
||||
onAddToQueue={() => handleAddToQueue(song)}
|
||||
onToggleFavorite={() => handleToggleFavorite(song)}
|
||||
/>
|
||||
))}
|
||||
</IonList>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</IonAccordion>
|
||||
);
|
||||
})}
|
||||
</IonAccordionGroup>
|
||||
</div>
|
||||
</IonModal>
|
||||
</IonAccordion>
|
||||
);
|
||||
} else {
|
||||
// Unavailable songs get a simple item
|
||||
return (
|
||||
<IonItem
|
||||
key={songKey}
|
||||
detail={false}
|
||||
className="opacity-50"
|
||||
>
|
||||
{/* Number */}
|
||||
<div slot="start" className="flex-shrink-0 w-12 h-12 flex items-center justify-center text-gray-600 font-medium">
|
||||
{index + 1})
|
||||
</div>
|
||||
|
||||
<IonLabel>
|
||||
<h3 className="text-sm font-medium text-gray-400">
|
||||
{songListSong.artist}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-300">
|
||||
{songListSong.title}
|
||||
</p>
|
||||
</IonLabel>
|
||||
</IonItem>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</IonAccordionGroup>
|
||||
</IonContent>
|
||||
</IonModal>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import { IonHeader, IonToolbar, IonTitle, IonChip } from '@ionic/react';
|
||||
import { useTopPlayed } from '../../hooks';
|
||||
import { useAppSelector } from '../../redux';
|
||||
import { selectTopPlayed } from '../../redux';
|
||||
@ -99,17 +98,6 @@ const Top100: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<IonHeader>
|
||||
<IonToolbar>
|
||||
<IonTitle>
|
||||
Top 100 Played
|
||||
<IonChip color="primary" className="ml-2">
|
||||
{displayItems.length}
|
||||
</IonChip>
|
||||
</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<PageHeader
|
||||
title="Top 100 Played"
|
||||
subtitle={`${displayCount} items loaded (Mock Data)`}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user