Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
941107f71d
commit
e3c7879087
122
src/components/common/SelectSinger.tsx
Normal file
122
src/components/common/SelectSinger.tsx
Normal 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;
|
||||
218
src/components/common/SongInfo.tsx
Normal file
218
src/components/common/SongInfo.tsx
Normal 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;
|
||||
@ -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 (
|
||||
<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> = ({
|
||||
song,
|
||||
context,
|
||||
@ -23,8 +193,17 @@ const SongItem: React.FC<SongItemProps> = ({
|
||||
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<SongItemProps> = ({
|
||||
// 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(
|
||||
<ActionButton
|
||||
key="add"
|
||||
onClick={onAddToQueue || (() => {})}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
<IonIcon icon={add} />
|
||||
</ActionButton>
|
||||
);
|
||||
}
|
||||
|
||||
// Remove from Queue button (only for queue context, admin only)
|
||||
if (context === 'queue' && isAdmin && onRemoveFromQueue) {
|
||||
buttons.push(
|
||||
<ActionButton
|
||||
key="remove"
|
||||
onClick={onRemoveFromQueue}
|
||||
variant="danger"
|
||||
size="sm"
|
||||
>
|
||||
<IonIcon icon={trash} />
|
||||
</ActionButton>
|
||||
);
|
||||
}
|
||||
|
||||
// Delete from Favorites button (only for favorites context)
|
||||
if (context === 'favorites' && onDelete) {
|
||||
buttons.push(
|
||||
<ActionButton
|
||||
key="delete"
|
||||
onClick={onDelete}
|
||||
variant="danger"
|
||||
size="sm"
|
||||
>
|
||||
<IonIcon icon={trash} />
|
||||
</ActionButton>
|
||||
);
|
||||
}
|
||||
|
||||
// Toggle Favorite button (for all contexts except favorites)
|
||||
if (context !== 'favorites') {
|
||||
buttons.push(
|
||||
<ActionButton
|
||||
key="favorite"
|
||||
onClick={onToggleFavorite || (() => {})}
|
||||
variant={isInFavorites ? 'danger' : 'secondary'}
|
||||
size="sm"
|
||||
>
|
||||
<IonIcon icon={isInFavorites ? heart : heartOutline} />
|
||||
</ActionButton>
|
||||
);
|
||||
}
|
||||
|
||||
return buttons.length > 0 ? (
|
||||
<div className="flex gap-2">
|
||||
{buttons}
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
// 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 (
|
||||
<IonItem className={className}>
|
||||
<IonLabel className="flex-1 min-w-0">
|
||||
<h3 className="text-base bold-title break-words">
|
||||
{song.title}
|
||||
</h3>
|
||||
<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>
|
||||
<SongInfoDisplay
|
||||
song={song}
|
||||
showPath={shouldShowPath}
|
||||
showCount={shouldShowCount}
|
||||
/>
|
||||
|
||||
<div slot="end" className="flex gap-2 flex-shrink-0 ml-2">
|
||||
{renderActionPanel()}
|
||||
</div>
|
||||
{showActions && (
|
||||
<div slot="end" className="flex gap-2 flex-shrink-0 ml-2">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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';
|
||||
export { default as PlayerControls } from './PlayerControls';
|
||||
export { default as SelectSinger } from './SelectSinger';
|
||||
export { default as SongInfo } from './SongInfo';
|
||||
@ -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 (
|
||||
<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 */}
|
||||
<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`}>
|
||||
{queueItem.order}
|
||||
<div slot="start" className="ion-text-center" style={{ marginLeft: '-8px', marginRight: '12px' }}>
|
||||
<div className="ion-text-bold ion-color-medium" style={{ fontSize: '1rem', minWidth: '2rem' }}>
|
||||
{queueItem.order}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Song Info */}
|
||||
{/* Song Info with Singer Name on Top */}
|
||||
<IonLabel>
|
||||
<p className="text-sm font-semibold truncate">
|
||||
{/* Singer Name */}
|
||||
<div className="ion-text-bold ion-color-primary">
|
||||
{queueItem.singer.name}
|
||||
</p>
|
||||
<h3 className="text-sm font-semibold truncate">
|
||||
{queueItem.song.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 truncate">
|
||||
{queueItem.song.artist}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Song Info Display */}
|
||||
<SongInfoDisplay
|
||||
song={queueItem.song}
|
||||
showPath={false}
|
||||
showCount={false}
|
||||
/>
|
||||
</IonLabel>
|
||||
|
||||
{/* Delete Button or Drag Handle */}
|
||||
<div slot="end" className="flex items-center gap-2 ml-2">
|
||||
<div slot="end" style={{ marginRight: '-16px' }}>
|
||||
{canDelete && (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ActionButton
|
||||
@ -134,7 +142,7 @@ const Queue: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
{canReorder && queueMode === 'reorder' && (
|
||||
<div className="text-gray-400">
|
||||
<div className="ion-color-medium">
|
||||
<IonIcon icon={reorderTwoOutline} size="large" />
|
||||
</div>
|
||||
)}
|
||||
@ -165,34 +173,37 @@ const Queue: React.FC = () => {
|
||||
|
||||
return (
|
||||
<IonItemSliding key={firstItem.key}>
|
||||
<IonItem>
|
||||
<IonItem style={{ '--padding-start': '0px' }}>
|
||||
{/* 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`}>
|
||||
{firstItem.order}
|
||||
<div slot="start" className="ion-text-center" style={{ marginLeft: '-8px', marginRight: '12px' }}>
|
||||
<div className="ion-text-bold ion-color-medium" style={{ fontSize: '1rem', minWidth: '2rem' }}>
|
||||
{firstItem.order}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Song Info */}
|
||||
{/* Song Info with Singer Name on Top */}
|
||||
<IonLabel>
|
||||
<p className="text-sm font-semibold truncate">
|
||||
{/* Singer Name */}
|
||||
<div className="ion-text-bold ion-color-primary">
|
||||
{firstItem.singer.name}
|
||||
</p>
|
||||
<h3 className="text-sm font-semibold truncate">
|
||||
{firstItem.song.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 truncate">
|
||||
{firstItem.song.artist}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Song Info Display */}
|
||||
<SongInfoDisplay
|
||||
song={firstItem.song}
|
||||
showPath={false}
|
||||
showCount={false}
|
||||
/>
|
||||
</IonLabel>
|
||||
|
||||
{/* Delete Button */}
|
||||
<div slot="end" className="flex items-center gap-2 ml-2">
|
||||
<div slot="end" style={{ marginRight: '-16px' }}>
|
||||
{canDeleteFirstItem && queueMode === 'delete' && (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ActionButton
|
||||
onClick={() => handleRemoveFromQueue(firstItem)}
|
||||
variant="danger"
|
||||
size="sm"
|
||||
className="opacity-100"
|
||||
>
|
||||
<IonIcon icon={trash} />
|
||||
</ActionButton>
|
||||
@ -218,22 +229,19 @@ const Queue: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-end items-center mb-4 pr-4 right-button-container">
|
||||
|
||||
<div className="ion-padding ion-text-end">
|
||||
{isAdmin && (
|
||||
<IonButton
|
||||
onClick={toggleQueueMode}
|
||||
fill="outline"
|
||||
size="small"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<IonIcon icon={queueMode === 'delete' ? reorderThreeOutline : trash} />
|
||||
</IonButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<div className="ion-padding">
|
||||
{/* First Item (Currently Playing) */}
|
||||
{renderFirstItem()}
|
||||
|
||||
@ -241,13 +249,13 @@ const Queue: React.FC = () => {
|
||||
{canReorder && queueMode === 'reorder' ? (
|
||||
<IonReorderGroup disabled={false} onIonItemReorder={doReorder}>
|
||||
{listItems.map((queueItem, index) => (
|
||||
<IonReorder key={queueItem.key} style={{ minHeight: '60px' }}>
|
||||
<IonReorder key={queueItem.key}>
|
||||
{renderQueueItem(queueItem, index)}
|
||||
</IonReorder>
|
||||
))}
|
||||
</IonReorderGroup>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
{listItems.map((queueItem, index) => renderQueueItem(queueItem, index))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -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 (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<div className="mb-6">
|
||||
{/* Search Input */}
|
||||
<IonSearchbar
|
||||
placeholder="Search by title or artist..."
|
||||
value={searchTerm}
|
||||
onIonInput={(e) => handleSearchChange(e.detail.value || '')}
|
||||
debounce={300}
|
||||
showClearButton="focus"
|
||||
<>
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<div className="mb-6">
|
||||
{/* Search Input */}
|
||||
<IonSearchbar
|
||||
placeholder="Search by title or artist..."
|
||||
value={searchTerm}
|
||||
onIonInput={(e) => handleSearchChange(e.detail.value || '')}
|
||||
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>
|
||||
|
||||
{/* 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)}
|
||||
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>
|
||||
{/* Song Info Modal */}
|
||||
{selectedSong && (
|
||||
<SongInfo
|
||||
isOpen={isOpen}
|
||||
onClose={closeSongInfo}
|
||||
song={selectedSong}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -60,12 +60,12 @@ const SongLists: React.FC = () => {
|
||||
const renderSongListItem = (songList: SongList) => (
|
||||
<IonItem button onClick={() => handleSongListClick(songList.key!)} detail={false}>
|
||||
<IonLabel>
|
||||
<h3 className="text-sm font-medium text-gray-900">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{songList.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{songList.songs.length} song{songList.songs.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</IonLabel>
|
||||
<IonIcon icon={list} slot="end" color="primary" />
|
||||
</IonItem>
|
||||
@ -120,12 +120,12 @@ const SongLists: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<IonLabel>
|
||||
<h3 className="text-sm font-medium text-gray-900">
|
||||
{songListSong.artist}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
<div className="text-sm font-semibold text-gray-900">
|
||||
{songListSong.title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm italic text-gray-500">
|
||||
{songListSong.artist}
|
||||
</div>
|
||||
</IonLabel>
|
||||
|
||||
<IonChip slot="end" color="success">
|
||||
@ -162,12 +162,12 @@ const SongLists: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<IonLabel>
|
||||
<h3 className="text-sm font-medium text-gray-400">
|
||||
{songListSong.artist}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-300">
|
||||
<div className="text-sm font-semibold text-gray-400">
|
||||
{songListSong.title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm italic text-gray-300">
|
||||
{songListSong.artist}
|
||||
</div>
|
||||
</IonLabel>
|
||||
</IonItem>
|
||||
);
|
||||
|
||||
@ -10,4 +10,6 @@ export { useNewSongs } from './useNewSongs';
|
||||
export { useArtists } from './useArtists';
|
||||
export { useSingers } from './useSingers';
|
||||
export { useSongLists } from './useSongLists';
|
||||
export { useDisabledSongs } from './useDisabledSongs';
|
||||
export { useDisabledSongs } from './useDisabledSongs';
|
||||
export { useSelectSinger } from './useSelectSinger';
|
||||
export { useSongInfo } from './useSongInfo';
|
||||
24
src/hooks/useSelectSinger.ts
Normal file
24
src/hooks/useSelectSinger.ts
Normal 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
24
src/hooks/useSongInfo.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user