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,
"packages": {
"": {
"name": "singsalot-ai",
"version": "0.0.0",
"dependencies": {
"@ionic/core": "^8.6.5",
"@ionic/react": "^8.6.5",
"@reduxjs/toolkit": "^2.8.2",
"@tailwindcss/postcss": "^4.1.11",
"@types/react-router-dom": "^5.3.3",
@ -1221,6 +1224,32 @@
"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": {
"version": "4.0.1",
"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",
"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": {
"version": "1.12.14",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.12.14.tgz",
@ -3285,6 +3441,15 @@
"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": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@ -5336,6 +5501,26 @@
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
"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": {
"version": "4.0.1",
"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",
"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": {
"version": "1.12.14",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.12.14.tgz",
@ -6665,6 +6915,14 @@
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"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": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",

View File

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

View File

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

View File

@ -1,42 +1,80 @@
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 location = useLocation();
const navigate = useNavigate();
const navItems = [
{ path: '/queue', label: 'Queue', icon: '📋' },
{ path: '/search', label: 'Search', icon: '🔍' },
{ path: '/favorites', label: 'Favorites', icon: '❤️' },
{ path: '/new-songs', label: 'New Songs', icon: '🆕' },
{ path: '/artists', label: 'Artists', icon: '🎤' },
{ path: '/song-lists', label: 'Song Lists', icon: '📝' },
{ path: '/history', label: 'History', icon: '⏰' },
{ path: '/top-played', label: 'Top 100', icon: '🏆' },
{ path: '/singers', label: 'Singers', icon: '👥' },
{ path: '/queue', label: 'Queue', icon: list },
{ path: '/search', label: 'Search', icon: search },
{ path: '/favorites', label: 'Favorites', icon: heart },
{ path: '/new-songs', label: 'New Songs', icon: add },
{ path: '/artists', label: 'Artists', icon: mic },
{ path: '/song-lists', label: 'Song Lists', icon: documentText },
{ path: '/history', label: 'History', icon: time },
{ path: '/top-played', label: 'Top 100', icon: trophy },
{ 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 (
<nav className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex space-x-8">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) => `
flex items-center px-3 py-4 text-sm font-medium border-b-2 transition-colors
${isActive
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}
`}
>
<span className="mr-2">{item.icon}</span>
{item.label}
</NavLink>
))}
</div>
</div>
</nav>
<>
{isMobile ? (
<IonTabs>
<IonTabBar slot="bottom">
{currentItems.map((item) => (
<IonTabButton
key={item.path}
tab={item.path}
selected={location.pathname === item.path}
onClick={() => navigate(item.path)}
>
<IonIcon icon={item.icon} />
<IonLabel>{item.label}</IonLabel>
</IonTabButton>
))}
</IonTabBar>
</IonTabs>
) : (
<nav className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex space-x-8">
{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 { IonButton } from '@ionic/react';
import type { ActionButtonProps } from '../../types';
const ActionButton: React.FC<ActionButtonProps> = ({
@ -9,46 +10,43 @@ const ActionButton: React.FC<ActionButtonProps> = ({
disabled = false,
className = ''
}) => {
const getVariantStyles = () => {
const getVariant = () => {
switch (variant) {
case 'primary':
return 'bg-blue-600 hover:bg-blue-700 text-white';
return 'primary';
case 'secondary':
return 'bg-gray-200 hover:bg-gray-300 text-gray-800';
return 'medium';
case 'danger':
return 'bg-red-600 hover:bg-red-700 text-white';
return 'danger';
default:
return 'bg-blue-600 hover:bg-blue-700 text-white';
return 'primary';
}
};
const getSizeStyles = () => {
const getSize = () => {
switch (size) {
case 'sm':
return 'px-2 py-1 text-xs';
return 'small';
case 'md':
return 'px-3 py-2 text-sm';
return 'default';
case 'lg':
return 'px-4 py-2 text-base';
return 'large';
default:
return 'px-3 py-2 text-sm';
return 'default';
}
};
return (
<button
<IonButton
onClick={onClick}
disabled={disabled}
className={`
font-medium rounded-md transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2
${getVariantStyles()}
${getSizeStyles()}
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
${className}
`}
fill="solid"
color={getVariant()}
size={getSize()}
className={className}
>
{children}
</button>
</IonButton>
);
};

View File

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

View File

@ -1,4 +1,6 @@
import React from 'react';
import { IonCard, IonCardContent, IonChip, IonIcon } from '@ionic/react';
import { play, pause, stop } from 'ionicons/icons';
import ActionButton from './ActionButton';
import { useAppSelector } 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 - hasSongsInQueue:', hasSongsInQueue);
const getStateColor = () => {
switch (currentState) {
case PlayerState.playing:
return 'success';
case PlayerState.paused:
return 'warning';
default:
return 'medium';
}
};
return (
<div className={`bg-white rounded-lg shadow p-4 ${className}`}>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<h3 className="text-lg font-medium text-gray-900">Player Controls</h3>
<span className={`px-2 py-1 text-xs rounded-full ${
currentState === PlayerState.playing
? 'bg-green-100 text-green-800'
: currentState === PlayerState.paused
? '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
<IonCard className={className}>
<IonCardContent>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<h3 className="text-lg font-medium text-gray-900">Player Controls</h3>
<IonChip color={getStateColor()}>
{currentState}
</IonChip>
</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 { IonItem, IonLabel } from '@ionic/react';
import ActionButton from './ActionButton';
import type { SongItemProps } from '../../types';
@ -132,11 +133,8 @@ const SongItem: React.FC<SongItemProps> = ({
};
return (
<div className={`
flex items-center justify-between p-4 border-b border-gray-200 hover:bg-gray-50 transition-colors
${className}
`}>
<div className="flex-1 min-w-0">
<IonItem className={className}>
<IonLabel>
<h3 className="text-sm font-medium text-gray-900 truncate">
{song.title}
</h3>
@ -154,12 +152,12 @@ const SongItem: React.FC<SongItemProps> = ({
Played {song.count} times
</p>
)}
</div>
</IonLabel>
<div className="ml-4 flex-shrink-0">
<div slot="end" className="flex gap-2">
{renderActionPanel()}
</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';
const Toast: React.FC<ToastProps> = ({
@ -7,50 +8,41 @@ const Toast: React.FC<ToastProps> = ({
duration = 3000,
onClose
}) => {
const [isVisible, setIsVisible] = useState(true);
const [isOpen, setIsOpen] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setIsVisible(false);
setTimeout(onClose, 300); // Wait for fade out animation
}, duration);
const handleDismiss = () => {
setIsOpen(false);
onClose();
};
return () => clearTimeout(timer);
}, [duration, onClose]);
const getTypeStyles = () => {
const getColor = () => {
switch (type) {
case 'success':
return 'bg-green-500 text-white';
return 'success';
case 'error':
return 'bg-red-500 text-white';
return 'danger';
case 'info':
default:
return 'bg-blue-500 text-white';
return 'primary';
}
};
return (
<div
className={`
fixed top-4 right-4 z-50 px-4 py-2 rounded-md shadow-lg transition-opacity duration-300
${getTypeStyles()}
${isVisible ? 'opacity-100' : 'opacity-0'}
`}
>
<div className="flex items-center">
<span className="text-sm font-medium">{message}</span>
<button
onClick={() => {
setIsVisible(false);
setTimeout(onClose, 300);
}}
className="ml-3 text-white hover:text-gray-200 focus:outline-none"
>
×
</button>
</div>
</div>
<IonToast
isOpen={isOpen}
onDidDismiss={handleDismiss}
message={message}
duration={duration}
color={getColor()}
position="top"
buttons={[
{
text: '×',
role: 'cancel',
handler: handleDismiss
}
]}
/>
);
};

View File

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

View File

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