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 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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';
|
||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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';
|
||||||
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 */
|
/* 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;
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user