Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
edc508b5e7
commit
d7cf384429
246
package-lock.json
generated
246
package-lock.json
generated
@ -20,10 +20,13 @@
|
|||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@vitejs/plugin-react-swc": "^3.10.2",
|
"@vitejs/plugin-react-swc": "^3.10.2",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
"eslint": "^9.30.1",
|
"eslint": "^9.30.1",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"globals": "^16.3.0",
|
"globals": "^16.3.0",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^4.1.11",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.35.1",
|
"typescript-eslint": "^8.35.1",
|
||||||
"vite": "^7.0.4"
|
"vite": "^7.0.4"
|
||||||
@ -2234,6 +2237,43 @@
|
|||||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
@ -2262,6 +2302,38 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/callsites": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||||
@ -2271,6 +2343,26 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"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==",
|
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/emoji-regex": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
"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==",
|
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@ -3071,6 +3182,21 @@
|
|||||||
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@ -3194,6 +3320,12 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@ -3558,6 +3690,12 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.14",
|
"version": "0.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
|
||||||
"integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="
|
"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": {
|
"node_modules/uri-js": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
"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==",
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||||
"dev": true
|
"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": {
|
"balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
@ -5313,12 +5495,30 @@
|
|||||||
"fill-range": "^7.1.1"
|
"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": {
|
"callsites": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||||
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
|
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
|
||||||
"dev": true
|
"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": {
|
"chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"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==",
|
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
|
||||||
"dev": true
|
"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": {
|
"emoji-regex": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
"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==",
|
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
||||||
"dev": true
|
"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": {
|
"fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@ -5918,6 +6130,18 @@
|
|||||||
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
||||||
"dev": true
|
"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": {
|
"optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@ -5994,6 +6218,12 @@
|
|||||||
"source-map-js": "^1.2.1"
|
"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": {
|
"prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@ -6217,6 +6447,12 @@
|
|||||||
"has-flag": "^4.0.0"
|
"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": {
|
"tinyglobby": {
|
||||||
"version": "0.2.14",
|
"version": "0.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
|
||||||
"integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="
|
"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": {
|
"uri-js": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||||
|
|||||||
@ -23,10 +23,13 @@
|
|||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@vitejs/plugin-react-swc": "^3.10.2",
|
"@vitejs/plugin-react-swc": "^3.10.2",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
"eslint": "^9.30.1",
|
"eslint": "^9.30.1",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"globals": "^16.3.0",
|
"globals": "^16.3.0",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^4.1.11",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.35.1",
|
"typescript-eslint": "^8.35.1",
|
||||||
"vite": "^7.0.4"
|
"vite": "^7.0.4"
|
||||||
|
|||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
48
src/App.tsx
48
src/App.tsx
@ -1,35 +1,23 @@
|
|||||||
import { useState } from 'react'
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import reactLogo from './assets/react.svg'
|
import Layout from './components/Layout/Layout';
|
||||||
import viteLogo from '/vite.svg'
|
import Navigation from './components/Navigation/Navigation';
|
||||||
import './App.css'
|
import { Search, Queue, History, TopPlayed } from './features';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [count, setCount] = useState(0)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Router>
|
||||||
<div>
|
<Layout>
|
||||||
<a href="https://vite.dev" target="_blank">
|
<Navigation />
|
||||||
<img src={viteLogo} className="logo" alt="Vite logo" />
|
<Routes>
|
||||||
</a>
|
<Route path="/" element={<Search />} />
|
||||||
<a href="https://react.dev" target="_blank">
|
<Route path="/queue" element={<Queue />} />
|
||||||
<img src={reactLogo} className="logo react" alt="React logo" />
|
<Route path="/history" element={<History />} />
|
||||||
</a>
|
<Route path="/top-played" element={<TopPlayed />} />
|
||||||
</div>
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
<h1>Vite + React</h1>
|
</Routes>
|
||||||
<div className="card">
|
</Layout>
|
||||||
<button onClick={() => setCount((count) => count + 1)}>
|
</Router>
|
||||||
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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App;
|
||||||
|
|||||||
64
src/components/Layout/Layout.tsx
Normal file
64
src/components/Layout/Layout.tsx
Normal 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;
|
||||||
38
src/components/Navigation/Navigation.tsx
Normal file
38
src/components/Navigation/Navigation.tsx
Normal 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;
|
||||||
55
src/components/common/ActionButton.tsx
Normal file
55
src/components/common/ActionButton.tsx
Normal 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;
|
||||||
34
src/components/common/EmptyState.tsx
Normal file
34
src/components/common/EmptyState.tsx
Normal 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;
|
||||||
130
src/components/common/SongItem.tsx
Normal file
130
src/components/common/SongItem.tsx
Normal 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;
|
||||||
57
src/components/common/Toast.tsx
Normal file
57
src/components/common/Toast.tsx
Normal 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;
|
||||||
4
src/components/common/index.ts
Normal file
4
src/components/common/index.ts
Normal 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
82
src/constants/index.ts
Normal 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;
|
||||||
63
src/features/History/History.tsx
Normal file
63
src/features/History/History.tsx
Normal 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;
|
||||||
95
src/features/Queue/Queue.tsx
Normal file
95
src/features/Queue/Queue.tsx
Normal 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;
|
||||||
78
src/features/Search/Search.tsx
Normal file
78
src/features/Search/Search.tsx
Normal 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;
|
||||||
81
src/features/TopPlayed/TopPlayed.tsx
Normal file
81
src/features/TopPlayed/TopPlayed.tsx
Normal 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
4
src/features/index.ts
Normal 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
11
src/firebase/config.ts
Normal 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
139
src/firebase/services.ts
Normal 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
7
src/hooks/index.ts
Normal 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';
|
||||||
105
src/hooks/useFirebaseSync.ts
Normal file
105
src/hooks/useFirebaseSync.ts
Normal 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
36
src/hooks/useHistory.ts
Normal 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
54
src/hooks/useQueue.ts
Normal 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
57
src/hooks/useSearch.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
90
src/hooks/useSongOperations.ts
Normal file
90
src/hooks/useSongOperations.ts
Normal 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
45
src/hooks/useToast.ts
Normal 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
50
src/hooks/useTopPlayed.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -1,68 +1,3 @@
|
|||||||
:root {
|
@tailwind base;
|
||||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
@tailwind components;
|
||||||
line-height: 1.5;
|
@tailwind utilities;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -2,9 +2,13 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import { store } from './redux/store';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<Provider store={store}>
|
||||||
|
<App />
|
||||||
|
</Provider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
79
src/redux/authSlice.ts
Normal file
79
src/redux/authSlice.ts
Normal 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;
|
||||||
164
src/redux/controllerSlice.ts
Normal file
164
src/redux/controllerSlice.ts
Normal 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
7
src/redux/hooks.ts
Normal 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
52
src/redux/index.ts
Normal 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
9
src/redux/playerSlice.ts
Normal 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
9
src/redux/queueSlice.ts
Normal 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
88
src/redux/selectors.ts
Normal 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
14
src/redux/store.ts
Normal 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
149
src/types/index.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
70
src/utils/dataProcessing.ts
Normal file
70
src/utils/dataProcessing.ts
Normal 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
11
tailwind.config.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user