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

This commit is contained in:
Matt Bruce 2025-07-19 11:25:38 -05:00
parent 941107f71d
commit e3c7879087
12 changed files with 733 additions and 184 deletions

View File

@ -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<SelectSingerProps> = ({ 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<QueueItem, 'key'> = {
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 (
<IonModal
isOpen={isOpen}
onDidDismiss={onClose}
breakpoints={[0, 0.5, 0.8]}
initialBreakpoint={0.8}
>
<IonHeader>
<IonToolbar>
<IonTitle>Select Singer</IonTitle>
<IonButton slot="end" fill="clear" onClick={onClose}>
<IonIcon icon={close} />
</IonButton>
</IonToolbar>
</IonHeader>
<IonContent>
{/* Song Information */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold mb-2">{song.title}</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 italic mb-1">{song.artist}</p>
<p className="text-xs text-gray-500 dark:text-gray-500">{song.path}</p>
</div>
{/* Singers List */}
<IonList>
{singers.map((singer) => (
<IonItem
key={singer.key}
button
onClick={() => handleSelectSinger(singer)}
disabled={isLoading}
>
<IonLabel>
<h2>{singer.name}</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
Last login: {new Date(singer.lastLogin).toLocaleDateString()}
</p>
</IonLabel>
</IonItem>
))}
</IonList>
{singers.length === 0 && (
<div className="p-4 text-center text-gray-500 dark:text-gray-400">
No singers available
</div>
)}
</IonContent>
</IonModal>
);
};
export default SelectSinger;

View File

@ -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<SongInfoProps> = ({ 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 */}
<IonModal
isOpen={isOpen}
onDidDismiss={onClose}
breakpoints={[0, 0.5, 0.8]}
initialBreakpoint={0.8}
>
<IonHeader>
<IonToolbar>
<IonTitle>Song Info</IonTitle>
<IonButton slot="end" fill="clear" onClick={onClose}>
<IonIcon icon={close} />
</IonButton>
</IonToolbar>
</IonHeader>
<IonContent>
<div className="p-4">
{/* Song Information using SongItem component */}
<div className="mb-6">
<SongItem
song={song}
context="queue" // This context doesn't show any buttons
isAdmin={isAdmin}
showActions={false}
showPath={false}
showCount={false}
className="border-b border-gray-200 dark:border-gray-700"
/>
</div>
{/* Action Buttons */}
<div className="flex flex-col items-center space-y-4">
{/* Queue Song Button */}
<IonButton
fill="solid"
color="primary"
onClick={handleQueueSong}
className="h-12 w-80"
style={{ width: '320px' }}
>
<IonIcon icon={people} slot="start" />
Queue Song
</IonButton>
{/* Artist Songs Button */}
<IonButton
fill="solid"
color="primary"
onClick={handleArtistSongs}
className="h-12 w-80"
style={{ width: '320px' }}
>
<IonIcon icon={add} slot="start" />
Artist Songs
</IonButton>
{/* Favorite/Unfavorite Button */}
<IonButton
fill="solid"
color={isInFavorites ? "danger" : "primary"}
onClick={handleToggleFavorite}
className="h-12 w-80"
style={{ width: '320px' }}
>
<IonIcon icon={isInFavorites ? heart : heartOutline} slot="start" />
{isInFavorites ? 'Unfavorite Song' : 'Favorite Song'}
</IonButton>
{/* Disable/Enable Button (Admin Only) */}
{isAdmin && (
<IonButton
fill="solid"
color={isDisabled ? "success" : "warning"}
onClick={handleToggleDisabled}
className="h-12 w-80"
style={{ width: '320px' }}
>
<IonIcon icon={isDisabled ? checkmark : ban} slot="start" />
{isDisabled ? 'Enable Song' : 'Disable Song'}
</IonButton>
)}
</div>
</div>
</IonContent>
</IonModal>
{/* Select Singer Modal */}
{selectSingerSong && (
<SelectSinger
isOpen={isSelectSingerOpen}
onClose={closeSelectSinger}
song={selectSingerSong}
/>
)}
{/* Artist Songs Modal */}
<IonModal
isOpen={showArtistSongs}
onDidDismiss={() => setShowArtistSongs(false)}
breakpoints={[0, 0.5, 0.8]}
initialBreakpoint={0.8}
>
<IonHeader>
<IonToolbar>
<IonTitle>Songs by {song.artist}</IonTitle>
<IonButton slot="end" fill="clear" onClick={() => setShowArtistSongs(false)}>
<IonIcon icon={close} />
</IonButton>
</IonToolbar>
</IonHeader>
<IonContent>
<div className="p-4">
{artistSongs.length > 0 ? (
<IonList>
{artistSongs.map((artistSong) => (
<IonItem key={artistSong.path}>
<IonLabel>
<div className="text-base font-bold">{artistSong.title}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{artistSong.path}</div>
</IonLabel>
</IonItem>
))}
</IonList>
) : (
<div className="p-4 text-center text-gray-500 dark:text-gray-400">
No other songs found by this artist
</div>
)}
</div>
</IonContent>
</IonModal>
</>
);
};
export default SongInfo;

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { IonItem, IonLabel, IonIcon } from '@ionic/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 ActionButton from './ActionButton';
import { useAppSelector } from '../../redux'; import { useAppSelector } from '../../redux';
import { selectQueue, selectFavorites } from '../../redux'; import { selectQueue, selectFavorites } from '../../redux';
@ -16,6 +16,176 @@ const extractFilename = (path: string): string => {
return parts[parts.length - 1] || ''; 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 (
<IonLabel>
<div
className="ion-text-bold"
style={{
fontWeight: 'bold',
fontSize: '1rem',
color: 'black'
}}
>
{song.title}
</div>
<div
className="ion-text-italic ion-color-medium"
style={{
fontSize: '0.875rem',
fontStyle: 'italic',
color: '#6b7280'
}}
>
{song.artist}
</div>
{/* Show filename if showPath is true */}
{showPath && song.path && (
<div
className="ion-text-sm ion-color-medium"
style={{
fontSize: '0.75rem',
color: '#9ca3af'
}}
>
{extractFilename(song.path)}
</div>
)}
{/* Show play count if showCount is true */}
{showCount && song.count && (
<div
className="ion-text-sm ion-color-medium"
style={{
fontSize: '0.75rem',
color: '#9ca3af'
}}
>
Played {song.count} times
</div>
)}
</IonLabel>
);
};
// 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(
<ActionButton
key="info"
onClick={onSelectSinger}
variant="secondary"
size="sm"
>
<IonIcon icon={informationCircle} />
</ActionButton>
);
}
// Add to Queue button
if (showAddButton && !isInQueue) {
buttons.push(
<ActionButton
key="add"
onClick={onAddToQueue || (() => {})}
variant="primary"
size="sm"
>
<IonIcon icon={add} />
</ActionButton>
);
}
// Remove from Queue button
if (showRemoveButton && isAdmin && onRemoveFromQueue) {
buttons.push(
<ActionButton
key="remove"
onClick={onRemoveFromQueue}
variant="danger"
size="sm"
>
<IonIcon icon={trash} />
</ActionButton>
);
}
// Delete from Favorites button
if (showDeleteButton && onDelete) {
buttons.push(
<ActionButton
key="delete"
onClick={onDelete}
variant="danger"
size="sm"
>
<IonIcon icon={trash} />
</ActionButton>
);
}
// Toggle Favorite button
if (showFavoriteButton) {
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;
};
// Main SongItem Component
const SongItem: React.FC<SongItemProps> = ({ const SongItem: React.FC<SongItemProps> = ({
song, song,
context, context,
@ -23,8 +193,17 @@ const SongItem: React.FC<SongItemProps> = ({
onRemoveFromQueue, onRemoveFromQueue,
onToggleFavorite, onToggleFavorite,
onDelete, onDelete,
onSelectSinger,
isAdmin = false, isAdmin = false,
className = '' className = '',
showActions = true,
showPath,
showCount,
showInfoButton,
showAddButton,
showRemoveButton,
showDeleteButton,
showFavoriteButton
}) => { }) => {
// Get current state from Redux // Get current state from Redux
const queue = useAppSelector(selectQueue); const queue = useAppSelector(selectQueue);
@ -33,97 +212,45 @@ const SongItem: React.FC<SongItemProps> = ({
// Check if song is in queue or favorites based on path // 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 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 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) // Default values based on context if not explicitly provided
if (context !== 'queue' && !isInQueue) { const shouldShowPath = showPath !== undefined ? showPath : context !== 'queue';
buttons.push( const shouldShowCount = showCount !== undefined ? showCount : context === 'queue';
<ActionButton
key="add" // Default values for action buttons based on context if not explicitly provided
onClick={onAddToQueue || (() => {})} const shouldShowInfoButton = showInfoButton !== undefined ? showInfoButton : context !== 'queue';
variant="primary" const shouldShowAddButton = showAddButton !== undefined ? showAddButton : context !== 'queue';
size="sm" const shouldShowRemoveButton = showRemoveButton !== undefined ? showRemoveButton : context === 'queue' && isAdmin;
> const shouldShowDeleteButton = showDeleteButton !== undefined ? showDeleteButton : context === 'favorites';
<IonIcon icon={add} /> const shouldShowFavoriteButton = showFavoriteButton !== undefined ? showFavoriteButton : context !== 'favorites';
</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 ( return (
<IonItem className={className}> <IonItem className={className}>
<IonLabel className="flex-1 min-w-0"> <SongInfoDisplay
<h3 className="text-base bold-title break-words"> song={song}
{song.title} showPath={shouldShowPath}
</h3> showCount={shouldShowCount}
<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 break-words">
{extractFilename(song.path)}
</p>
)}
{song.count && (
<p className="text-xs text-gray-400">
Played {song.count} times
</p>
)}
</IonLabel>
<div slot="end" className="flex gap-2 flex-shrink-0 ml-2"> {showActions && (
{renderActionPanel()} <div slot="end" className="flex gap-2 flex-shrink-0 ml-2">
</div> <SongActionButtons
isAdmin={isAdmin}
isInQueue={isInQueue}
isInFavorites={isInFavorites}
showInfoButton={shouldShowInfoButton}
showAddButton={shouldShowAddButton}
showRemoveButton={shouldShowRemoveButton}
showDeleteButton={shouldShowDeleteButton}
showFavoriteButton={shouldShowFavoriteButton}
onAddToQueue={onAddToQueue}
onRemoveFromQueue={onRemoveFromQueue}
onToggleFavorite={onToggleFavorite}
onDelete={onDelete}
onSelectSinger={onSelectSinger}
/>
</div>
)}
</IonItem> </IonItem>
); );
}; };

View File

@ -5,4 +5,6 @@ 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 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';
export { default as SelectSinger } from './SelectSinger';
export { default as SongInfo } from './SongInfo';

View File

@ -1,13 +1,14 @@
import React, { useState, useEffect } from 'react'; 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 { trash, reorderThreeOutline, reorderTwoOutline } from 'ionicons/icons';
import { ActionButton } from '../../components/common';
import { useQueue } from '../../hooks'; import { useQueue } from '../../hooks';
import { useAppSelector } from '../../redux'; import { useAppSelector } from '../../redux';
import { selectQueueLength, selectPlayerStateMemoized, selectIsAdmin, selectControllerName } 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 { queueService } from '../../firebase/services';
import { debugLog } from '../../utils/logger'; import { debugLog } from '../../utils/logger';
import { PlayerState } from '../../types';
import type { QueueItem } from '../../types'; import type { QueueItem } from '../../types';
type QueueMode = 'delete' | 'reorder'; type QueueMode = 'delete' | 'reorder';
@ -101,27 +102,34 @@ const Queue: React.FC = () => {
return ( return (
<IonItemSliding key={queueItem.key}> <IonItemSliding key={queueItem.key}>
<IonItem className={`${canReorder && queueMode === 'reorder' ? 'border-l-4 border-blue-200 bg-blue-50' : ''}`}> <IonItem
className={`${canReorder && queueMode === 'reorder' ? 'ion-border-start ion-border-primary ion-bg-primary-tint' : ''}`}
style={{ '--padding-start': '0px' }}
>
{/* Order Number */} {/* Order Number */}
<div slot="start" className={`relative flex-shrink-0 w-12 h-12 flex items-center justify-center font-medium rounded-full bg-gray-100 text-gray-600 !border-none`}> <div slot="start" className="ion-text-center" style={{ marginLeft: '-8px', marginRight: '12px' }}>
{queueItem.order} <div className="ion-text-bold ion-color-medium" style={{ fontSize: '1rem', minWidth: '2rem' }}>
{queueItem.order}
</div>
</div> </div>
{/* Song Info */} {/* Song Info with Singer Name on Top */}
<IonLabel> <IonLabel>
<p className="text-sm font-semibold truncate"> {/* Singer Name */}
<div className="ion-text-bold ion-color-primary">
{queueItem.singer.name} {queueItem.singer.name}
</p> </div>
<h3 className="text-sm font-semibold truncate">
{queueItem.song.title} {/* Song Info Display */}
</h3> <SongInfoDisplay
<p className="text-sm text-gray-500 truncate"> song={queueItem.song}
{queueItem.song.artist} showPath={false}
</p> showCount={false}
/>
</IonLabel> </IonLabel>
{/* Delete Button or Drag Handle */} {/* Delete Button or Drag Handle */}
<div slot="end" className="flex items-center gap-2 ml-2"> <div slot="end" style={{ marginRight: '-16px' }}>
{canDelete && ( {canDelete && (
<div onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>
<ActionButton <ActionButton
@ -134,7 +142,7 @@ const Queue: React.FC = () => {
</div> </div>
)} )}
{canReorder && queueMode === 'reorder' && ( {canReorder && queueMode === 'reorder' && (
<div className="text-gray-400"> <div className="ion-color-medium">
<IonIcon icon={reorderTwoOutline} size="large" /> <IonIcon icon={reorderTwoOutline} size="large" />
</div> </div>
)} )}
@ -165,34 +173,37 @@ const Queue: React.FC = () => {
return ( return (
<IonItemSliding key={firstItem.key}> <IonItemSliding key={firstItem.key}>
<IonItem> <IonItem style={{ '--padding-start': '0px' }}>
{/* Order Number */} {/* Order Number */}
<div slot="start" className={`relative flex-shrink-0 w-12 h-12 flex items-center justify-center font-medium rounded-full bg-gray-100 text-gray-600 !border-none`}> <div slot="start" className="ion-text-center" style={{ marginLeft: '-8px', marginRight: '12px' }}>
{firstItem.order} <div className="ion-text-bold ion-color-medium" style={{ fontSize: '1rem', minWidth: '2rem' }}>
{firstItem.order}
</div>
</div> </div>
{/* Song Info */} {/* Song Info with Singer Name on Top */}
<IonLabel> <IonLabel>
<p className="text-sm font-semibold truncate"> {/* Singer Name */}
<div className="ion-text-bold ion-color-primary">
{firstItem.singer.name} {firstItem.singer.name}
</p> </div>
<h3 className="text-sm font-semibold truncate">
{firstItem.song.title} {/* Song Info Display */}
</h3> <SongInfoDisplay
<p className="text-sm text-gray-500 truncate"> song={firstItem.song}
{firstItem.song.artist} showPath={false}
</p> showCount={false}
/>
</IonLabel> </IonLabel>
{/* Delete Button */} {/* Delete Button */}
<div slot="end" className="flex items-center gap-2 ml-2"> <div slot="end" style={{ marginRight: '-16px' }}>
{canDeleteFirstItem && queueMode === 'delete' && ( {canDeleteFirstItem && queueMode === 'delete' && (
<div onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>
<ActionButton <ActionButton
onClick={() => handleRemoveFromQueue(firstItem)} onClick={() => handleRemoveFromQueue(firstItem)}
variant="danger" variant="danger"
size="sm" size="sm"
className="opacity-100"
> >
<IonIcon icon={trash} /> <IonIcon icon={trash} />
</ActionButton> </ActionButton>
@ -218,22 +229,19 @@ const Queue: React.FC = () => {
return ( return (
<> <>
<div className="flex justify-end items-center mb-4 pr-4 right-button-container"> <div className="ion-padding ion-text-end">
{isAdmin && ( {isAdmin && (
<IonButton <IonButton
onClick={toggleQueueMode} onClick={toggleQueueMode}
fill="outline" fill="outline"
size="small" size="small"
className="flex items-center gap-2"
> >
<IonIcon icon={queueMode === 'delete' ? reorderThreeOutline : trash} /> <IonIcon icon={queueMode === 'delete' ? reorderThreeOutline : trash} />
</IonButton> </IonButton>
)} )}
</div> </div>
<div className="ion-padding">
<div className="max-w-4xl mx-auto p-6">
{/* First Item (Currently Playing) */} {/* First Item (Currently Playing) */}
{renderFirstItem()} {renderFirstItem()}
@ -241,13 +249,13 @@ const Queue: React.FC = () => {
{canReorder && queueMode === 'reorder' ? ( {canReorder && queueMode === 'reorder' ? (
<IonReorderGroup disabled={false} onIonItemReorder={doReorder}> <IonReorderGroup disabled={false} onIonItemReorder={doReorder}>
{listItems.map((queueItem, index) => ( {listItems.map((queueItem, index) => (
<IonReorder key={queueItem.key} style={{ minHeight: '60px' }}> <IonReorder key={queueItem.key}>
{renderQueueItem(queueItem, index)} {renderQueueItem(queueItem, index)}
</IonReorder> </IonReorder>
))} ))}
</IonReorderGroup> </IonReorderGroup>
) : ( ) : (
<div className="space-y-2"> <div>
{listItems.map((queueItem, index) => renderQueueItem(queueItem, index))} {listItems.map((queueItem, index) => renderQueueItem(queueItem, index))}
</div> </div>
)} )}

View File

@ -1,7 +1,7 @@
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, SongItem, SongInfo } from '../../components/common';
import { useSearch } from '../../hooks'; import { useSearch, useSongInfo } from '../../hooks';
import { useAppSelector } from '../../redux'; import { useAppSelector } from '../../redux';
import { selectIsAdmin, selectSongs } from '../../redux'; import { selectIsAdmin, selectSongs } from '../../redux';
import { debugLog } from '../../utils/logger'; import { debugLog } from '../../utils/logger';
@ -17,6 +17,8 @@ const Search: React.FC = () => {
loadMore, loadMore,
} = useSearch(); } = useSearch();
const { isOpen, selectedSong, openSongInfo, closeSongInfo } = useSongInfo();
const isAdmin = useAppSelector(selectIsAdmin); const isAdmin = useAppSelector(selectIsAdmin);
const songs = useAppSelector(selectSongs); const songs = useAppSelector(selectSongs);
const songsCount = Object.keys(songs).length; const songsCount = Object.keys(songs).length;
@ -39,47 +41,59 @@ const Search: React.FC = () => {
debugLog('Search component - showing:', searchResults.songs.length, 'of', searchResults.count); debugLog('Search component - showing:', searchResults.songs.length, 'of', searchResults.count);
return ( return (
<div className="max-w-4xl mx-auto p-6"> <>
<div className="mb-6"> <div className="max-w-4xl mx-auto p-6">
{/* Search Input */} <div className="mb-6">
<IonSearchbar {/* Search Input */}
placeholder="Search by title or artist..." <IonSearchbar
value={searchTerm} placeholder="Search by title or artist..."
onIonInput={(e) => handleSearchChange(e.detail.value || '')} value={searchTerm}
debounce={300} onIonInput={(e) => handleSearchChange(e.detail.value || '')}
showClearButton="focus" debounce={300}
showClearButton="focus"
/>
</div>
{/* Search Results */}
<InfiniteScrollList<Song>
items={searchResults.songs}
isLoading={songsCount === 0}
hasMore={searchResults.hasMore}
onLoadMore={loadMore}
renderItem={(song) => (
<SongItem
song={song}
context="search"
onAddToQueue={() => 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 && (
<div className="mt-4 text-sm text-gray-500 text-center">
Found {searchResults.count} song{searchResults.count !== 1 ? 's' : ''}
{searchResults.hasMore && ` • Scroll down to load more`}
</div>
)}
</div> </div>
{/* Search Results */} {/* Song Info Modal */}
<InfiniteScrollList<Song> {selectedSong && (
items={searchResults.songs} <SongInfo
isLoading={songsCount === 0} isOpen={isOpen}
hasMore={searchResults.hasMore} onClose={closeSongInfo}
onLoadMore={loadMore} song={selectedSong}
renderItem={(song) => ( />
<SongItem
song={song}
context="search"
onAddToQueue={() => 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 && (
<div className="mt-4 text-sm text-gray-500 text-center">
Found {searchResults.count} song{searchResults.count !== 1 ? 's' : ''}
{searchResults.hasMore && ` • Scroll down to load more`}
</div>
)} )}
</div> </>
); );
}; };

View File

@ -60,12 +60,12 @@ const SongLists: React.FC = () => {
const renderSongListItem = (songList: SongList) => ( const renderSongListItem = (songList: SongList) => (
<IonItem button onClick={() => handleSongListClick(songList.key!)} detail={false}> <IonItem button onClick={() => handleSongListClick(songList.key!)} detail={false}>
<IonLabel> <IonLabel>
<h3 className="text-sm font-medium text-gray-900"> <div className="text-sm font-medium text-gray-900">
{songList.title} {songList.title}
</h3> </div>
<p className="text-sm text-gray-500"> <div className="text-sm text-gray-500">
{songList.songs.length} song{songList.songs.length !== 1 ? 's' : ''} {songList.songs.length} song{songList.songs.length !== 1 ? 's' : ''}
</p> </div>
</IonLabel> </IonLabel>
<IonIcon icon={list} slot="end" color="primary" /> <IonIcon icon={list} slot="end" color="primary" />
</IonItem> </IonItem>
@ -120,12 +120,12 @@ const SongLists: React.FC = () => {
</div> </div>
<IonLabel> <IonLabel>
<h3 className="text-sm font-medium text-gray-900"> <div className="text-sm font-semibold text-gray-900">
{songListSong.artist}
</h3>
<p className="text-sm text-gray-500">
{songListSong.title} {songListSong.title}
</p> </div>
<div className="text-sm italic text-gray-500">
{songListSong.artist}
</div>
</IonLabel> </IonLabel>
<IonChip slot="end" color="success"> <IonChip slot="end" color="success">
@ -162,12 +162,12 @@ const SongLists: React.FC = () => {
</div> </div>
<IonLabel> <IonLabel>
<h3 className="text-sm font-medium text-gray-400"> <div className="text-sm font-semibold text-gray-400">
{songListSong.artist}
</h3>
<p className="text-sm text-gray-300">
{songListSong.title} {songListSong.title}
</p> </div>
<div className="text-sm italic text-gray-300">
{songListSong.artist}
</div>
</IonLabel> </IonLabel>
</IonItem> </IonItem>
); );

View File

@ -10,4 +10,6 @@ export { useNewSongs } from './useNewSongs';
export { useArtists } from './useArtists'; export { useArtists } from './useArtists';
export { useSingers } from './useSingers'; export { useSingers } from './useSingers';
export { useSongLists } from './useSongLists'; export { useSongLists } from './useSongLists';
export { useDisabledSongs } from './useDisabledSongs'; export { useDisabledSongs } from './useDisabledSongs';
export { useSelectSinger } from './useSelectSinger';
export { useSongInfo } from './useSongInfo';

View File

@ -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<Song | null>(null);
const openSelectSinger = useCallback((song: Song) => {
setSelectedSong(song);
setIsOpen(true);
}, []);
const closeSelectSinger = useCallback(() => {
setIsOpen(false);
setSelectedSong(null);
}, []);
return {
isOpen,
selectedSong,
openSelectSinger,
closeSelectSinger,
};
};

24
src/hooks/useSongInfo.ts Normal file
View File

@ -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<Song | null>(null);
const openSongInfo = useCallback((song: Song) => {
setSelectedSong(song);
setIsOpen(true);
}, []);
const closeSongInfo = useCallback(() => {
setIsOpen(false);
setSelectedSong(null);
}, []);
return {
isOpen,
selectedSong,
openSongInfo,
closeSongInfo,
};
};

View File

@ -118,8 +118,7 @@ ion-accordion ion-item {
/* Custom modal styling for Singers component */ /* Custom modal styling for Singers component */
ion-modal ion-input-label, ion-modal ion-input-label,
ion-modal .ion-input-label, ion-modal .ion-input-label {
ion-modal ion-label {
font-weight: bold !important; font-weight: bold !important;
font-size: 1rem !important; font-size: 1rem !important;
color: var(--ion-text-color) !important; color: var(--ion-text-color) !important;

View File

@ -128,13 +128,22 @@ export interface ActionButtonProps {
export interface SongItemProps { export interface SongItemProps {
song: Song; song: Song;
context: 'search' | 'queue' | 'history' | 'favorites' | 'topPlayed'; context: 'search' | 'queue' | 'favorites' | 'history' | 'songlists' | 'top100' | 'new';
onAddToQueue?: () => void; onAddToQueue?: () => void;
onRemoveFromQueue?: () => void; onRemoveFromQueue?: () => void;
onToggleFavorite?: () => void; onToggleFavorite?: () => void;
onDelete?: () => void; onDelete?: () => void;
onSelectSinger?: () => void;
isAdmin?: boolean; isAdmin?: boolean;
className?: string; className?: string;
showActions?: boolean;
showPath?: boolean;
showCount?: boolean;
showInfoButton?: boolean;
showAddButton?: boolean;
showRemoveButton?: boolean;
showDeleteButton?: boolean;
showFavoriteButton?: boolean;
} }
export interface LayoutProps { export interface LayoutProps {