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

This commit is contained in:
Matt Bruce 2025-07-17 09:40:17 -05:00
parent edc508b5e7
commit d7cf384429
40 changed files with 2312 additions and 99 deletions

246
package-lock.json generated
View File

@ -20,10 +20,13 @@
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react-swc": "^3.10.2",
"autoprefixer": "^10.4.21",
"eslint": "^9.30.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.11",
"typescript": "~5.8.3",
"typescript-eslint": "^8.35.1",
"vite": "^7.0.4"
@ -2234,6 +2237,43 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true
},
"node_modules/autoprefixer": {
"version": "10.4.21",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
"integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"dependencies": {
"browserslist": "^4.24.4",
"caniuse-lite": "^1.0.30001702",
"fraction.js": "^4.3.7",
"normalize-range": "^0.1.2",
"picocolors": "^1.1.1",
"postcss-value-parser": "^4.2.0"
},
"bin": {
"autoprefixer": "bin/autoprefixer"
},
"engines": {
"node": "^10 || ^12 || >=14"
},
"peerDependencies": {
"postcss": "^8.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -2262,6 +2302,38 @@
"node": ">=8"
}
},
"node_modules/browserslist": {
"version": "4.25.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz",
"integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/browserslist"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"dependencies": {
"caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173",
"node-releases": "^2.0.19",
"update-browserslist-db": "^1.1.3"
},
"bin": {
"browserslist": "cli.js"
},
"engines": {
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@ -2271,6 +2343,26 @@
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001727",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz",
"integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
]
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -2372,6 +2464,12 @@
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true
},
"node_modules/electron-to-chromium": {
"version": "1.5.186",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.186.tgz",
"integrity": "sha512-lur7L4BFklgepaJxj4DqPk7vKbTEl0pajNlg2QjE5shefmlmBLm2HvQ7PMf1R/GvlevT/581cop33/quQcfX3A==",
"dev": true
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@ -2766,6 +2864,19 @@
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"dev": true
},
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
"integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==",
"dev": true,
"engines": {
"node": "*"
},
"funding": {
"type": "patreon",
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -3071,6 +3182,21 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true
},
"node_modules/node-releases": {
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
"dev": true
},
"node_modules/normalize-range": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
"integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -3194,6 +3320,12 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@ -3558,6 +3690,12 @@
"node": ">=8"
}
},
"node_modules/tailwindcss": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz",
"integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==",
"dev": true
},
"node_modules/tinyglobby": {
"version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
@ -3682,6 +3820,36 @@
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
"integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="
},
"node_modules/update-browserslist-db": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
"integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/browserslist"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"dependencies": {
"escalade": "^3.2.0",
"picocolors": "^1.1.1"
},
"bin": {
"update-browserslist-db": "cli.js"
},
"peerDependencies": {
"browserslist": ">= 4.21.0"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@ -5288,6 +5456,20 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true
},
"autoprefixer": {
"version": "10.4.21",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
"integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==",
"dev": true,
"requires": {
"browserslist": "^4.24.4",
"caniuse-lite": "^1.0.30001702",
"fraction.js": "^4.3.7",
"normalize-range": "^0.1.2",
"picocolors": "^1.1.1",
"postcss-value-parser": "^4.2.0"
}
},
"balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -5313,12 +5495,30 @@
"fill-range": "^7.1.1"
}
},
"browserslist": {
"version": "4.25.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz",
"integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==",
"dev": true,
"requires": {
"caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173",
"node-releases": "^2.0.19",
"update-browserslist-db": "^1.1.3"
}
},
"callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
"dev": true
},
"caniuse-lite": {
"version": "1.0.30001727",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz",
"integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==",
"dev": true
},
"chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -5394,6 +5594,12 @@
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true
},
"electron-to-chromium": {
"version": "1.5.186",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.186.tgz",
"integrity": "sha512-lur7L4BFklgepaJxj4DqPk7vKbTEl0pajNlg2QjE5shefmlmBLm2HvQ7PMf1R/GvlevT/581cop33/quQcfX3A==",
"dev": true
},
"emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@ -5696,6 +5902,12 @@
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"dev": true
},
"fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
"integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==",
"dev": true
},
"fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -5918,6 +6130,18 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true
},
"node-releases": {
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
"dev": true
},
"normalize-range": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
"integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
"dev": true
},
"optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -5994,6 +6218,12 @@
"source-map-js": "^1.2.1"
}
},
"postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true
},
"prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@ -6217,6 +6447,12 @@
"has-flag": "^4.0.0"
}
},
"tailwindcss": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz",
"integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==",
"dev": true
},
"tinyglobby": {
"version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
@ -6295,6 +6531,16 @@
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
"integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="
},
"update-browserslist-db": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
"integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
"dev": true,
"requires": {
"escalade": "^3.2.0",
"picocolors": "^1.1.1"
}
},
"uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",

View File

@ -23,10 +23,13 @@
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react-swc": "^3.10.2",
"autoprefixer": "^10.4.21",
"eslint": "^9.30.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.11",
"typescript": "~5.8.3",
"typescript-eslint": "^8.35.1",
"vite": "^7.0.4"

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -1,35 +1,23 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
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, TopPlayed } from './features';
function App() {
const [count, setCount] = useState(0)
return (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
<Router>
<Layout>
<Navigation />
<Routes>
<Route path="/" element={<Search />} />
<Route path="/queue" element={<Queue />} />
<Route path="/history" element={<History />} />
<Route path="/top-played" element={<TopPlayed />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
</Router>
);
}
export default App
export default App;

View File

@ -0,0 +1,64 @@
import React from 'react';
import { useSelector } from 'react-redux';
import type { RootState } from '../../redux/store';
import type { LayoutProps } from '../../types';
const Layout: React.FC<LayoutProps> = ({ children }) => {
// TODO: Replace with actual Redux selectors
const currentSinger = useSelector((state: RootState) => state.auth?.singer || '');
const isAdmin = useSelector((state: RootState) => state.auth?.isAdmin || false);
const controllerName = useSelector((state: RootState) => state.auth?.controller || '');
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 */}
<div className="flex items-center">
<h1 className="text-xl font-bold text-gray-900">
🎤 Karaoke App
</h1>
{controllerName && (
<span className="ml-4 text-sm text-gray-500">
Controller: {controllerName}
</span>
)}
</div>
{/* User Info */}
<div className="flex items-center space-x-4">
{currentSinger && (
<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>
)}
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="flex-1">
{children}
</main>
{/* 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">
<div className="text-center text-sm text-gray-500">
<p>🎵 Powered by Firebase Realtime Database</p>
</div>
</div>
</footer>
</div>
);
};
export default Layout;

View File

@ -0,0 +1,38 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
const Navigation: React.FC = () => {
const navItems = [
{ path: '/', label: 'Search', icon: '🔍' },
{ path: '/queue', label: 'Queue', icon: '📋' },
{ path: '/history', label: 'History', icon: '⏰' },
{ path: '/top-played', label: 'Top Played', icon: '🏆' },
];
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>
);
};
export default Navigation;

View File

@ -0,0 +1,55 @@
import React from 'react';
import type { ActionButtonProps } from '../../types';
const ActionButton: React.FC<ActionButtonProps> = ({
onClick,
children,
variant = 'primary',
size = 'md',
disabled = false,
className = ''
}) => {
const getVariantStyles = () => {
switch (variant) {
case 'primary':
return 'bg-blue-600 hover:bg-blue-700 text-white';
case 'secondary':
return 'bg-gray-200 hover:bg-gray-300 text-gray-800';
case 'danger':
return 'bg-red-600 hover:bg-red-700 text-white';
default:
return 'bg-blue-600 hover:bg-blue-700 text-white';
}
};
const getSizeStyles = () => {
switch (size) {
case 'sm':
return 'px-2 py-1 text-xs';
case 'md':
return 'px-3 py-2 text-sm';
case 'lg':
return 'px-4 py-2 text-base';
default:
return 'px-3 py-2 text-sm';
}
};
return (
<button
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}
`}
>
{children}
</button>
);
};
export default ActionButton;

View File

@ -0,0 +1,34 @@
import React from 'react';
import type { EmptyStateProps } from '../../types';
const EmptyState: React.FC<EmptyStateProps> = ({
title,
message,
icon,
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>
);
};
export default EmptyState;

View File

@ -0,0 +1,130 @@
import React from 'react';
import ActionButton from './ActionButton';
import type { SongItemProps } from '../../types';
const SongItem: React.FC<SongItemProps> = ({
song,
context,
onAddToQueue,
onRemoveFromQueue,
onToggleFavorite,
onDelete,
isAdmin = false,
className = ''
}) => {
const renderActionPanel = () => {
switch (context) {
case 'search':
return (
<div className="flex gap-2">
<ActionButton
onClick={onAddToQueue || (() => {})}
variant="primary"
size="sm"
>
Add to Queue
</ActionButton>
<ActionButton
onClick={onToggleFavorite || (() => {})}
variant={song.favorite ? 'danger' : 'secondary'}
size="sm"
>
{song.favorite ? '❤️' : '🤍'}
</ActionButton>
</div>
);
case 'queue':
return (
<div className="flex gap-2">
{isAdmin && (
<ActionButton
onClick={onRemoveFromQueue || (() => {})}
variant="danger"
size="sm"
>
Remove
</ActionButton>
)}
<ActionButton
onClick={onToggleFavorite || (() => {})}
variant={song.favorite ? 'danger' : 'secondary'}
size="sm"
>
{song.favorite ? '❤️' : '🤍'}
</ActionButton>
</div>
);
case 'history':
return (
<div className="flex gap-2">
<ActionButton
onClick={onAddToQueue || (() => {})}
variant="primary"
size="sm"
>
Add to Queue
</ActionButton>
<ActionButton
onClick={onToggleFavorite || (() => {})}
variant={song.favorite ? 'danger' : 'secondary'}
size="sm"
>
{song.favorite ? '❤️' : '🤍'}
</ActionButton>
</div>
);
case 'favorites':
return (
<div className="flex gap-2">
<ActionButton
onClick={onAddToQueue || (() => {})}
variant="primary"
size="sm"
>
Add to Queue
</ActionButton>
<ActionButton
onClick={onDelete || (() => {})}
variant="danger"
size="sm"
>
Remove
</ActionButton>
</div>
);
default:
return null;
}
};
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">
<h3 className="text-sm font-medium text-gray-900 truncate">
{song.title}
</h3>
<p className="text-sm text-gray-500 truncate">
{song.artist}
</p>
{song.count && (
<p className="text-xs text-gray-400">
Played {song.count} times
</p>
)}
</div>
<div className="ml-4 flex-shrink-0">
{renderActionPanel()}
</div>
</div>
);
};
export default SongItem;

View File

@ -0,0 +1,57 @@
import React, { useEffect, useState } from 'react';
import type { ToastProps } from '../../types';
const Toast: React.FC<ToastProps> = ({
message,
type = 'info',
duration = 3000,
onClose
}) => {
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setIsVisible(false);
setTimeout(onClose, 300); // Wait for fade out animation
}, duration);
return () => clearTimeout(timer);
}, [duration, onClose]);
const getTypeStyles = () => {
switch (type) {
case 'success':
return 'bg-green-500 text-white';
case 'error':
return 'bg-red-500 text-white';
case 'info':
default:
return 'bg-blue-500 text-white';
}
};
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>
);
};
export default Toast;

View File

@ -0,0 +1,4 @@
export { default as EmptyState } from './EmptyState';
export { default as Toast } from './Toast';
export { default as ActionButton } from './ActionButton';
export { default as SongItem } from './SongItem';

82
src/constants/index.ts Normal file
View File

@ -0,0 +1,82 @@
// App constants
export const APP_NAME = '🎤 Karaoke App';
export const APP_VERSION = '1.0.0';
// Firebase configuration
export const FIREBASE_CONFIG = {
// These will be replaced with environment variables
apiKey: import.meta.env.VITE_FIREBASE_API_KEY || 'your-api-key',
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN || 'your-project-id.firebaseapp.com',
databaseURL: import.meta.env.VITE_FIREBASE_DATABASE_URL || 'https://your-project-id-default-rtdb.firebaseio.com',
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID || 'your-project-id',
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET || 'your-project-id.appspot.com',
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID || '123456789',
appId: import.meta.env.VITE_FIREBASE_APP_ID || 'your-app-id',
};
// UI constants
export const UI_CONSTANTS = {
TOAST_DURATION: {
SUCCESS: 3000,
ERROR: 5000,
INFO: 3000,
},
SEARCH: {
DEBOUNCE_DELAY: 300,
MIN_SEARCH_LENGTH: 2,
},
QUEUE: {
MAX_ITEMS: 100,
},
HISTORY: {
MAX_ITEMS: 50,
},
TOP_PLAYED: {
MAX_ITEMS: 20,
},
} as const;
// Route constants
export const ROUTES = {
HOME: '/',
SEARCH: '/',
QUEUE: '/queue',
HISTORY: '/history',
TOP_PLAYED: '/top-played',
} as const;
// Player states
export const PLAYER_STATES = {
PLAYING: 'Playing',
PAUSED: 'Paused',
STOPPED: 'Stopped',
} as const;
// Error messages
export const ERROR_MESSAGES = {
CONTROLLER_NOT_FOUND: 'Controller not found',
NETWORK_ERROR: 'Network error. Please check your connection.',
UNAUTHORIZED: 'You are not authorized to perform this action.',
SONG_NOT_FOUND: 'Song not found',
QUEUE_FULL: 'Queue is full',
FIREBASE_ERROR: 'Firebase operation failed',
} as const;
// Success messages
export const SUCCESS_MESSAGES = {
SONG_ADDED_TO_QUEUE: 'Song added to queue',
SONG_REMOVED_FROM_QUEUE: 'Song removed from queue',
SONG_ADDED_TO_FAVORITES: 'Song added to favorites',
SONG_REMOVED_FROM_FAVORITES: 'Song removed from favorites',
QUEUE_REORDERED: 'Queue reordered',
} as const;
// Feature flags
export const FEATURES = {
ENABLE_SEARCH: true,
ENABLE_QUEUE_REORDER: true,
ENABLE_FAVORITES: true,
ENABLE_HISTORY: true,
ENABLE_TOP_PLAYED: true,
ENABLE_ADMIN_CONTROLS: true,
} as const;

View File

@ -0,0 +1,63 @@
import React from 'react';
import { SongItem, EmptyState } from '../../components/common';
import { useHistory } from '../../hooks';
import { formatDate } from '../../utils/dataProcessing';
const History: React.FC = () => {
const {
historyItems,
handleAddToQueue,
handleToggleFavorite,
} = useHistory();
return (
<div className="max-w-4xl mx-auto p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 mb-2">Recently Played</h1>
<p className="text-sm text-gray-600">
{historyItems.length} song{historyItems.length !== 1 ? 's' : ''} in history
</p>
</div>
{/* History List */}
<div className="bg-white rounded-lg shadow">
{historyItems.length === 0 ? (
<EmptyState
title="No history yet"
message="Songs will appear here after they've been played"
icon={
<svg className="h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
}
/>
) : (
<div className="divide-y divide-gray-200">
{historyItems.map((song) => (
<div key={song.key} className="flex items-center">
{/* Song Info */}
<div className="flex-1">
<SongItem
song={song}
context="history"
onAddToQueue={() => handleAddToQueue(song)}
onToggleFavorite={() => handleToggleFavorite(song)}
/>
</div>
{/* Play Date */}
{song.date && (
<div className="flex-shrink-0 px-4 py-2 text-sm text-gray-500">
{formatDate(song.date)}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
);
};
export default History;

View File

@ -0,0 +1,95 @@
import React from 'react';
import { SongItem, EmptyState, ActionButton } from '../../components/common';
import { useQueue } from '../../hooks';
const Queue: React.FC = () => {
const {
queueItems,
queueStats,
canReorder,
handleRemoveFromQueue,
handleToggleFavorite,
handleMoveUp,
handleMoveDown,
} = useQueue();
return (
<div className="max-w-4xl mx-auto p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 mb-2">Queue</h1>
<p className="text-sm text-gray-600">
{queueStats.totalSongs} song{queueStats.totalSongs !== 1 ? 's' : ''} in queue
</p>
</div>
{/* Queue List */}
<div className="bg-white rounded-lg shadow">
{queueItems.length === 0 ? (
<EmptyState
title="Queue is empty"
message="Add songs from search, history, or favorites to get started"
icon={
<svg className="h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
</svg>
}
/>
) : (
<div className="divide-y divide-gray-200">
{queueItems.map((queueItem, index) => (
<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>
{/* Song Info */}
<div className="flex-1">
<SongItem
song={queueItem.song}
context="queue"
onRemoveFromQueue={() => handleRemoveFromQueue(queueItem)}
onToggleFavorite={() => handleToggleFavorite(queueItem.song)}
isAdmin={canReorder}
/>
</div>
{/* 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">
<ActionButton
onClick={() => handleMoveUp(queueItem)}
variant="secondary"
size="sm"
disabled={index === 0}
>
</ActionButton>
<ActionButton
onClick={() => handleMoveDown(queueItem)}
variant="secondary"
size="sm"
disabled={index === queueItems.length - 1}
>
</ActionButton>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
);
};
export default Queue;

View File

@ -0,0 +1,78 @@
import React from 'react';
import { useAppSelector } from '../../redux';
import { SongItem, EmptyState } from '../../components/common';
import { useSearch } from '../../hooks';
import { selectIsAdmin } from '../../redux';
const Search: React.FC = () => {
const {
searchTerm,
searchResults,
handleSearchChange,
handleAddToQueue,
handleToggleFavorite,
} = useSearch();
const isAdmin = useAppSelector(selectIsAdmin);
return (
<div className="max-w-4xl mx-auto p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 mb-4">Search Songs</h1>
{/* Search Input */}
<div className="relative">
<input
type="text"
placeholder="Search by title or artist..."
value={searchTerm}
onChange={(e) => 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"
/>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</div>
</div>
{/* Search Results */}
<div className="bg-white rounded-lg shadow">
{searchResults.songs.length === 0 ? (
<EmptyState
title={searchTerm ? "No songs found" : "No songs available"}
message={searchTerm ? "Try adjusting your search terms" : "Songs will appear here once loaded"}
icon={
<svg className="h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
</svg>
}
/>
) : (
<div className="divide-y divide-gray-200">
{searchResults.songs.map((song) => (
<SongItem
key={song.key}
song={song}
context="search"
onAddToQueue={() => handleAddToQueue(song)}
onToggleFavorite={() => handleToggleFavorite(song)}
isAdmin={isAdmin}
/>
))}
</div>
)}
</div>
{/* Search Stats */}
{searchTerm && (
<div className="mt-4 text-sm text-gray-500 text-center">
Found {searchResults.count} song{searchResults.count !== 1 ? 's' : ''}
</div>
)}
</div>
);
};
export default Search;

View File

@ -0,0 +1,81 @@
import React from 'react';
import { SongItem, EmptyState } from '../../components/common';
import { useTopPlayed } from '../../hooks';
const TopPlayed: React.FC = () => {
const {
topPlayedItems,
handleAddToQueue,
handleToggleFavorite,
} = useTopPlayed();
return (
<div className="max-w-4xl mx-auto p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 mb-2">Most Played</h1>
<p className="text-sm text-gray-600">
Top {topPlayedItems.length} song{topPlayedItems.length !== 1 ? 's' : ''} by play count
</p>
</div>
{/* Top Played List */}
<div className="bg-white rounded-lg shadow">
{topPlayedItems.length === 0 ? (
<EmptyState
title="No play data yet"
message="Song play counts will appear here after songs have been played"
icon={
<svg className="h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
}
/>
) : (
<div className="divide-y divide-gray-200">
{topPlayedItems.map((song, index) => (
<div key={song.key} className="flex items-center">
{/* Rank */}
<div className="flex-shrink-0 w-12 h-12 flex items-center justify-center">
<div className={`
w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold
${index === 0 ? 'bg-yellow-100 text-yellow-800' : ''}
${index === 1 ? 'bg-gray-100 text-gray-800' : ''}
${index === 2 ? 'bg-orange-100 text-orange-800' : ''}
${index > 2 ? 'bg-gray-50 text-gray-600' : ''}
`}>
{index + 1}
</div>
</div>
{/* Song Info */}
<div className="flex-1">
<SongItem
song={{
...song,
path: '', // TopPlayed doesn't have path
disabled: false,
favorite: false
}}
context="search"
onAddToQueue={() => handleAddToQueue(song)}
onToggleFavorite={() => handleToggleFavorite(song)}
/>
</div>
{/* Play Count */}
<div className="flex-shrink-0 px-4 py-2 text-sm text-gray-600">
<div className="font-medium">{song.count}</div>
<div className="text-xs text-gray-400">
play{song.count !== 1 ? 's' : ''}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default TopPlayed;

4
src/features/index.ts Normal file
View File

@ -0,0 +1,4 @@
export { default as Search } from './Search/Search';
export { default as Queue } from './Queue/Queue';
export { default as History } from './History/History';
export { default as TopPlayed } from './TopPlayed/TopPlayed';

11
src/firebase/config.ts Normal file
View File

@ -0,0 +1,11 @@
import { initializeApp } from 'firebase/app';
import { getDatabase } from 'firebase/database';
import { FIREBASE_CONFIG } from '../constants';
// Initialize Firebase
const app = initializeApp(FIREBASE_CONFIG);
// Initialize Realtime Database and get a reference to the service
export const database = getDatabase(app);
export default app;

139
src/firebase/services.ts Normal file
View File

@ -0,0 +1,139 @@
import {
ref,
get,
set,
push,
remove,
onValue,
off,
update
} from 'firebase/database';
import { database } from './config';
import type { Song, QueueItem, Controller } from '../types';
// Basic CRUD operations for controllers
export const controllerService = {
// Get a specific controller
getController: async (controllerName: string) => {
const controllerRef = ref(database, `controllers/${controllerName}`);
const snapshot = await get(controllerRef);
return snapshot.exists() ? snapshot.val() : null;
},
// Set/update a controller
setController: async (controllerName: string, data: Controller) => {
const controllerRef = ref(database, `controllers/${controllerName}`);
await set(controllerRef, data);
},
// Update specific parts of a controller
updateController: async (controllerName: string, updates: Partial<Controller>) => {
const controllerRef = ref(database, `controllers/${controllerName}`);
await update(controllerRef, updates);
},
// Listen to controller changes in real-time
subscribeToController: (controllerName: string, callback: (data: Controller | null) => void) => {
const controllerRef = ref(database, `controllers/${controllerName}`);
onValue(controllerRef, (snapshot) => {
callback(snapshot.exists() ? snapshot.val() : null);
});
// Return unsubscribe function
return () => off(controllerRef);
}
};
// Queue management operations
export const queueService = {
// Add song to queue
addToQueue: async (controllerName: string, queueItem: Omit<QueueItem, 'key'>) => {
const queueRef = ref(database, `controllers/${controllerName}/player/queue`);
return await push(queueRef, queueItem);
},
// Remove song from queue
removeFromQueue: async (controllerName: string, queueItemKey: string) => {
const queueItemRef = ref(database, `controllers/${controllerName}/player/queue/${queueItemKey}`);
await remove(queueItemRef);
},
// Update queue item
updateQueueItem: async (controllerName: string, queueItemKey: string, updates: Partial<QueueItem>) => {
const queueItemRef = ref(database, `controllers/${controllerName}/player/queue/${queueItemKey}`);
await update(queueItemRef, updates);
},
// Listen to queue changes
subscribeToQueue: (controllerName: string, callback: (data: Record<string, QueueItem>) => void) => {
const queueRef = ref(database, `controllers/${controllerName}/player/queue`);
onValue(queueRef, (snapshot) => {
callback(snapshot.exists() ? snapshot.val() : {});
});
return () => off(queueRef);
}
};
// Player state operations
export const playerService = {
// Update player state
updatePlayerState: async (controllerName: string, state: Partial<Controller['player']>) => {
const playerRef = ref(database, `controllers/${controllerName}/player`);
await update(playerRef, state);
},
// Listen to player state changes
subscribeToPlayerState: (controllerName: string, callback: (data: Controller['player']) => void) => {
const playerRef = ref(database, `controllers/${controllerName}/player`);
onValue(playerRef, (snapshot) => {
callback(snapshot.exists() ? snapshot.val() : {});
});
return () => off(playerRef);
}
};
// History operations
export const historyService = {
// Add song to history
addToHistory: async (controllerName: string, song: Omit<Song, 'key'>) => {
const historyRef = ref(database, `controllers/${controllerName}/history`);
return await push(historyRef, song);
},
// Listen to history changes
subscribeToHistory: (controllerName: string, callback: (data: Record<string, Song>) => void) => {
const historyRef = ref(database, `controllers/${controllerName}/history`);
onValue(historyRef, (snapshot) => {
callback(snapshot.exists() ? snapshot.val() : {});
});
return () => off(historyRef);
}
};
// Favorites operations
export const favoritesService = {
// Add song to favorites
addToFavorites: async (controllerName: string, song: Omit<Song, 'key'>) => {
const favoritesRef = ref(database, `controllers/${controllerName}/favorites`);
return await push(favoritesRef, song);
},
// Remove song from favorites
removeFromFavorites: async (controllerName: string, songKey: string) => {
const songRef = ref(database, `controllers/${controllerName}/favorites/${songKey}`);
await remove(songRef);
},
// Listen to favorites changes
subscribeToFavorites: (controllerName: string, callback: (data: Record<string, Song>) => void) => {
const favoritesRef = ref(database, `controllers/${controllerName}/favorites`);
onValue(favoritesRef, (snapshot) => {
callback(snapshot.exists() ? snapshot.val() : {});
});
return () => off(favoritesRef);
}
};

7
src/hooks/index.ts Normal file
View File

@ -0,0 +1,7 @@
export { useFirebaseSync } from './useFirebaseSync';
export { useSongOperations } from './useSongOperations';
export { useToast } from './useToast';
export { useSearch } from './useSearch';
export { useQueue } from './useQueue';
export { useHistory } from './useHistory';
export { useTopPlayed } from './useTopPlayed';

View File

@ -0,0 +1,105 @@
import { useEffect, useRef } from 'react';
import { useAppDispatch } from '../redux';
import {
setController,
updateQueue,
updateFavorites,
updateHistory
} from '../redux';
import {
controllerService,
queueService,
favoritesService,
historyService
} from '../firebase/services';
export const useFirebaseSync = (controllerName: string) => {
const dispatch = useAppDispatch();
const unsubscribeRefs = useRef<Array<() => void>>([]);
// Subscribe to controller changes
useEffect(() => {
if (!controllerName) return;
const unsubscribe = controllerService.subscribeToController(
controllerName,
(controller) => {
if (controller) {
dispatch(setController(controller));
}
}
);
unsubscribeRefs.current.push(unsubscribe);
return () => {
unsubscribe();
};
}, [controllerName, dispatch]);
// Subscribe to queue changes
useEffect(() => {
if (!controllerName) return;
const unsubscribe = queueService.subscribeToQueue(
controllerName,
(queue) => {
dispatch(updateQueue(queue));
}
);
unsubscribeRefs.current.push(unsubscribe);
return () => {
unsubscribe();
};
}, [controllerName, dispatch]);
// Subscribe to favorites changes
useEffect(() => {
if (!controllerName) return;
const unsubscribe = favoritesService.subscribeToFavorites(
controllerName,
(favorites) => {
dispatch(updateFavorites(favorites));
}
);
unsubscribeRefs.current.push(unsubscribe);
return () => {
unsubscribe();
};
}, [controllerName, dispatch]);
// Subscribe to history changes
useEffect(() => {
if (!controllerName) return;
const unsubscribe = historyService.subscribeToHistory(
controllerName,
(history) => {
dispatch(updateHistory(history));
}
);
unsubscribeRefs.current.push(unsubscribe);
return () => {
unsubscribe();
};
}, [controllerName, dispatch]);
// Cleanup all subscriptions on unmount
useEffect(() => {
return () => {
unsubscribeRefs.current.forEach(unsubscribe => unsubscribe());
unsubscribeRefs.current = [];
};
}, []);
return {
isConnected: true, // TODO: Implement connection status
};
};

36
src/hooks/useHistory.ts Normal file
View File

@ -0,0 +1,36 @@
import { useCallback } from 'react';
import { useAppSelector } from '../redux';
import { selectHistoryArray } from '../redux/selectors';
import { useSongOperations } from './useSongOperations';
import { useToast } from './useToast';
import type { Song } from '../types';
export const useHistory = () => {
const historyItems = useAppSelector(selectHistoryArray);
const { addToQueue, toggleFavorite } = useSongOperations();
const { showSuccess, showError } = useToast();
const handleAddToQueue = useCallback(async (song: Song) => {
try {
await addToQueue(song);
showSuccess('Song added to queue');
} catch {
showError('Failed to add song to queue');
}
}, [addToQueue, showSuccess, showError]);
const handleToggleFavorite = useCallback(async (song: Song) => {
try {
await toggleFavorite(song);
showSuccess(song.favorite ? 'Removed from favorites' : 'Added to favorites');
} catch {
showError('Failed to update favorites');
}
}, [toggleFavorite, showSuccess, showError]);
return {
historyItems,
handleAddToQueue,
handleToggleFavorite,
};
};

54
src/hooks/useQueue.ts Normal file
View File

@ -0,0 +1,54 @@
import { useCallback } from 'react';
import { useAppSelector } from '../redux';
import { selectQueueWithUserInfo, selectQueueStats, selectCanReorderQueue } from '../redux/selectors';
import { useSongOperations } from './useSongOperations';
import { useToast } from './useToast';
import type { QueueItem } from '../types';
export const useQueue = () => {
const queueItems = useAppSelector(selectQueueWithUserInfo);
const queueStats = useAppSelector(selectQueueStats);
const canReorder = useAppSelector(selectCanReorderQueue);
const { removeFromQueue, toggleFavorite } = useSongOperations();
const { showSuccess, showError } = useToast();
const handleRemoveFromQueue = useCallback(async (queueItem: QueueItem) => {
if (!queueItem.key) return;
try {
await removeFromQueue(queueItem.key);
showSuccess('Song removed from queue');
} catch {
showError('Failed to remove song from queue');
}
}, [removeFromQueue, showSuccess, showError]);
const handleToggleFavorite = useCallback(async (song: QueueItem['song']) => {
try {
await toggleFavorite(song);
showSuccess(song.favorite ? 'Removed from favorites' : 'Added to favorites');
} catch {
showError('Failed to update favorites');
}
}, [toggleFavorite, showSuccess, showError]);
const handleMoveUp = useCallback(async (queueItem: QueueItem) => {
// TODO: Implement move up logic
console.log('Move up:', queueItem);
}, []);
const handleMoveDown = useCallback(async (queueItem: QueueItem) => {
// TODO: Implement move down logic
console.log('Move down:', queueItem);
}, []);
return {
queueItems,
queueStats,
canReorder,
handleRemoveFromQueue,
handleToggleFavorite,
handleMoveUp,
handleMoveDown,
};
};

57
src/hooks/useSearch.ts Normal file
View File

@ -0,0 +1,57 @@
import { useState, useCallback, useMemo } from 'react';
import { useAppSelector } from '../redux';
import { selectSearchResults } from '../redux/selectors';
import { useSongOperations } from './useSongOperations';
import { useToast } from './useToast';
import { UI_CONSTANTS } from '../constants';
import type { Song } from '../types';
export const useSearch = () => {
const [searchTerm, setSearchTerm] = useState('');
const { addToQueue, toggleFavorite } = useSongOperations();
const { showSuccess, showError } = useToast();
// Get filtered search results using selector
const searchResults = useAppSelector(state =>
selectSearchResults(state, searchTerm)
);
// Debounced search term for performance
const debouncedSearchTerm = useMemo(() => {
if (searchTerm.length < UI_CONSTANTS.SEARCH.MIN_SEARCH_LENGTH) {
return '';
}
return searchTerm;
}, [searchTerm]);
const handleSearchChange = useCallback((value: string) => {
setSearchTerm(value);
}, []);
const handleAddToQueue = useCallback(async (song: Song) => {
try {
await addToQueue(song);
showSuccess('Song added to queue');
} catch {
showError('Failed to add song to queue');
}
}, [addToQueue, showSuccess, showError]);
const handleToggleFavorite = useCallback(async (song: Song) => {
try {
await toggleFavorite(song);
showSuccess(song.favorite ? 'Removed from favorites' : 'Added to favorites');
} catch {
showError('Failed to update favorites');
}
}, [toggleFavorite, showSuccess, showError]);
return {
searchTerm,
debouncedSearchTerm,
searchResults,
handleSearchChange,
handleAddToQueue,
handleToggleFavorite,
};
};

View File

@ -0,0 +1,90 @@
import { useCallback } from 'react';
import { useAppSelector } from '../redux';
import { selectControllerName, selectCurrentSinger } from '../redux';
import { queueService, favoritesService } from '../firebase/services';
import type { Song, QueueItem } from '../types';
export const useSongOperations = () => {
const controllerName = useAppSelector(selectControllerName);
const currentSinger = useAppSelector(selectCurrentSinger);
const currentQueue = useAppSelector((state) => state.controller.data?.player?.queue || {});
const addToQueue = useCallback(async (song: Song) => {
if (!controllerName || !currentSinger) {
throw new Error('Controller name or singer not available');
}
try {
const nextOrder = Object.keys(currentQueue).length + 1;
const queueItem: Omit<QueueItem, 'key'> = {
order: nextOrder,
singer: {
name: currentSinger,
lastLogin: new Date().toISOString(),
},
song,
};
await queueService.addToQueue(controllerName, queueItem);
} catch (error) {
console.error('Failed to add song to queue:', error);
throw error;
}
}, [controllerName, currentSinger, currentQueue]);
const removeFromQueue = useCallback(async (queueItemKey: string) => {
if (!controllerName) {
throw new Error('Controller name not available');
}
try {
await queueService.removeFromQueue(controllerName, queueItemKey);
} catch (error) {
console.error('Failed to remove song from queue:', error);
throw error;
}
}, [controllerName]);
const toggleFavorite = useCallback(async (song: Song) => {
if (!controllerName) {
throw new Error('Controller name not available');
}
try {
if (song.favorite) {
// Remove from favorites
if (song.key) {
await favoritesService.removeFromFavorites(controllerName, song.key);
}
} else {
// Add to favorites
await favoritesService.addToFavorites(controllerName, song);
}
} catch (error) {
console.error('Failed to toggle favorite:', error);
throw error;
}
}, [controllerName]);
const removeFromFavorites = useCallback(async (songKey: string) => {
if (!controllerName) {
throw new Error('Controller name not available');
}
try {
await favoritesService.removeFromFavorites(controllerName, songKey);
} catch (error) {
console.error('Failed to remove from favorites:', error);
throw error;
}
}, [controllerName]);
return {
addToQueue,
removeFromQueue,
toggleFavorite,
removeFromFavorites,
canAddToQueue: !!controllerName && !!currentSinger,
};
};

45
src/hooks/useToast.ts Normal file
View File

@ -0,0 +1,45 @@
import { useState, useCallback } from 'react';
import type { ToastProps } from '../types';
interface ToastItem extends Omit<ToastProps, 'onClose'> {
id: string;
}
export const useToast = () => {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const showToast = useCallback((toast: Omit<ToastProps, 'onClose'>) => {
const id = Math.random().toString(36).substr(2, 9);
const newToast: ToastItem = {
...toast,
id,
};
setToasts(prev => [...prev, newToast]);
}, []);
const removeToast = useCallback((id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id));
}, []);
const showSuccess = useCallback((message: string, duration = 3000) => {
showToast({ message, type: 'success', duration });
}, [showToast]);
const showError = useCallback((message: string, duration = 5000) => {
showToast({ message, type: 'error', duration });
}, [showToast]);
const showInfo = useCallback((message: string, duration = 3000) => {
showToast({ message, type: 'info', duration });
}, [showToast]);
return {
toasts,
showToast,
showSuccess,
showError,
showInfo,
removeToast,
};
};

50
src/hooks/useTopPlayed.ts Normal file
View File

@ -0,0 +1,50 @@
import { useCallback } from 'react';
import { useAppSelector } from '../redux';
import { selectTopPlayedArray } from '../redux/selectors';
import { useSongOperations } from './useSongOperations';
import { useToast } from './useToast';
import type { TopPlayed } from '../types';
export const useTopPlayed = () => {
const topPlayedItems = useAppSelector(selectTopPlayedArray);
const { addToQueue, toggleFavorite } = useSongOperations();
const { showSuccess, showError } = useToast();
const handleAddToQueue = useCallback(async (song: TopPlayed) => {
try {
// Convert TopPlayed to Song format for queue
const songForQueue = {
...song,
path: '', // TopPlayed doesn't have path
disabled: false,
favorite: false,
};
await addToQueue(songForQueue);
showSuccess('Song added to queue');
} catch {
showError('Failed to add song to queue');
}
}, [addToQueue, showSuccess, showError]);
const handleToggleFavorite = useCallback(async (song: TopPlayed) => {
try {
// Convert TopPlayed to Song format for favorites
const songForFavorites = {
...song,
path: '', // TopPlayed doesn't have path
disabled: false,
favorite: false,
};
await toggleFavorite(songForFavorites);
showSuccess(songForFavorites.favorite ? 'Removed from favorites' : 'Added to favorites');
} catch {
showError('Failed to update favorites');
}
}, [toggleFavorite, showSuccess, showError]);
return {
topPlayedItems,
handleAddToQueue,
handleToggleFavorite,
};
};

View File

@ -1,68 +1,3 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -2,9 +2,13 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { Provider } from 'react-redux';
import { store } from './redux/store';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<Provider store={store}>
<App />
</Provider>
</StrictMode>,
)

79
src/redux/authSlice.ts Normal file
View File

@ -0,0 +1,79 @@
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import type { Authentication } from '../types';
// Initial state
interface AuthState {
data: Authentication | null;
loading: boolean;
error: string | null;
}
const initialState: AuthState = {
data: null,
loading: false,
error: null,
};
// Slice
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
setAuth: (state, action: PayloadAction<Authentication>) => {
state.data = action.payload;
state.error = null;
},
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload;
},
setError: (state, action: PayloadAction<string>) => {
state.error = action.payload;
},
clearError: (state) => {
state.error = null;
},
logout: (state) => {
state.data = null;
state.error = null;
},
updateSinger: (state, action: PayloadAction<string>) => {
if (state.data) {
state.data.singer = action.payload;
}
},
setAdminStatus: (state, action: PayloadAction<boolean>) => {
if (state.data) {
state.data.isAdmin = action.payload;
}
},
},
});
// Export actions
export const {
setAuth,
setLoading,
setError,
clearError,
logout,
updateSinger,
setAdminStatus,
} = authSlice.actions;
// Export selectors
export const selectAuth = (state: { auth: AuthState }) => state.auth.data;
export const selectAuthLoading = (state: { auth: AuthState }) => state.auth.loading;
export const selectAuthError = (state: { auth: AuthState }) => state.auth.error;
export const selectIsAuthenticated = (state: { auth: AuthState }) => state.auth.data?.authenticated || false;
export const selectCurrentSinger = (state: { auth: AuthState }) => state.auth.data?.singer || '';
export const selectIsAdmin = (state: { auth: AuthState }) => state.auth.data?.isAdmin || false;
export const selectControllerName = (state: { auth: AuthState }) => state.auth.data?.controller || '';
export default authSlice.reducer;

View File

@ -0,0 +1,164 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import type { Controller, Song, QueueItem, TopPlayed } from '../types';
import { controllerService } from '../firebase/services';
// Async thunks for Firebase operations
export const fetchController = createAsyncThunk(
'controller/fetchController',
async (controllerName: string) => {
const controller = await controllerService.getController(controllerName);
if (!controller) {
throw new Error('Controller not found');
}
return controller;
}
);
export const updateController = createAsyncThunk(
'controller/updateController',
async ({ controllerName, updates }: { controllerName: string; updates: Partial<Controller> }) => {
await controllerService.updateController(controllerName, updates);
return updates;
}
);
// Initial state
interface ControllerState {
data: Controller | null;
loading: boolean;
error: string | null;
lastUpdated: number | null;
}
const initialState: ControllerState = {
data: null,
loading: false,
error: null,
lastUpdated: null,
};
// Slice
const controllerSlice = createSlice({
name: 'controller',
initialState,
reducers: {
// Sync actions for real-time updates
setController: (state, action: PayloadAction<Controller>) => {
state.data = action.payload;
state.lastUpdated = Date.now();
state.error = null;
},
updateSongs: (state, action: PayloadAction<Record<string, Song>>) => {
if (state.data) {
state.data.songs = action.payload;
state.lastUpdated = Date.now();
}
},
updateQueue: (state, action: PayloadAction<Record<string, QueueItem>>) => {
if (state.data) {
state.data.player.queue = action.payload;
state.lastUpdated = Date.now();
}
},
updateFavorites: (state, action: PayloadAction<Record<string, Song>>) => {
if (state.data) {
state.data.favorites = action.payload;
state.lastUpdated = Date.now();
}
},
updateHistory: (state, action: PayloadAction<Record<string, Song>>) => {
if (state.data) {
state.data.history = action.payload;
state.lastUpdated = Date.now();
}
},
updateTopPlayed: (state, action: PayloadAction<Record<string, TopPlayed>>) => {
if (state.data) {
state.data.topPlayed = action.payload;
state.lastUpdated = Date.now();
}
},
clearError: (state) => {
state.error = null;
},
resetController: (state) => {
state.data = null;
state.loading = false;
state.error = null;
state.lastUpdated = null;
},
},
extraReducers: (builder) => {
builder
// fetchController
.addCase(fetchController.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchController.fulfilled, (state, action) => {
state.loading = false;
state.data = action.payload;
state.lastUpdated = Date.now();
state.error = null;
})
.addCase(fetchController.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message || 'Failed to fetch controller';
})
// updateController
.addCase(updateController.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(updateController.fulfilled, (state, action) => {
state.loading = false;
if (state.data) {
state.data = { ...state.data, ...action.payload };
state.lastUpdated = Date.now();
}
state.error = null;
})
.addCase(updateController.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message || 'Failed to update controller';
});
},
});
// Export actions
export const {
setController,
updateSongs,
updateQueue,
updateFavorites,
updateHistory,
updateTopPlayed,
clearError,
resetController,
} = controllerSlice.actions;
// Export selectors
export const selectController = (state: { controller: ControllerState }) => state.controller.data;
export const selectControllerLoading = (state: { controller: ControllerState }) => state.controller.loading;
export const selectControllerError = (state: { controller: ControllerState }) => state.controller.error;
export const selectLastUpdated = (state: { controller: ControllerState }) => state.controller.lastUpdated;
// Selectors for specific data
export const selectSongs = (state: { controller: ControllerState }) => state.controller.data?.songs || {};
export const selectQueue = (state: { controller: ControllerState }) => state.controller.data?.player?.queue || {};
export const selectFavorites = (state: { controller: ControllerState }) => state.controller.data?.favorites || {};
export const selectHistory = (state: { controller: ControllerState }) => state.controller.data?.history || {};
export const selectTopPlayed = (state: { controller: ControllerState }) => state.controller.data?.topPlayed || {};
export const selectPlayerState = (state: { controller: ControllerState }) => state.controller.data?.player?.state;
export const selectSettings = (state: { controller: ControllerState }) => state.controller.data?.player?.settings;
export const selectSingers = (state: { controller: ControllerState }) => state.controller.data?.player?.singers || {};
export default controllerSlice.reducer;

7
src/redux/hooks.ts Normal file
View File

@ -0,0 +1,7 @@
import { useDispatch, useSelector } from 'react-redux';
import type { TypedUseSelectorHook } from 'react-redux';
import type { RootState, AppDispatch } from './store';
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

52
src/redux/index.ts Normal file
View File

@ -0,0 +1,52 @@
// Store
export { store } from './store';
export type { RootState, AppDispatch } from './store';
// Hooks
export { useAppDispatch, useAppSelector } from './hooks';
// Controller slice
export {
fetchController,
updateController,
setController,
updateSongs,
updateQueue,
updateFavorites,
updateHistory,
updateTopPlayed,
resetController,
selectController,
selectControllerLoading,
selectControllerError,
selectLastUpdated,
selectSongs,
selectQueue,
selectFavorites,
selectHistory,
selectTopPlayed,
selectPlayerState,
selectSettings,
selectSingers,
} from './controllerSlice';
// Auth slice
export {
setAuth,
setLoading,
setError,
clearError,
logout,
updateSinger,
setAdminStatus,
selectAuth,
selectAuthLoading,
selectAuthError,
selectIsAuthenticated,
selectCurrentSinger,
selectIsAdmin,
selectControllerName,
} from './authSlice';
// Enhanced selectors
export * from './selectors';

9
src/redux/playerSlice.ts Normal file
View File

@ -0,0 +1,9 @@
import { createSlice } from '@reduxjs/toolkit';
const playerSlice = createSlice({
name: 'player',
initialState: {},
reducers: {},
});
export default playerSlice.reducer;

9
src/redux/queueSlice.ts Normal file
View File

@ -0,0 +1,9 @@
import { createSlice } from '@reduxjs/toolkit';
const queueSlice = createSlice({
name: 'queue',
initialState: {},
reducers: {},
});
export default queueSlice.reducer;

88
src/redux/selectors.ts Normal file
View File

@ -0,0 +1,88 @@
import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from '../types';
import {
selectSongs,
selectQueue,
selectFavorites,
selectHistory,
selectTopPlayed,
selectIsAdmin,
selectCurrentSinger
} from './index';
import {
objectToArray,
filterSongs,
sortQueueByOrder,
sortHistoryByDate,
sortTopPlayedByCount,
limitArray,
getQueueStats
} from '../utils/dataProcessing';
import { UI_CONSTANTS } from '../constants';
// Enhanced selectors with data processing
export const selectSongsArray = createSelector(
[selectSongs],
(songs) => objectToArray(songs)
);
export const selectFilteredSongs = createSelector(
[selectSongsArray, (state: RootState, searchTerm: string) => searchTerm],
(songs, searchTerm) => filterSongs(songs, searchTerm)
);
export const selectQueueArray = createSelector(
[selectQueue],
(queue) => sortQueueByOrder(objectToArray(queue))
);
export const selectQueueStats = createSelector(
[selectQueue],
(queue) => getQueueStats(queue)
);
export const selectHistoryArray = createSelector(
[selectHistory],
(history) => limitArray(sortHistoryByDate(objectToArray(history)), UI_CONSTANTS.HISTORY.MAX_ITEMS)
);
export const selectFavoritesArray = createSelector(
[selectFavorites],
(favorites) => objectToArray(favorites)
);
export const selectTopPlayedArray = createSelector(
[selectTopPlayed],
(topPlayed) => limitArray(sortTopPlayedByCount(objectToArray(topPlayed)), UI_CONSTANTS.TOP_PLAYED.MAX_ITEMS)
);
// User-specific selectors
export const selectUserQueueItems = createSelector(
[selectQueueArray, selectCurrentSinger],
(queueArray, currentSinger) =>
queueArray.filter(item => item.singer.name === currentSinger)
);
export const selectCanReorderQueue = createSelector(
[selectIsAdmin],
(isAdmin) => isAdmin
);
// Search-specific selectors
export const selectSearchResults = createSelector(
[selectFilteredSongs],
(filteredSongs) => ({
songs: filteredSongs,
count: filteredSongs.length,
})
);
// Queue-specific selectors
export const selectQueueWithUserInfo = createSelector(
[selectQueueArray, selectCurrentSinger],
(queueArray, currentSinger) =>
queueArray.map(item => ({
...item,
isCurrentUser: item.singer.name === currentSinger,
}))
);

14
src/redux/store.ts Normal file
View File

@ -0,0 +1,14 @@
import { configureStore } from '@reduxjs/toolkit';
import type { RootState as AppRootState } from '../types';
import controllerReducer from './controllerSlice';
import authReducer from './authSlice';
export const store = configureStore({
reducer: {
controller: controllerReducer,
auth: authReducer,
},
});
export type RootState = AppRootState;
export type AppDispatch = typeof store.dispatch;

149
src/types/index.ts Normal file
View File

@ -0,0 +1,149 @@
// Core data types (from docs/types.ts)
export const PlayerState = {
playing: "Playing",
paused: "Paused",
stopped: "Stopped"
} as const;
export type PlayerStateType = typeof PlayerState[keyof typeof PlayerState];
export interface Keyable {
key?: string;
}
export interface Authentication {
authenticated: boolean;
singer: string;
isAdmin: boolean;
controller: string;
}
export interface History {
songs: Song[],
topPlayed: TopPlayed[]
}
export interface Player {
state: PlayerStateType;
}
export interface QueueItem extends Keyable {
order: number,
singer: Singer;
song: Song;
}
export interface Settings {
autoadvance: boolean;
userpick: boolean;
}
export interface Singer extends Keyable {
name: string;
lastLogin: string;
}
export interface SongBase extends Keyable {
path: string;
}
export interface Song extends SongBase {
artist: string;
title: string;
count?: number;
disabled?: boolean;
favorite?: boolean;
date?: string;
}
export type PickedSong = {
song: Song
}
export interface SongList extends Keyable {
title: string;
songs: SongListSong[];
}
export interface SongListSong extends Keyable {
artist: string;
position: number;
title: string;
foundSongs?: Song[];
}
export interface TopPlayed extends Keyable {
artist: string;
title: string;
count: number;
}
// Firebase data structure types
export interface Controller {
favorites: Record<string, Song>;
history: Record<string, Song>;
topPlayed: Record<string, TopPlayed>;
newSongs: Record<string, Song>;
player: {
queue: Record<string, QueueItem>;
settings: Settings;
singers: Record<string, Singer>;
state: Player;
};
songList: Record<string, unknown>;
songs: Record<string, Song>;
}
// UI Component Props types
export interface EmptyStateProps {
title: string;
message?: string;
icon?: React.ReactNode;
action?: React.ReactNode;
}
export interface ToastProps {
message: string;
type?: 'success' | 'error' | 'info';
duration?: number;
onClose: () => void;
}
export interface ActionButtonProps {
onClick: () => void;
children: React.ReactNode;
variant?: 'primary' | 'secondary' | 'danger';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
className?: string;
}
export interface SongItemProps {
song: Song;
context: 'search' | 'queue' | 'history' | 'favorites';
onAddToQueue?: () => void;
onRemoveFromQueue?: () => void;
onToggleFavorite?: () => void;
onDelete?: () => void;
isAdmin?: boolean;
className?: string;
}
export interface LayoutProps {
children: React.ReactNode;
}
// Redux state types
export interface RootState {
controller: {
data: Controller | null;
loading: boolean;
error: string | null;
lastUpdated: number | null;
};
auth: {
data: Authentication | null;
loading: boolean;
error: string | null;
};
}

View File

@ -0,0 +1,70 @@
import type { Song, QueueItem, TopPlayed } from '../types';
// Convert Firebase object to array with keys
export const objectToArray = <T extends { key?: string }>(
obj: Record<string, T>
): T[] => {
return Object.entries(obj).map(([key, item]) => ({
...item,
key,
}));
};
// Filter songs by search term
export const filterSongs = (songs: Song[], searchTerm: string): Song[] => {
if (!searchTerm.trim()) return songs;
const term = searchTerm.toLowerCase();
return songs.filter(song =>
song.title.toLowerCase().includes(term) ||
song.artist.toLowerCase().includes(term)
);
};
// Sort queue items by order
export const sortQueueByOrder = (queueItems: QueueItem[]): QueueItem[] => {
return [...queueItems].sort((a, b) => a.order - b.order);
};
// Sort history by date (most recent first)
export const sortHistoryByDate = (songs: Song[]): Song[] => {
return [...songs].sort((a, b) => {
if (!a.date || !b.date) return 0;
return new Date(b.date).getTime() - new Date(a.date).getTime();
});
};
// Sort top played by count (highest first)
export const sortTopPlayedByCount = (songs: TopPlayed[]): TopPlayed[] => {
return [...songs].sort((a, b) => b.count - a.count);
};
// Limit array to specified length
export const limitArray = <T>(array: T[], limit: number): T[] => {
return array.slice(0, limit);
};
// Format date for display
export const formatDate = (dateString: string): string => {
const date = new Date(dateString);
const now = new Date();
const diffInHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60));
if (diffInHours < 1) return 'Just now';
if (diffInHours < 24) return `${diffInHours} hour${diffInHours !== 1 ? 's' : ''} ago`;
const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays < 7) return `${diffInDays} day${diffInDays !== 1 ? 's' : ''} ago`;
return date.toLocaleDateString();
};
// Get queue statistics
export const getQueueStats = (queue: Record<string, QueueItem>) => {
const queueArray = objectToArray(queue);
return {
totalSongs: queueArray.length,
singers: [...new Set(queueArray.map(item => item.singer.name))],
estimatedDuration: queueArray.length * 3, // Rough estimate: 3 minutes per song
};
};

11
tailwind.config.js Normal file
View File

@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}