diff --git a/src/components/common/SelectSinger.tsx b/src/components/common/SelectSinger.tsx new file mode 100644 index 0000000..53bbd7e --- /dev/null +++ b/src/components/common/SelectSinger.tsx @@ -0,0 +1,122 @@ +import React, { useState } from 'react'; +import { + IonModal, + IonHeader, + IonToolbar, + IonTitle, + IonContent, + IonList, + IonItem, + IonLabel, + IonButton, + IonIcon +} from '@ionic/react'; +import { close } from 'ionicons/icons'; +import { useAppSelector } from '../../redux'; +import { selectSingersArray, selectControllerName, selectQueueObject } from '../../redux'; +import { queueService } from '../../firebase/services'; +import { useToast } from '../../hooks/useToast'; +import type { Song, Singer, QueueItem } from '../../types'; + +interface SelectSingerProps { + isOpen: boolean; + onClose: () => void; + song: Song; +} + +const SelectSinger: React.FC = ({ isOpen, onClose, song }) => { + const singers = useAppSelector(selectSingersArray); + const controllerName = useAppSelector(selectControllerName); + const currentQueue = useAppSelector(selectQueueObject); + const { showSuccess, showError } = useToast(); + const [isLoading, setIsLoading] = useState(false); + + const handleSelectSinger = async (singer: Singer) => { + if (!controllerName) { + showError('Controller not found'); + return; + } + + setIsLoading(true); + try { + // Calculate the next order by finding the highest order value and adding 1 + const queueItems = Object.values(currentQueue) as QueueItem[]; + const maxOrder = queueItems.length > 0 + ? Math.max(...queueItems.map(item => item.order || 0)) + : 0; + const nextOrder = maxOrder + 1; + + const queueItem: Omit = { + order: nextOrder, + singer: { + name: singer.name, + lastLogin: singer.lastLogin, + }, + song: song, + }; + + await queueService.addToQueue(controllerName, queueItem); + showSuccess(`${song.title} added to queue for ${singer.name}`); + onClose(); + } catch (error) { + console.error('Failed to add song to queue:', error); + showError('Failed to add song to queue'); + } finally { + setIsLoading(false); + } + }; + + return ( + + + + Select Singer + + + + + + + + {/* Song Information */} +
+

{song.title}

+

{song.artist}

+

{song.path}

+
+ + {/* Singers List */} + + {singers.map((singer) => ( + handleSelectSinger(singer)} + disabled={isLoading} + > + +

{singer.name}

+

+ Last login: {new Date(singer.lastLogin).toLocaleDateString()} +

+
+
+ ))} +
+ + {singers.length === 0 && ( +
+ No singers available +
+ )} +
+
+ ); +}; + +export default SelectSinger; \ No newline at end of file diff --git a/src/components/common/SongInfo.tsx b/src/components/common/SongInfo.tsx new file mode 100644 index 0000000..804796f --- /dev/null +++ b/src/components/common/SongInfo.tsx @@ -0,0 +1,218 @@ +import React, { useState } from 'react'; +import { + IonModal, IonHeader, IonToolbar, IonTitle, IonContent, + IonButton, IonIcon, IonList, IonItem, IonLabel +} from '@ionic/react'; +import { + add, heart, heartOutline, ban, checkmark, close, people +} from 'ionicons/icons'; +import { useAppSelector } from '../../redux'; +import { selectIsAdmin, selectFavorites, selectSongs } from '../../redux'; +import { useSongOperations } from '../../hooks/useSongOperations'; +import { useDisabledSongs } from '../../hooks/useDisabledSongs'; +import { useSelectSinger } from '../../hooks/useSelectSinger'; +import { useToast } from '../../hooks/useToast'; +import SelectSinger from './SelectSinger'; +import SongItem from './SongItem'; +import type { Song } from '../../types'; + +interface SongInfoProps { + isOpen: boolean; + onClose: () => void; + song: Song; +} + +const SongInfo: React.FC = ({ isOpen, onClose, song }) => { + const isAdmin = useAppSelector(selectIsAdmin); + const favorites = useAppSelector(selectFavorites); + const allSongs = useAppSelector(selectSongs); + const { toggleFavorite } = useSongOperations(); + const { isSongDisabled, addDisabledSong, removeDisabledSong } = useDisabledSongs(); + const { showSuccess, showError } = useToast(); + + const { + isOpen: isSelectSingerOpen, + selectedSong: selectSingerSong, + openSelectSinger, + closeSelectSinger + } = useSelectSinger(); + const [showArtistSongs, setShowArtistSongs] = useState(false); + + const isInFavorites = (Object.values(favorites) as Song[]).some(favSong => favSong.path === song.path); + const isDisabled = isSongDisabled(song); + + const artistSongs = (Object.values(allSongs) as Song[]).filter(s => + s.artist.toLowerCase() === song.artist.toLowerCase() && s.path !== song.path + ); + + const handleQueueSong = () => { + openSelectSinger(song); + }; + + const handleArtistSongs = () => { + setShowArtistSongs(true); + }; + + const handleToggleFavorite = async () => { + try { + await toggleFavorite(song); + showSuccess(isInFavorites ? 'Removed from favorites' : 'Added to favorites'); + } catch { + showError('Failed to update favorites'); + } + }; + + const handleToggleDisabled = async () => { + try { + if (isDisabled) { + await removeDisabledSong(song); + showSuccess('Song enabled'); + } else { + await addDisabledSong(song); + showSuccess('Song disabled'); + } + } catch { + showError('Failed to update song status'); + } + }; + + return ( + <> + {/* Main Song Info Modal */} + + + + Song Info + + + + + + + +
+ {/* Song Information using SongItem component */} +
+ +
+ + {/* Action Buttons */} +
+ {/* Queue Song Button */} + + + Queue Song + + + {/* Artist Songs Button */} + + + Artist Songs + + + {/* Favorite/Unfavorite Button */} + + + {isInFavorites ? 'Unfavorite Song' : 'Favorite Song'} + + + {/* Disable/Enable Button (Admin Only) */} + {isAdmin && ( + + + {isDisabled ? 'Enable Song' : 'Disable Song'} + + )} +
+
+
+
+ + {/* Select Singer Modal */} + {selectSingerSong && ( + + )} + + {/* Artist Songs Modal */} + setShowArtistSongs(false)} + breakpoints={[0, 0.5, 0.8]} + initialBreakpoint={0.8} + > + + + Songs by {song.artist} + setShowArtistSongs(false)}> + + + + + + +
+ {artistSongs.length > 0 ? ( + + {artistSongs.map((artistSong) => ( + + +
{artistSong.title}
+
{artistSong.path}
+
+
+ ))} +
+ ) : ( +
+ No other songs found by this artist +
+ )} +
+
+
+ + ); +}; + +export default SongInfo; \ No newline at end of file diff --git a/src/components/common/SongItem.tsx b/src/components/common/SongItem.tsx index 991c267..2a3ce19 100644 --- a/src/components/common/SongItem.tsx +++ b/src/components/common/SongItem.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { IonItem, IonLabel, IonIcon } from '@ionic/react'; -import { add, heart, heartOutline, trash } from 'ionicons/icons'; +import { add, heart, heartOutline, trash, informationCircle } from 'ionicons/icons'; import ActionButton from './ActionButton'; import { useAppSelector } from '../../redux'; import { selectQueue, selectFavorites } from '../../redux'; @@ -16,6 +16,176 @@ const extractFilename = (path: string): string => { return parts[parts.length - 1] || ''; }; +// Song Information Display Component +export const SongInfoDisplay: React.FC<{ + song: Song; + showPath?: boolean; + showCount?: boolean; +}> = ({ + song, + showPath = false, + showCount = false +}) => { + return ( + +
+ {song.title} +
+
+ {song.artist} +
+ {/* Show filename if showPath is true */} + {showPath && song.path && ( +
+ {extractFilename(song.path)} +
+ )} + {/* Show play count if showCount is true */} + {showCount && song.count && ( +
+ Played {song.count} times +
+ )} +
+ ); +}; + +// Action Buttons Component +export const SongActionButtons: React.FC<{ + isAdmin: boolean; + isInQueue: boolean; + isInFavorites: boolean; + showInfoButton?: boolean; + showAddButton?: boolean; + showRemoveButton?: boolean; + showDeleteButton?: boolean; + showFavoriteButton?: boolean; + onAddToQueue?: () => void; + onRemoveFromQueue?: () => void; + onToggleFavorite?: () => void; + onDelete?: () => void; + onSelectSinger?: () => void; +}> = ({ + isAdmin, + isInQueue, + isInFavorites, + showInfoButton = false, + showAddButton = false, + showRemoveButton = false, + showDeleteButton = false, + showFavoriteButton = false, + onAddToQueue, + onRemoveFromQueue, + onToggleFavorite, + onDelete, + onSelectSinger +}) => { + const buttons = []; + + // Info button + if (showInfoButton && onSelectSinger) { + buttons.push( + + + + ); + } + + // Add to Queue button + if (showAddButton && !isInQueue) { + buttons.push( + {})} + variant="primary" + size="sm" + > + + + ); + } + + // Remove from Queue button + if (showRemoveButton && isAdmin && onRemoveFromQueue) { + buttons.push( + + + + ); + } + + // Delete from Favorites button + if (showDeleteButton && onDelete) { + buttons.push( + + + + ); + } + + // Toggle Favorite button + if (showFavoriteButton) { + buttons.push( + {})} + variant={isInFavorites ? 'danger' : 'secondary'} + size="sm" + > + + + ); + } + + return buttons.length > 0 ? ( +
+ {buttons} +
+ ) : null; +}; + +// Main SongItem Component const SongItem: React.FC = ({ song, context, @@ -23,8 +193,17 @@ const SongItem: React.FC = ({ onRemoveFromQueue, onToggleFavorite, onDelete, + onSelectSinger, isAdmin = false, - className = '' + className = '', + showActions = true, + showPath, + showCount, + showInfoButton, + showAddButton, + showRemoveButton, + showDeleteButton, + showFavoriteButton }) => { // Get current state from Redux const queue = useAppSelector(selectQueue); @@ -33,97 +212,45 @@ const SongItem: React.FC = ({ // Check if song is in queue or favorites based on path const isInQueue = (Object.values(queue) as QueueItem[]).some(item => item.song.path === song.path); const isInFavorites = (Object.values(favorites) as Song[]).some(favSong => favSong.path === song.path); - const renderActionPanel = () => { - const buttons = []; - // Add to Queue button (for all contexts except queue, only if not already in queue) - if (context !== 'queue' && !isInQueue) { - buttons.push( - {})} - variant="primary" - size="sm" - > - - - ); - } - - // Remove from Queue button (only for queue context, admin only) - if (context === 'queue' && isAdmin && onRemoveFromQueue) { - buttons.push( - - - - ); - } - - // Delete from Favorites button (only for favorites context) - if (context === 'favorites' && onDelete) { - buttons.push( - - - - ); - } - - // Toggle Favorite button (for all contexts except favorites) - if (context !== 'favorites') { - buttons.push( - {})} - variant={isInFavorites ? 'danger' : 'secondary'} - size="sm" - > - - - ); - } - - return buttons.length > 0 ? ( -
- {buttons} -
- ) : null; - }; + // Default values based on context if not explicitly provided + const shouldShowPath = showPath !== undefined ? showPath : context !== 'queue'; + const shouldShowCount = showCount !== undefined ? showCount : context === 'queue'; + + // Default values for action buttons based on context if not explicitly provided + const shouldShowInfoButton = showInfoButton !== undefined ? showInfoButton : context !== 'queue'; + const shouldShowAddButton = showAddButton !== undefined ? showAddButton : context !== 'queue'; + const shouldShowRemoveButton = showRemoveButton !== undefined ? showRemoveButton : context === 'queue' && isAdmin; + const shouldShowDeleteButton = showDeleteButton !== undefined ? showDeleteButton : context === 'favorites'; + const shouldShowFavoriteButton = showFavoriteButton !== undefined ? showFavoriteButton : context !== 'favorites'; return ( - -

- {song.title} -

-

- {song.artist} -

- {/* Show filename for all contexts except queue */} - {context !== 'queue' && song.path && ( -

- {extractFilename(song.path)} -

- )} - {song.count && ( -

- Played {song.count} times -

- )} -
+ -
- {renderActionPanel()} -
+ {showActions && ( +
+ +
+ )}
); }; diff --git a/src/components/common/index.ts b/src/components/common/index.ts index 99d5548..8eca193 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -5,4 +5,6 @@ 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'; \ No newline at end of file +export { default as PlayerControls } from './PlayerControls'; +export { default as SelectSinger } from './SelectSinger'; +export { default as SongInfo } from './SongInfo'; \ No newline at end of file diff --git a/src/features/Queue/Queue.tsx b/src/features/Queue/Queue.tsx index 861d5b6..0c45ada 100644 --- a/src/features/Queue/Queue.tsx +++ b/src/features/Queue/Queue.tsx @@ -1,13 +1,14 @@ import React, { useState, useEffect } from 'react'; -import { IonButton, IonIcon, IonReorderGroup, IonReorder, IonItem, IonLabel, IonItemSliding, IonItemOptions, IonItemOption } from '@ionic/react'; +import { IonItem, IonLabel, IonItemSliding, IonItemOptions, IonItemOption, IonButton, IonIcon, IonReorderGroup, IonReorder } from '@ionic/react'; import { trash, reorderThreeOutline, reorderTwoOutline } from 'ionicons/icons'; -import { ActionButton } from '../../components/common'; import { useQueue } from '../../hooks'; import { useAppSelector } from '../../redux'; import { selectQueueLength, selectPlayerStateMemoized, selectIsAdmin, selectControllerName } from '../../redux'; -import { PlayerState } from '../../types'; +import { ActionButton } from '../../components/common'; +import { SongInfoDisplay } from '../../components/common/SongItem'; import { queueService } from '../../firebase/services'; import { debugLog } from '../../utils/logger'; +import { PlayerState } from '../../types'; import type { QueueItem } from '../../types'; type QueueMode = 'delete' | 'reorder'; @@ -101,27 +102,34 @@ const Queue: React.FC = () => { return ( - + {/* Order Number */} -
- {queueItem.order} +
+
+ {queueItem.order} +
- {/* Song Info */} + {/* Song Info with Singer Name on Top */} -

+ {/* Singer Name */} +

{queueItem.singer.name} -

-

- {queueItem.song.title} -

-

- {queueItem.song.artist} -

+
+ + {/* Song Info Display */} +
{/* Delete Button or Drag Handle */} -
+
{canDelete && (
e.stopPropagation()}> {
)} {canReorder && queueMode === 'reorder' && ( -
+
)} @@ -165,34 +173,37 @@ const Queue: React.FC = () => { return ( - + {/* Order Number */} -
- {firstItem.order} +
+
+ {firstItem.order} +
- {/* Song Info */} + {/* Song Info with Singer Name on Top */} -

+ {/* Singer Name */} +

{firstItem.singer.name} -

-

- {firstItem.song.title} -

-

- {firstItem.song.artist} -

+
+ + {/* Song Info Display */} +
{/* Delete Button */} -
+
{canDeleteFirstItem && queueMode === 'delete' && (
e.stopPropagation()}> handleRemoveFromQueue(firstItem)} variant="danger" size="sm" - className="opacity-100" > @@ -218,22 +229,19 @@ const Queue: React.FC = () => { return ( <> -
- +
{isAdmin && ( )}
- -
+
{/* First Item (Currently Playing) */} {renderFirstItem()} @@ -241,13 +249,13 @@ const Queue: React.FC = () => { {canReorder && queueMode === 'reorder' ? ( {listItems.map((queueItem, index) => ( - + {renderQueueItem(queueItem, index)} ))} ) : ( -
+
{listItems.map((queueItem, index) => renderQueueItem(queueItem, index))}
)} diff --git a/src/features/Search/Search.tsx b/src/features/Search/Search.tsx index 9dbc74e..00b03a4 100644 --- a/src/features/Search/Search.tsx +++ b/src/features/Search/Search.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { IonSearchbar } from '@ionic/react'; -import { InfiniteScrollList, SongItem } from '../../components/common'; -import { useSearch } from '../../hooks'; +import { InfiniteScrollList, SongItem, SongInfo } from '../../components/common'; +import { useSearch, useSongInfo } from '../../hooks'; import { useAppSelector } from '../../redux'; import { selectIsAdmin, selectSongs } from '../../redux'; import { debugLog } from '../../utils/logger'; @@ -17,6 +17,8 @@ const Search: React.FC = () => { loadMore, } = useSearch(); + const { isOpen, selectedSong, openSongInfo, closeSongInfo } = useSongInfo(); + const isAdmin = useAppSelector(selectIsAdmin); const songs = useAppSelector(selectSongs); const songsCount = Object.keys(songs).length; @@ -39,47 +41,59 @@ const Search: React.FC = () => { debugLog('Search component - showing:', searchResults.songs.length, 'of', searchResults.count); return ( -
-
- {/* Search Input */} - handleSearchChange(e.detail.value || '')} - debounce={300} - showClearButton="focus" + <> +
+
+ {/* Search Input */} + handleSearchChange(e.detail.value || '')} + debounce={300} + showClearButton="focus" + /> +
+ + {/* Search Results */} + + items={searchResults.songs} + isLoading={songsCount === 0} + hasMore={searchResults.hasMore} + onLoadMore={loadMore} + renderItem={(song) => ( + handleAddToQueue(song)} + onToggleFavorite={() => handleToggleFavorite(song)} + onSelectSinger={() => openSongInfo(song)} + isAdmin={isAdmin} + /> + )} + 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" /> + + {/* Search Stats */} + {searchTerm && ( +
+ Found {searchResults.count} song{searchResults.count !== 1 ? 's' : ''} + {searchResults.hasMore && ` • Scroll down to load more`} +
+ )}
- {/* Search Results */} - - items={searchResults.songs} - isLoading={songsCount === 0} - hasMore={searchResults.hasMore} - onLoadMore={loadMore} - renderItem={(song) => ( - handleAddToQueue(song)} - onToggleFavorite={() => handleToggleFavorite(song)} - isAdmin={isAdmin} - /> - )} - 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" - /> - - {/* Search Stats */} - {searchTerm && ( -
- Found {searchResults.count} song{searchResults.count !== 1 ? 's' : ''} - {searchResults.hasMore && ` • Scroll down to load more`} -
+ {/* Song Info Modal */} + {selectedSong && ( + )} -
+ ); }; diff --git a/src/features/SongLists/SongLists.tsx b/src/features/SongLists/SongLists.tsx index 9bad097..28e6ecb 100644 --- a/src/features/SongLists/SongLists.tsx +++ b/src/features/SongLists/SongLists.tsx @@ -60,12 +60,12 @@ const SongLists: React.FC = () => { const renderSongListItem = (songList: SongList) => ( handleSongListClick(songList.key!)} detail={false}> -

+
{songList.title} -

-

+

+
{songList.songs.length} song{songList.songs.length !== 1 ? 's' : ''} -

+
@@ -120,12 +120,12 @@ const SongLists: React.FC = () => {
-

- {songListSong.artist} -

-

+

{songListSong.title} -

+
+
+ {songListSong.artist} +
@@ -162,12 +162,12 @@ const SongLists: React.FC = () => {
-

- {songListSong.artist} -

-

+

{songListSong.title} -

+
+
+ {songListSong.artist} +
); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 1b0461c..8cb7628 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -10,4 +10,6 @@ export { useNewSongs } from './useNewSongs'; export { useArtists } from './useArtists'; export { useSingers } from './useSingers'; export { useSongLists } from './useSongLists'; -export { useDisabledSongs } from './useDisabledSongs'; \ No newline at end of file +export { useDisabledSongs } from './useDisabledSongs'; +export { useSelectSinger } from './useSelectSinger'; +export { useSongInfo } from './useSongInfo'; \ No newline at end of file diff --git a/src/hooks/useSelectSinger.ts b/src/hooks/useSelectSinger.ts new file mode 100644 index 0000000..b132175 --- /dev/null +++ b/src/hooks/useSelectSinger.ts @@ -0,0 +1,24 @@ +import { useState, useCallback } from 'react'; +import type { Song } from '../types'; + +export const useSelectSinger = () => { + const [isOpen, setIsOpen] = useState(false); + const [selectedSong, setSelectedSong] = useState(null); + + const openSelectSinger = useCallback((song: Song) => { + setSelectedSong(song); + setIsOpen(true); + }, []); + + const closeSelectSinger = useCallback(() => { + setIsOpen(false); + setSelectedSong(null); + }, []); + + return { + isOpen, + selectedSong, + openSelectSinger, + closeSelectSinger, + }; +}; \ No newline at end of file diff --git a/src/hooks/useSongInfo.ts b/src/hooks/useSongInfo.ts new file mode 100644 index 0000000..6a1517e --- /dev/null +++ b/src/hooks/useSongInfo.ts @@ -0,0 +1,24 @@ +import { useState, useCallback } from 'react'; +import type { Song } from '../types'; + +export const useSongInfo = () => { + const [isOpen, setIsOpen] = useState(false); + const [selectedSong, setSelectedSong] = useState(null); + + const openSongInfo = useCallback((song: Song) => { + setSelectedSong(song); + setIsOpen(true); + }, []); + + const closeSongInfo = useCallback(() => { + setIsOpen(false); + setSelectedSong(null); + }, []); + + return { + isOpen, + selectedSong, + openSongInfo, + closeSongInfo, + }; +}; \ No newline at end of file diff --git a/src/index.css b/src/index.css index db3ea09..031dce7 100644 --- a/src/index.css +++ b/src/index.css @@ -118,8 +118,7 @@ ion-accordion ion-item { /* Custom modal styling for Singers component */ ion-modal ion-input-label, -ion-modal .ion-input-label, -ion-modal ion-label { +ion-modal .ion-input-label { font-weight: bold !important; font-size: 1rem !important; color: var(--ion-text-color) !important; diff --git a/src/types/index.ts b/src/types/index.ts index e86e290..4c74d9e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -128,13 +128,22 @@ export interface ActionButtonProps { export interface SongItemProps { song: Song; - context: 'search' | 'queue' | 'history' | 'favorites' | 'topPlayed'; + context: 'search' | 'queue' | 'favorites' | 'history' | 'songlists' | 'top100' | 'new'; onAddToQueue?: () => void; onRemoveFromQueue?: () => void; onToggleFavorite?: () => void; onDelete?: () => void; + onSelectSinger?: () => void; isAdmin?: boolean; className?: string; + showActions?: boolean; + showPath?: boolean; + showCount?: boolean; + showInfoButton?: boolean; + showAddButton?: boolean; + showRemoveButton?: boolean; + showDeleteButton?: boolean; + showFavoriteButton?: boolean; } export interface LayoutProps {