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

This commit is contained in:
mbrucedogs 2025-07-20 20:10:38 -05:00
parent 3aee1fc44e
commit d98a8a6d14
21 changed files with 229 additions and 245 deletions

View File

@ -5,7 +5,7 @@ import TopPlayed from './features/TopPlayed/Top100';
import { FirebaseProvider } from './firebase/FirebaseProvider';
import { ErrorBoundary } from './components/common';
import { AuthInitializer } from './components/Auth';
import { SongInfoProvider } from './components/common/SongInfoProvider';
import { ModalProvider } from './components/common/ModalProvider';
function App() {
return (
@ -13,7 +13,7 @@ function App() {
<FirebaseProvider>
<Router>
<AuthInitializer>
<SongInfoProvider>
<ModalProvider>
<Layout>
<Routes>
<Route path="/" element={<Navigate to="/queue" replace />} />
@ -30,7 +30,7 @@ function App() {
<Route path="*" element={<Navigate to="/queue" replace />} />
</Routes>
</Layout>
</SongInfoProvider>
</ModalProvider>
</AuthInitializer>
</Router>
</FirebaseProvider>

View File

@ -13,7 +13,8 @@ const ActionButton: React.FC<ActionButtonProps> = ({
icon,
iconSlot = ActionButtonIconSlot.START,
iconSize = ActionButtonIconSize.LARGE,
fill = 'solid'
fill = 'solid',
'aria-label': ariaLabel
}) => {
const getVariant = () => {
switch (variant) {
@ -51,6 +52,7 @@ const ActionButton: React.FC<ActionButtonProps> = ({
color={getVariant()}
size={getSize()}
className={className}
aria-label={ariaLabel}
style={{
minWidth: isIconOnly ? '40px' : '40px',
minHeight: '40px',

View File

@ -0,0 +1,36 @@
import React from 'react';
import { IonHeader, IonToolbar, IonTitle } from '@ionic/react';
import { ActionButton } from './index';
import { ActionButtonVariant, ActionButtonSize, ActionButtonIconSlot } from '../../types';
import { Icons } from '../../constants';
interface ModalHeaderProps {
title: string;
onClose: () => void;
className?: string;
}
export const ModalHeader: React.FC<ModalHeaderProps> = ({
title,
onClose,
className = ''
}) => {
return (
<IonHeader className={className}>
<IonToolbar>
<IonTitle>{title}</IonTitle>
<div slot="end">
<ActionButton
onClick={onClose}
variant={ActionButtonVariant.SECONDARY}
size={ActionButtonSize.SMALL}
icon={Icons.CLOSE}
iconSlot={ActionButtonIconSlot.ICON_ONLY}
fill="clear"
aria-label={`Close ${title} modal`}
/>
</div>
</IonToolbar>
</IonHeader>
);
};

View File

@ -0,0 +1,98 @@
import React, { useState, useCallback } from 'react';
import { ModalContext } from '../../contexts/ModalContext';
import { ModalType, type ModalViewType, type ModalState, type ModalItem } from '../../types/modal';
import SongInfo from './SongInfo';
import SelectSinger from './SelectSinger';
import type { Song } from '../../types';
interface ModalProviderProps {
children: React.ReactNode;
}
export const ModalProvider: React.FC<ModalProviderProps> = ({ children }) => {
const [modalState, setModalState] = useState<ModalState>({
stack: [],
});
const openModal = useCallback((type: ModalViewType, data: Song): string => {
const id = Math.random().toString(36).substr(2, 9);
const newModal: ModalItem = {
id,
type,
data,
isOpen: true,
};
setModalState(prev => ({
stack: [...prev.stack, newModal],
}));
return id;
}, []);
const closeModal = useCallback((id: string) => {
setModalState(prev => ({
stack: prev.stack.filter(modal => modal.id !== id),
}));
}, []);
const closeTopModal = useCallback(() => {
setModalState(prev => ({
stack: prev.stack.slice(0, -1),
}));
}, []);
const openSongInfo = useCallback((song: Song): string => {
return openModal(ModalType.SONG_INFO, song);
}, [openModal]);
const openSelectSinger = useCallback((song: Song): string => {
return openModal(ModalType.SELECT_SINGER, song);
}, [openModal]);
const contextValue = {
openModal,
closeModal,
closeTopModal,
openSongInfo,
openSelectSinger,
modalState,
};
// Render all modals in the stack
const renderModals = () => {
return modalState.stack.map((modal) => {
const handleClose = () => closeModal(modal.id);
switch (modal.type) {
case ModalType.SONG_INFO:
return (
<SongInfo
key={modal.id}
isOpen={modal.isOpen}
onClose={handleClose}
song={modal.data}
/>
);
case ModalType.SELECT_SINGER:
return (
<SelectSinger
key={modal.id}
isOpen={modal.isOpen}
onClose={handleClose}
song={modal.data}
/>
);
default:
return null;
}
});
};
return (
<ModalContext.Provider value={contextValue}>
{children}
{renderModals()}
</ModalContext.Provider>
);
};

View File

@ -1,9 +1,6 @@
import React, { useState } from 'react';
import {
IonModal,
IonHeader,
IonToolbar,
IonTitle,
IonContent,
IonList,
IonItem,
@ -13,9 +10,7 @@ import { useAppSelector } from '../../redux';
import { selectSingersArray, selectControllerName, selectQueueObject } from '../../redux';
import { queueService } from '../../firebase/services';
import { useToast } from '../../hooks/useToast';
import { ActionButton } from './index';
import { ActionButtonVariant, ActionButtonSize, ActionButtonIconSlot } from '../../types';
import { Icons } from '../../constants';
import { ModalHeader } from './ModalHeader';
import type { Song, Singer, QueueItem } from '../../types';
interface SelectSingerProps {
@ -72,22 +67,10 @@ const SelectSinger: React.FC<SelectSingerProps> = ({ isOpen, onClose, song }) =>
onDidDismiss={onClose}
breakpoints={[0, 0.5, 0.8]}
initialBreakpoint={0.8}
keepContentsMounted={false}
backdropDismiss={true}
>
<IonHeader>
<IonToolbar>
<IonTitle>Select Singer</IonTitle>
<div slot="end">
<ActionButton
onClick={onClose}
variant={ActionButtonVariant.SECONDARY}
size={ActionButtonSize.SMALL}
icon={Icons.CLOSE}
iconSlot={ActionButtonIconSlot.ICON_ONLY}
fill="clear"
/>
</div>
</IonToolbar>
</IonHeader>
<ModalHeader title="Select Singer" onClose={onClose} />
<IonContent>
{/* Song Information */}

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react';
import {
IonModal, IonHeader, IonToolbar, IonTitle, IonContent,
IonModal, IonContent,
IonButton, IonIcon, IonList, IonItem, IonLabel
} from '@ionic/react';
import {
@ -10,12 +10,10 @@ import { useAppSelector } from '../../redux';
import { selectIsAdmin, selectFavorites, selectSongs, selectQueue } from '../../redux';
import { useSongOperations } from '../../hooks/useSongOperations';
import { useDisabledSongs } from '../../hooks/useDisabledSongs';
import { useSelectSinger } from '../../hooks/useSelectSinger';
import { useModal } from '../../hooks/useModalContext';
import { useToast } from '../../hooks/useToast';
import SelectSinger from './SelectSinger';
import { ActionButton } from './index';
import { ActionButtonVariant, ActionButtonSize, ActionButtonIconSlot } from '../../types';
import { Icons } from '../../constants';
import { ModalHeader } from './ModalHeader';
import { SongInfoDisplay } from './SongItem';
import type { Song, QueueItem } from '../../types';
@ -34,12 +32,7 @@ const SongInfo: React.FC<SongInfoProps> = ({ isOpen, onClose, song }) => {
const { isSongDisabled, addDisabledSong, removeDisabledSong } = useDisabledSongs();
const { showSuccess, showError } = useToast();
const {
isOpen: isSelectSingerOpen,
selectedSong: selectSingerSong,
openSelectSinger,
closeSelectSinger
} = useSelectSinger();
const { openSelectSinger } = useModal();
const [showArtistSongs, setShowArtistSongs] = useState(false);
const isInFavorites = (Object.values(favorites) as Song[]).some(favSong => favSong.path === song.path);
@ -89,22 +82,10 @@ const SongInfo: React.FC<SongInfoProps> = ({ isOpen, onClose, song }) => {
onDidDismiss={onClose}
breakpoints={[0, 0.5, 0.8]}
initialBreakpoint={0.8}
keepContentsMounted={false}
backdropDismiss={true}
>
<IonHeader>
<IonToolbar>
<IonTitle>Song Info</IonTitle>
<div slot="end">
<ActionButton
onClick={onClose}
variant={ActionButtonVariant.SECONDARY}
size={ActionButtonSize.SMALL}
icon={Icons.CLOSE}
iconSlot={ActionButtonIconSlot.ICON_ONLY}
fill="clear"
/>
</div>
</IonToolbar>
</IonHeader>
<ModalHeader title="Song Info" onClose={onClose} />
<IonContent>
<div className="p-4">
@ -177,15 +158,6 @@ const SongInfo: React.FC<SongInfoProps> = ({ isOpen, onClose, song }) => {
</IonContent>
</IonModal>
{/* Select Singer Modal */}
{selectSingerSong && (
<SelectSinger
isOpen={isSelectSingerOpen}
onClose={closeSelectSinger}
song={selectSingerSong}
/>
)}
{/* Artist Songs Modal */}
<IonModal
isOpen={showArtistSongs}
@ -193,18 +165,10 @@ const SongInfo: React.FC<SongInfoProps> = ({ isOpen, onClose, song }) => {
breakpoints={[0, 0.5, 0.8]}
initialBreakpoint={0.8}
>
<IonHeader>
<IonToolbar>
<IonTitle>Songs by {song.artist}</IonTitle>
<ActionButton
onClick={() => setShowArtistSongs(false)}
variant={ActionButtonVariant.SECONDARY}
size={ActionButtonSize.SMALL}
icon={Icons.CLOSE}
iconSlot={ActionButtonIconSlot.ICON_ONLY}
/>
</IonToolbar>
</IonHeader>
<ModalHeader
title={`Songs by ${song.artist}`}
onClose={() => setShowArtistSongs(false)}
/>
<IonContent>
<div className="p-4">

View File

@ -1,48 +0,0 @@
import React, { useState, useCallback } from 'react';
import type { ReactNode } from 'react';
import SongInfo from './SongInfo';
import { SongInfoContext, type SongInfoContextType } from '../../contexts/SongInfoContext';
import type { Song } from '../../types';
interface SongInfoProviderProps {
children: ReactNode;
}
export const SongInfoProvider: React.FC<SongInfoProviderProps> = ({ children }) => {
const [selectedSong, setSelectedSong] = useState<Song | null>(null);
const [isOpen, setIsOpen] = useState(false);
const openSongInfo = useCallback((song: Song) => {
setSelectedSong(song);
setIsOpen(true);
}, []);
const closeSongInfo = useCallback(() => {
setIsOpen(false);
setSelectedSong(null);
}, []);
const contextValue: SongInfoContextType = {
openSongInfo,
closeSongInfo,
isOpen,
selectedSong,
};
return (
<SongInfoContext.Provider value={contextValue}>
{children}
{/* Song Info Modal */}
{selectedSong && (
<SongInfo
isOpen={isOpen}
onClose={closeSongInfo}
song={selectedSong}
/>
)}
</SongInfoContext.Provider>
);
};

View File

@ -5,7 +5,7 @@ import { useAppSelector } from '../../redux';
import { selectQueue, selectFavorites } from '../../redux';
import { useSongOperations } from '../../hooks/useSongOperations';
import { useToast } from '../../hooks/useToast';
import { useSongInfo } from '../../hooks/useSongInfoContext';
import { useModal } from '../../hooks/useModalContext';
import { debugLog } from '../../utils/logger';
import type { SongItemProps, QueueItem, Song } from '../../types';
import { ActionButtonVariant, ActionButtonSize, ActionButtonIconSlot } from '../../types';
@ -217,7 +217,7 @@ const SongItem: React.FC<SongItemProps> = ({
// Get song operations and hooks
const { addToQueue, removeFromQueue, toggleFavorite } = useSongOperations();
const { showSuccess, showError } = useToast();
const { openSongInfo } = useSongInfo();
const { openSongInfo } = useModal();
// 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);

View File

@ -9,4 +9,5 @@ export { default as SongInfo } from './SongInfo';
export { default as Toast } from './Toast';
export { TwoLineDisplay } from './TwoLineDisplay';
export { ListItem } from './ListItem';
export { NumberDisplay } from './NumberDisplay';
export { NumberDisplay } from './NumberDisplay';
export { ModalHeader } from './ModalHeader';

View File

@ -0,0 +1,4 @@
import { createContext } from 'react';
import type { ModalContextType } from '../types/modal';
export const ModalContext = createContext<ModalContextType | undefined>(undefined);

View File

@ -1,11 +0,0 @@
import { createContext } from 'react';
import type { Song } from '../types';
export interface SongInfoContextType {
openSongInfo: (song: Song) => void;
closeSongInfo: () => void;
isOpen: boolean;
selectedSong: Song | null;
}
export const SongInfoContext = createContext<SongInfoContextType | null>(null);

View File

@ -1,9 +1,7 @@
import React, { useState } from 'react';
import { IonSearchbar, IonModal, IonHeader, IonToolbar, IonTitle, IonContent, IonItem } from '@ionic/react';
import { IonSearchbar, IonModal, IonContent, IonItem } from '@ionic/react';
import { list } from 'ionicons/icons';
import { InfiniteScrollList, SongItem, ListItem, NumberDisplay, ActionButton } from '../../components/common';
import { ActionButtonVariant, ActionButtonSize, ActionButtonIconSlot } from '../../types';
import { Icons } from '../../constants';
import { InfiniteScrollList, SongItem, ListItem, NumberDisplay, ModalHeader } from '../../components/common';
import { useArtists } from '../../hooks';
import { useAppSelector } from '../../redux';
import { selectSongs } from '../../redux';
@ -82,21 +80,7 @@ const Artists: React.FC = () => {
isOpen={!!selectedArtist}
onDidDismiss={handleCloseArtistSongs}
>
<IonHeader>
<IonToolbar>
<IonTitle>Songs by {selectedArtist}</IonTitle>
<div slot="end">
<ActionButton
onClick={handleCloseArtistSongs}
variant={ActionButtonVariant.SECONDARY}
size={ActionButtonSize.SMALL}
icon={Icons.CLOSE}
iconSlot={ActionButtonIconSlot.ICON_ONLY}
fill="clear"
/>
</div>
</IonToolbar>
</IonHeader>
<ModalHeader title={`Songs by ${selectedArtist}`} onClose={handleCloseArtistSongs} />
<IonContent>
<div style={{ padding: '10px' }}>

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { IonItem, IonLabel, IonModal, IonHeader, IonToolbar, IonTitle, IonButton, IonContent, IonInput, IonLabel as IonInputLabel } from '@ionic/react';
import { InfiniteScrollList, ActionButton, NumberDisplay } from '../../components/common';
import { IonItem, IonLabel, IonModal, IonButton, IonContent, IonInput, IonLabel as IonInputLabel } from '@ionic/react';
import { InfiniteScrollList, ActionButton, NumberDisplay, ModalHeader } from '../../components/common';
import { useSingers } from '../../hooks';
import { useAppSelector } from '../../redux';
import { selectSingers } from '../../redux';
@ -109,21 +109,7 @@ const Singers: React.FC = () => {
breakpoints={[0, 0.5, 0.8]}
initialBreakpoint={0.8}
>
<IonHeader>
<IonToolbar>
<IonTitle>Add New Singer</IonTitle>
<div slot="end">
<ActionButton
onClick={handleCloseAddModal}
variant={ActionButtonVariant.SECONDARY}
size={ActionButtonSize.SMALL}
icon={Icons.CLOSE}
iconSlot={ActionButtonIconSlot.ICON_ONLY}
fill="clear"
/>
</div>
</IonToolbar>
</IonHeader>
<ModalHeader title="Add New Singer" onClose={handleCloseAddModal} />
<IonContent className="ion-padding">
<div>

View File

@ -1,9 +1,7 @@
import React, { useState, useMemo, useCallback } from 'react';
import { IonItem, IonModal, IonHeader, IonToolbar, IonTitle, IonChip, IonContent, IonList, IonAccordionGroup, IonAccordion } from '@ionic/react';
import { IonItem, IonModal, IonChip, IonContent, IonList, IonAccordionGroup, IonAccordion } from '@ionic/react';
import { list } from 'ionicons/icons';
import { InfiniteScrollList, SongItem, ListItem, TwoLineDisplay, NumberDisplay, ActionButton } from '../../components/common';
import { ActionButtonVariant, ActionButtonSize, ActionButtonIconSlot } from '../../types';
import { Icons } from '../../constants';
import { InfiniteScrollList, SongItem, ListItem, TwoLineDisplay, NumberDisplay, ModalHeader } from '../../components/common';
import { useSongLists } from '../../hooks';
import { useAppSelector } from '../../redux';
import { selectSongList } from '../../redux';
@ -90,21 +88,7 @@ const SongLists: React.FC = () => {
isOpen={!!finalSelectedList}
onDidDismiss={handleCloseSongList}
>
<IonHeader>
<IonToolbar>
<IonTitle>{finalSelectedList?.title}</IonTitle>
<div slot="end">
<ActionButton
onClick={handleCloseSongList}
variant={ActionButtonVariant.SECONDARY}
size={ActionButtonSize.SMALL}
icon={Icons.CLOSE}
iconSlot={ActionButtonIconSlot.ICON_ONLY}
fill="clear"
/>
</div>
</IonToolbar>
</IonHeader>
<ModalHeader title={finalSelectedList?.title || ''} onClose={handleCloseSongList} />
<IonContent>
<IonAccordionGroup value={expandedSongKey}>

View File

@ -1,15 +1,14 @@
import React, { useState, useMemo, useCallback } from 'react';
import { IonChip, IonModal, IonHeader, IonToolbar, IonTitle, IonIcon, IonContent, IonList } from '@ionic/react';
import { IonChip, IonModal, IonIcon, IonContent, IonList } from '@ionic/react';
import { list } from 'ionicons/icons';
import { useTopPlayed } from '../../hooks';
import { useAppSelector } from '../../redux';
import { selectTopPlayed, selectSongsArray } from '../../redux';
import { InfiniteScrollList, SongItem, ListItem, ActionButton } from '../../components/common';
import { InfiniteScrollList, SongItem, ListItem, ModalHeader } from '../../components/common';
import { filterSongs } from '../../utils/dataProcessing';
import { debugLog } from '../../utils/logger';
import { ActionButtonVariant, ActionButtonSize, ActionButtonIconSlot } from '../../types';
import { Icons } from '../../constants';
import type { TopPlayed } from '../../types';
const Top100: React.FC = () => {
@ -117,21 +116,7 @@ const Top100: React.FC = () => {
breakpoints={[0, 0.5, 0.8]}
initialBreakpoint={0.8}
>
<IonHeader>
<IonToolbar>
<IonTitle>{selectedTopPlayed?.artist}</IonTitle>
<div slot="end">
<ActionButton
onClick={handleCloseModal}
variant={ActionButtonVariant.SECONDARY}
size={ActionButtonSize.SMALL}
icon={Icons.CLOSE}
iconSlot={ActionButtonIconSlot.ICON_ONLY}
fill="clear"
/>
</div>
</IonToolbar>
</IonHeader>
<ModalHeader title={selectedTopPlayed?.artist || ''} onClose={handleCloseModal} />
<IonContent>
<IonList>

View File

@ -11,5 +11,5 @@ export { useArtists } from './useArtists';
export { useSingers } from './useSingers';
export { useSongLists } from './useSongLists';
export { useDisabledSongs } from './useDisabledSongs';
export { useSelectSinger } from './useSelectSinger';
export { useSongInfo } from './useSongInfo';

View File

@ -0,0 +1,12 @@
import { useContext } from 'react';
import { ModalContext } from '../contexts/ModalContext';
export const useModal = () => {
const context = useContext(ModalContext);
if (context === undefined) {
throw new Error('useModal must be used within a ModalProvider');
}
return context;
};

View File

@ -1,24 +0,0 @@
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,
};
};

View File

@ -1,10 +0,0 @@
import { useContext } from 'react';
import { SongInfoContext, type SongInfoContextType } from '../contexts/SongInfoContext';
export const useSongInfo = (): SongInfoContextType => {
const context = useContext(SongInfoContext);
if (context === null) {
throw new Error('useSongInfo must be used within a SongInfoProvider');
}
return context;
};

View File

@ -160,6 +160,7 @@ export interface ActionButtonProps {
iconSlot?: ActionButtonIconSlotType;
iconSize?: ActionButtonIconSizeType;
fill?: 'solid' | 'outline' | 'clear';
'aria-label'?: string;
}
export interface SongItemProps {
@ -195,4 +196,8 @@ export interface RootState {
loading: boolean;
error: string | null;
};
}
}
// Modal types
export { ModalType } from './modal';
export type { ModalViewType, ModalState, ModalContextType, ModalItem } from './modal';

33
src/types/modal.ts Normal file
View File

@ -0,0 +1,33 @@
import type { Song } from './index';
export const ModalType = {
SONG_INFO: 'songInfo',
SELECT_SINGER: 'selectSinger'
} as const;
export type ModalViewType = typeof ModalType[keyof typeof ModalType];
export interface ModalItem {
id: string;
type: ModalViewType;
data: Song;
isOpen: boolean;
}
export interface ModalState {
stack: ModalItem[];
}
export interface ModalContextType {
// Generic modal methods
openModal: (type: ModalViewType, data: Song) => string; // Returns modal ID
closeModal: (id: string) => void;
closeTopModal: () => void;
// Specific modal methods for convenience
openSongInfo: (song: Song) => string;
openSelectSinger: (song: Song) => string;
// State
modalState: ModalState;
}