From d7cf384429aa12ce7e475aa85d3c077887330d23 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Thu, 17 Jul 2025 09:40:17 -0500 Subject: [PATCH] Signed-off-by: Matt Bruce --- package-lock.json | 246 +++++++++++++++++++++++ package.json | 3 + postcss.config.js | 6 + src/App.tsx | 48 ++--- src/components/Layout/Layout.tsx | 64 ++++++ src/components/Navigation/Navigation.tsx | 38 ++++ src/components/common/ActionButton.tsx | 55 +++++ src/components/common/EmptyState.tsx | 34 ++++ src/components/common/SongItem.tsx | 130 ++++++++++++ src/components/common/Toast.tsx | 57 ++++++ src/components/common/index.ts | 4 + src/constants/index.ts | 82 ++++++++ src/features/History/History.tsx | 63 ++++++ src/features/Queue/Queue.tsx | 95 +++++++++ src/features/Search/Search.tsx | 78 +++++++ src/features/TopPlayed/TopPlayed.tsx | 81 ++++++++ src/features/index.ts | 4 + src/firebase/config.ts | 11 + src/firebase/services.ts | 139 +++++++++++++ src/hooks/index.ts | 7 + src/hooks/useFirebaseSync.ts | 105 ++++++++++ src/hooks/useHistory.ts | 36 ++++ src/hooks/useQueue.ts | 54 +++++ src/hooks/useSearch.ts | 57 ++++++ src/hooks/useSongOperations.ts | 90 +++++++++ src/hooks/useToast.ts | 45 +++++ src/hooks/useTopPlayed.ts | 50 +++++ src/index.css | 71 +------ src/main.tsx | 6 +- src/redux/authSlice.ts | 79 ++++++++ src/redux/controllerSlice.ts | 164 +++++++++++++++ src/redux/hooks.ts | 7 + src/redux/index.ts | 52 +++++ src/redux/playerSlice.ts | 9 + src/redux/queueSlice.ts | 9 + src/redux/selectors.ts | 88 ++++++++ src/redux/store.ts | 14 ++ src/types/index.ts | 149 ++++++++++++++ src/utils/dataProcessing.ts | 70 +++++++ tailwind.config.js | 11 + 40 files changed, 2312 insertions(+), 99 deletions(-) create mode 100644 postcss.config.js create mode 100644 src/components/Layout/Layout.tsx create mode 100644 src/components/Navigation/Navigation.tsx create mode 100644 src/components/common/ActionButton.tsx create mode 100644 src/components/common/EmptyState.tsx create mode 100644 src/components/common/SongItem.tsx create mode 100644 src/components/common/Toast.tsx create mode 100644 src/components/common/index.ts create mode 100644 src/constants/index.ts create mode 100644 src/features/History/History.tsx create mode 100644 src/features/Queue/Queue.tsx create mode 100644 src/features/Search/Search.tsx create mode 100644 src/features/TopPlayed/TopPlayed.tsx create mode 100644 src/features/index.ts create mode 100644 src/firebase/config.ts create mode 100644 src/firebase/services.ts create mode 100644 src/hooks/index.ts create mode 100644 src/hooks/useFirebaseSync.ts create mode 100644 src/hooks/useHistory.ts create mode 100644 src/hooks/useQueue.ts create mode 100644 src/hooks/useSearch.ts create mode 100644 src/hooks/useSongOperations.ts create mode 100644 src/hooks/useToast.ts create mode 100644 src/hooks/useTopPlayed.ts create mode 100644 src/redux/authSlice.ts create mode 100644 src/redux/controllerSlice.ts create mode 100644 src/redux/hooks.ts create mode 100644 src/redux/index.ts create mode 100644 src/redux/playerSlice.ts create mode 100644 src/redux/queueSlice.ts create mode 100644 src/redux/selectors.ts create mode 100644 src/redux/store.ts create mode 100644 src/types/index.ts create mode 100644 src/utils/dataProcessing.ts create mode 100644 tailwind.config.js diff --git a/package-lock.json b/package-lock.json index c0ea668..7511c93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,10 +20,13 @@ "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react-swc": "^3.10.2", + "autoprefixer": "^10.4.21", "eslint": "^9.30.1", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.11", "typescript": "~5.8.3", "typescript-eslint": "^8.35.1", "vite": "^7.0.4" @@ -2234,6 +2237,43 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2262,6 +2302,38 @@ "node": ">=8" } }, + "node_modules/browserslist": { + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2271,6 +2343,26 @@ "node": ">=6" } }, + "node_modules/caniuse-lite": { + "version": "1.0.30001727", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", + "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2372,6 +2464,12 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/electron-to-chromium": { + "version": "1.5.186", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.186.tgz", + "integrity": "sha512-lur7L4BFklgepaJxj4DqPk7vKbTEl0pajNlg2QjE5shefmlmBLm2HvQ7PMf1R/GvlevT/581cop33/quQcfX3A==", + "dev": true + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -2766,6 +2864,19 @@ "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3071,6 +3182,21 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3194,6 +3320,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -3558,6 +3690,12 @@ "node": ">=8" } }, + "node_modules/tailwindcss": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz", + "integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==", + "dev": true + }, "node_modules/tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", @@ -3682,6 +3820,36 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==" }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -5288,6 +5456,20 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "dev": true, + "requires": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + } + }, "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -5313,12 +5495,30 @@ "fill-range": "^7.1.1" } }, + "browserslist": { + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + } + }, "callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true }, + "caniuse-lite": { + "version": "1.0.30001727", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", + "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", + "dev": true + }, "chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5394,6 +5594,12 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "electron-to-chromium": { + "version": "1.5.186", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.186.tgz", + "integrity": "sha512-lur7L4BFklgepaJxj4DqPk7vKbTEl0pajNlg2QjE5shefmlmBLm2HvQ7PMf1R/GvlevT/581cop33/quQcfX3A==", + "dev": true + }, "emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -5696,6 +5902,12 @@ "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true }, + "fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true + }, "fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -5918,6 +6130,18 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true + }, + "normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true + }, "optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5994,6 +6218,12 @@ "source-map-js": "^1.2.1" } }, + "postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, "prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6217,6 +6447,12 @@ "has-flag": "^4.0.0" } }, + "tailwindcss": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz", + "integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==", + "dev": true + }, "tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", @@ -6295,6 +6531,16 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==" }, + "update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "requires": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + } + }, "uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/package.json b/package.json index 0551e77..4459322 100644 --- a/package.json +++ b/package.json @@ -23,10 +23,13 @@ "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react-swc": "^3.10.2", + "autoprefixer": "^10.4.21", "eslint": "^9.30.1", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.11", "typescript": "~5.8.3", "typescript-eslint": "^8.35.1", "vite": "^7.0.4" diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..387612e --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 3d7ded3..d85a2a8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,35 +1,23 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' -import './App.css' +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; +import Layout from './components/Layout/Layout'; +import Navigation from './components/Navigation/Navigation'; +import { Search, Queue, History, TopPlayed } from './features'; function App() { - const [count, setCount] = useState(0) - return ( - <> -
- - Vite logo - - - React logo - -
-

Vite + React

-
- -

- Edit src/App.tsx and save to test HMR -

-
-

- Click on the Vite and React logos to learn more -

- - ) + + + + + } /> + } /> + } /> + } /> + } /> + + + + ); } -export default App +export default App; diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx new file mode 100644 index 0000000..554a4a0 --- /dev/null +++ b/src/components/Layout/Layout.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import type { RootState } from '../../redux/store'; +import type { LayoutProps } from '../../types'; + +const Layout: React.FC = ({ children }) => { + // TODO: Replace with actual Redux selectors + const currentSinger = useSelector((state: RootState) => state.auth?.singer || ''); + const isAdmin = useSelector((state: RootState) => state.auth?.isAdmin || false); + const controllerName = useSelector((state: RootState) => state.auth?.controller || ''); + + return ( +
+ {/* Header */} +
+
+
+ {/* Logo/Title */} +
+

+ 🎤 Karaoke App +

+ {controllerName && ( + + Controller: {controllerName} + + )} +
+ + {/* User Info */} +
+ {currentSinger && ( +
+ {currentSinger} + {isAdmin && ( + + Admin + + )} +
+ )} +
+
+
+
+ + {/* Main Content */} +
+ {children} +
+ + {/* Footer */} +
+
+
+

🎵 Powered by Firebase Realtime Database

+
+
+
+
+ ); +}; + +export default Layout; \ No newline at end of file diff --git a/src/components/Navigation/Navigation.tsx b/src/components/Navigation/Navigation.tsx new file mode 100644 index 0000000..390ac88 --- /dev/null +++ b/src/components/Navigation/Navigation.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { NavLink } from 'react-router-dom'; + +const Navigation: React.FC = () => { + const navItems = [ + { path: '/', label: 'Search', icon: '🔍' }, + { path: '/queue', label: 'Queue', icon: '📋' }, + { path: '/history', label: 'History', icon: '⏰' }, + { path: '/top-played', label: 'Top Played', icon: '🏆' }, + ]; + + return ( + + ); +}; + +export default Navigation; \ No newline at end of file diff --git a/src/components/common/ActionButton.tsx b/src/components/common/ActionButton.tsx new file mode 100644 index 0000000..b7a74f2 --- /dev/null +++ b/src/components/common/ActionButton.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import type { ActionButtonProps } from '../../types'; + +const ActionButton: React.FC = ({ + onClick, + children, + variant = 'primary', + size = 'md', + disabled = false, + className = '' +}) => { + const getVariantStyles = () => { + switch (variant) { + case 'primary': + return 'bg-blue-600 hover:bg-blue-700 text-white'; + case 'secondary': + return 'bg-gray-200 hover:bg-gray-300 text-gray-800'; + case 'danger': + return 'bg-red-600 hover:bg-red-700 text-white'; + default: + return 'bg-blue-600 hover:bg-blue-700 text-white'; + } + }; + + const getSizeStyles = () => { + switch (size) { + case 'sm': + return 'px-2 py-1 text-xs'; + case 'md': + return 'px-3 py-2 text-sm'; + case 'lg': + return 'px-4 py-2 text-base'; + default: + return 'px-3 py-2 text-sm'; + } + }; + + return ( + + ); +}; + +export default ActionButton; \ No newline at end of file diff --git a/src/components/common/EmptyState.tsx b/src/components/common/EmptyState.tsx new file mode 100644 index 0000000..8013080 --- /dev/null +++ b/src/components/common/EmptyState.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import type { EmptyStateProps } from '../../types'; + +const EmptyState: React.FC = ({ + title, + message, + icon, + action +}) => { + return ( +
+ {icon && ( +
+ {icon} +
+ )} +

+ {title} +

+ {message && ( +

+ {message} +

+ )} + {action && ( +
+ {action} +
+ )} +
+ ); +}; + +export default EmptyState; \ No newline at end of file diff --git a/src/components/common/SongItem.tsx b/src/components/common/SongItem.tsx new file mode 100644 index 0000000..4d2b0fc --- /dev/null +++ b/src/components/common/SongItem.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import ActionButton from './ActionButton'; +import type { SongItemProps } from '../../types'; + +const SongItem: React.FC = ({ + song, + context, + onAddToQueue, + onRemoveFromQueue, + onToggleFavorite, + onDelete, + isAdmin = false, + className = '' +}) => { + const renderActionPanel = () => { + switch (context) { + case 'search': + return ( +
+ {})} + variant="primary" + size="sm" + > + Add to Queue + + {})} + variant={song.favorite ? 'danger' : 'secondary'} + size="sm" + > + {song.favorite ? '❤️' : '🤍'} + +
+ ); + + case 'queue': + return ( +
+ {isAdmin && ( + {})} + variant="danger" + size="sm" + > + Remove + + )} + {})} + variant={song.favorite ? 'danger' : 'secondary'} + size="sm" + > + {song.favorite ? '❤️' : '🤍'} + +
+ ); + + case 'history': + return ( +
+ {})} + variant="primary" + size="sm" + > + Add to Queue + + {})} + variant={song.favorite ? 'danger' : 'secondary'} + size="sm" + > + {song.favorite ? '❤️' : '🤍'} + +
+ ); + + case 'favorites': + return ( +
+ {})} + variant="primary" + size="sm" + > + Add to Queue + + {})} + variant="danger" + size="sm" + > + Remove + +
+ ); + + default: + return null; + } + }; + + return ( +
+
+

+ {song.title} +

+

+ {song.artist} +

+ {song.count && ( +

+ Played {song.count} times +

+ )} +
+ +
+ {renderActionPanel()} +
+
+ ); +}; + +export default SongItem; \ No newline at end of file diff --git a/src/components/common/Toast.tsx b/src/components/common/Toast.tsx new file mode 100644 index 0000000..60f72e6 --- /dev/null +++ b/src/components/common/Toast.tsx @@ -0,0 +1,57 @@ +import React, { useEffect, useState } from 'react'; +import type { ToastProps } from '../../types'; + +const Toast: React.FC = ({ + message, + type = 'info', + duration = 3000, + onClose +}) => { + const [isVisible, setIsVisible] = useState(true); + + useEffect(() => { + const timer = setTimeout(() => { + setIsVisible(false); + setTimeout(onClose, 300); // Wait for fade out animation + }, duration); + + return () => clearTimeout(timer); + }, [duration, onClose]); + + const getTypeStyles = () => { + switch (type) { + case 'success': + return 'bg-green-500 text-white'; + case 'error': + return 'bg-red-500 text-white'; + case 'info': + default: + return 'bg-blue-500 text-white'; + } + }; + + return ( +
+
+ {message} + +
+
+ ); +}; + +export default Toast; \ No newline at end of file diff --git a/src/components/common/index.ts b/src/components/common/index.ts new file mode 100644 index 0000000..f19f7e1 --- /dev/null +++ b/src/components/common/index.ts @@ -0,0 +1,4 @@ +export { default as EmptyState } from './EmptyState'; +export { default as Toast } from './Toast'; +export { default as ActionButton } from './ActionButton'; +export { default as SongItem } from './SongItem'; \ No newline at end of file diff --git a/src/constants/index.ts b/src/constants/index.ts new file mode 100644 index 0000000..ff86ee3 --- /dev/null +++ b/src/constants/index.ts @@ -0,0 +1,82 @@ +// App constants +export const APP_NAME = '🎤 Karaoke App'; +export const APP_VERSION = '1.0.0'; + +// Firebase configuration +export const FIREBASE_CONFIG = { + // These will be replaced with environment variables + apiKey: import.meta.env.VITE_FIREBASE_API_KEY || 'your-api-key', + authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN || 'your-project-id.firebaseapp.com', + databaseURL: import.meta.env.VITE_FIREBASE_DATABASE_URL || 'https://your-project-id-default-rtdb.firebaseio.com', + projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID || 'your-project-id', + storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET || 'your-project-id.appspot.com', + messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID || '123456789', + appId: import.meta.env.VITE_FIREBASE_APP_ID || 'your-app-id', +}; + +// UI constants +export const UI_CONSTANTS = { + TOAST_DURATION: { + SUCCESS: 3000, + ERROR: 5000, + INFO: 3000, + }, + SEARCH: { + DEBOUNCE_DELAY: 300, + MIN_SEARCH_LENGTH: 2, + }, + QUEUE: { + MAX_ITEMS: 100, + }, + HISTORY: { + MAX_ITEMS: 50, + }, + TOP_PLAYED: { + MAX_ITEMS: 20, + }, +} as const; + +// Route constants +export const ROUTES = { + HOME: '/', + SEARCH: '/', + QUEUE: '/queue', + HISTORY: '/history', + TOP_PLAYED: '/top-played', +} as const; + +// Player states +export const PLAYER_STATES = { + PLAYING: 'Playing', + PAUSED: 'Paused', + STOPPED: 'Stopped', +} as const; + +// Error messages +export const ERROR_MESSAGES = { + CONTROLLER_NOT_FOUND: 'Controller not found', + NETWORK_ERROR: 'Network error. Please check your connection.', + UNAUTHORIZED: 'You are not authorized to perform this action.', + SONG_NOT_FOUND: 'Song not found', + QUEUE_FULL: 'Queue is full', + FIREBASE_ERROR: 'Firebase operation failed', +} as const; + +// Success messages +export const SUCCESS_MESSAGES = { + SONG_ADDED_TO_QUEUE: 'Song added to queue', + SONG_REMOVED_FROM_QUEUE: 'Song removed from queue', + SONG_ADDED_TO_FAVORITES: 'Song added to favorites', + SONG_REMOVED_FROM_FAVORITES: 'Song removed from favorites', + QUEUE_REORDERED: 'Queue reordered', +} as const; + +// Feature flags +export const FEATURES = { + ENABLE_SEARCH: true, + ENABLE_QUEUE_REORDER: true, + ENABLE_FAVORITES: true, + ENABLE_HISTORY: true, + ENABLE_TOP_PLAYED: true, + ENABLE_ADMIN_CONTROLS: true, +} as const; \ No newline at end of file diff --git a/src/features/History/History.tsx b/src/features/History/History.tsx new file mode 100644 index 0000000..8d0ef34 --- /dev/null +++ b/src/features/History/History.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { SongItem, EmptyState } from '../../components/common'; +import { useHistory } from '../../hooks'; +import { formatDate } from '../../utils/dataProcessing'; + +const History: React.FC = () => { + const { + historyItems, + handleAddToQueue, + handleToggleFavorite, + } = useHistory(); + + return ( +
+
+

Recently Played

+

+ {historyItems.length} song{historyItems.length !== 1 ? 's' : ''} in history +

+
+ + {/* History List */} +
+ {historyItems.length === 0 ? ( + + + + } + /> + ) : ( +
+ {historyItems.map((song) => ( +
+ {/* Song Info */} +
+ handleAddToQueue(song)} + onToggleFavorite={() => handleToggleFavorite(song)} + /> +
+ + {/* Play Date */} + {song.date && ( +
+ {formatDate(song.date)} +
+ )} +
+ ))} +
+ )} +
+
+ ); +}; + +export default History; \ No newline at end of file diff --git a/src/features/Queue/Queue.tsx b/src/features/Queue/Queue.tsx new file mode 100644 index 0000000..a30afb9 --- /dev/null +++ b/src/features/Queue/Queue.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { SongItem, EmptyState, ActionButton } from '../../components/common'; +import { useQueue } from '../../hooks'; + +const Queue: React.FC = () => { + const { + queueItems, + queueStats, + canReorder, + handleRemoveFromQueue, + handleToggleFavorite, + handleMoveUp, + handleMoveDown, + } = useQueue(); + + return ( +
+
+

Queue

+

+ {queueStats.totalSongs} song{queueStats.totalSongs !== 1 ? 's' : ''} in queue +

+
+ + {/* Queue List */} +
+ {queueItems.length === 0 ? ( + + + + } + /> + ) : ( +
+ {queueItems.map((queueItem, index) => ( +
+ {/* Order Number */} +
+ {queueItem.order} +
+ + {/* Song Info */} +
+ handleRemoveFromQueue(queueItem)} + onToggleFavorite={() => handleToggleFavorite(queueItem.song)} + isAdmin={canReorder} + /> +
+ + {/* Singer Info */} +
+
{queueItem.singer.name}
+
+ {queueItem.isCurrentUser ? '(You)' : ''} +
+
+ + {/* Admin Controls */} + {canReorder && ( +
+ handleMoveUp(queueItem)} + variant="secondary" + size="sm" + disabled={index === 0} + > + ↑ + + handleMoveDown(queueItem)} + variant="secondary" + size="sm" + disabled={index === queueItems.length - 1} + > + ↓ + +
+ )} +
+ ))} +
+ )} +
+
+ ); +}; + +export default Queue; \ No newline at end of file diff --git a/src/features/Search/Search.tsx b/src/features/Search/Search.tsx new file mode 100644 index 0000000..f6cdb65 --- /dev/null +++ b/src/features/Search/Search.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { useAppSelector } from '../../redux'; +import { SongItem, EmptyState } from '../../components/common'; +import { useSearch } from '../../hooks'; +import { selectIsAdmin } from '../../redux'; + +const Search: React.FC = () => { + const { + searchTerm, + searchResults, + handleSearchChange, + handleAddToQueue, + handleToggleFavorite, + } = useSearch(); + + const isAdmin = useAppSelector(selectIsAdmin); + + return ( +
+
+

Search Songs

+ + {/* Search Input */} +
+ handleSearchChange(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+ + + +
+
+
+ + {/* Search Results */} +
+ {searchResults.songs.length === 0 ? ( + + + + } + /> + ) : ( +
+ {searchResults.songs.map((song) => ( + handleAddToQueue(song)} + onToggleFavorite={() => handleToggleFavorite(song)} + isAdmin={isAdmin} + /> + ))} +
+ )} +
+ + {/* Search Stats */} + {searchTerm && ( +
+ Found {searchResults.count} song{searchResults.count !== 1 ? 's' : ''} +
+ )} +
+ ); +}; + +export default Search; \ No newline at end of file diff --git a/src/features/TopPlayed/TopPlayed.tsx b/src/features/TopPlayed/TopPlayed.tsx new file mode 100644 index 0000000..57242cf --- /dev/null +++ b/src/features/TopPlayed/TopPlayed.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { SongItem, EmptyState } from '../../components/common'; +import { useTopPlayed } from '../../hooks'; + +const TopPlayed: React.FC = () => { + const { + topPlayedItems, + handleAddToQueue, + handleToggleFavorite, + } = useTopPlayed(); + + return ( +
+
+

Most Played

+

+ Top {topPlayedItems.length} song{topPlayedItems.length !== 1 ? 's' : ''} by play count +

+
+ + {/* Top Played List */} +
+ {topPlayedItems.length === 0 ? ( + + + + } + /> + ) : ( +
+ {topPlayedItems.map((song, index) => ( +
+ {/* Rank */} +
+
2 ? 'bg-gray-50 text-gray-600' : ''} + `}> + {index + 1} +
+
+ + {/* Song Info */} +
+ handleAddToQueue(song)} + onToggleFavorite={() => handleToggleFavorite(song)} + /> +
+ + {/* Play Count */} +
+
{song.count}
+
+ play{song.count !== 1 ? 's' : ''} +
+
+
+ ))} +
+ )} +
+
+ ); +}; + +export default TopPlayed; \ No newline at end of file diff --git a/src/features/index.ts b/src/features/index.ts new file mode 100644 index 0000000..783334d --- /dev/null +++ b/src/features/index.ts @@ -0,0 +1,4 @@ +export { default as Search } from './Search/Search'; +export { default as Queue } from './Queue/Queue'; +export { default as History } from './History/History'; +export { default as TopPlayed } from './TopPlayed/TopPlayed'; \ No newline at end of file diff --git a/src/firebase/config.ts b/src/firebase/config.ts new file mode 100644 index 0000000..1eec292 --- /dev/null +++ b/src/firebase/config.ts @@ -0,0 +1,11 @@ +import { initializeApp } from 'firebase/app'; +import { getDatabase } from 'firebase/database'; +import { FIREBASE_CONFIG } from '../constants'; + +// Initialize Firebase +const app = initializeApp(FIREBASE_CONFIG); + +// Initialize Realtime Database and get a reference to the service +export const database = getDatabase(app); + +export default app; \ No newline at end of file diff --git a/src/firebase/services.ts b/src/firebase/services.ts new file mode 100644 index 0000000..91cf166 --- /dev/null +++ b/src/firebase/services.ts @@ -0,0 +1,139 @@ +import { + ref, + get, + set, + push, + remove, + onValue, + off, + update +} from 'firebase/database'; +import { database } from './config'; +import type { Song, QueueItem, Controller } from '../types'; + +// Basic CRUD operations for controllers +export const controllerService = { + // Get a specific controller + getController: async (controllerName: string) => { + const controllerRef = ref(database, `controllers/${controllerName}`); + const snapshot = await get(controllerRef); + return snapshot.exists() ? snapshot.val() : null; + }, + + // Set/update a controller + setController: async (controllerName: string, data: Controller) => { + const controllerRef = ref(database, `controllers/${controllerName}`); + await set(controllerRef, data); + }, + + // Update specific parts of a controller + updateController: async (controllerName: string, updates: Partial) => { + const controllerRef = ref(database, `controllers/${controllerName}`); + await update(controllerRef, updates); + }, + + // Listen to controller changes in real-time + subscribeToController: (controllerName: string, callback: (data: Controller | null) => void) => { + const controllerRef = ref(database, `controllers/${controllerName}`); + onValue(controllerRef, (snapshot) => { + callback(snapshot.exists() ? snapshot.val() : null); + }); + + // Return unsubscribe function + return () => off(controllerRef); + } +}; + +// Queue management operations +export const queueService = { + // Add song to queue + addToQueue: async (controllerName: string, queueItem: Omit) => { + const queueRef = ref(database, `controllers/${controllerName}/player/queue`); + return await push(queueRef, queueItem); + }, + + // Remove song from queue + removeFromQueue: async (controllerName: string, queueItemKey: string) => { + const queueItemRef = ref(database, `controllers/${controllerName}/player/queue/${queueItemKey}`); + await remove(queueItemRef); + }, + + // Update queue item + updateQueueItem: async (controllerName: string, queueItemKey: string, updates: Partial) => { + const queueItemRef = ref(database, `controllers/${controllerName}/player/queue/${queueItemKey}`); + await update(queueItemRef, updates); + }, + + // Listen to queue changes + subscribeToQueue: (controllerName: string, callback: (data: Record) => void) => { + const queueRef = ref(database, `controllers/${controllerName}/player/queue`); + onValue(queueRef, (snapshot) => { + callback(snapshot.exists() ? snapshot.val() : {}); + }); + + return () => off(queueRef); + } +}; + +// Player state operations +export const playerService = { + // Update player state + updatePlayerState: async (controllerName: string, state: Partial) => { + const playerRef = ref(database, `controllers/${controllerName}/player`); + await update(playerRef, state); + }, + + // Listen to player state changes + subscribeToPlayerState: (controllerName: string, callback: (data: Controller['player']) => void) => { + const playerRef = ref(database, `controllers/${controllerName}/player`); + onValue(playerRef, (snapshot) => { + callback(snapshot.exists() ? snapshot.val() : {}); + }); + + return () => off(playerRef); + } +}; + +// History operations +export const historyService = { + // Add song to history + addToHistory: async (controllerName: string, song: Omit) => { + const historyRef = ref(database, `controllers/${controllerName}/history`); + return await push(historyRef, song); + }, + + // Listen to history changes + subscribeToHistory: (controllerName: string, callback: (data: Record) => void) => { + const historyRef = ref(database, `controllers/${controllerName}/history`); + onValue(historyRef, (snapshot) => { + callback(snapshot.exists() ? snapshot.val() : {}); + }); + + return () => off(historyRef); + } +}; + +// Favorites operations +export const favoritesService = { + // Add song to favorites + addToFavorites: async (controllerName: string, song: Omit) => { + const favoritesRef = ref(database, `controllers/${controllerName}/favorites`); + return await push(favoritesRef, song); + }, + + // Remove song from favorites + removeFromFavorites: async (controllerName: string, songKey: string) => { + const songRef = ref(database, `controllers/${controllerName}/favorites/${songKey}`); + await remove(songRef); + }, + + // Listen to favorites changes + subscribeToFavorites: (controllerName: string, callback: (data: Record) => void) => { + const favoritesRef = ref(database, `controllers/${controllerName}/favorites`); + onValue(favoritesRef, (snapshot) => { + callback(snapshot.exists() ? snapshot.val() : {}); + }); + + return () => off(favoritesRef); + } +}; \ No newline at end of file diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..6b43fbf --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,7 @@ +export { useFirebaseSync } from './useFirebaseSync'; +export { useSongOperations } from './useSongOperations'; +export { useToast } from './useToast'; +export { useSearch } from './useSearch'; +export { useQueue } from './useQueue'; +export { useHistory } from './useHistory'; +export { useTopPlayed } from './useTopPlayed'; \ No newline at end of file diff --git a/src/hooks/useFirebaseSync.ts b/src/hooks/useFirebaseSync.ts new file mode 100644 index 0000000..b3d2e5d --- /dev/null +++ b/src/hooks/useFirebaseSync.ts @@ -0,0 +1,105 @@ +import { useEffect, useRef } from 'react'; +import { useAppDispatch } from '../redux'; +import { + setController, + updateQueue, + updateFavorites, + updateHistory +} from '../redux'; +import { + controllerService, + queueService, + favoritesService, + historyService +} from '../firebase/services'; + +export const useFirebaseSync = (controllerName: string) => { + const dispatch = useAppDispatch(); + const unsubscribeRefs = useRef void>>([]); + + // Subscribe to controller changes + useEffect(() => { + if (!controllerName) return; + + const unsubscribe = controllerService.subscribeToController( + controllerName, + (controller) => { + if (controller) { + dispatch(setController(controller)); + } + } + ); + + unsubscribeRefs.current.push(unsubscribe); + + return () => { + unsubscribe(); + }; + }, [controllerName, dispatch]); + + // Subscribe to queue changes + useEffect(() => { + if (!controllerName) return; + + const unsubscribe = queueService.subscribeToQueue( + controllerName, + (queue) => { + dispatch(updateQueue(queue)); + } + ); + + unsubscribeRefs.current.push(unsubscribe); + + return () => { + unsubscribe(); + }; + }, [controllerName, dispatch]); + + // Subscribe to favorites changes + useEffect(() => { + if (!controllerName) return; + + const unsubscribe = favoritesService.subscribeToFavorites( + controllerName, + (favorites) => { + dispatch(updateFavorites(favorites)); + } + ); + + unsubscribeRefs.current.push(unsubscribe); + + return () => { + unsubscribe(); + }; + }, [controllerName, dispatch]); + + // Subscribe to history changes + useEffect(() => { + if (!controllerName) return; + + const unsubscribe = historyService.subscribeToHistory( + controllerName, + (history) => { + dispatch(updateHistory(history)); + } + ); + + unsubscribeRefs.current.push(unsubscribe); + + return () => { + unsubscribe(); + }; + }, [controllerName, dispatch]); + + // Cleanup all subscriptions on unmount + useEffect(() => { + return () => { + unsubscribeRefs.current.forEach(unsubscribe => unsubscribe()); + unsubscribeRefs.current = []; + }; + }, []); + + return { + isConnected: true, // TODO: Implement connection status + }; +}; \ No newline at end of file diff --git a/src/hooks/useHistory.ts b/src/hooks/useHistory.ts new file mode 100644 index 0000000..8943d7d --- /dev/null +++ b/src/hooks/useHistory.ts @@ -0,0 +1,36 @@ +import { useCallback } from 'react'; +import { useAppSelector } from '../redux'; +import { selectHistoryArray } from '../redux/selectors'; +import { useSongOperations } from './useSongOperations'; +import { useToast } from './useToast'; +import type { Song } from '../types'; + +export const useHistory = () => { + const historyItems = useAppSelector(selectHistoryArray); + const { addToQueue, toggleFavorite } = useSongOperations(); + const { showSuccess, showError } = useToast(); + + const handleAddToQueue = useCallback(async (song: Song) => { + try { + await addToQueue(song); + showSuccess('Song added to queue'); + } catch { + showError('Failed to add song to queue'); + } + }, [addToQueue, showSuccess, showError]); + + const handleToggleFavorite = useCallback(async (song: Song) => { + try { + await toggleFavorite(song); + showSuccess(song.favorite ? 'Removed from favorites' : 'Added to favorites'); + } catch { + showError('Failed to update favorites'); + } + }, [toggleFavorite, showSuccess, showError]); + + return { + historyItems, + handleAddToQueue, + handleToggleFavorite, + }; +}; \ No newline at end of file diff --git a/src/hooks/useQueue.ts b/src/hooks/useQueue.ts new file mode 100644 index 0000000..ed71f8b --- /dev/null +++ b/src/hooks/useQueue.ts @@ -0,0 +1,54 @@ +import { useCallback } from 'react'; +import { useAppSelector } from '../redux'; +import { selectQueueWithUserInfo, selectQueueStats, selectCanReorderQueue } from '../redux/selectors'; +import { useSongOperations } from './useSongOperations'; +import { useToast } from './useToast'; +import type { QueueItem } from '../types'; + +export const useQueue = () => { + const queueItems = useAppSelector(selectQueueWithUserInfo); + const queueStats = useAppSelector(selectQueueStats); + const canReorder = useAppSelector(selectCanReorderQueue); + const { removeFromQueue, toggleFavorite } = useSongOperations(); + const { showSuccess, showError } = useToast(); + + const handleRemoveFromQueue = useCallback(async (queueItem: QueueItem) => { + if (!queueItem.key) return; + + try { + await removeFromQueue(queueItem.key); + showSuccess('Song removed from queue'); + } catch { + showError('Failed to remove song from queue'); + } + }, [removeFromQueue, showSuccess, showError]); + + const handleToggleFavorite = useCallback(async (song: QueueItem['song']) => { + try { + await toggleFavorite(song); + showSuccess(song.favorite ? 'Removed from favorites' : 'Added to favorites'); + } catch { + showError('Failed to update favorites'); + } + }, [toggleFavorite, showSuccess, showError]); + + const handleMoveUp = useCallback(async (queueItem: QueueItem) => { + // TODO: Implement move up logic + console.log('Move up:', queueItem); + }, []); + + const handleMoveDown = useCallback(async (queueItem: QueueItem) => { + // TODO: Implement move down logic + console.log('Move down:', queueItem); + }, []); + + return { + queueItems, + queueStats, + canReorder, + handleRemoveFromQueue, + handleToggleFavorite, + handleMoveUp, + handleMoveDown, + }; +}; \ No newline at end of file diff --git a/src/hooks/useSearch.ts b/src/hooks/useSearch.ts new file mode 100644 index 0000000..c16fa5b --- /dev/null +++ b/src/hooks/useSearch.ts @@ -0,0 +1,57 @@ +import { useState, useCallback, useMemo } from 'react'; +import { useAppSelector } from '../redux'; +import { selectSearchResults } from '../redux/selectors'; +import { useSongOperations } from './useSongOperations'; +import { useToast } from './useToast'; +import { UI_CONSTANTS } from '../constants'; +import type { Song } from '../types'; + +export const useSearch = () => { + const [searchTerm, setSearchTerm] = useState(''); + const { addToQueue, toggleFavorite } = useSongOperations(); + const { showSuccess, showError } = useToast(); + + // Get filtered search results using selector + const searchResults = useAppSelector(state => + selectSearchResults(state, searchTerm) + ); + + // Debounced search term for performance + const debouncedSearchTerm = useMemo(() => { + if (searchTerm.length < UI_CONSTANTS.SEARCH.MIN_SEARCH_LENGTH) { + return ''; + } + return searchTerm; + }, [searchTerm]); + + const handleSearchChange = useCallback((value: string) => { + setSearchTerm(value); + }, []); + + const handleAddToQueue = useCallback(async (song: Song) => { + try { + await addToQueue(song); + showSuccess('Song added to queue'); + } catch { + showError('Failed to add song to queue'); + } + }, [addToQueue, showSuccess, showError]); + + const handleToggleFavorite = useCallback(async (song: Song) => { + try { + await toggleFavorite(song); + showSuccess(song.favorite ? 'Removed from favorites' : 'Added to favorites'); + } catch { + showError('Failed to update favorites'); + } + }, [toggleFavorite, showSuccess, showError]); + + return { + searchTerm, + debouncedSearchTerm, + searchResults, + handleSearchChange, + handleAddToQueue, + handleToggleFavorite, + }; +}; \ No newline at end of file diff --git a/src/hooks/useSongOperations.ts b/src/hooks/useSongOperations.ts new file mode 100644 index 0000000..c3f1342 --- /dev/null +++ b/src/hooks/useSongOperations.ts @@ -0,0 +1,90 @@ +import { useCallback } from 'react'; +import { useAppSelector } from '../redux'; +import { selectControllerName, selectCurrentSinger } from '../redux'; +import { queueService, favoritesService } from '../firebase/services'; +import type { Song, QueueItem } from '../types'; + +export const useSongOperations = () => { + const controllerName = useAppSelector(selectControllerName); + const currentSinger = useAppSelector(selectCurrentSinger); + const currentQueue = useAppSelector((state) => state.controller.data?.player?.queue || {}); + + const addToQueue = useCallback(async (song: Song) => { + if (!controllerName || !currentSinger) { + throw new Error('Controller name or singer not available'); + } + + try { + const nextOrder = Object.keys(currentQueue).length + 1; + + const queueItem: Omit = { + order: nextOrder, + singer: { + name: currentSinger, + lastLogin: new Date().toISOString(), + }, + song, + }; + + await queueService.addToQueue(controllerName, queueItem); + } catch (error) { + console.error('Failed to add song to queue:', error); + throw error; + } + }, [controllerName, currentSinger, currentQueue]); + + const removeFromQueue = useCallback(async (queueItemKey: string) => { + if (!controllerName) { + throw new Error('Controller name not available'); + } + + try { + await queueService.removeFromQueue(controllerName, queueItemKey); + } catch (error) { + console.error('Failed to remove song from queue:', error); + throw error; + } + }, [controllerName]); + + const toggleFavorite = useCallback(async (song: Song) => { + if (!controllerName) { + throw new Error('Controller name not available'); + } + + try { + if (song.favorite) { + // Remove from favorites + if (song.key) { + await favoritesService.removeFromFavorites(controllerName, song.key); + } + } else { + // Add to favorites + await favoritesService.addToFavorites(controllerName, song); + } + } catch (error) { + console.error('Failed to toggle favorite:', error); + throw error; + } + }, [controllerName]); + + const removeFromFavorites = useCallback(async (songKey: string) => { + if (!controllerName) { + throw new Error('Controller name not available'); + } + + try { + await favoritesService.removeFromFavorites(controllerName, songKey); + } catch (error) { + console.error('Failed to remove from favorites:', error); + throw error; + } + }, [controllerName]); + + return { + addToQueue, + removeFromQueue, + toggleFavorite, + removeFromFavorites, + canAddToQueue: !!controllerName && !!currentSinger, + }; +}; \ No newline at end of file diff --git a/src/hooks/useToast.ts b/src/hooks/useToast.ts new file mode 100644 index 0000000..bdd4e1f --- /dev/null +++ b/src/hooks/useToast.ts @@ -0,0 +1,45 @@ +import { useState, useCallback } from 'react'; +import type { ToastProps } from '../types'; + +interface ToastItem extends Omit { + id: string; +} + +export const useToast = () => { + const [toasts, setToasts] = useState([]); + + const showToast = useCallback((toast: Omit) => { + const id = Math.random().toString(36).substr(2, 9); + const newToast: ToastItem = { + ...toast, + id, + }; + + setToasts(prev => [...prev, newToast]); + }, []); + + const removeToast = useCallback((id: string) => { + setToasts(prev => prev.filter(toast => toast.id !== id)); + }, []); + + const showSuccess = useCallback((message: string, duration = 3000) => { + showToast({ message, type: 'success', duration }); + }, [showToast]); + + const showError = useCallback((message: string, duration = 5000) => { + showToast({ message, type: 'error', duration }); + }, [showToast]); + + const showInfo = useCallback((message: string, duration = 3000) => { + showToast({ message, type: 'info', duration }); + }, [showToast]); + + return { + toasts, + showToast, + showSuccess, + showError, + showInfo, + removeToast, + }; +}; \ No newline at end of file diff --git a/src/hooks/useTopPlayed.ts b/src/hooks/useTopPlayed.ts new file mode 100644 index 0000000..8f3b6f4 --- /dev/null +++ b/src/hooks/useTopPlayed.ts @@ -0,0 +1,50 @@ +import { useCallback } from 'react'; +import { useAppSelector } from '../redux'; +import { selectTopPlayedArray } from '../redux/selectors'; +import { useSongOperations } from './useSongOperations'; +import { useToast } from './useToast'; +import type { TopPlayed } from '../types'; + +export const useTopPlayed = () => { + const topPlayedItems = useAppSelector(selectTopPlayedArray); + const { addToQueue, toggleFavorite } = useSongOperations(); + const { showSuccess, showError } = useToast(); + + const handleAddToQueue = useCallback(async (song: TopPlayed) => { + try { + // Convert TopPlayed to Song format for queue + const songForQueue = { + ...song, + path: '', // TopPlayed doesn't have path + disabled: false, + favorite: false, + }; + await addToQueue(songForQueue); + showSuccess('Song added to queue'); + } catch { + showError('Failed to add song to queue'); + } + }, [addToQueue, showSuccess, showError]); + + const handleToggleFavorite = useCallback(async (song: TopPlayed) => { + try { + // Convert TopPlayed to Song format for favorites + const songForFavorites = { + ...song, + path: '', // TopPlayed doesn't have path + disabled: false, + favorite: false, + }; + await toggleFavorite(songForFavorites); + showSuccess(songForFavorites.favorite ? 'Removed from favorites' : 'Added to favorites'); + } catch { + showError('Failed to update favorites'); + } + }, [toggleFavorite, showSuccess, showError]); + + return { + topPlayedItems, + handleAddToQueue, + handleToggleFavorite, + }; +}; \ No newline at end of file diff --git a/src/index.css b/src/index.css index 08a3ac9..b5c61c9 100644 --- a/src/index.css +++ b/src/index.css @@ -1,68 +1,3 @@ -:root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - -body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } -} +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/src/main.tsx b/src/main.tsx index bef5202..9d070ed 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,9 +2,13 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' +import { Provider } from 'react-redux'; +import { store } from './redux/store'; createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/src/redux/authSlice.ts b/src/redux/authSlice.ts new file mode 100644 index 0000000..162e316 --- /dev/null +++ b/src/redux/authSlice.ts @@ -0,0 +1,79 @@ +import { createSlice } from '@reduxjs/toolkit'; +import type { PayloadAction } from '@reduxjs/toolkit'; +import type { Authentication } from '../types'; + +// Initial state +interface AuthState { + data: Authentication | null; + loading: boolean; + error: string | null; +} + +const initialState: AuthState = { + data: null, + loading: false, + error: null, +}; + +// Slice +const authSlice = createSlice({ + name: 'auth', + initialState, + reducers: { + setAuth: (state, action: PayloadAction) => { + state.data = action.payload; + state.error = null; + }, + + setLoading: (state, action: PayloadAction) => { + state.loading = action.payload; + }, + + setError: (state, action: PayloadAction) => { + state.error = action.payload; + }, + + clearError: (state) => { + state.error = null; + }, + + logout: (state) => { + state.data = null; + state.error = null; + }, + + updateSinger: (state, action: PayloadAction) => { + if (state.data) { + state.data.singer = action.payload; + } + }, + + setAdminStatus: (state, action: PayloadAction) => { + if (state.data) { + state.data.isAdmin = action.payload; + } + }, + }, +}); + +// Export actions +export const { + setAuth, + setLoading, + setError, + clearError, + logout, + updateSinger, + setAdminStatus, +} = authSlice.actions; + +// Export selectors +export const selectAuth = (state: { auth: AuthState }) => state.auth.data; +export const selectAuthLoading = (state: { auth: AuthState }) => state.auth.loading; +export const selectAuthError = (state: { auth: AuthState }) => state.auth.error; +export const selectIsAuthenticated = (state: { auth: AuthState }) => state.auth.data?.authenticated || false; +export const selectCurrentSinger = (state: { auth: AuthState }) => state.auth.data?.singer || ''; +export const selectIsAdmin = (state: { auth: AuthState }) => state.auth.data?.isAdmin || false; +export const selectControllerName = (state: { auth: AuthState }) => state.auth.data?.controller || ''; + +export default authSlice.reducer; \ No newline at end of file diff --git a/src/redux/controllerSlice.ts b/src/redux/controllerSlice.ts new file mode 100644 index 0000000..08aecfb --- /dev/null +++ b/src/redux/controllerSlice.ts @@ -0,0 +1,164 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import type { PayloadAction } from '@reduxjs/toolkit'; +import type { Controller, Song, QueueItem, TopPlayed } from '../types'; +import { controllerService } from '../firebase/services'; + +// Async thunks for Firebase operations +export const fetchController = createAsyncThunk( + 'controller/fetchController', + async (controllerName: string) => { + const controller = await controllerService.getController(controllerName); + if (!controller) { + throw new Error('Controller not found'); + } + return controller; + } +); + +export const updateController = createAsyncThunk( + 'controller/updateController', + async ({ controllerName, updates }: { controllerName: string; updates: Partial }) => { + await controllerService.updateController(controllerName, updates); + return updates; + } +); + +// Initial state +interface ControllerState { + data: Controller | null; + loading: boolean; + error: string | null; + lastUpdated: number | null; +} + +const initialState: ControllerState = { + data: null, + loading: false, + error: null, + lastUpdated: null, +}; + +// Slice +const controllerSlice = createSlice({ + name: 'controller', + initialState, + reducers: { + // Sync actions for real-time updates + setController: (state, action: PayloadAction) => { + state.data = action.payload; + state.lastUpdated = Date.now(); + state.error = null; + }, + + updateSongs: (state, action: PayloadAction>) => { + if (state.data) { + state.data.songs = action.payload; + state.lastUpdated = Date.now(); + } + }, + + updateQueue: (state, action: PayloadAction>) => { + if (state.data) { + state.data.player.queue = action.payload; + state.lastUpdated = Date.now(); + } + }, + + updateFavorites: (state, action: PayloadAction>) => { + if (state.data) { + state.data.favorites = action.payload; + state.lastUpdated = Date.now(); + } + }, + + updateHistory: (state, action: PayloadAction>) => { + if (state.data) { + state.data.history = action.payload; + state.lastUpdated = Date.now(); + } + }, + + updateTopPlayed: (state, action: PayloadAction>) => { + if (state.data) { + state.data.topPlayed = action.payload; + state.lastUpdated = Date.now(); + } + }, + + clearError: (state) => { + state.error = null; + }, + + resetController: (state) => { + state.data = null; + state.loading = false; + state.error = null; + state.lastUpdated = null; + }, + }, + extraReducers: (builder) => { + builder + // fetchController + .addCase(fetchController.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(fetchController.fulfilled, (state, action) => { + state.loading = false; + state.data = action.payload; + state.lastUpdated = Date.now(); + state.error = null; + }) + .addCase(fetchController.rejected, (state, action) => { + state.loading = false; + state.error = action.error.message || 'Failed to fetch controller'; + }) + // updateController + .addCase(updateController.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(updateController.fulfilled, (state, action) => { + state.loading = false; + if (state.data) { + state.data = { ...state.data, ...action.payload }; + state.lastUpdated = Date.now(); + } + state.error = null; + }) + .addCase(updateController.rejected, (state, action) => { + state.loading = false; + state.error = action.error.message || 'Failed to update controller'; + }); + }, +}); + +// Export actions +export const { + setController, + updateSongs, + updateQueue, + updateFavorites, + updateHistory, + updateTopPlayed, + clearError, + resetController, +} = controllerSlice.actions; + +// Export selectors +export const selectController = (state: { controller: ControllerState }) => state.controller.data; +export const selectControllerLoading = (state: { controller: ControllerState }) => state.controller.loading; +export const selectControllerError = (state: { controller: ControllerState }) => state.controller.error; +export const selectLastUpdated = (state: { controller: ControllerState }) => state.controller.lastUpdated; + +// Selectors for specific data +export const selectSongs = (state: { controller: ControllerState }) => state.controller.data?.songs || {}; +export const selectQueue = (state: { controller: ControllerState }) => state.controller.data?.player?.queue || {}; +export const selectFavorites = (state: { controller: ControllerState }) => state.controller.data?.favorites || {}; +export const selectHistory = (state: { controller: ControllerState }) => state.controller.data?.history || {}; +export const selectTopPlayed = (state: { controller: ControllerState }) => state.controller.data?.topPlayed || {}; +export const selectPlayerState = (state: { controller: ControllerState }) => state.controller.data?.player?.state; +export const selectSettings = (state: { controller: ControllerState }) => state.controller.data?.player?.settings; +export const selectSingers = (state: { controller: ControllerState }) => state.controller.data?.player?.singers || {}; + +export default controllerSlice.reducer; \ No newline at end of file diff --git a/src/redux/hooks.ts b/src/redux/hooks.ts new file mode 100644 index 0000000..0a63942 --- /dev/null +++ b/src/redux/hooks.ts @@ -0,0 +1,7 @@ +import { useDispatch, useSelector } from 'react-redux'; +import type { TypedUseSelectorHook } from 'react-redux'; +import type { RootState, AppDispatch } from './store'; + +// Use throughout your app instead of plain `useDispatch` and `useSelector` +export const useAppDispatch = () => useDispatch(); +export const useAppSelector: TypedUseSelectorHook = useSelector; \ No newline at end of file diff --git a/src/redux/index.ts b/src/redux/index.ts new file mode 100644 index 0000000..3ad554d --- /dev/null +++ b/src/redux/index.ts @@ -0,0 +1,52 @@ +// Store +export { store } from './store'; +export type { RootState, AppDispatch } from './store'; + +// Hooks +export { useAppDispatch, useAppSelector } from './hooks'; + +// Controller slice +export { + fetchController, + updateController, + setController, + updateSongs, + updateQueue, + updateFavorites, + updateHistory, + updateTopPlayed, + resetController, + selectController, + selectControllerLoading, + selectControllerError, + selectLastUpdated, + selectSongs, + selectQueue, + selectFavorites, + selectHistory, + selectTopPlayed, + selectPlayerState, + selectSettings, + selectSingers, +} from './controllerSlice'; + +// Auth slice +export { + setAuth, + setLoading, + setError, + clearError, + logout, + updateSinger, + setAdminStatus, + selectAuth, + selectAuthLoading, + selectAuthError, + selectIsAuthenticated, + selectCurrentSinger, + selectIsAdmin, + selectControllerName, +} from './authSlice'; + +// Enhanced selectors +export * from './selectors'; \ No newline at end of file diff --git a/src/redux/playerSlice.ts b/src/redux/playerSlice.ts new file mode 100644 index 0000000..242faea --- /dev/null +++ b/src/redux/playerSlice.ts @@ -0,0 +1,9 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const playerSlice = createSlice({ + name: 'player', + initialState: {}, + reducers: {}, +}); + +export default playerSlice.reducer; \ No newline at end of file diff --git a/src/redux/queueSlice.ts b/src/redux/queueSlice.ts new file mode 100644 index 0000000..d0335a6 --- /dev/null +++ b/src/redux/queueSlice.ts @@ -0,0 +1,9 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const queueSlice = createSlice({ + name: 'queue', + initialState: {}, + reducers: {}, +}); + +export default queueSlice.reducer; \ No newline at end of file diff --git a/src/redux/selectors.ts b/src/redux/selectors.ts new file mode 100644 index 0000000..7a4dab6 --- /dev/null +++ b/src/redux/selectors.ts @@ -0,0 +1,88 @@ +import { createSelector } from '@reduxjs/toolkit'; +import type { RootState } from '../types'; +import { + selectSongs, + selectQueue, + selectFavorites, + selectHistory, + selectTopPlayed, + selectIsAdmin, + selectCurrentSinger +} from './index'; +import { + objectToArray, + filterSongs, + sortQueueByOrder, + sortHistoryByDate, + sortTopPlayedByCount, + limitArray, + getQueueStats +} from '../utils/dataProcessing'; +import { UI_CONSTANTS } from '../constants'; + +// Enhanced selectors with data processing +export const selectSongsArray = createSelector( + [selectSongs], + (songs) => objectToArray(songs) +); + +export const selectFilteredSongs = createSelector( + [selectSongsArray, (state: RootState, searchTerm: string) => searchTerm], + (songs, searchTerm) => filterSongs(songs, searchTerm) +); + +export const selectQueueArray = createSelector( + [selectQueue], + (queue) => sortQueueByOrder(objectToArray(queue)) +); + +export const selectQueueStats = createSelector( + [selectQueue], + (queue) => getQueueStats(queue) +); + +export const selectHistoryArray = createSelector( + [selectHistory], + (history) => limitArray(sortHistoryByDate(objectToArray(history)), UI_CONSTANTS.HISTORY.MAX_ITEMS) +); + +export const selectFavoritesArray = createSelector( + [selectFavorites], + (favorites) => objectToArray(favorites) +); + +export const selectTopPlayedArray = createSelector( + [selectTopPlayed], + (topPlayed) => limitArray(sortTopPlayedByCount(objectToArray(topPlayed)), UI_CONSTANTS.TOP_PLAYED.MAX_ITEMS) +); + +// User-specific selectors +export const selectUserQueueItems = createSelector( + [selectQueueArray, selectCurrentSinger], + (queueArray, currentSinger) => + queueArray.filter(item => item.singer.name === currentSinger) +); + +export const selectCanReorderQueue = createSelector( + [selectIsAdmin], + (isAdmin) => isAdmin +); + +// Search-specific selectors +export const selectSearchResults = createSelector( + [selectFilteredSongs], + (filteredSongs) => ({ + songs: filteredSongs, + count: filteredSongs.length, + }) +); + +// Queue-specific selectors +export const selectQueueWithUserInfo = createSelector( + [selectQueueArray, selectCurrentSinger], + (queueArray, currentSinger) => + queueArray.map(item => ({ + ...item, + isCurrentUser: item.singer.name === currentSinger, + })) +); \ No newline at end of file diff --git a/src/redux/store.ts b/src/redux/store.ts new file mode 100644 index 0000000..644d615 --- /dev/null +++ b/src/redux/store.ts @@ -0,0 +1,14 @@ +import { configureStore } from '@reduxjs/toolkit'; +import type { RootState as AppRootState } from '../types'; +import controllerReducer from './controllerSlice'; +import authReducer from './authSlice'; + +export const store = configureStore({ + reducer: { + controller: controllerReducer, + auth: authReducer, + }, +}); + +export type RootState = AppRootState; +export type AppDispatch = typeof store.dispatch; \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..2feb67e --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,149 @@ +// Core data types (from docs/types.ts) +export const PlayerState = { + playing: "Playing", + paused: "Paused", + stopped: "Stopped" +} as const; + +export type PlayerStateType = typeof PlayerState[keyof typeof PlayerState]; + +export interface Keyable { + key?: string; +} + +export interface Authentication { + authenticated: boolean; + singer: string; + isAdmin: boolean; + controller: string; +} + +export interface History { + songs: Song[], + topPlayed: TopPlayed[] +} + +export interface Player { + state: PlayerStateType; +} + +export interface QueueItem extends Keyable { + order: number, + singer: Singer; + song: Song; +} + +export interface Settings { + autoadvance: boolean; + userpick: boolean; +} + +export interface Singer extends Keyable { + name: string; + lastLogin: string; +} + +export interface SongBase extends Keyable { + path: string; +} + +export interface Song extends SongBase { + artist: string; + title: string; + count?: number; + disabled?: boolean; + favorite?: boolean; + date?: string; +} + +export type PickedSong = { + song: Song +} + +export interface SongList extends Keyable { + title: string; + songs: SongListSong[]; +} + +export interface SongListSong extends Keyable { + artist: string; + position: number; + title: string; + foundSongs?: Song[]; +} + +export interface TopPlayed extends Keyable { + artist: string; + title: string; + count: number; +} + +// Firebase data structure types +export interface Controller { + favorites: Record; + history: Record; + topPlayed: Record; + newSongs: Record; + player: { + queue: Record; + settings: Settings; + singers: Record; + state: Player; + }; + songList: Record; + songs: Record; +} + +// UI Component Props types +export interface EmptyStateProps { + title: string; + message?: string; + icon?: React.ReactNode; + action?: React.ReactNode; +} + +export interface ToastProps { + message: string; + type?: 'success' | 'error' | 'info'; + duration?: number; + onClose: () => void; +} + +export interface ActionButtonProps { + onClick: () => void; + children: React.ReactNode; + variant?: 'primary' | 'secondary' | 'danger'; + size?: 'sm' | 'md' | 'lg'; + disabled?: boolean; + className?: string; +} + +export interface SongItemProps { + song: Song; + context: 'search' | 'queue' | 'history' | 'favorites'; + onAddToQueue?: () => void; + onRemoveFromQueue?: () => void; + onToggleFavorite?: () => void; + onDelete?: () => void; + isAdmin?: boolean; + className?: string; +} + +export interface LayoutProps { + children: React.ReactNode; +} + +// Redux state types +export interface RootState { + controller: { + data: Controller | null; + loading: boolean; + error: string | null; + lastUpdated: number | null; + }; + auth: { + data: Authentication | null; + loading: boolean; + error: string | null; + }; +} \ No newline at end of file diff --git a/src/utils/dataProcessing.ts b/src/utils/dataProcessing.ts new file mode 100644 index 0000000..8d354e3 --- /dev/null +++ b/src/utils/dataProcessing.ts @@ -0,0 +1,70 @@ +import type { Song, QueueItem, TopPlayed } from '../types'; + +// Convert Firebase object to array with keys +export const objectToArray = ( + obj: Record +): T[] => { + return Object.entries(obj).map(([key, item]) => ({ + ...item, + key, + })); +}; + +// Filter songs by search term +export const filterSongs = (songs: Song[], searchTerm: string): Song[] => { + if (!searchTerm.trim()) return songs; + + const term = searchTerm.toLowerCase(); + return songs.filter(song => + song.title.toLowerCase().includes(term) || + song.artist.toLowerCase().includes(term) + ); +}; + +// Sort queue items by order +export const sortQueueByOrder = (queueItems: QueueItem[]): QueueItem[] => { + return [...queueItems].sort((a, b) => a.order - b.order); +}; + +// Sort history by date (most recent first) +export const sortHistoryByDate = (songs: Song[]): Song[] => { + return [...songs].sort((a, b) => { + if (!a.date || !b.date) return 0; + return new Date(b.date).getTime() - new Date(a.date).getTime(); + }); +}; + +// Sort top played by count (highest first) +export const sortTopPlayedByCount = (songs: TopPlayed[]): TopPlayed[] => { + return [...songs].sort((a, b) => b.count - a.count); +}; + +// Limit array to specified length +export const limitArray = (array: T[], limit: number): T[] => { + return array.slice(0, limit); +}; + +// Format date for display +export const formatDate = (dateString: string): string => { + const date = new Date(dateString); + const now = new Date(); + const diffInHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60)); + + if (diffInHours < 1) return 'Just now'; + if (diffInHours < 24) return `${diffInHours} hour${diffInHours !== 1 ? 's' : ''} ago`; + + const diffInDays = Math.floor(diffInHours / 24); + if (diffInDays < 7) return `${diffInDays} day${diffInDays !== 1 ? 's' : ''} ago`; + + return date.toLocaleDateString(); +}; + +// Get queue statistics +export const getQueueStats = (queue: Record) => { + const queueArray = objectToArray(queue); + return { + totalSongs: queueArray.length, + singers: [...new Set(queueArray.map(item => item.singer.name))], + estimatedDuration: queueArray.length * 3, // Rough estimate: 3 minutes per song + }; +}; \ No newline at end of file diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..da7c9ef --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} \ No newline at end of file