diff --git a/package-lock.json b/package-lock.json
index c0ea668..7511c93 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 0551e77..4459322 100644
--- a/package.json
+++ b/package.json
@@ -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"
diff --git a/postcss.config.js b/postcss.config.js
new file mode 100644
index 0000000..387612e
--- /dev/null
+++ b/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
\ No newline at end of file
diff --git a/src/App.tsx b/src/App.tsx
index 3d7ded3..d85a2a8 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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 (
- <>
-
- Vite + React
-
-
-
- Edit src/App.tsx and save to test HMR
-
-
-
- Click on the Vite and React logos to learn more
-
- >
- )
+
+
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+ );
}
-export default App
+export default App;
diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx
new file mode 100644
index 0000000..554a4a0
--- /dev/null
+++ b/src/components/Layout/Layout.tsx
@@ -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 = ({ 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 (
+
+ {/* Header */}
+
+
+
+ {/* Logo/Title */}
+
+
+ 🎤 Karaoke App
+
+ {controllerName && (
+
+ Controller: {controllerName}
+
+ )}
+
+
+ {/* User Info */}
+
+ {currentSinger && (
+
+ {currentSinger}
+ {isAdmin && (
+
+ Admin
+
+ )}
+
+ )}
+
+
+
+
+
+ {/* Main Content */}
+
+ {children}
+
+
+ {/* Footer */}
+
+
+ );
+};
+
+export default Layout;
\ No newline at end of file
diff --git a/src/components/Navigation/Navigation.tsx b/src/components/Navigation/Navigation.tsx
new file mode 100644
index 0000000..390ac88
--- /dev/null
+++ b/src/components/Navigation/Navigation.tsx
@@ -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 (
+
+ );
+};
+
+export default Navigation;
\ No newline at end of file
diff --git a/src/components/common/ActionButton.tsx b/src/components/common/ActionButton.tsx
new file mode 100644
index 0000000..b7a74f2
--- /dev/null
+++ b/src/components/common/ActionButton.tsx
@@ -0,0 +1,55 @@
+import React from 'react';
+import type { ActionButtonProps } from '../../types';
+
+const ActionButton: React.FC = ({
+ 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 (
+
+ );
+};
+
+export default ActionButton;
\ No newline at end of file
diff --git a/src/components/common/EmptyState.tsx b/src/components/common/EmptyState.tsx
new file mode 100644
index 0000000..8013080
--- /dev/null
+++ b/src/components/common/EmptyState.tsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import type { EmptyStateProps } from '../../types';
+
+const EmptyState: React.FC = ({
+ title,
+ message,
+ icon,
+ action
+}) => {
+ return (
+
+ {icon && (
+
+ {icon}
+
+ )}
+
+ {title}
+
+ {message && (
+
+ {message}
+
+ )}
+ {action && (
+
+ {action}
+
+ )}
+
+ );
+};
+
+export default EmptyState;
\ No newline at end of file
diff --git a/src/components/common/SongItem.tsx b/src/components/common/SongItem.tsx
new file mode 100644
index 0000000..4d2b0fc
--- /dev/null
+++ b/src/components/common/SongItem.tsx
@@ -0,0 +1,130 @@
+import React from 'react';
+import ActionButton from './ActionButton';
+import type { SongItemProps } from '../../types';
+
+const SongItem: React.FC = ({
+ song,
+ context,
+ onAddToQueue,
+ onRemoveFromQueue,
+ onToggleFavorite,
+ onDelete,
+ isAdmin = false,
+ className = ''
+}) => {
+ const renderActionPanel = () => {
+ switch (context) {
+ case 'search':
+ return (
+
+
{})}
+ variant="primary"
+ size="sm"
+ >
+ Add to Queue
+
+
{})}
+ variant={song.favorite ? 'danger' : 'secondary'}
+ size="sm"
+ >
+ {song.favorite ? '❤️' : '🤍'}
+
+
+ );
+
+ case 'queue':
+ return (
+
+ {isAdmin && (
+
{})}
+ variant="danger"
+ size="sm"
+ >
+ Remove
+
+ )}
+
{})}
+ variant={song.favorite ? 'danger' : 'secondary'}
+ size="sm"
+ >
+ {song.favorite ? '❤️' : '🤍'}
+
+
+ );
+
+ case 'history':
+ return (
+
+
{})}
+ variant="primary"
+ size="sm"
+ >
+ Add to Queue
+
+
{})}
+ variant={song.favorite ? 'danger' : 'secondary'}
+ size="sm"
+ >
+ {song.favorite ? '❤️' : '🤍'}
+
+
+ );
+
+ case 'favorites':
+ return (
+
+
{})}
+ variant="primary"
+ size="sm"
+ >
+ Add to Queue
+
+
{})}
+ variant="danger"
+ size="sm"
+ >
+ Remove
+
+
+ );
+
+ default:
+ return null;
+ }
+ };
+
+ return (
+
+
+
+ {song.title}
+
+
+ {song.artist}
+
+ {song.count && (
+
+ Played {song.count} times
+
+ )}
+
+
+
+ {renderActionPanel()}
+
+
+ );
+};
+
+export default SongItem;
\ No newline at end of file
diff --git a/src/components/common/Toast.tsx b/src/components/common/Toast.tsx
new file mode 100644
index 0000000..60f72e6
--- /dev/null
+++ b/src/components/common/Toast.tsx
@@ -0,0 +1,57 @@
+import React, { useEffect, useState } from 'react';
+import type { ToastProps } from '../../types';
+
+const Toast: React.FC = ({
+ 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 (
+
+
+ {message}
+
+
+
+ );
+};
+
+export default Toast;
\ No newline at end of file
diff --git a/src/components/common/index.ts b/src/components/common/index.ts
new file mode 100644
index 0000000..f19f7e1
--- /dev/null
+++ b/src/components/common/index.ts
@@ -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';
\ No newline at end of file
diff --git a/src/constants/index.ts b/src/constants/index.ts
new file mode 100644
index 0000000..ff86ee3
--- /dev/null
+++ b/src/constants/index.ts
@@ -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;
\ No newline at end of file
diff --git a/src/features/History/History.tsx b/src/features/History/History.tsx
new file mode 100644
index 0000000..8d0ef34
--- /dev/null
+++ b/src/features/History/History.tsx
@@ -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 (
+
+
+
Recently Played
+
+ {historyItems.length} song{historyItems.length !== 1 ? 's' : ''} in history
+
+
+
+ {/* History List */}
+
+ {historyItems.length === 0 ? (
+
+
+
+ }
+ />
+ ) : (
+
+ {historyItems.map((song) => (
+
+ {/* Song Info */}
+
+ handleAddToQueue(song)}
+ onToggleFavorite={() => handleToggleFavorite(song)}
+ />
+
+
+ {/* Play Date */}
+ {song.date && (
+
+ {formatDate(song.date)}
+
+ )}
+
+ ))}
+
+ )}
+
+
+ );
+};
+
+export default History;
\ No newline at end of file
diff --git a/src/features/Queue/Queue.tsx b/src/features/Queue/Queue.tsx
new file mode 100644
index 0000000..a30afb9
--- /dev/null
+++ b/src/features/Queue/Queue.tsx
@@ -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 (
+
+
+
Queue
+
+ {queueStats.totalSongs} song{queueStats.totalSongs !== 1 ? 's' : ''} in queue
+
+
+
+ {/* Queue List */}
+
+ {queueItems.length === 0 ? (
+
+
+
+ }
+ />
+ ) : (
+
+ {queueItems.map((queueItem, index) => (
+
+ {/* Order Number */}
+
+ {queueItem.order}
+
+
+ {/* Song Info */}
+
+ handleRemoveFromQueue(queueItem)}
+ onToggleFavorite={() => handleToggleFavorite(queueItem.song)}
+ isAdmin={canReorder}
+ />
+
+
+ {/* Singer Info */}
+
+
{queueItem.singer.name}
+
+ {queueItem.isCurrentUser ? '(You)' : ''}
+
+
+
+ {/* Admin Controls */}
+ {canReorder && (
+
+
handleMoveUp(queueItem)}
+ variant="secondary"
+ size="sm"
+ disabled={index === 0}
+ >
+ ↑
+
+
handleMoveDown(queueItem)}
+ variant="secondary"
+ size="sm"
+ disabled={index === queueItems.length - 1}
+ >
+ ↓
+
+
+ )}
+
+ ))}
+
+ )}
+
+
+ );
+};
+
+export default Queue;
\ No newline at end of file
diff --git a/src/features/Search/Search.tsx b/src/features/Search/Search.tsx
new file mode 100644
index 0000000..f6cdb65
--- /dev/null
+++ b/src/features/Search/Search.tsx
@@ -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 (
+
+
+
Search Songs
+
+ {/* Search Input */}
+
+
handleSearchChange(e.target.value)}
+ className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+ />
+
+
+
+
+ {/* Search Results */}
+
+ {searchResults.songs.length === 0 ? (
+
+
+
+ }
+ />
+ ) : (
+
+ {searchResults.songs.map((song) => (
+ handleAddToQueue(song)}
+ onToggleFavorite={() => handleToggleFavorite(song)}
+ isAdmin={isAdmin}
+ />
+ ))}
+
+ )}
+
+
+ {/* Search Stats */}
+ {searchTerm && (
+
+ Found {searchResults.count} song{searchResults.count !== 1 ? 's' : ''}
+
+ )}
+
+ );
+};
+
+export default Search;
\ No newline at end of file
diff --git a/src/features/TopPlayed/TopPlayed.tsx b/src/features/TopPlayed/TopPlayed.tsx
new file mode 100644
index 0000000..57242cf
--- /dev/null
+++ b/src/features/TopPlayed/TopPlayed.tsx
@@ -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 (
+
+
+
Most Played
+
+ Top {topPlayedItems.length} song{topPlayedItems.length !== 1 ? 's' : ''} by play count
+
+
+
+ {/* Top Played List */}
+
+ {topPlayedItems.length === 0 ? (
+
+
+
+ }
+ />
+ ) : (
+
+ {topPlayedItems.map((song, index) => (
+
+ {/* Rank */}
+
+
2 ? 'bg-gray-50 text-gray-600' : ''}
+ `}>
+ {index + 1}
+
+
+
+ {/* Song Info */}
+
+ handleAddToQueue(song)}
+ onToggleFavorite={() => handleToggleFavorite(song)}
+ />
+
+
+ {/* Play Count */}
+
+
{song.count}
+
+ play{song.count !== 1 ? 's' : ''}
+
+
+
+ ))}
+
+ )}
+
+
+ );
+};
+
+export default TopPlayed;
\ No newline at end of file
diff --git a/src/features/index.ts b/src/features/index.ts
new file mode 100644
index 0000000..783334d
--- /dev/null
+++ b/src/features/index.ts
@@ -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';
\ No newline at end of file
diff --git a/src/firebase/config.ts b/src/firebase/config.ts
new file mode 100644
index 0000000..1eec292
--- /dev/null
+++ b/src/firebase/config.ts
@@ -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;
\ No newline at end of file
diff --git a/src/firebase/services.ts b/src/firebase/services.ts
new file mode 100644
index 0000000..91cf166
--- /dev/null
+++ b/src/firebase/services.ts
@@ -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) => {
+ 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) => {
+ 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) => {
+ const queueItemRef = ref(database, `controllers/${controllerName}/player/queue/${queueItemKey}`);
+ await update(queueItemRef, updates);
+ },
+
+ // Listen to queue changes
+ subscribeToQueue: (controllerName: string, callback: (data: Record) => 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) => {
+ 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) => {
+ const historyRef = ref(database, `controllers/${controllerName}/history`);
+ return await push(historyRef, song);
+ },
+
+ // Listen to history changes
+ subscribeToHistory: (controllerName: string, callback: (data: Record) => 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) => {
+ 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) => void) => {
+ const favoritesRef = ref(database, `controllers/${controllerName}/favorites`);
+ onValue(favoritesRef, (snapshot) => {
+ callback(snapshot.exists() ? snapshot.val() : {});
+ });
+
+ return () => off(favoritesRef);
+ }
+};
\ No newline at end of file
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
new file mode 100644
index 0000000..6b43fbf
--- /dev/null
+++ b/src/hooks/index.ts
@@ -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';
\ No newline at end of file
diff --git a/src/hooks/useFirebaseSync.ts b/src/hooks/useFirebaseSync.ts
new file mode 100644
index 0000000..b3d2e5d
--- /dev/null
+++ b/src/hooks/useFirebaseSync.ts
@@ -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 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
+ };
+};
\ No newline at end of file
diff --git a/src/hooks/useHistory.ts b/src/hooks/useHistory.ts
new file mode 100644
index 0000000..8943d7d
--- /dev/null
+++ b/src/hooks/useHistory.ts
@@ -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,
+ };
+};
\ No newline at end of file
diff --git a/src/hooks/useQueue.ts b/src/hooks/useQueue.ts
new file mode 100644
index 0000000..ed71f8b
--- /dev/null
+++ b/src/hooks/useQueue.ts
@@ -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,
+ };
+};
\ No newline at end of file
diff --git a/src/hooks/useSearch.ts b/src/hooks/useSearch.ts
new file mode 100644
index 0000000..c16fa5b
--- /dev/null
+++ b/src/hooks/useSearch.ts
@@ -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,
+ };
+};
\ No newline at end of file
diff --git a/src/hooks/useSongOperations.ts b/src/hooks/useSongOperations.ts
new file mode 100644
index 0000000..c3f1342
--- /dev/null
+++ b/src/hooks/useSongOperations.ts
@@ -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 = {
+ 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,
+ };
+};
\ No newline at end of file
diff --git a/src/hooks/useToast.ts b/src/hooks/useToast.ts
new file mode 100644
index 0000000..bdd4e1f
--- /dev/null
+++ b/src/hooks/useToast.ts
@@ -0,0 +1,45 @@
+import { useState, useCallback } from 'react';
+import type { ToastProps } from '../types';
+
+interface ToastItem extends Omit {
+ id: string;
+}
+
+export const useToast = () => {
+ const [toasts, setToasts] = useState([]);
+
+ const showToast = useCallback((toast: Omit) => {
+ 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,
+ };
+};
\ No newline at end of file
diff --git a/src/hooks/useTopPlayed.ts b/src/hooks/useTopPlayed.ts
new file mode 100644
index 0000000..8f3b6f4
--- /dev/null
+++ b/src/hooks/useTopPlayed.ts
@@ -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,
+ };
+};
\ No newline at end of file
diff --git a/src/index.css b/src/index.css
index 08a3ac9..b5c61c9 100644
--- a/src/index.css
+++ b/src/index.css
@@ -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;
diff --git a/src/main.tsx b/src/main.tsx
index bef5202..9d070ed 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -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(
-
+
+
+
,
)
diff --git a/src/redux/authSlice.ts b/src/redux/authSlice.ts
new file mode 100644
index 0000000..162e316
--- /dev/null
+++ b/src/redux/authSlice.ts
@@ -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) => {
+ state.data = action.payload;
+ state.error = null;
+ },
+
+ setLoading: (state, action: PayloadAction) => {
+ state.loading = action.payload;
+ },
+
+ setError: (state, action: PayloadAction) => {
+ state.error = action.payload;
+ },
+
+ clearError: (state) => {
+ state.error = null;
+ },
+
+ logout: (state) => {
+ state.data = null;
+ state.error = null;
+ },
+
+ updateSinger: (state, action: PayloadAction) => {
+ if (state.data) {
+ state.data.singer = action.payload;
+ }
+ },
+
+ setAdminStatus: (state, action: PayloadAction) => {
+ 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;
\ No newline at end of file
diff --git a/src/redux/controllerSlice.ts b/src/redux/controllerSlice.ts
new file mode 100644
index 0000000..08aecfb
--- /dev/null
+++ b/src/redux/controllerSlice.ts
@@ -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 }) => {
+ 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) => {
+ state.data = action.payload;
+ state.lastUpdated = Date.now();
+ state.error = null;
+ },
+
+ updateSongs: (state, action: PayloadAction>) => {
+ if (state.data) {
+ state.data.songs = action.payload;
+ state.lastUpdated = Date.now();
+ }
+ },
+
+ updateQueue: (state, action: PayloadAction>) => {
+ if (state.data) {
+ state.data.player.queue = action.payload;
+ state.lastUpdated = Date.now();
+ }
+ },
+
+ updateFavorites: (state, action: PayloadAction>) => {
+ if (state.data) {
+ state.data.favorites = action.payload;
+ state.lastUpdated = Date.now();
+ }
+ },
+
+ updateHistory: (state, action: PayloadAction>) => {
+ if (state.data) {
+ state.data.history = action.payload;
+ state.lastUpdated = Date.now();
+ }
+ },
+
+ updateTopPlayed: (state, action: PayloadAction>) => {
+ 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;
\ No newline at end of file
diff --git a/src/redux/hooks.ts b/src/redux/hooks.ts
new file mode 100644
index 0000000..0a63942
--- /dev/null
+++ b/src/redux/hooks.ts
@@ -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();
+export const useAppSelector: TypedUseSelectorHook = useSelector;
\ No newline at end of file
diff --git a/src/redux/index.ts b/src/redux/index.ts
new file mode 100644
index 0000000..3ad554d
--- /dev/null
+++ b/src/redux/index.ts
@@ -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';
\ No newline at end of file
diff --git a/src/redux/playerSlice.ts b/src/redux/playerSlice.ts
new file mode 100644
index 0000000..242faea
--- /dev/null
+++ b/src/redux/playerSlice.ts
@@ -0,0 +1,9 @@
+import { createSlice } from '@reduxjs/toolkit';
+
+const playerSlice = createSlice({
+ name: 'player',
+ initialState: {},
+ reducers: {},
+});
+
+export default playerSlice.reducer;
\ No newline at end of file
diff --git a/src/redux/queueSlice.ts b/src/redux/queueSlice.ts
new file mode 100644
index 0000000..d0335a6
--- /dev/null
+++ b/src/redux/queueSlice.ts
@@ -0,0 +1,9 @@
+import { createSlice } from '@reduxjs/toolkit';
+
+const queueSlice = createSlice({
+ name: 'queue',
+ initialState: {},
+ reducers: {},
+});
+
+export default queueSlice.reducer;
\ No newline at end of file
diff --git a/src/redux/selectors.ts b/src/redux/selectors.ts
new file mode 100644
index 0000000..7a4dab6
--- /dev/null
+++ b/src/redux/selectors.ts
@@ -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,
+ }))
+);
\ No newline at end of file
diff --git a/src/redux/store.ts b/src/redux/store.ts
new file mode 100644
index 0000000..644d615
--- /dev/null
+++ b/src/redux/store.ts
@@ -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;
\ No newline at end of file
diff --git a/src/types/index.ts b/src/types/index.ts
new file mode 100644
index 0000000..2feb67e
--- /dev/null
+++ b/src/types/index.ts
@@ -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;
+ history: Record;
+ topPlayed: Record;
+ newSongs: Record;
+ player: {
+ queue: Record;
+ settings: Settings;
+ singers: Record;
+ state: Player;
+ };
+ songList: Record;
+ songs: Record;
+}
+
+// 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;
+ };
+}
\ No newline at end of file
diff --git a/src/utils/dataProcessing.ts b/src/utils/dataProcessing.ts
new file mode 100644
index 0000000..8d354e3
--- /dev/null
+++ b/src/utils/dataProcessing.ts
@@ -0,0 +1,70 @@
+import type { Song, QueueItem, TopPlayed } from '../types';
+
+// Convert Firebase object to array with keys
+export const objectToArray = (
+ obj: Record
+): 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 = (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) => {
+ 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
+ };
+};
\ No newline at end of file
diff --git a/tailwind.config.js b/tailwind.config.js
new file mode 100644
index 0000000..da7c9ef
--- /dev/null
+++ b/tailwind.config.js
@@ -0,0 +1,11 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: [
+ "./index.html",
+ "./src/**/*.{js,ts,jsx,tsx}",
+ ],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+}
\ No newline at end of file