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

This commit is contained in:
Matt Bruce 2025-07-17 16:16:00 -05:00
parent 2803c0cfe4
commit 445d72c4f8
12 changed files with 591 additions and 266 deletions

258
package-lock.json generated
View File

@ -5,8 +5,11 @@
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "singsalot-ai",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@ionic/core": "^8.6.5",
"@ionic/react": "^8.6.5",
"@reduxjs/toolkit": "^2.8.2", "@reduxjs/toolkit": "^2.8.2",
"@tailwindcss/postcss": "^4.1.11", "@tailwindcss/postcss": "^4.1.11",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
@ -1221,6 +1224,32 @@
"url": "https://github.com/sponsors/nzakas" "url": "https://github.com/sponsors/nzakas"
} }
}, },
"node_modules/@ionic/core": {
"version": "8.6.5",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.6.5.tgz",
"integrity": "sha512-HN+6/Q67fEEpRA86QzXSrCahuHwaTPBsa910RuvY0pIYuoY4rpzGPU9ZOQ5q2wBsrln921rroEPU1xdpPKIH8Q==",
"license": "MIT",
"dependencies": {
"@stencil/core": "4.33.1",
"ionicons": "^7.2.2",
"tslib": "^2.1.0"
}
},
"node_modules/@ionic/react": {
"version": "8.6.5",
"resolved": "https://registry.npmjs.org/@ionic/react/-/react-8.6.5.tgz",
"integrity": "sha512-reUKqlU3cIJoHuDibB8WUd32a7nqg5aMsIfPnXOVydIUsJdvQnwjACbYiP0g+4AFzTVPsw/Cmyqh85GhXGw4WA==",
"license": "MIT",
"dependencies": {
"@ionic/core": "8.6.5",
"ionicons": "^7.0.0",
"tslib": "*"
},
"peerDependencies": {
"react": ">=16.8.6",
"react-dom": ">=16.8.6"
}
},
"node_modules/@isaacs/fs-minipass": { "node_modules/@isaacs/fs-minipass": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@ -1653,6 +1682,133 @@
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==" "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="
}, },
"node_modules/@stencil/core": {
"version": "4.33.1",
"resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.33.1.tgz",
"integrity": "sha512-12k9xhAJBkpg598it+NRmaYIdEe6TSnsL/v6/KRXDcUyTK11VYwZQej2eHnMWtqot+znJ+GNTqb5YbiXi+5Low==",
"license": "MIT",
"bin": {
"stencil": "bin/stencil"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=7.10.0"
},
"optionalDependencies": {
"@rollup/rollup-darwin-arm64": "4.34.9",
"@rollup/rollup-darwin-x64": "4.34.9",
"@rollup/rollup-linux-arm64-gnu": "4.34.9",
"@rollup/rollup-linux-arm64-musl": "4.34.9",
"@rollup/rollup-linux-x64-gnu": "4.34.9",
"@rollup/rollup-linux-x64-musl": "4.34.9",
"@rollup/rollup-win32-arm64-msvc": "4.34.9",
"@rollup/rollup-win32-x64-msvc": "4.34.9"
}
},
"node_modules/@stencil/core/node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.9.tgz",
"integrity": "sha512-0CY3/K54slrzLDjOA7TOjN1NuLKERBgk9nY5V34mhmuu673YNb+7ghaDUs6N0ujXR7fz5XaS5Aa6d2TNxZd0OQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@stencil/core/node_modules/@rollup/rollup-darwin-x64": {
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.9.tgz",
"integrity": "sha512-eOojSEAi/acnsJVYRxnMkPFqcxSMFfrw7r2iD9Q32SGkb/Q9FpUY1UlAu1DH9T7j++gZ0lHjnm4OyH2vCI7l7Q==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@stencil/core/node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.9.tgz",
"integrity": "sha512-6TZjPHjKZUQKmVKMUowF3ewHxctrRR09eYyvT5eFv8w/fXarEra83A2mHTVJLA5xU91aCNOUnM+DWFMSbQ0Nxw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@stencil/core/node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.9.tgz",
"integrity": "sha512-LD2fytxZJZ6xzOKnMbIpgzFOuIKlxVOpiMAXawsAZ2mHBPEYOnLRK5TTEsID6z4eM23DuO88X0Tq1mErHMVq0A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@stencil/core/node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.9.tgz",
"integrity": "sha512-FwBHNSOjUTQLP4MG7y6rR6qbGw4MFeQnIBrMe161QGaQoBQLqSUEKlHIiVgF3g/mb3lxlxzJOpIBhaP+C+KP2A==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@stencil/core/node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.9.tgz",
"integrity": "sha512-cYRpV4650z2I3/s6+5/LONkjIz8MBeqrk+vPXV10ORBnshpn8S32bPqQ2Utv39jCiDcO2eJTuSlPXpnvmaIgRA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@stencil/core/node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.9.tgz",
"integrity": "sha512-z4mQK9dAN6byRA/vsSgQiPeuO63wdiDxZ9yg9iyX2QTzKuQM7T4xlBoeUP/J8uiFkqxkcWndWi+W7bXdPbt27Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@stencil/core/node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.9.tgz",
"integrity": "sha512-AyleYRPU7+rgkMWbEh71fQlrzRfeP6SyMnRf9XX4fCdDPAJumdSBqYEcWPMzVQ4ScAl7E4oFfK0GUVn77xSwbw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@swc/core": { "node_modules/@swc/core": {
"version": "1.12.14", "version": "1.12.14",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.12.14.tgz", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.12.14.tgz",
@ -3285,6 +3441,15 @@
"node": ">=0.8.19" "node": ">=0.8.19"
} }
}, },
"node_modules/ionicons": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/ionicons/-/ionicons-7.4.0.tgz",
"integrity": "sha512-ZK94MMqgzMCPPMhmk8Ouu6goyVHFIlw/ACP6oe3FrikcI0N7CX0xcwVaEbUc0G/v3W0shI93vo+9ve/KpvcNhQ==",
"license": "MIT",
"dependencies": {
"@stencil/core": "^4.0.3"
}
},
"node_modules/is-extglob": { "node_modules/is-extglob": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@ -5336,6 +5501,26 @@
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
"dev": true "dev": true
}, },
"@ionic/core": {
"version": "8.6.5",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.6.5.tgz",
"integrity": "sha512-HN+6/Q67fEEpRA86QzXSrCahuHwaTPBsa910RuvY0pIYuoY4rpzGPU9ZOQ5q2wBsrln921rroEPU1xdpPKIH8Q==",
"requires": {
"@stencil/core": "4.33.1",
"ionicons": "^7.2.2",
"tslib": "^2.1.0"
}
},
"@ionic/react": {
"version": "8.6.5",
"resolved": "https://registry.npmjs.org/@ionic/react/-/react-8.6.5.tgz",
"integrity": "sha512-reUKqlU3cIJoHuDibB8WUd32a7nqg5aMsIfPnXOVydIUsJdvQnwjACbYiP0g+4AFzTVPsw/Cmyqh85GhXGw4WA==",
"requires": {
"@ionic/core": "8.6.5",
"ionicons": "^7.0.0",
"tslib": "*"
}
},
"@isaacs/fs-minipass": { "@isaacs/fs-minipass": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@ -5621,6 +5806,71 @@
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==" "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="
}, },
"@stencil/core": {
"version": "4.33.1",
"resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.33.1.tgz",
"integrity": "sha512-12k9xhAJBkpg598it+NRmaYIdEe6TSnsL/v6/KRXDcUyTK11VYwZQej2eHnMWtqot+znJ+GNTqb5YbiXi+5Low==",
"requires": {
"@rollup/rollup-darwin-arm64": "4.34.9",
"@rollup/rollup-darwin-x64": "4.34.9",
"@rollup/rollup-linux-arm64-gnu": "4.34.9",
"@rollup/rollup-linux-arm64-musl": "4.34.9",
"@rollup/rollup-linux-x64-gnu": "4.34.9",
"@rollup/rollup-linux-x64-musl": "4.34.9",
"@rollup/rollup-win32-arm64-msvc": "4.34.9",
"@rollup/rollup-win32-x64-msvc": "4.34.9"
},
"dependencies": {
"@rollup/rollup-darwin-arm64": {
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.9.tgz",
"integrity": "sha512-0CY3/K54slrzLDjOA7TOjN1NuLKERBgk9nY5V34mhmuu673YNb+7ghaDUs6N0ujXR7fz5XaS5Aa6d2TNxZd0OQ==",
"optional": true
},
"@rollup/rollup-darwin-x64": {
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.9.tgz",
"integrity": "sha512-eOojSEAi/acnsJVYRxnMkPFqcxSMFfrw7r2iD9Q32SGkb/Q9FpUY1UlAu1DH9T7j++gZ0lHjnm4OyH2vCI7l7Q==",
"optional": true
},
"@rollup/rollup-linux-arm64-gnu": {
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.9.tgz",
"integrity": "sha512-6TZjPHjKZUQKmVKMUowF3ewHxctrRR09eYyvT5eFv8w/fXarEra83A2mHTVJLA5xU91aCNOUnM+DWFMSbQ0Nxw==",
"optional": true
},
"@rollup/rollup-linux-arm64-musl": {
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.9.tgz",
"integrity": "sha512-LD2fytxZJZ6xzOKnMbIpgzFOuIKlxVOpiMAXawsAZ2mHBPEYOnLRK5TTEsID6z4eM23DuO88X0Tq1mErHMVq0A==",
"optional": true
},
"@rollup/rollup-linux-x64-gnu": {
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.9.tgz",
"integrity": "sha512-FwBHNSOjUTQLP4MG7y6rR6qbGw4MFeQnIBrMe161QGaQoBQLqSUEKlHIiVgF3g/mb3lxlxzJOpIBhaP+C+KP2A==",
"optional": true
},
"@rollup/rollup-linux-x64-musl": {
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.9.tgz",
"integrity": "sha512-cYRpV4650z2I3/s6+5/LONkjIz8MBeqrk+vPXV10ORBnshpn8S32bPqQ2Utv39jCiDcO2eJTuSlPXpnvmaIgRA==",
"optional": true
},
"@rollup/rollup-win32-arm64-msvc": {
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.9.tgz",
"integrity": "sha512-z4mQK9dAN6byRA/vsSgQiPeuO63wdiDxZ9yg9iyX2QTzKuQM7T4xlBoeUP/J8uiFkqxkcWndWi+W7bXdPbt27Q==",
"optional": true
},
"@rollup/rollup-win32-x64-msvc": {
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.9.tgz",
"integrity": "sha512-AyleYRPU7+rgkMWbEh71fQlrzRfeP6SyMnRf9XX4fCdDPAJumdSBqYEcWPMzVQ4ScAl7E4oFfK0GUVn77xSwbw==",
"optional": true
}
}
},
"@swc/core": { "@swc/core": {
"version": "1.12.14", "version": "1.12.14",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.12.14.tgz", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.12.14.tgz",
@ -6665,6 +6915,14 @@
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"dev": true "dev": true
}, },
"ionicons": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/ionicons/-/ionicons-7.4.0.tgz",
"integrity": "sha512-ZK94MMqgzMCPPMhmk8Ouu6goyVHFIlw/ACP6oe3FrikcI0N7CX0xcwVaEbUc0G/v3W0shI93vo+9ve/KpvcNhQ==",
"requires": {
"@stencil/core": "^4.0.3"
}
},
"is-extglob": { "is-extglob": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",

View File

@ -10,6 +10,8 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@ionic/core": "^8.6.5",
"@ionic/react": "^8.6.5",
"@reduxjs/toolkit": "^2.8.2", "@reduxjs/toolkit": "^2.8.2",
"@tailwindcss/postcss": "^4.1.11", "@tailwindcss/postcss": "^4.1.11",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",

View File

@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { useSelector, useDispatch } from 'react-redux'; import { useSelector, useDispatch } from 'react-redux';
import { IonApp, IonHeader, IonToolbar, IonTitle, IonContent, IonFooter, IonChip } from '@ionic/react';
import { selectCurrentSinger, selectIsAdmin, selectControllerName } from '../../redux/authSlice'; import { selectCurrentSinger, selectIsAdmin, selectControllerName } from '../../redux/authSlice';
import { logout } from '../../redux/authSlice'; import { logout } from '../../redux/authSlice';
import { ActionButton } from '../common'; import { ActionButton } from '../common';
@ -18,63 +19,55 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
}; };
return ( return (
<div className="min-h-screen bg-gray-50"> <IonApp>
{/* Header */} <IonHeader>
<header className="bg-white shadow-sm border-b border-gray-200"> <IonToolbar>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <IonTitle>
<div className="flex justify-between items-center h-16">
{/* Logo/Title */}
<div className="flex items-center"> <div className="flex items-center">
<h1 className="text-xl font-bold text-gray-900"> <span>🎤 Karaoke App</span>
🎤 Karaoke App
</h1>
{controllerName && ( {controllerName && (
<span className="ml-4 text-sm text-gray-500"> <span className="ml-4 text-sm text-gray-500">
Party: {controllerName} Party: {controllerName}
</span> </span>
)} )}
</div> </div>
</IonTitle>
{/* User Info & Logout */}
<div className="flex items-center space-x-4"> {/* User Info & Logout */}
{currentSinger && ( {currentSinger && (
<div className="flex items-center space-x-3"> <div slot="end" className="flex items-center space-x-3">
<div className="text-sm text-gray-600"> <div className="text-sm text-gray-600">
<span className="font-medium">{currentSinger}</span> <span className="font-medium">{currentSinger}</span>
{isAdmin && ( {isAdmin && (
<span className="ml-2 px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded-full"> <IonChip color="primary">
Admin Admin
</span> </IonChip>
)} )}
</div> </div>
<ActionButton <ActionButton
onClick={handleLogout} onClick={handleLogout}
variant="secondary" variant="secondary"
size="sm" size="sm"
> >
Logout Logout
</ActionButton> </ActionButton>
</div>
)}
</div> </div>
</div> )}
</div> </IonToolbar>
</header> </IonHeader>
{/* Main Content */} <IonContent>
<main className="flex-1">
{children} {children}
</main> </IonContent>
{/* Footer */} <IonFooter>
<footer className="bg-white border-t border-gray-200 mt-auto"> <IonToolbar>
<div className="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8">
<div className="text-center text-sm text-gray-500"> <div className="text-center text-sm text-gray-500">
<p>🎵 Powered by Firebase Realtime Database</p> <p>🎵 Powered by Firebase Realtime Database</p>
</div> </div>
</div> </IonToolbar>
</footer> </IonFooter>
</div> </IonApp>
); );
}; };

View File

@ -1,42 +1,80 @@
import React from 'react'; import React from 'react';
import { NavLink } from 'react-router-dom'; import { IonTabs, IonTabBar, IonTabButton, 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 Navigation: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const navItems = [ const navItems = [
{ path: '/queue', label: 'Queue', icon: '📋' }, { path: '/queue', label: 'Queue', icon: list },
{ path: '/search', label: 'Search', icon: '🔍' }, { path: '/search', label: 'Search', icon: search },
{ path: '/favorites', label: 'Favorites', icon: '❤️' }, { path: '/favorites', label: 'Favorites', icon: heart },
{ path: '/new-songs', label: 'New Songs', icon: '🆕' }, { path: '/new-songs', label: 'New Songs', icon: add },
{ path: '/artists', label: 'Artists', icon: '🎤' }, { path: '/artists', label: 'Artists', icon: mic },
{ path: '/song-lists', label: 'Song Lists', icon: '📝' }, { path: '/song-lists', label: 'Song Lists', icon: documentText },
{ path: '/history', label: 'History', icon: '⏰' }, { path: '/history', label: 'History', icon: time },
{ path: '/top-played', label: 'Top 100', icon: '🏆' }, { path: '/top-played', label: 'Top 100', icon: trophy },
{ path: '/singers', label: 'Singers', icon: '👥' }, { 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 if we're on mobile (you can adjust this breakpoint)
const isMobile = window.innerWidth < 768;
const currentItems = isMobile ? mobileNavItems : navItems;
return ( return (
<nav className="bg-white border-b border-gray-200"> <>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> {isMobile ? (
<div className="flex space-x-8"> <IonTabs>
{navItems.map((item) => ( <IonTabBar slot="bottom">
<NavLink {currentItems.map((item) => (
key={item.path} <IonTabButton
to={item.path} key={item.path}
className={({ isActive }) => ` tab={item.path}
flex items-center px-3 py-4 text-sm font-medium border-b-2 transition-colors selected={location.pathname === item.path}
${isActive onClick={() => navigate(item.path)}
? 'border-blue-500 text-blue-600' >
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' <IonIcon icon={item.icon} />
} <IonLabel>{item.label}</IonLabel>
`} </IonTabButton>
> ))}
<span className="mr-2">{item.icon}</span> </IonTabBar>
{item.label} </IonTabs>
</NavLink> ) : (
))} <nav className="bg-white border-b border-gray-200">
</div> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
</div> <div className="flex space-x-8">
</nav> {currentItems.map((item) => (
<button
key={item.path}
onClick={() => navigate(item.path)}
className={`
flex items-center px-3 py-4 text-sm font-medium border-b-2 transition-colors
${location.pathname === item.path
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}
`}
>
<IonIcon icon={item.icon} className="mr-2" />
{item.label}
</button>
))}
</div>
</div>
</nav>
)}
</>
); );
}; };

View File

@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { IonButton } from '@ionic/react';
import type { ActionButtonProps } from '../../types'; import type { ActionButtonProps } from '../../types';
const ActionButton: React.FC<ActionButtonProps> = ({ const ActionButton: React.FC<ActionButtonProps> = ({
@ -9,46 +10,43 @@ const ActionButton: React.FC<ActionButtonProps> = ({
disabled = false, disabled = false,
className = '' className = ''
}) => { }) => {
const getVariantStyles = () => { const getVariant = () => {
switch (variant) { switch (variant) {
case 'primary': case 'primary':
return 'bg-blue-600 hover:bg-blue-700 text-white'; return 'primary';
case 'secondary': case 'secondary':
return 'bg-gray-200 hover:bg-gray-300 text-gray-800'; return 'medium';
case 'danger': case 'danger':
return 'bg-red-600 hover:bg-red-700 text-white'; return 'danger';
default: default:
return 'bg-blue-600 hover:bg-blue-700 text-white'; return 'primary';
} }
}; };
const getSizeStyles = () => { const getSize = () => {
switch (size) { switch (size) {
case 'sm': case 'sm':
return 'px-2 py-1 text-xs'; return 'small';
case 'md': case 'md':
return 'px-3 py-2 text-sm'; return 'default';
case 'lg': case 'lg':
return 'px-4 py-2 text-base'; return 'large';
default: default:
return 'px-3 py-2 text-sm'; return 'default';
} }
}; };
return ( return (
<button <IonButton
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
className={` fill="solid"
font-medium rounded-md transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 color={getVariant()}
${getVariantStyles()} size={getSize()}
${getSizeStyles()} className={className}
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
${className}
`}
> >
{children} {children}
</button> </IonButton>
); );
}; };

View File

@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { IonContent } from '@ionic/react';
import type { EmptyStateProps } from '../../types'; import type { EmptyStateProps } from '../../types';
const EmptyState: React.FC<EmptyStateProps> = ({ const EmptyState: React.FC<EmptyStateProps> = ({
@ -8,26 +9,28 @@ const EmptyState: React.FC<EmptyStateProps> = ({
action action
}) => { }) => {
return ( return (
<div className="flex flex-col items-center justify-center py-12 px-4 text-center"> <IonContent className="ion-padding">
{icon && ( <div className="flex flex-col items-center justify-center py-12 px-4 text-center">
<div className="mb-4 text-gray-400"> {icon && (
{icon} <div className="mb-4 text-gray-400">
</div> {icon}
)} </div>
<h3 className="text-lg font-medium text-gray-900 mb-2"> )}
{title} <h3 className="text-lg font-medium text-gray-900 mb-2">
</h3> {title}
{message && ( </h3>
<p className="text-sm text-gray-500 mb-4 max-w-sm"> {message && (
{message} <p className="text-sm text-gray-500 mb-4 max-w-sm">
</p> {message}
)} </p>
{action && ( )}
<div className="mt-4"> {action && (
{action} <div className="mt-4">
</div> {action}
)} </div>
</div> )}
</div>
</IonContent>
); );
}; };

View File

@ -1,4 +1,6 @@
import React from 'react'; import React from 'react';
import { IonCard, IonCardContent, IonChip, IonIcon } from '@ionic/react';
import { play, pause, stop } from 'ionicons/icons';
import ActionButton from './ActionButton'; import ActionButton from './ActionButton';
import { useAppSelector } from '../../redux'; import { useAppSelector } from '../../redux';
import { selectPlayerState, selectIsAdmin, selectQueue } from '../../redux'; import { selectPlayerState, selectIsAdmin, selectQueue } from '../../redux';
@ -70,63 +72,73 @@ const PlayerControls: React.FC<PlayerControlsProps> = ({ className = '' }) => {
console.log('PlayerControls - currentState:', currentState); console.log('PlayerControls - currentState:', currentState);
console.log('PlayerControls - hasSongsInQueue:', hasSongsInQueue); console.log('PlayerControls - hasSongsInQueue:', hasSongsInQueue);
const getStateColor = () => {
switch (currentState) {
case PlayerState.playing:
return 'success';
case PlayerState.paused:
return 'warning';
default:
return 'medium';
}
};
return ( return (
<div className={`bg-white rounded-lg shadow p-4 ${className}`}> <IonCard className={className}>
<div className="flex items-center justify-between"> <IonCardContent>
<div className="flex items-center space-x-2"> <div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-gray-900">Player Controls</h3> <div className="flex items-center space-x-2">
<span className={`px-2 py-1 text-xs rounded-full ${ <h3 className="text-lg font-medium text-gray-900">Player Controls</h3>
currentState === PlayerState.playing <IonChip color={getStateColor()}>
? 'bg-green-100 text-green-800' {currentState}
: currentState === PlayerState.paused </IonChip>
? 'bg-yellow-100 text-yellow-800'
: 'bg-gray-100 text-gray-800'
}`}>
{currentState}
</span>
</div>
</div>
<div className="mt-4 flex items-center justify-center space-x-3">
{currentState === PlayerState.playing ? (
<ActionButton
onClick={handlePause}
variant="primary"
size="lg"
>
Pause
</ActionButton>
) : (
<ActionButton
onClick={handlePlay}
variant="primary"
size="lg"
disabled={!hasSongsInQueue}
>
Play
</ActionButton>
)}
{currentState !== PlayerState.stopped && (
<ActionButton
onClick={handleStop}
variant="danger"
size="sm"
>
Stop
</ActionButton>
)}
</div>
<div className="mt-3 text-xs text-gray-500 text-center">
Admin controls - Only visible to admin users
{!hasSongsInQueue && (
<div className="mt-1 text-orange-600">
Add songs to queue to enable playback controls
</div> </div>
)} </div>
</div>
</div> <div className="mt-4 flex items-center justify-center space-x-3">
{currentState === PlayerState.playing ? (
<ActionButton
onClick={handlePause}
variant="primary"
size="lg"
>
<IonIcon icon={pause} slot="start" />
Pause
</ActionButton>
) : (
<ActionButton
onClick={handlePlay}
variant="primary"
size="lg"
disabled={!hasSongsInQueue}
>
<IonIcon icon={play} slot="start" />
Play
</ActionButton>
)}
{currentState !== PlayerState.stopped && (
<ActionButton
onClick={handleStop}
variant="danger"
size="sm"
>
<IonIcon icon={stop} slot="start" />
Stop
</ActionButton>
)}
</div>
<div className="mt-3 text-xs text-gray-500 text-center">
Admin controls - Only visible to admin users
{!hasSongsInQueue && (
<div className="mt-1 text-orange-600">
Add songs to queue to enable playback controls
</div>
)}
</div>
</IonCardContent>
</IonCard>
); );
}; };

View File

@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { IonItem, IonLabel } from '@ionic/react';
import ActionButton from './ActionButton'; import ActionButton from './ActionButton';
import type { SongItemProps } from '../../types'; import type { SongItemProps } from '../../types';
@ -132,11 +133,8 @@ const SongItem: React.FC<SongItemProps> = ({
}; };
return ( return (
<div className={` <IonItem className={className}>
flex items-center justify-between p-4 border-b border-gray-200 hover:bg-gray-50 transition-colors <IonLabel>
${className}
`}>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-gray-900 truncate"> <h3 className="text-sm font-medium text-gray-900 truncate">
{song.title} {song.title}
</h3> </h3>
@ -154,12 +152,12 @@ const SongItem: React.FC<SongItemProps> = ({
Played {song.count} times Played {song.count} times
</p> </p>
)} )}
</div> </IonLabel>
<div className="ml-4 flex-shrink-0"> <div slot="end" className="flex gap-2">
{renderActionPanel()} {renderActionPanel()}
</div> </div>
</div> </IonItem>
); );
}; };

View File

@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useState } from 'react';
import { IonToast } from '@ionic/react';
import type { ToastProps } from '../../types'; import type { ToastProps } from '../../types';
const Toast: React.FC<ToastProps> = ({ const Toast: React.FC<ToastProps> = ({
@ -7,50 +8,41 @@ const Toast: React.FC<ToastProps> = ({
duration = 3000, duration = 3000,
onClose onClose
}) => { }) => {
const [isVisible, setIsVisible] = useState(true); const [isOpen, setIsOpen] = useState(true);
useEffect(() => { const handleDismiss = () => {
const timer = setTimeout(() => { setIsOpen(false);
setIsVisible(false); onClose();
setTimeout(onClose, 300); // Wait for fade out animation };
}, duration);
return () => clearTimeout(timer); const getColor = () => {
}, [duration, onClose]);
const getTypeStyles = () => {
switch (type) { switch (type) {
case 'success': case 'success':
return 'bg-green-500 text-white'; return 'success';
case 'error': case 'error':
return 'bg-red-500 text-white'; return 'danger';
case 'info': case 'info':
default: default:
return 'bg-blue-500 text-white'; return 'primary';
} }
}; };
return ( return (
<div <IonToast
className={` isOpen={isOpen}
fixed top-4 right-4 z-50 px-4 py-2 rounded-md shadow-lg transition-opacity duration-300 onDidDismiss={handleDismiss}
${getTypeStyles()} message={message}
${isVisible ? 'opacity-100' : 'opacity-0'} duration={duration}
`} color={getColor()}
> position="top"
<div className="flex items-center"> buttons={[
<span className="text-sm font-medium">{message}</span> {
<button text: '×',
onClick={() => { role: 'cancel',
setIsVisible(false); handler: handleDismiss
setTimeout(onClose, 300); }
}} ]}
className="ml-3 text-white hover:text-gray-200 focus:outline-none" />
>
×
</button>
</div>
</div>
); );
}; };

View File

@ -1,5 +1,7 @@
import React from 'react'; import React from 'react';
import { SongItem, EmptyState, ActionButton, PlayerControls } from '../../components/common'; import { IonList, IonItem, IonItemSliding, IonItemOptions, IonItemOption, IonIcon, IonLabel, IonChip } from '@ionic/react';
import { trash, arrowUp, arrowDown } from 'ionicons/icons';
import { EmptyState, ActionButton, PlayerControls } from '../../components/common';
import { useQueue } from '../../hooks'; import { useQueue } from '../../hooks';
import { useAppSelector } from '../../redux'; import { useAppSelector } from '../../redux';
import { selectQueue, selectPlayerState } from '../../redux'; import { selectQueue, selectPlayerState } from '../../redux';
@ -11,7 +13,6 @@ const Queue: React.FC = () => {
queueStats, queueStats,
canReorder, canReorder,
handleRemoveFromQueue, handleRemoveFromQueue,
handleToggleFavorite,
handleMoveUp, handleMoveUp,
handleMoveDown, handleMoveDown,
} = useQueue(); } = useQueue();
@ -73,67 +74,79 @@ const Queue: React.FC = () => {
} }
/> />
) : ( ) : (
<div className="divide-y divide-gray-200"> <IonList>
{queueItems.map((queueItem, index) => { {queueItems.map((queueItem, index) => {
console.log(`Queue item ${index}: order=${queueItem.order}, key=${queueItem.key}`); console.log(`Queue item ${index}: order=${queueItem.order}, key=${queueItem.key}`);
const canDelete = index === 0 ? canDeleteFirstItem : true;
return ( return (
<div key={queueItem.key} className="flex items-center"> <IonItemSliding key={queueItem.key}>
{/* Order Number */} <IonItem>
<div className="flex-shrink-0 w-12 h-12 flex items-center justify-center bg-gray-100 text-gray-600 font-medium"> {/* Order Number */}
{queueItem.order} <div slot="start" className="flex-shrink-0 w-12 h-12 flex items-center justify-center bg-gray-100 text-gray-600 font-medium rounded-full">
</div> {queueItem.order}
</div>
{/* Song Info */} {/* Song Info */}
<div className="flex-1"> <IonLabel>
<SongItem <h3 className="text-sm font-medium text-gray-900 truncate">
song={queueItem.song} {queueItem.song.title}
context="queue" </h3>
onRemoveFromQueue={ <p className="text-sm text-gray-500 truncate">
// Only allow removal of first item when stopped or paused {queueItem.song.artist}
index === 0 && !canDeleteFirstItem </p>
? undefined <div className="flex items-center mt-1">
: () => handleRemoveFromQueue(queueItem) <IonChip color="medium">
} {queueItem.singer.name}
onToggleFavorite={() => handleToggleFavorite(queueItem.song)} </IonChip>
isAdmin={canReorder} {queueItem.isCurrentUser && (
/> <IonChip color="primary">
</div> You
</IonChip>
)}
</div>
</IonLabel>
{/* Singer Info */} {/* Admin Controls */}
<div className="flex-shrink-0 px-4 py-2 text-sm text-gray-600"> {canReorder && (
<div className="font-medium">{queueItem.singer.name}</div> <div slot="end" className="flex flex-col gap-1">
<div className="text-xs text-gray-400"> {queueItem.order > 2 && (
{queueItem.isCurrentUser ? '(You)' : ''} <ActionButton
</div> onClick={() => handleMoveUp(queueItem)}
</div> variant="secondary"
size="sm"
{/* Admin Controls */} >
{canReorder && ( <IonIcon icon={arrowUp} />
<div className="flex-shrink-0 px-4 py-2 flex flex-col gap-1"> </ActionButton>
{queueItem.order > 2 && ( )}
<ActionButton {queueItem.order > 1 && queueItem.order < queueItems.length && (
onClick={() => handleMoveUp(queueItem)} <ActionButton
variant="secondary" onClick={() => handleMoveDown(queueItem)}
size="sm" variant="secondary"
> size="sm"
>
</ActionButton> <IonIcon icon={arrowDown} />
</ActionButton>
)}
</div>
)} )}
{queueItem.order > 1 && queueItem.order < queueItems.length && ( </IonItem>
<ActionButton
onClick={() => handleMoveDown(queueItem)} {/* Swipe Actions */}
variant="secondary" {canDelete && (
size="sm" <IonItemOptions side="end">
<IonItemOption
color="danger"
onClick={() => handleRemoveFromQueue(queueItem)}
> >
<IonIcon icon={trash} slot="icon-only" />
</ActionButton> </IonItemOption>
)} </IonItemOptions>
</div> )}
)} </IonItemSliding>
</div> );
);
})} })}
</div> </IonList>
)} )}
</div> </div>
</div> </div>

View File

@ -1,3 +1,17 @@
/* Ionic CSS imports */
@import '@ionic/react/css/core.css';
@import '@ionic/react/css/normalize.css';
@import '@ionic/react/css/structure.css';
@import '@ionic/react/css/typography.css';
@import '@ionic/react/css/display.css';
@import '@ionic/react/css/padding.css';
@import '@ionic/react/css/float-elements.css';
@import '@ionic/react/css/text-alignment.css';
@import '@ionic/react/css/text-transformation.css';
@import '@ionic/react/css/flex-utils.css';
@import '@ionic/react/css/display.css';
/* Tailwind CSS */
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;

View File

@ -1,10 +1,14 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { setupIonicReact } from '@ionic/react'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { store } from './redux/store'; import { store } from './redux/store';
// Initialize Ionic React
setupIonicReact();
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<Provider store={store}> <Provider store={store}>