From 0bbd36010ae79cf8c591ea403cb2b5f1caf17dc7 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Thu, 17 Jul 2025 17:21:26 -0500 Subject: [PATCH] Signed-off-by: Matt Bruce --- src/App.tsx | 2 - src/components/Layout/Layout.tsx | 122 +++++---- src/components/Navigation/Navigation.tsx | 171 ++++++++---- src/features/Artists/Artists.tsx | 146 +++++------ src/features/Favorites/Favorites.tsx | 48 ++-- src/features/History/History.tsx | 56 ++-- src/features/NewSongs/NewSongs.tsx | 52 ++-- src/features/Search/Search.tsx | 22 +- src/features/Singers/Singers.tsx | 150 ++++++----- src/features/SongLists/SongLists.tsx | 320 ++++++++++++----------- src/features/TopPlayed/Top100.tsx | 117 ++++----- src/index.css | 18 ++ 12 files changed, 674 insertions(+), 550 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 1402c5b..a241b82 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,5 @@ import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import Layout from './components/Layout/Layout'; -import Navigation from './components/Navigation/Navigation'; import { Search, Queue, History, Favorites, NewSongs, Artists, Singers, SongLists } from './features'; import TopPlayed from './features/TopPlayed/Top100'; import { FirebaseProvider } from './firebase/FirebaseProvider'; @@ -14,7 +13,6 @@ function App() { - } /> } /> diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx index f11bec4..33b5364 100644 --- a/src/components/Layout/Layout.tsx +++ b/src/components/Layout/Layout.tsx @@ -1,9 +1,10 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { useSelector, useDispatch } from 'react-redux'; -import { IonApp, IonHeader, IonToolbar, IonTitle, IonContent, IonFooter, IonChip } from '@ionic/react'; +import { IonApp, IonHeader, IonToolbar, IonTitle, IonContent, IonChip, IonMenuButton } from '@ionic/react'; import { selectCurrentSinger, selectIsAdmin, selectControllerName } from '../../redux/authSlice'; import { logout } from '../../redux/authSlice'; import { ActionButton } from '../common'; +import Navigation from '../Navigation/Navigation'; import type { LayoutProps } from '../../types'; const Layout: React.FC = ({ children }) => { @@ -11,6 +12,18 @@ const Layout: React.FC = ({ children }) => { const isAdmin = useSelector(selectIsAdmin); const controllerName = useSelector(selectControllerName); const dispatch = useDispatch(); + const [isLargeScreen, setIsLargeScreen] = useState(false); + + // Check screen size for responsive layout + useEffect(() => { + const checkScreenSize = () => { + setIsLargeScreen(window.innerWidth >= 768); + }; + + checkScreenSize(); + window.addEventListener('resize', checkScreenSize); + return () => window.removeEventListener('resize', checkScreenSize); + }, []); const handleLogout = () => { dispatch(logout()); @@ -20,53 +33,70 @@ const Layout: React.FC = ({ children }) => { return ( - - - -
- 🎤 Karaoke App - {controllerName && ( - - Party: {controllerName} - - )} -
-
- - {/* User Info & Logout */} - {currentSinger && ( -
-
- {currentSinger} - {isAdmin && ( - - Admin - + {/* Navigation - rendered outside header for proper positioning */} + + + {/* Main content wrapper */} +
+ + + {/* Only show hamburger button on mobile */} + {!isLargeScreen && } + + +
+ 🎤 Karaoke App + {controllerName && ( + + Party: {controllerName} + )}
- - Logout - -
- )} - - + + + {/* User Info & Logout */} + {currentSinger && ( +
+
+ {currentSinger} + {isAdmin && ( + + Admin + + )} +
+ + Logout + +
+ )} + + - - {children} - - - - -
-

🎵 Powered by Firebase Realtime Database

-
-
-
+ + {children} + +
); }; diff --git a/src/components/Navigation/Navigation.tsx b/src/components/Navigation/Navigation.tsx index 2a9be16..c5eb310 100644 --- a/src/components/Navigation/Navigation.tsx +++ b/src/components/Navigation/Navigation.tsx @@ -1,11 +1,12 @@ -import React from 'react'; -import { IonTabs, IonTabBar, IonTabButton, IonLabel, IonIcon } from '@ionic/react'; +import React, { useState, useEffect } from 'react'; +import { IonMenu, IonHeader, IonToolbar, IonTitle, IonContent, IonList, IonItem, IonLabel, IonIcon } from '@ionic/react'; import { list, search, heart, add, mic, documentText, time, trophy, people } from 'ionicons/icons'; import { useLocation, useNavigate } from 'react-router-dom'; const Navigation: React.FC = () => { const location = useLocation(); const navigate = useNavigate(); + const [isLargeScreen, setIsLargeScreen] = useState(false); const navItems = [ { path: '/queue', label: 'Queue', icon: list }, @@ -19,62 +20,126 @@ const Navigation: React.FC = () => { { path: '/singers', label: 'Singers', icon: people }, ]; - // For mobile, show bottom tabs with main features - const mobileNavItems = [ - { path: '/queue', label: 'Queue', icon: list }, - { path: '/search', label: 'Search', icon: search }, - { path: '/favorites', label: 'Favorites', icon: heart }, - { path: '/history', label: 'History', icon: time }, - ]; + // Check screen size for responsive menu behavior + useEffect(() => { + const checkScreenSize = () => { + const large = window.innerWidth >= 768; + console.log('Screen width:', window.innerWidth, 'Is large screen:', large); + setIsLargeScreen(large); + }; - // Check if we're on mobile (you can adjust this breakpoint) - const isMobile = window.innerWidth < 768; + checkScreenSize(); + window.addEventListener('resize', checkScreenSize); + return () => window.removeEventListener('resize', checkScreenSize); + }, []); - const currentItems = isMobile ? mobileNavItems : navItems; + const handleNavigation = (path: string) => { + navigate(path); + // Close menu on mobile after navigation + if (!isLargeScreen) { + const menu = document.querySelector('ion-menu'); + if (menu) { + menu.close(); + } + } + }; - return ( - <> - {isMobile ? ( - - - {currentItems.map((item) => ( - navigate(item.path)} - > - - {item.label} - - ))} - - - ) : ( - - )} - +
+ ); + } + + // For mobile screens, use the Ionic menu + console.log('Rendering mobile menu'); + return ( + + + + Menu + + + + + {navItems.map((item) => ( + handleNavigation(item.path)} + className={location.pathname === item.path ? 'ion-activated' : ''} + > + + {item.label} + + ))} + + + ); }; diff --git a/src/features/Artists/Artists.tsx b/src/features/Artists/Artists.tsx index 3db93d9..6e0512e 100644 --- a/src/features/Artists/Artists.tsx +++ b/src/features/Artists/Artists.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; -import { ActionButton } from '../../components/common'; +import { IonSearchbar, IonList, IonItem, IonLabel, IonModal, IonHeader, IonToolbar, IonTitle, IonContent, IonButton, IonIcon, IonChip } from '@ionic/react'; +import { close, add, heart, heartOutline } from 'ionicons/icons'; import { useArtists } from '../../hooks'; import { useAppSelector } from '../../redux'; import { selectSongs } from '../../redux'; @@ -72,20 +73,13 @@ const Artists: React.FC = () => {

Artists

{/* Search Input */} -
- handleSearchChange(e.target.value)} - className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" - /> -
- - - -
-
+ handleSearchChange(e.detail.value || '')} + debounce={300} + showClearButton="focus" + /> {/* Debug info */}
@@ -120,27 +114,21 @@ const Artists: React.FC = () => {

) : ( -
+ {artists.map((artist) => ( -
-
+ handleArtistClick(artist)}> +

{artist}

{getSongCountByArtist(artist)} song{getSongCountByArtist(artist) !== 1 ? 's' : ''}

-
-
- handleArtistClick(artist)} - variant="primary" - size="sm" - > - View Songs - -
-
+ + + View Songs + + ))} {/* Infinite scroll trigger */} @@ -158,66 +146,54 @@ const Artists: React.FC = () => {
)} - + )} {/* Artist Songs Modal */} - {selectedArtist && ( -
-
-
-
-

- Songs by {selectedArtist} -

- - Close - -
-
- -
-
- {selectedArtistSongs.map((song) => ( -
-
-
-

- {song.title} -

-

- {song.artist} -

-
-
- handleAddToQueue(song)} - variant="primary" - size="sm" - > - Add to Queue - - handleToggleFavorite(song)} - variant="secondary" - size="sm" - > - {song.favorite ? 'Remove from Favorites' : 'Add to Favorites'} - -
-
-
- ))} -
-
-
-
- )} + + + + Songs by {selectedArtist} + + + + + + + + + {selectedArtistSongs.map((song) => ( + + +

+ {song.title} +

+

+ {song.artist} +

+
+
+ handleAddToQueue(song)} + > + + + handleToggleFavorite(song)} + > + + +
+
+ ))} +
+
+
); }; diff --git a/src/features/Favorites/Favorites.tsx b/src/features/Favorites/Favorites.tsx index 2257386..4a10a07 100644 --- a/src/features/Favorites/Favorites.tsx +++ b/src/features/Favorites/Favorites.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { IonHeader, IonToolbar, IonTitle, IonChip } from '@ionic/react'; import { InfiniteScrollList } from '../../components/common'; import { useFavorites } from '../../hooks'; import { useAppSelector } from '../../redux'; @@ -21,22 +22,37 @@ const Favorites: React.FC = () => { console.log('Favorites component - favorites items:', favoritesItems); return ( - + <> + + + + Favorites + + {favoritesItems.length} + + + + + +
+ +
+ ); }; diff --git a/src/features/History/History.tsx b/src/features/History/History.tsx index 499e143..d6a4cb1 100644 --- a/src/features/History/History.tsx +++ b/src/features/History/History.tsx @@ -1,4 +1,6 @@ import React from 'react'; +import { IonHeader, IonToolbar, IonTitle, IonChip, IonIcon } from '@ionic/react'; +import { time } from 'ionicons/icons'; import { InfiniteScrollList } from '../../components/common'; import { useHistory } from '../../hooks'; import { useAppSelector } from '../../redux'; @@ -26,32 +28,48 @@ const History: React.FC = () => { const renderExtraContent = (item: Song) => { if (item.date) { return ( -
+ + {formatDate(item.date)} -
+ ); } return null; }; return ( - + <> + + + + Recently Played + + {historyItems.length} + + + + + +
+ +
+ ); }; diff --git a/src/features/NewSongs/NewSongs.tsx b/src/features/NewSongs/NewSongs.tsx index dbb860a..d21e561 100644 --- a/src/features/NewSongs/NewSongs.tsx +++ b/src/features/NewSongs/NewSongs.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { IonHeader, IonToolbar, IonTitle, IonChip } from '@ionic/react'; import { InfiniteScrollList } from '../../components/common'; import { useNewSongs } from '../../hooks'; import { useAppSelector } from '../../redux'; @@ -17,26 +18,41 @@ const NewSongs: React.FC = () => { const newSongsCount = Object.keys(newSongs).length; // Debug logging - console.log('NewSongs component - newSongs count:', newSongsCount); - console.log('NewSongs component - newSongs items:', newSongsItems); + console.log('NewSongs component - new songs count:', newSongsCount); + console.log('NewSongs component - new songs items:', newSongsItems); return ( - + <> + + + + New Songs + + {newSongsItems.length} + + + + + +
+ +
+ ); }; diff --git a/src/features/Search/Search.tsx b/src/features/Search/Search.tsx index 2bdf7d8..ec03b3a 100644 --- a/src/features/Search/Search.tsx +++ b/src/features/Search/Search.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { IonSearchbar } from '@ionic/react'; import { InfiniteScrollList } from '../../components/common'; import { useSearch } from '../../hooks'; import { useAppSelector } from '../../redux'; @@ -39,20 +40,13 @@ const Search: React.FC = () => {

Search Songs

{/* Search Input */} -
- handleSearchChange(e.target.value)} - className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" - /> -
- - - -
-
+ handleSearchChange(e.detail.value || '')} + debounce={300} + showClearButton="focus" + /> {/* Debug info */}
diff --git a/src/features/Singers/Singers.tsx b/src/features/Singers/Singers.tsx index 08bd6df..081ce5b 100644 --- a/src/features/Singers/Singers.tsx +++ b/src/features/Singers/Singers.tsx @@ -1,5 +1,7 @@ import React from 'react'; -import { ActionButton, EmptyState } from '../../components/common'; +import { IonHeader, IonToolbar, IonTitle, IonContent, IonList, IonItem, IonLabel, IonItemSliding, IonItemOptions, IonItemOption, IonIcon, IonChip } from '@ionic/react'; +import { people, trash, time } from 'ionicons/icons'; +import { EmptyState } from '../../components/common'; import { useSingers } from '../../hooks'; import { useAppSelector } from '../../redux'; import { selectSingers } from '../../redux'; @@ -20,73 +22,89 @@ const Singers: React.FC = () => { console.log('Singers component - singers:', singers); return ( -
-
-

Singers

-

- {singers.length} singer{singers.length !== 1 ? 's' : ''} in the party -

- - {/* Debug info */} -
- Singers loaded: {singersCount} -
-
+ <> + + + + Singers + + {singers.length} + + + + - {/* Singers List */} -
- {singersCount === 0 ? ( - - - - } - /> - ) : singers.length === 0 ? ( - - - - } - /> - ) : ( -
- {singers.map((singer) => ( -
- {/* Singer Info */} -
-

- {singer.name} -

-

- Last login: {formatDate(singer.lastLogin)} -

-
- - {/* Admin Controls */} - {isAdmin && ( -
- handleRemoveSinger(singer)} - variant="danger" - size="sm" - > - Remove - -
- )} -
- ))} + +
+

+ {singers.length} singer{singers.length !== 1 ? 's' : ''} in the party +

+ + {/* Debug info */} +
+ Singers loaded: {singersCount}
- )} -
-
+ + {/* Singers List */} +
+ {singersCount === 0 ? ( + + + + } + /> + ) : singers.length === 0 ? ( + + + + } + /> + ) : ( + + {singers.map((singer) => ( + + + + +

+ {singer.name} +

+
+ + + {formatDate(singer.lastLogin)} + +
+
+
+ + {/* Swipe to Remove (Admin Only) */} + {isAdmin && ( + + handleRemoveSinger(singer)} + > + + + + )} +
+ ))} +
+ )} +
+
+ + ); }; diff --git a/src/features/SongLists/SongLists.tsx b/src/features/SongLists/SongLists.tsx index 7fe96be..692af0b 100644 --- a/src/features/SongLists/SongLists.tsx +++ b/src/features/SongLists/SongLists.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; -import { ActionButton, SongItem } from '../../components/common'; +import { IonHeader, IonToolbar, IonTitle, IonContent, IonList, IonItem, IonLabel, IonModal, IonButton, IonIcon, IonChip, IonAccordion, IonAccordionGroup } from '@ionic/react'; +import { close, documentText, add, heart, heartOutline } from 'ionicons/icons'; import { useSongLists } from '../../hooks'; import { useAppSelector } from '../../redux'; import { selectSongList } from '../../redux'; @@ -47,7 +48,6 @@ const SongLists: React.FC = () => { return () => observer.disconnect(); }, [loadMore, hasMore, songListCount]); const [selectedSongList, setSelectedSongList] = useState(null); - const [expandedSongs, setExpandedSongs] = useState>(new Set()); // Debug logging - only log when data changes useEffect(() => { @@ -64,16 +64,6 @@ const SongLists: React.FC = () => { setSelectedSongList(null); }; - const handleToggleExpanded = (songKey: string) => { - const newExpanded = new Set(expandedSongs); - if (newExpanded.has(songKey)) { - newExpanded.delete(songKey); - } else { - newExpanded.add(songKey); - } - setExpandedSongs(newExpanded); - }; - const finalSelectedList = selectedSongList ? allSongLists.find(list => list.key === selectedSongList) : null; @@ -93,164 +83,176 @@ const SongLists: React.FC = () => { }, [selectedSongList, finalSelectedList, songLists.length]); return ( -
-
-

Song Lists

-

- {songLists.length} song list{songLists.length !== 1 ? 's' : ''} available -

- - {/* Debug info */} -
- Song lists loaded: {songListCount} -
-
+ <> + + + + Song Lists + + {songLists.length} + + + + - {/* Song Lists */} -
- {songListCount === 0 ? ( -
-
- - - -
-

Loading song lists...

-

Please wait while song lists are being loaded

+ +
+

+ {songLists.length} song list{songLists.length !== 1 ? 's' : ''} available +

+ + {/* Debug info */} +
+ Song lists loaded: {songListCount}
- ) : songLists.length === 0 ? ( -
-
- - - -
-

No song lists available

-

Song lists will appear here when they're available

-
- ) : ( -
- {songLists.map((songList) => ( -
-
-

- {songList.title} -

-

- {songList.songs.length} song{songList.songs.length !== 1 ? 's' : ''} -

-
-
- handleSongListClick(songList.key!)} - variant="primary" - size="sm" - > - View Songs - -
-
- ))} - - {/* Infinite scroll trigger */} - {hasMore && ( -
-
- - - + + {/* Song Lists */} +
+ {songListCount === 0 ? ( +
+
+ + - Loading more song lists...
+

Loading song lists...

+

Please wait while song lists are being loaded

+ ) : songLists.length === 0 ? ( +
+
+ + + +
+

No song lists available

+

Song lists will appear here when they're available

+
+ ) : ( + + {songLists.map((songList) => ( + handleSongListClick(songList.key!)}> + + +

+ {songList.title} +

+

+ {songList.songs.length} song{songList.songs.length !== 1 ? 's' : ''} +

+
+ + View Songs + +
+ ))} + + {/* Infinite scroll trigger */} + {hasMore && ( +
+
+ + + + + Loading more song lists... +
+
+ )} +
)}
- )} -
+
+ {/* Song List Modal */} - {finalSelectedList && ( -
-
-
-
-

- {finalSelectedList.title} -

- - Close - -
-
- -
-
- {finalSelectedList.songs.map((songListSong: SongListSong, idx) => { - const availableSongs = checkSongAvailability(songListSong); - const isExpanded = expandedSongs.has(songListSong.key!); - const isAvailable = availableSongs.length > 0; + + + + {finalSelectedList?.title} + + + + + + + + + {finalSelectedList?.songs.map((songListSong: SongListSong, idx) => { + const availableSongs = checkSongAvailability(songListSong); + const isAvailable = availableSongs.length > 0; - return ( -
- {/* Song List Song Row */} -
-
-

- {songListSong.title} -

-

- {songListSong.artist} • Position {songListSong.position} -

- {!isAvailable && ( -

- Not available in catalog -

- )} -
-
- {isAvailable && ( - handleToggleExpanded(songListSong.key!)} - variant="secondary" - size="sm" - > - {isExpanded ? 'Hide' : 'Show'} ({availableSongs.length}) - - )} -
-
- - {/* Available Songs (when expanded) */} - {isExpanded && isAvailable && ( -
- {availableSongs.map((song: Song, sidx) => ( -
- handleAddToQueue(song)} - onToggleFavorite={() => handleToggleFavorite(song)} - /> -
- ))} -
+ return ( + + + +

+ {songListSong.title} +

+

+ {songListSong.artist} • Position {songListSong.position} +

+ {!isAvailable && ( +

+ Not available in catalog +

)} -
- ); - })} -
-
-
-
- )} -
+ + {isAvailable && ( + + {availableSongs.length} version{availableSongs.length !== 1 ? 's' : ''} + + )} + + +
+ {isAvailable ? ( + + {availableSongs.map((song: Song, sidx) => ( + + +

+ {song.title} +

+

+ {song.artist} +

+
+
+ handleAddToQueue(song)} + > + + + handleToggleFavorite(song)} + > + + +
+
+ ))} +
+ ) : ( +
+ No matching songs found in catalog +
+ )} +
+ + ); + })} + + + + ); }; diff --git a/src/features/TopPlayed/Top100.tsx b/src/features/TopPlayed/Top100.tsx index 16907dd..ee9c145 100644 --- a/src/features/TopPlayed/Top100.tsx +++ b/src/features/TopPlayed/Top100.tsx @@ -1,11 +1,11 @@ import React from 'react'; +import { IonHeader, IonToolbar, IonTitle, IonChip, IonIcon } from '@ionic/react'; import { InfiniteScrollList } from '../../components/common'; import { useTopPlayed } from '../../hooks'; import { useAppSelector } from '../../redux'; import { selectTopPlayed } from '../../redux'; -import type { TopPlayed, Song } from '../../types'; -const Top100: React.FC = () => { +const TopPlayed: React.FC = () => { const { topPlayedItems, hasMore, @@ -18,79 +18,52 @@ const Top100: React.FC = () => { const topPlayedCount = Object.keys(topPlayed).length; // Debug logging - console.log('TopPlayed component - topPlayed count:', topPlayedCount); - console.log('TopPlayed component - topPlayed items:', topPlayedItems); + console.log('TopPlayed component - top played count:', topPlayedCount); + console.log('TopPlayed component - top played items:', topPlayedItems); - // Convert TopPlayed items to Song format for the InfiniteScrollList - const songItems = topPlayedItems.map((item: TopPlayed) => ({ - ...item, - path: '', // TopPlayed doesn't have path - disabled: false, - favorite: false, - })); - - // Render extra content for top played items (rank and play count) - const renderExtraContent = (item: Song, index: number) => { - return ( - <> - {/* Rank */} -
-
2 ? 'bg-gray-50 text-gray-600' : ''} - `}> - {index + 1} -
-
- - {/* Play Count */} -
-
{item.count}
-
- play{item.count !== 1 ? 's' : ''} -
-
- - ); - }; - - // Wrapper functions to handle type conversion - const handleAddToQueueWrapper = (song: Song) => { - const topPlayedItem = topPlayedItems.find(item => item.key === song.key); - if (topPlayedItem) { - handleAddToQueue(topPlayedItem); - } - }; - - const handleToggleFavoriteWrapper = (song: Song) => { - const topPlayedItem = topPlayedItems.find(item => item.key === song.key); - if (topPlayedItem) { - handleToggleFavorite(topPlayedItem); - } - }; + const renderExtraContent = (item: any, index: number) => ( +
+
+ + #{index + 1} +
+
+ ); return ( - + <> + + + + Top 100 Played + + {topPlayedItems.length} + + + + + +
+ +
+ ); }; -export default Top100; \ No newline at end of file +export default TopPlayed; \ No newline at end of file diff --git a/src/index.css b/src/index.css index 02d604f..aea3914 100644 --- a/src/index.css +++ b/src/index.css @@ -15,3 +15,21 @@ @tailwind base; @tailwind components; @tailwind utilities; + +/* Ensure menu button is visible */ +ion-menu-button { + --color: var(--ion-color-primary); + --padding-start: 8px; + --padding-end: 8px; +} + +/* Menu item styling */ +ion-item.ion-activated { + --background: var(--ion-color-primary); + --color: var(--ion-color-primary-contrast); +} + +/* Ensure mobile menu appears above other content */ +ion-menu { + --z-index: 1000; +}