From d98a8a6d14df033574fb5fb4bc44eb1d425e1dbe Mon Sep 17 00:00:00 2001 From: mbrucedogs Date: Sun, 20 Jul 2025 20:10:38 -0500 Subject: [PATCH] Signed-off-by: mbrucedogs --- src/App.tsx | 6 +- src/components/common/ActionButton.tsx | 4 +- src/components/common/ModalHeader.tsx | 36 ++++++++ src/components/common/ModalProvider.tsx | 98 ++++++++++++++++++++++ src/components/common/SelectSinger.tsx | 25 +----- src/components/common/SongInfo.tsx | 60 +++---------- src/components/common/SongInfoProvider.tsx | 48 ----------- src/components/common/SongItem.tsx | 4 +- src/components/common/index.ts | 3 +- src/contexts/ModalContext.tsx | 4 + src/contexts/SongInfoContext.tsx | 11 --- src/features/Artists/Artists.tsx | 22 +---- src/features/Singers/Singers.tsx | 20 +---- src/features/SongLists/SongLists.tsx | 22 +---- src/features/TopPlayed/Top100.tsx | 23 +---- src/hooks/index.ts | 2 +- src/hooks/useModalContext.ts | 12 +++ src/hooks/useSelectSinger.ts | 24 ------ src/hooks/useSongInfoContext.ts | 10 --- src/types/index.ts | 7 +- src/types/modal.ts | 33 ++++++++ 21 files changed, 229 insertions(+), 245 deletions(-) create mode 100644 src/components/common/ModalHeader.tsx create mode 100644 src/components/common/ModalProvider.tsx delete mode 100644 src/components/common/SongInfoProvider.tsx create mode 100644 src/contexts/ModalContext.tsx delete mode 100644 src/contexts/SongInfoContext.tsx create mode 100644 src/hooks/useModalContext.ts delete mode 100644 src/hooks/useSelectSinger.ts delete mode 100644 src/hooks/useSongInfoContext.ts create mode 100644 src/types/modal.ts diff --git a/src/App.tsx b/src/App.tsx index 01afe18..e33f10c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { - + } /> @@ -30,7 +30,7 @@ function App() { } /> - + diff --git a/src/components/common/ActionButton.tsx b/src/components/common/ActionButton.tsx index 16818ab..1bec3a4 100644 --- a/src/components/common/ActionButton.tsx +++ b/src/components/common/ActionButton.tsx @@ -13,7 +13,8 @@ const ActionButton: React.FC = ({ 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 = ({ color={getVariant()} size={getSize()} className={className} + aria-label={ariaLabel} style={{ minWidth: isIconOnly ? '40px' : '40px', minHeight: '40px', diff --git a/src/components/common/ModalHeader.tsx b/src/components/common/ModalHeader.tsx new file mode 100644 index 0000000..b1b066a --- /dev/null +++ b/src/components/common/ModalHeader.tsx @@ -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 = ({ + title, + onClose, + className = '' +}) => { + return ( + + + {title} +
+ +
+
+
+ ); +}; \ No newline at end of file diff --git a/src/components/common/ModalProvider.tsx b/src/components/common/ModalProvider.tsx new file mode 100644 index 0000000..3274f5d --- /dev/null +++ b/src/components/common/ModalProvider.tsx @@ -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 = ({ children }) => { + const [modalState, setModalState] = useState({ + 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 ( + + ); + case ModalType.SELECT_SINGER: + return ( + + ); + default: + return null; + } + }); + }; + + return ( + + {children} + {renderModals()} + + ); +}; \ No newline at end of file diff --git a/src/components/common/SelectSinger.tsx b/src/components/common/SelectSinger.tsx index 09b3d10..8eb211a 100644 --- a/src/components/common/SelectSinger.tsx +++ b/src/components/common/SelectSinger.tsx @@ -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 = ({ isOpen, onClose, song }) => onDidDismiss={onClose} breakpoints={[0, 0.5, 0.8]} initialBreakpoint={0.8} + keepContentsMounted={false} + backdropDismiss={true} > - - - Select Singer -
- -
-
-
+ {/* Song Information */} diff --git a/src/components/common/SongInfo.tsx b/src/components/common/SongInfo.tsx index 5c3f6d2..b3a15e1 100644 --- a/src/components/common/SongInfo.tsx +++ b/src/components/common/SongInfo.tsx @@ -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 = ({ 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 = ({ isOpen, onClose, song }) => { onDidDismiss={onClose} breakpoints={[0, 0.5, 0.8]} initialBreakpoint={0.8} + keepContentsMounted={false} + backdropDismiss={true} > - - - Song Info -
- -
-
-
+
@@ -177,15 +158,6 @@ const SongInfo: React.FC = ({ isOpen, onClose, song }) => { - {/* Select Singer Modal */} - {selectSingerSong && ( - - )} - {/* Artist Songs Modal */} = ({ isOpen, onClose, song }) => { breakpoints={[0, 0.5, 0.8]} initialBreakpoint={0.8} > - - - Songs by {song.artist} - setShowArtistSongs(false)} - variant={ActionButtonVariant.SECONDARY} - size={ActionButtonSize.SMALL} - icon={Icons.CLOSE} - iconSlot={ActionButtonIconSlot.ICON_ONLY} - /> - - + setShowArtistSongs(false)} + />
diff --git a/src/components/common/SongInfoProvider.tsx b/src/components/common/SongInfoProvider.tsx deleted file mode 100644 index 8980faf..0000000 --- a/src/components/common/SongInfoProvider.tsx +++ /dev/null @@ -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 = ({ children }) => { - const [selectedSong, setSelectedSong] = useState(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 ( - - {children} - - {/* Song Info Modal */} - {selectedSong && ( - - )} - - ); -}; - - \ No newline at end of file diff --git a/src/components/common/SongItem.tsx b/src/components/common/SongItem.tsx index c50dbff..b4146da 100644 --- a/src/components/common/SongItem.tsx +++ b/src/components/common/SongItem.tsx @@ -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 = ({ // 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); diff --git a/src/components/common/index.ts b/src/components/common/index.ts index 13723f2..12049c1 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -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'; \ No newline at end of file +export { NumberDisplay } from './NumberDisplay'; +export { ModalHeader } from './ModalHeader'; \ No newline at end of file diff --git a/src/contexts/ModalContext.tsx b/src/contexts/ModalContext.tsx new file mode 100644 index 0000000..6cbb01e --- /dev/null +++ b/src/contexts/ModalContext.tsx @@ -0,0 +1,4 @@ +import { createContext } from 'react'; +import type { ModalContextType } from '../types/modal'; + +export const ModalContext = createContext(undefined); \ No newline at end of file diff --git a/src/contexts/SongInfoContext.tsx b/src/contexts/SongInfoContext.tsx deleted file mode 100644 index d23785d..0000000 --- a/src/contexts/SongInfoContext.tsx +++ /dev/null @@ -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(null); \ No newline at end of file diff --git a/src/features/Artists/Artists.tsx b/src/features/Artists/Artists.tsx index d4f377b..5097dc0 100644 --- a/src/features/Artists/Artists.tsx +++ b/src/features/Artists/Artists.tsx @@ -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} > - - - Songs by {selectedArtist} -
- -
-
-
+
diff --git a/src/features/Singers/Singers.tsx b/src/features/Singers/Singers.tsx index bee0923..4108fb7 100644 --- a/src/features/Singers/Singers.tsx +++ b/src/features/Singers/Singers.tsx @@ -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} > - - - Add New Singer -
- -
-
-
+
diff --git a/src/features/SongLists/SongLists.tsx b/src/features/SongLists/SongLists.tsx index f1a5547..75530f5 100644 --- a/src/features/SongLists/SongLists.tsx +++ b/src/features/SongLists/SongLists.tsx @@ -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} > - - - {finalSelectedList?.title} -
- -
-
-
+ diff --git a/src/features/TopPlayed/Top100.tsx b/src/features/TopPlayed/Top100.tsx index 526e196..3cd91c6 100644 --- a/src/features/TopPlayed/Top100.tsx +++ b/src/features/TopPlayed/Top100.tsx @@ -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} > - - - {selectedTopPlayed?.artist} -
- -
-
-
+ diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 8cb7628..2e1b9b8 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -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'; \ No newline at end of file diff --git a/src/hooks/useModalContext.ts b/src/hooks/useModalContext.ts new file mode 100644 index 0000000..ef42f51 --- /dev/null +++ b/src/hooks/useModalContext.ts @@ -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; +}; \ No newline at end of file diff --git a/src/hooks/useSelectSinger.ts b/src/hooks/useSelectSinger.ts deleted file mode 100644 index b132175..0000000 --- a/src/hooks/useSelectSinger.ts +++ /dev/null @@ -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(null); - - const openSelectSinger = useCallback((song: Song) => { - setSelectedSong(song); - setIsOpen(true); - }, []); - - const closeSelectSinger = useCallback(() => { - setIsOpen(false); - setSelectedSong(null); - }, []); - - return { - isOpen, - selectedSong, - openSelectSinger, - closeSelectSinger, - }; -}; \ No newline at end of file diff --git a/src/hooks/useSongInfoContext.ts b/src/hooks/useSongInfoContext.ts deleted file mode 100644 index c20ccc9..0000000 --- a/src/hooks/useSongInfoContext.ts +++ /dev/null @@ -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; -}; \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index de9d17b..94e35bc 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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; }; -} \ No newline at end of file +} + +// Modal types +export { ModalType } from './modal'; +export type { ModalViewType, ModalState, ModalContextType, ModalItem } from './modal'; \ No newline at end of file diff --git a/src/types/modal.ts b/src/types/modal.ts new file mode 100644 index 0000000..6e35265 --- /dev/null +++ b/src/types/modal.ts @@ -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; +} \ No newline at end of file