diff --git a/.env.example b/.env.example index 9d9aa4e..603d7a7 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,10 @@ +# Copy this file to .env and fill in your actual values + +# OpenWeather API Key for fetching weather data +VITE_OPENWEATHER_API_KEY=your_openweather_api_key_here + # Key for fetching weather data VITE_OPENWEATHER_API_KEY= + # Key for Google Calendar login- put your google client id here VITE_GOOGLE_CLIENT_ID= \ No newline at end of file diff --git a/README.md b/README.md index b9b61fc..396a6bb 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ [![TailwindCSS](https://img.shields.io/badge/TailwindCSS-4.1.13-38bdf8?logo=tailwindcss&style=flat-square)](https://tailwindcss.com/) [![OpenWeatherMap](https://img.shields.io/badge/API-OpenWeatherMap-orange?style=flat-square)](https://openweathermap.org/) + --- @@ -48,9 +49,11 @@ ### ⏰ *Hourly & 5-Day Forecasts* - Interactive hourly temperature chart (Recharts powered). - 5-day forecast with min/max, humidity, wind, and weather icons. + Screenshot 2025-09-28 101158 + ### 🎨 *Modern UI & Animations* - Responsive, mobile-friendly design with Tailwind CSS. - Smooth skeleton loaders and animated transitions. @@ -97,6 +100,7 @@ How to get your Google OAuth Client ID? Follow the steps below --- + ## 📸 Screenshots & Animations diff --git a/index.html b/index.html index 21191b9..32c92ee 100644 --- a/index.html +++ b/index.html @@ -1,6 +1,8 @@ + + diff --git a/package-lock.json b/package-lock.json index 17d72a0..429f2c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,11 +21,13 @@ "cmdk": "^1.1.1", "data-fns": "^1.1.0", "date-fns": "^4.1.0", + "framer-motion": "^12.23.24", "lucide-react": "^0.544.0", "next-themes": "^0.4.6", "react": "^19.1.1", "react-dom": "^19.1.1", "react-error-boundary": "^6.0.0", + "react-is": "^19.2.0", "react-router-dom": "^7.9.2", "recharts": "^3.2.1", "sonner": "^2.0.7", @@ -33,6 +35,7 @@ "tailwindcss": "^4.1.13" }, "devDependencies": { + "@cloudflare/workers-types": "^4.20251011.0", "@eslint/js": "^9.36.0", "@types/node": "^24.5.2", "@types/react": "^19.1.13", @@ -339,6 +342,13 @@ "node": ">=6.9.0" } }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20251011.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20251011.0.tgz", + "integrity": "sha512-gQpih+pbq3sP4uXltUeCSbPgZxTNp2gQd8639SaIbQMwgA6oJNHLhIART1fWy6DQACngiRzDVULA2x0ohmkGTQ==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", @@ -346,6 +356,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -362,6 +373,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -378,6 +390,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -394,6 +407,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -410,6 +424,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -426,6 +441,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -442,6 +458,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -458,6 +475,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -474,6 +492,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -490,6 +509,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -506,6 +526,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -522,6 +543,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -538,6 +560,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -554,6 +577,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -570,6 +594,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -586,6 +611,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -602,6 +628,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -618,6 +645,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -634,6 +662,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -650,6 +679,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -666,6 +696,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -682,6 +713,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -698,6 +730,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -714,6 +747,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -730,6 +764,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -746,6 +781,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1489,6 +1525,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1502,6 +1539,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1515,6 +1553,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1528,6 +1567,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1541,6 +1581,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1554,6 +1595,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1567,6 +1609,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1580,6 +1623,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1593,6 +1637,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1606,6 +1651,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1619,6 +1665,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1632,6 +1679,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1645,6 +1693,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1658,6 +1707,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1671,6 +1721,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1684,6 +1735,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1697,6 +1749,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1710,6 +1763,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1723,6 +1777,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1736,6 +1791,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1749,6 +1805,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1762,6 +1819,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2207,6 +2265,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -2220,7 +2279,7 @@ "version": "24.5.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz", "integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.12.0" @@ -2230,7 +2289,7 @@ "version": "19.1.13", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz", "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -2240,7 +2299,7 @@ "version": "19.1.9", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz", "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.0.0" @@ -2850,7 +2909,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/d3-array": { @@ -3076,6 +3135,7 @@ "version": "0.25.10", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -3445,10 +3505,38 @@ "dev": true, "license": "ISC" }, + "node_modules/framer-motion": { + "version": "12.23.24", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz", + "integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.23.23", + "motion-utils": "^12.23.6", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -4060,6 +4148,21 @@ "node": ">= 18" } }, + "node_modules/motion-dom": { + "version": "12.23.23", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", + "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.23.6" + } + }, + "node_modules/motion-utils": { + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4071,6 +4174,7 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, "funding": [ { "type": "github", @@ -4196,6 +4300,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -4215,6 +4320,7 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -4314,11 +4420,10 @@ } }, "node_modules/react-is": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz", - "integrity": "sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==", - "license": "MIT", - "peer": true + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz", + "integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==", + "license": "MIT" }, "node_modules/react-redux": { "version": "9.2.0", @@ -4533,6 +4638,7 @@ "version": "4.52.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.2.tgz", "integrity": "sha512-I25/2QgoROE1vYV+NQ1En9T9UFB9Cmfm2CJ83zZOlaDpvz29wGQSZXWKw7MiNXau7wYgB/T9fVIdIuEQ+KbiiA==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -4748,6 +4854,7 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -4764,6 +4871,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -4781,6 +4889,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -4886,7 +4995,7 @@ "version": "7.12.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", "integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unit-fns": { @@ -5017,6 +5126,7 @@ "version": "7.1.7", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz", "integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==", + "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", @@ -5091,6 +5201,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -5108,6 +5219,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" diff --git a/package.json b/package.json index a5a7271..436068c 100644 --- a/package.json +++ b/package.json @@ -22,11 +22,13 @@ "cmdk": "^1.1.1", "data-fns": "^1.1.0", "date-fns": "^4.1.0", + "framer-motion": "^12.23.24", "lucide-react": "^0.544.0", "next-themes": "^0.4.6", "react": "^19.1.1", "react-dom": "^19.1.1", "react-error-boundary": "^6.0.0", + "react-is": "^19.2.0", "react-router-dom": "^7.9.2", "recharts": "^3.2.1", "sonner": "^2.0.7", @@ -34,6 +36,7 @@ "tailwindcss": "^4.1.13" }, "devDependencies": { + "@cloudflare/workers-types": "^4.20251011.0", "@eslint/js": "^9.36.0", "@types/node": "^24.5.2", "@types/react": "^19.1.13", diff --git a/public/demo/rainy-mood.mp3 b/public/demo/rainy-mood.mp3 new file mode 100644 index 0000000..953115e Binary files /dev/null and b/public/demo/rainy-mood.mp3 differ diff --git a/public/demo/rainy.jpg b/public/demo/rainy.jpg new file mode 100644 index 0000000..d6c49dc Binary files /dev/null and b/public/demo/rainy.jpg differ diff --git a/public/demo/sunny-vibes.mp3 b/public/demo/sunny-vibes.mp3 new file mode 100644 index 0000000..5a08a6c Binary files /dev/null and b/public/demo/sunny-vibes.mp3 differ diff --git a/public/demo/sunny.jpg b/public/demo/sunny.jpg new file mode 100644 index 0000000..689f6bd Binary files /dev/null and b/public/demo/sunny.jpg differ diff --git a/src/App.css b/src/App.css index e69de29..e648074 100644 --- a/src/App.css +++ b/src/App.css @@ -0,0 +1,21 @@ +@keyframes slow-spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +.animate-slow-spin { + animation: slow-spin 60s linear infinite; +} + +.bg-gradient-radial { + background-image: radial-gradient(var(--tw-gradient-stops)); +} + +.bg-gradient-conic { + background-image: conic-gradient(var(--tw-gradient-stops)); +} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 4cd14fe..95ae0ae 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,55 +1,55 @@ import './App.css'; -import {BrowserRouter,Route,Routes} from 'react-router-dom' +import { BrowserRouter, Route, Routes } from 'react-router-dom' +import MusicPage from './pages/music-page'; +import SharedPlaylistPage from './pages/shared-playlist-page'; import Layout from './components/layout' import { ThemeProvider } from './context/theme-provider'; +import { PlaylistProvider } from './context/playlist-provider'; import WeatherDashboard from './pages/weather-dashboard'; import CityPage from './pages/city-page'; -import {QueryClient, QueryClientProvider} from '@tanstack/react-query'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import { Toaster } from 'sonner'; import { GoogleOAuthProvider } from '@react-oauth/google'; import PlannerPage from './pages/planner-page'; -import { ErrorBoundary } from 'react-error-boundary'; // Make sure this is imported +import { ErrorBoundary } from 'react-error-boundary'; -const queryClient=new QueryClient({ - defaultOptions:{ - queries:{ - staleTime:5*60*1000, - gcTime:10*60*1000, - retry:false, - refetchOnWindowFocus:false, - } +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + retry: false, + refetchOnWindowFocus: false, + } } }); -function App(){ +function App() { return ( - // The GoogleOAuthProvider must wrap everything that might use it. - // We also add a non-null assertion (!) because this variable is required. - - - }/> - }/> - - {/* - corrected structure. - The ErrorBoundary now wraps PlannerPage *inside* the element prop. - */} - Something went wrong with the planner. Please try again.}> - - - } - /> - - - + + + + } /> + } /> + } /> + } /> + Something went wrong with the planner. Please try again.}> + + + } + /> + + + + @@ -58,5 +58,4 @@ function App(){ ); } -export default App; - +export default App; \ No newline at end of file diff --git a/src/api/cloudflare-storage.ts b/src/api/cloudflare-storage.ts new file mode 100644 index 0000000..f15d350 --- /dev/null +++ b/src/api/cloudflare-storage.ts @@ -0,0 +1,381 @@ +// Cloudflare R2 Storage Service for SkyBuddy Playlists +// This service handles playlist storage and retrieval using Cloudflare R2 with CDN + +import type { Playlist } from '../context/playlist-provider'; + +export interface CloudflareConfig { + accountId: string; + accessKeyId: string; + secretAccessKey: string; + bucketName: string; + cdnUrl: string; + region?: string; +} + +export interface StorageResponse { + success: boolean; + data?: T; + error?: string; +} + +export interface R2Object { + key: string; + size: number; + etag: string; + lastModified: string; +} + +export interface R2ListResponse { + objects: R2Object[]; + truncated: boolean; + nextContinuationToken?: string; +} + +export interface CloudflareStorageOptions { + compress?: boolean; + ttl?: number; + metadata?: Record; +} + +class CloudflareR2Service { + private config: CloudflareConfig; + private baseUrl: string; + + constructor(config: CloudflareConfig) { + this.config = config; + this.baseUrl = `https://api.cloudflare.com/client/v4/accounts/${config.accountId}/r2/buckets/${config.bucketName}/objects`; + } + + /** + * Generate authentication headers for Cloudflare R2 API + */ + private getAuthHeaders(): Record { + const timestamp = Math.floor(Date.now() / 1000).toString(); + + return { + 'Authorization': `Bearer ${this.config.accessKeyId}`, + 'X-Auth-Email': this.config.secretAccessKey, // In production, use proper OAuth + 'Content-Type': 'application/json', + 'X-Timestamp': timestamp + }; + } + + /** + * Store data in Cloudflare R2 + */ + async store( + key: string, + data: T, + options: CloudflareStorageOptions = {} + ): Promise> { + try { + const payload = { + key, + data: options.compress ? this.compress(JSON.stringify(data)) : JSON.stringify(data), + metadata: { + 'content-type': 'application/json', + 'skybuddy-version': '1.0', + 'created-at': new Date().toISOString(), + ...options.metadata + }, + ...(options.ttl && { ttl: options.ttl }) + }; + + const response = await fetch(`${this.baseUrl}/${encodeURIComponent(key)}`, { + method: 'PUT', + headers: this.getAuthHeaders(), + body: JSON.stringify(payload) + }); if (!response.ok) { + throw new Error(`Storage failed: ${response.status} ${response.statusText}`); + } + + await response.json(); // Consume response body + + return { + success: true, + data: this.getCdnUrl(key) + }; + } catch (error) { + console.error('R2 Storage Error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Storage failed' + }; + } + } + + /** + * Retrieve data from Cloudflare R2 + */ + async retrieve(key: string): Promise> { + try { + // Try CDN first for faster access + const cdnUrl = this.getCdnUrl(key); + let response = await fetch(cdnUrl, { + headers: { + 'Cache-Control': 'max-age=3600' // 1 hour cache + } + }); + + // Fallback to R2 API if CDN fails + if (!response.ok) { + response = await fetch(`${this.baseUrl}/${encodeURIComponent(key)}`, { + method: 'GET', + headers: this.getAuthHeaders() + }); + } + + if (!response.ok) { + if (response.status === 404) { + return { success: false, error: 'Data not found' }; + } + throw new Error(`Retrieval failed: ${response.status} ${response.statusText}`); + } + + const rawData = await response.text(); + const data = JSON.parse(this.isCompressed(rawData) ? this.decompress(rawData) : rawData); + + return { + success: true, + data + }; + } catch (error) { + console.error('R2 Retrieval Error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Retrieval failed' + }; + } + } + + /** + * Delete data from Cloudflare R2 + */ + async delete(key: string): Promise> { + try { + const response = await fetch(`${this.baseUrl}/${encodeURIComponent(key)}`, { + method: 'DELETE', + headers: this.getAuthHeaders() + }); + + if (!response.ok && response.status !== 404) { + throw new Error(`Deletion failed: ${response.status} ${response.statusText}`); + } + + return { success: true, data: true }; + } catch (error) { + console.error('R2 Deletion Error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Deletion failed' + }; + } + } + + /** + * List objects with a specific prefix + */ + async list(prefix: string = ''): Promise> { + try { + const response = await fetch(`${this.baseUrl}?prefix=${encodeURIComponent(prefix)}`, { + method: 'GET', + headers: this.getAuthHeaders() + }); + + if (!response.ok) { + throw new Error(`List failed: ${response.status} ${response.statusText}`); + } const result = await response.json() as R2ListResponse; + const keys = result.objects?.map((obj: R2Object) => obj.key) || []; + + return { + success: true, + data: keys + }; + } catch (error) { + console.error('R2 List Error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'List failed' + }; + } + } + + /** + * Generate CDN URL for faster access + */ + private getCdnUrl(key: string): string { + return `${this.config.cdnUrl}/${encodeURIComponent(key)}`; + } + + /** + * Simple compression for large playlist data + */ + private compress(data: string): string { + // Simple base64 encoding as compression placeholder + // In production, use proper compression like gzip + return btoa(data); + } + + /** + * Decompress data + */ + private decompress(data: string): string { + try { + return atob(data); + } catch { + return data; // Return as-is if not compressed + } + } + + /** + * Check if data is compressed + */ + private isCompressed(data: string): boolean { + // Simple check - in production, use proper compression detection + try { + atob(data); + return !data.startsWith('{') && !data.startsWith('['); + } catch { + return false; + } + } + + /** + * Generate shareable URLs with optional expiration + */ + async generateShareableUrl(key: string, expiresIn: number = 7 * 24 * 3600): Promise> { + try { + const expiration = Math.floor((Date.now() / 1000) + expiresIn); + + // Create a signed URL for sharing + const shareUrl = `${this.config.cdnUrl}/${encodeURIComponent(key)}?expires=${expiration}`; + + return { + success: true, + data: shareUrl + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'URL generation failed' + }; + } + } +} + +// Storage service instance +let r2Service: CloudflareR2Service | null = null; + +/** + * Initialize Cloudflare R2 service + */ +export function initializeCloudflareStorage(config: CloudflareConfig): void { + r2Service = new CloudflareR2Service(config); +} + +/** + * Get the R2 service instance + */ +export function getStorageService(): CloudflareR2Service { + if (!r2Service) { + throw new Error('Cloudflare R2 service not initialized. Call initializeCloudflareStorage() first.'); + } + return r2Service; +} + +/** + * Playlist-specific storage operations + */ +export class PlaylistCloudStorage { + private service: CloudflareR2Service; + private readonly PLAYLIST_PREFIX = 'playlists/'; + private readonly SHARED_PREFIX = 'shared/'; + private readonly FAVORITES_KEY = 'user/favorites'; + + constructor(service: CloudflareR2Service) { + this.service = service; + } + + // Store user's playlists + async storePlaylists(userId: string, playlists: Playlist[]): Promise { + const key = `${this.PLAYLIST_PREFIX}${userId}`; + return this.service.store(key, playlists, { + compress: true, + metadata: { + 'data-type': 'playlists', + 'user-id': userId, + 'count': playlists.length.toString() + } + }); + } + + // Retrieve user's playlists + async getPlaylists(userId: string): Promise> { + const key = `${this.PLAYLIST_PREFIX}${userId}`; + return this.service.retrieve(key); + } + + // Store shared playlist + async storeSharedPlaylist(shareId: string, playlist: Playlist): Promise { + const key = `${this.SHARED_PREFIX}${shareId}`; + return this.service.store(key, playlist, { + compress: true, + ttl: 30 * 24 * 3600, // 30 days + metadata: { + 'data-type': 'shared-playlist', + 'share-id': shareId, + 'playlist-name': playlist.name + } + }); + } + + // Retrieve shared playlist + async getSharedPlaylist(shareId: string): Promise> { + const key = `${this.SHARED_PREFIX}${shareId}`; + return this.service.retrieve(key); + } + + // Store user favorites + async storeFavorites(userId: string, favorites: string[]): Promise { + const key = `${this.FAVORITES_KEY}/${userId}`; + return this.service.store(key, favorites); + } + + // Retrieve user favorites + async getFavorites(userId: string): Promise> { + const key = `${this.FAVORITES_KEY}/${userId}`; + return this.service.retrieve(key); + } + + // Generate shareable URL for playlist + async generatePlaylistShareUrl(shareId: string): Promise> { + const key = `${this.SHARED_PREFIX}${shareId}`; + return this.service.generateShareableUrl(key); + } + + // Delete user's playlists + async deletePlaylists(userId: string): Promise { + const key = `${this.PLAYLIST_PREFIX}${userId}`; + return this.service.delete(key); + } + + // Delete shared playlist + async deleteSharedPlaylist(shareId: string): Promise { + const key = `${this.SHARED_PREFIX}${shareId}`; + return this.service.delete(key); + } +} + +// Environment configuration helper +export function getCloudflareConfig(): CloudflareConfig { + return { + accountId: import.meta.env.VITE_CLOUDFLARE_ACCOUNT_ID || '', + accessKeyId: import.meta.env.VITE_CLOUDFLARE_ACCESS_KEY_ID || '', + secretAccessKey: import.meta.env.VITE_CLOUDFLARE_SECRET_ACCESS_KEY || '', + bucketName: import.meta.env.VITE_CLOUDFLARE_BUCKET_NAME || 'skybuddy-playlists', + cdnUrl: import.meta.env.VITE_CLOUDFLARE_CDN_URL || '', + region: import.meta.env.VITE_CLOUDFLARE_REGION || 'auto' + }; +} + +export default CloudflareR2Service; diff --git a/src/api/spotify.ts b/src/api/spotify.ts new file mode 100644 index 0000000..e454965 --- /dev/null +++ b/src/api/spotify.ts @@ -0,0 +1,136 @@ +// Future Spotify API integration +// This is a placeholder for when we implement real Spotify API integration + +export interface SpotifyTrack { + id: string; + name: string; + artists: Array<{ name: string }>; + duration_ms: number; + preview_url: string | null; + external_urls: { spotify: string }; + album: { + name: string; + images: Array<{ url: string; width: number; height: number }>; + }; +} + +export interface SpotifyPlaylist { + id: string; + name: string; + description: string; + images: Array<{ url: string; width: number; height: number }>; + tracks: { + total: number; + items: Array<{ track: SpotifyTrack }>; + }; + external_urls: { spotify: string }; +} + +// Mock Spotify API client for demonstration +export class SpotifyAPI { + private accessToken: string | null = null; + + // In a real implementation, this would handle OAuth flow + async authenticate(): Promise { + // Mock authentication - in real app this would use Spotify OAuth + this.accessToken = 'mock_token'; + return true; + } + + // Search for tracks (mock implementation) + async searchTracks(query: string, limit: number = 20): Promise { + // Mock data for demonstration + return [ + { + id: 'mock_track_1', + name: `Search result for "${query}"`, + artists: [{ name: 'Mock Artist' }], + duration_ms: 180000, + preview_url: null, + external_urls: { spotify: 'https://open.spotify.com/track/mock_track_1' }, + album: { + name: 'Mock Album', + images: [{ url: '/demo/sunny.jpg', width: 640, height: 640 }] + } + } + ]; + } + + // Get playlist details (mock implementation) + async getPlaylist(playlistId: string): Promise { + // Mock playlist data + return { + id: playlistId, + name: 'Mock Playlist', + description: 'A mock playlist for demonstration', + images: [{ url: '/demo/rainy.jpg', width: 640, height: 640 }], + tracks: { + total: 1, + items: [ + { + track: { + id: 'mock_track_1', + name: 'Mock Song', + artists: [{ name: 'Mock Artist' }], + duration_ms: 180000, + preview_url: null, + external_urls: { spotify: 'https://open.spotify.com/track/mock_track_1' }, + album: { + name: 'Mock Album', + images: [{ url: '/demo/sunny.jpg', width: 640, height: 640 }] + } + } + } + ] + }, + external_urls: { spotify: `https://open.spotify.com/playlist/${playlistId}` } + }; + } + + // Create playlist (mock implementation) + async createPlaylist(name: string, description?: string): Promise { + // In real implementation, this would create a playlist on Spotify + return { + id: 'new_playlist_' + Date.now(), + name, + description: description || '', + images: [], + tracks: { total: 0, items: [] }, + external_urls: { spotify: 'https://open.spotify.com/playlist/new_playlist' } + }; + } + + // Add tracks to playlist (mock implementation) + async addTracksToPlaylist(playlistId: string, trackIds: string[]): Promise { + // Mock success + return true; + } +} + +// Singleton instance +export const spotifyAPI = new SpotifyAPI(); + +// Helper functions for converting between our internal format and Spotify format +export function convertSpotifyTrackToInternal(spotifyTrack: SpotifyTrack) { + return { + id: spotifyTrack.id, + name: spotifyTrack.name, + artist: spotifyTrack.artists[0]?.name, + duration: Math.floor(spotifyTrack.duration_ms / 1000), + url: spotifyTrack.preview_url || undefined, + isLocal: false + }; +} + +export function convertSpotifyPlaylistToInternal(spotifyPlaylist: SpotifyPlaylist) { + return { + id: spotifyPlaylist.id, + name: spotifyPlaylist.name, + description: spotifyPlaylist.description, + tracks: spotifyPlaylist.tracks.items.map(item => convertSpotifyTrackToInternal(item.track)), + mood: 'General', // Would need to be determined by other means + createdAt: Date.now(), + updatedAt: Date.now(), + coverImage: spotifyPlaylist.images[0]?.url + }; +} diff --git a/src/components/header.tsx b/src/components/header.tsx index d284f3f..8580836 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -1,58 +1,67 @@ import { useTheme } from "@/context/theme-provider"; -import { Moon, Sun } from "lucide-react"; +import { Moon, Sun, Music } from "lucide-react"; import { Link } from "react-router-dom"; import CitySearch from "./city-search"; -import { Button } from "./ui/button"; // Using the project's button for consistency +import { Button } from "./ui/button"; const Header = () => { - const {theme,setTheme}=useTheme(); - const isDark=theme==='dark'; + const { theme, setTheme } = useTheme(); + const isDark = theme === 'dark'; + return (
-
- {/* Left section: Logo + Name */} - - SkyBuddy logo -

- SKYBuddy -

- +
+ {/* Left section: Logo + Name */} + + SkyBuddy logo +

+ SKYBuddy +

+ - {/* Search Bar */} - + {/* Right section: Search + Navigation + Theme Toggle */} +
+ {/* Search Bar */} + - {/* Right section: Planner Button + Theme Toggle */} -
- {/* The new button with text and an emoji */} - + + + {/* Planner Button */} + + - {/* Your original theme toggle */} -
setTheme(isDark?'light':'dark')} - className={`flex items-center cursor-pointer transition-transform duration-500 - ${isDark?'rotate-180':'rotate-0'}` - }> - {isDark? - ():( + {/* Theme Toggle */} +
setTheme(isDark ? 'light' : 'dark')} + className={`flex items-center cursor-pointer transition-transform duration-500 ${isDark ? 'rotate-180' : 'rotate-0'}`} + > + {isDark ? ( + + ) : ( - )} + )} +
-
- ) + ); } -export default Header; - +export default Header; \ No newline at end of file diff --git a/src/components/mood-selector.tsx b/src/components/mood-selector.tsx index 7c44b06..aba9cd0 100644 --- a/src/components/mood-selector.tsx +++ b/src/components/mood-selector.tsx @@ -1,6 +1,5 @@ -import { useState } from 'react'; import { Smile, Wind, Brain, Zap, Sparkles } from 'lucide-react'; -import type { MoodType } from '@/types/playlist'; +import type { MoodType } from '../types/playlist'; interface MoodSelectorProps { selectedMood?: MoodType; @@ -23,31 +22,32 @@ const MoodSelector = ({ selectedMood, onMoodChange }: MoodSelectorProps) => { onMoodChange(mood); } }; - return ( -
-
-

How are you feeling?

- (Optional) +
+
+

How are you feeling?

+

Choose a mood to discover perfect playlists

-
- {moodOptions.map((mood) => ( +
{moodOptions.map((mood) => ( ))}
diff --git a/src/components/music-player.tsx b/src/components/music-player.tsx new file mode 100644 index 0000000..5f03d2b --- /dev/null +++ b/src/components/music-player.tsx @@ -0,0 +1,381 @@ +import React, { useRef, useState, useEffect } from 'react'; +import { motion } from 'framer-motion'; +import { useTheme } from '../context/theme-provider'; + +interface MusicPlayerProps { + mood?: string; +} + +const FAVORITES_KEY = 'skybuddy_favorite_tracks'; + +function getStoredFavorites(): string[] { + try { + const data = localStorage.getItem(FAVORITES_KEY); + return data ? JSON.parse(data) : []; + } catch { + return []; + } +} + +function saveFavorites(favs: string[]) { + localStorage.setItem(FAVORITES_KEY, JSON.stringify(favs)); +} + +// Dummy track data for now +const demoTracks = [ + { + title: 'Rainy Mood', + artist: 'SkyBuddy', + url: '/demo/rainy-mood.mp3', + cover: '/demo/rainy.jpg', + mood: 'Rain', + }, + { + title: 'Sunny Vibes', + artist: 'SkyBuddy', + url: '/demo/sunny-vibes.mp3', + cover: '/demo/sunny.jpg', + mood: 'Happy', + }, +]; + +export const MusicPlayer: React.FC = ({ mood }) => { + const { theme } = useTheme?.() || { + theme: typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + }; + const isDarkMode = theme === 'dark'; + + // Filter tracks by mood if provided + const tracks = mood ? demoTracks.filter(t => t.mood === mood) : demoTracks; + const [current, setCurrent] = useState(0); + const [playing, setPlaying] = useState(false); + const [favorites, setFavorites] = useState(() => getStoredFavorites()); + const [volume, setVolume] = useState(0.7); + const [shuffle, setShuffle] = useState(false); + const [progress, setProgress] = useState(0); + const [duration, setDuration] = useState(0); + const audioRef = useRef(null); + + // Favorite/unfavorite current track + function handleToggleFavorite() { + const trackId = tracks[current]?.title + tracks[current]?.artist; + let updated: string[]; + if (favorites.includes(trackId)) { + updated = favorites.filter(f => f !== trackId); + } else { + updated = [...favorites, trackId]; + } + setFavorites(updated); + saveFavorites(updated); + } + + // Keep favorites in sync with localStorage + useEffect(() => { + saveFavorites(favorites); + }, [favorites]); + + const playPause = () => { + if (playing) { + audioRef.current?.pause(); + } else { + audioRef.current?.play(); + } + setPlaying(!playing); + }; + + const next = () => { + if (shuffle) { + let nextIdx = Math.floor(Math.random() * tracks.length); + // Avoid repeating the same track + if (tracks.length > 1 && nextIdx === current) { + nextIdx = (nextIdx + 1) % tracks.length; + } + setCurrent(nextIdx); + } else { + setCurrent((prev) => (prev + 1) % tracks.length); + } + setPlaying(false); + setProgress(0); + }; + + const prev = () => { + setCurrent((prev) => (prev - 1 + tracks.length) % tracks.length); + setPlaying(false); + setProgress(0); + }; + + // Volume control + useEffect(() => { + if (audioRef.current) { + audioRef.current.volume = volume; + } + }, [volume]); + + // Progress bar animation + useEffect(() => { + const audio = audioRef.current; + if (!audio) return; + const updateProgress = () => { + setProgress(audio.currentTime); + }; + const setAudioDuration = () => { + setDuration(audio.duration || 0); + }; + audio.addEventListener('timeupdate', updateProgress); + audio.addEventListener('loadedmetadata', setAudioDuration); + return () => { + audio.removeEventListener('timeupdate', updateProgress); + audio.removeEventListener('loadedmetadata', setAudioDuration); + }; + }, [current]); + + // Seek bar handler + const handleSeek = (e: React.ChangeEvent) => { + const seekTime = Number(e.target.value); + if (audioRef.current) { + audioRef.current.currentTime = seekTime; + setProgress(seekTime); + } + }; + + // Icon helper for Material Icons + const Icon = ({ + name, + className = "", + style = {}, + ...props + }: React.HTMLAttributes & { name: string }) => ( + {name} + ); + + // Volume icon logic + const getVolumeIcon = () => { + if (volume === 0) return "volume_off"; + if (volume < 0.5) return "volume_down"; + return "volume_up"; + }; + + return ( + + {/* Updated glowing background effect that works well in both modes */} +
+
+
+
+ + {/* Music card with frosted glass effect */} +
+ + {tracks.length > 0 ? ( + <> + + cover +
+
+ + +
+

+ {tracks[current].title} +

+ + + +
+

{tracks[current].artist}

+
+ +
+ ); + + // Helper to format time in mm:ss + function formatTime(sec: number) { + if (!isFinite(sec)) return '0:00'; + const m = Math.floor(sec / 60); + const s = Math.floor(sec % 60); + return `${m}:${s.toString().padStart(2, '0')}`; + } +}; diff --git a/src/components/playlist-card.tsx b/src/components/playlist-card.tsx index 6f3f360..3a8fb4e 100644 --- a/src/components/playlist-card.tsx +++ b/src/components/playlist-card.tsx @@ -2,6 +2,7 @@ import { ExternalLink, Music2 } from 'lucide-react'; import { Card, CardContent } from '@/components/ui/card'; import type { SpotifyPlaylist } from '@/types/playlist'; import { useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; interface PlaylistCardProps { playlist: SpotifyPlaylist; @@ -10,13 +11,23 @@ interface PlaylistCardProps { const PlaylistCard = ({ playlist }: PlaylistCardProps) => { const [imageError, setImageError] = useState(false); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + // Try to get mood from search params, fallback to undefined + const mood = searchParams.get('mood') || undefined; + + const handleCardClick = (e: React.MouseEvent) => { + e.preventDefault(); + // Navigate to /music?mood=...&playlist=... + navigate(`/music?mood=${encodeURIComponent(mood || '')}&playlist=${encodeURIComponent(playlist.id)}`); + }; + return ( {/* Playlist Image - Smaller with fallback */} diff --git a/src/components/playlist-manager.tsx b/src/components/playlist-manager.tsx new file mode 100644 index 0000000..7aeffd3 --- /dev/null +++ b/src/components/playlist-manager.tsx @@ -0,0 +1,231 @@ +import React, { useState } from 'react'; +import { motion } from 'framer-motion'; +import { Plus, Trash2, Share2, Music } from 'lucide-react'; +import { usePlaylist } from '../hooks/use-playlist'; +import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; +import { Button } from './ui/button'; +import type { Playlist } from '../context/playlist-provider'; + +interface PlaylistManagerProps { + mood?: string; + onSelectPlaylist?: (id: string) => void; +} + +export const PlaylistManager: React.FC = ({ mood, onSelectPlaylist }) => { + const { + playlists, + createPlaylist, + deletePlaylist, + addTrack, + sharePlaylist + } = usePlaylist(); + + const [newName, setNewName] = useState(''); + const [newDescription, setNewDescription] = useState(''); + const [trackInputs, setTrackInputs] = useState<{ [playlistId: string]: string }>({}); + + // Filter playlists by mood if provided + const filteredPlaylists = mood + ? playlists.filter((p: Playlist) => p.mood.toLowerCase() === mood.toLowerCase()) + : playlists; + + // Handle creating new playlist + const handleCreatePlaylist = async () => { + if (!newName.trim()) return; + + try { + await createPlaylist(newName.trim(), mood || 'General', newDescription.trim() || undefined); + setNewName(''); + setNewDescription(''); + } catch (error) { + console.error('Failed to create playlist:', error); + } + }; + + // Handle adding track to playlist + const handleAddTrack = (playlistId: string) => { + const trackName = trackInputs[playlistId]?.trim(); + if (!trackName) return; + + try { + addTrack(playlistId, trackName); + setTrackInputs(prev => ({ ...prev, [playlistId]: '' })); + } catch (error) { + console.error('Failed to add track:', error); + } + }; + + // Handle sharing + const handleShare = async (playlistId: string) => { + try { + const shareId = await sharePlaylist(playlistId); + const shareUrl = `${window.location.origin}/playlist/shared/${shareId}`; + + // Copy to clipboard if available + if (navigator.clipboard) { + await navigator.clipboard.writeText(shareUrl); + alert('Share link copied to clipboard!'); + } else { + prompt('Copy this share link:', shareUrl); + } + } catch (error) { + console.error('Failed to share playlist:', error); + alert('Failed to share playlist'); + } + }; + return ( +
+ + + +
+ +
+
+
Your Music Collection
+ {mood &&
{mood} vibes
} +
+
+
+ + + {/* Playlists */} +
{filteredPlaylists.length === 0 ? ( +
+
+ +
+

No playlists yet

+

+ {mood ? `No playlists found for "${mood}" mood.` : 'Create your first playlist below to get started!'} +

+
+ ) : ( + filteredPlaylists.map((playlist: Playlist) => ( + onSelectPlaylist?.(playlist.id)} + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + className="group relative bg-gradient-to-br from-white to-gray-50 dark:from-gray-900 dark:to-gray-800 border border-gray-200 dark:border-gray-700 rounded-2xl p-6 hover:shadow-lg hover:border-purple-200 dark:hover:border-purple-800 transition-all duration-300" + > + {/* Playlist Header */} +
+
+

{playlist.name}

+
+ Mood: {playlist.mood} + {playlist.description && • {playlist.description}} + • {playlist.tracks.length} tracks + {playlist.isShared && • Shared} +
+
+ +
+ + +
+
+ + {/* Tracks */} +
+
Tracks:
+ + {playlist.tracks.length === 0 ? ( +

No tracks yet.

+ ) : ( +
+ {playlist.tracks.map((track: { id: string; name: string; artist?: string }) => ( +
+
+ {track.name} + {track.artist && ( + - {track.artist} + )} +
+
+ ))} +
+ )} + + {/* Add Track Input */} +
+ setTrackInputs(prev => ({ ...prev, [playlist.id]: e.target.value }))} + onKeyDown={(e) => { if (e.key === 'Enter') handleAddTrack(playlist.id); }} + className="flex-1 p-2 text-sm bg-background rounded border" + /> + +
+
+
+ )) + )} +
{/* Create New Playlist */} +
+
+
+
+ +
+

Create New Playlist

+
+ +
+
+ + setNewName(e.target.value)} + className="w-full p-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all placeholder:text-gray-400" + /> +
+ +
+ + setNewDescription(e.target.value)} + className="w-full p-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all placeholder:text-gray-400" + /> +
+ + +
+
+
+
+
+
+ ); +}; diff --git a/src/components/playlist-stats.tsx b/src/components/playlist-stats.tsx new file mode 100644 index 0000000..5f91666 --- /dev/null +++ b/src/components/playlist-stats.tsx @@ -0,0 +1,49 @@ +// src/components/playlist-stats.tsx +import React from 'react'; +import { Clock, Music, Calendar } from 'lucide-react'; +import type { Track } from '../types/playlist'; + +interface PlaylistStatsProps { + tracks: Track[]; + createdAt?: number; +} + +export const PlaylistStats: React.FC = ({ tracks, createdAt }) => { + // Calculate total duration (for local/r2 tracks with duration) + const totalDurationSeconds = tracks.reduce((total, track) => { + return total + (track.duration || 0); + }, 0); + + const hours = Math.floor(totalDurationSeconds / 3600); + const minutes = Math.floor((totalDurationSeconds % 3600) / 60); + + // Format created date + const createdDate = createdAt + ? new Date(createdAt).toLocaleDateString() + : 'Unknown date'; + + return ( +
+
+ + {tracks.length} tracks +
+ + {totalDurationSeconds > 0 && ( +
+ + + {hours > 0 ? `${hours} hr ${minutes} min` : `${minutes} min`} + +
+ )} + + {createdAt && ( +
+ + Created on {createdDate} +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/src/components/shareable-playlist.tsx b/src/components/shareable-playlist.tsx new file mode 100644 index 0000000..63334fe --- /dev/null +++ b/src/components/shareable-playlist.tsx @@ -0,0 +1,245 @@ +import React, { useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Share2, Copy, Check, ExternalLink, Import, QrCode } from 'lucide-react'; +import { usePlaylist } from '../hooks/use-playlist'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card'; +import { Button } from './ui/button'; +import type { Playlist } from '../context/playlist-provider'; + +interface ShareablePlaylistProps { + playlistId?: string; + onClose?: () => void; +} + +export const ShareablePlaylist: React.FC = ({ playlistId, onClose }) => { + const { playlists, sharePlaylist, importSharedPlaylist } = usePlaylist(); + const [shareUrl, setShareUrl] = useState(''); + const [importUrl, setImportUrl] = useState(''); + const [copied, setCopied] = useState(false); + const [isSharing, setIsSharing] = useState(false); + const [isImporting, setIsImporting] = useState(false); + const [error, setError] = useState(''); + + const playlist = playlistId ? playlists.find((p: Playlist) => p.id === playlistId) : null; + const handleShare = async () => { + if (!playlist) return; + + setIsSharing(true); + setError(''); + + try { + const shareId = await sharePlaylist(playlist.id); + const url = `${window.location.origin}/playlist/shared/${shareId}`; + setShareUrl(url); + } catch (err) { + setError('Failed to create shareable link'); + console.error('Share error:', err); + } finally { + setIsSharing(false); + } + }; + + const handleCopy = async () => { + if (!shareUrl) return; + + try { + await navigator.clipboard.writeText(shareUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Fallback for older browsers + const textArea = document.createElement('textarea'); + textArea.value = shareUrl; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const handleImport = async () => { + if (!importUrl.trim()) return; + + setIsImporting(true); + setError(''); + + try { + // Extract share ID from URL + const urlParts = importUrl.trim().split('/'); + const shareId = urlParts[urlParts.length - 1]; + + if (!shareId) { + throw new Error('Invalid share URL format'); + } + const success = await importSharedPlaylist(shareId); + if (success) { + setImportUrl(''); + alert('Playlist imported successfully!'); + } else { + throw new Error('Playlist not found or invalid share ID'); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to import playlist'); + } finally { + setIsImporting(false); + } + }; + + const generateQRCode = () => { + if (!shareUrl) return; + + // Using a simple QR code service for demo purposes + // In production, you might want to use a proper QR library + const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(shareUrl)}`; + window.open(qrUrl, '_blank'); + }; + + return ( + + + + + Share Playlists + + + Share your mood playlists with friends or import shared playlists + + + + + {/* Share Section */} + {playlist && ( +
+
+

Share "{playlist.name}"

+

+ Mood: {playlist.mood} • {playlist.tracks.length} tracks +

+ + {!shareUrl ? ( + + ) : ( +
+
+ + +
+ +
+ + +
+
+ )} +
+
+ )} + + {/* Import Section */} +
+
+

Import Shared Playlist

+
+ setImportUrl(e.target.value)} + className="flex-1 p-2 text-xs bg-background rounded border" + /> + +
+
+
+ + {/* Error Display */} + + {error && ( + + {error} + + )} + + + {/* Info */} +
+

• Shared playlists include all tracks and metadata

+

• Share links work across devices and browsers

+

• Imported playlists become part of your collection

+
+ + {onClose && ( + + )} +
+
+ ); +}; diff --git a/src/components/storage-status.tsx b/src/components/storage-status.tsx new file mode 100644 index 0000000..a2e5261 --- /dev/null +++ b/src/components/storage-status.tsx @@ -0,0 +1,192 @@ +import React, { useState, useEffect } from 'react'; +import { Cloud, HardDrive, Wifi, WifiOff, CheckCircle, AlertCircle } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; + +interface StorageStatusProps { + className?: string; +} + +type StorageType = 'cloud' | 'local' | 'checking'; +type ConnectionStatus = 'online' | 'offline' | 'checking'; + +export const StorageStatus: React.FC = ({ className = '' }) => { + const [storageType, setStorageType] = useState('checking'); + const [connectionStatus, setConnectionStatus] = useState('checking'); + const [isExpanded, setIsExpanded] = useState(false); + + useEffect(() => { + checkStorageStatus(); + checkConnectionStatus(); + + // Set up connection monitoring + const handleOnline = () => setConnectionStatus('online'); + const handleOffline = () => setConnectionStatus('offline'); + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + + const checkStorageStatus = async () => { + try { + // Check if Cloudflare config is available + const hasCloudflareConfig = !!( + import.meta.env.VITE_CLOUDFLARE_ACCOUNT_ID && + import.meta.env.VITE_CLOUDFLARE_ACCESS_KEY_ID && + import.meta.env.VITE_CLOUDFLARE_BUCKET_NAME + ); + + if (hasCloudflareConfig && navigator.onLine) { + // Try to ping the storage service + await fetch(import.meta.env.VITE_CLOUDFLARE_CDN_URL || '', { + method: 'HEAD', + mode: 'no-cors' + }).catch(() => null); + + setStorageType('cloud'); + } else { + setStorageType('local'); + } + } catch { + setStorageType('local'); + } + }; + + const checkConnectionStatus = () => { + setConnectionStatus(navigator.onLine ? 'online' : 'offline'); + }; + + const getStorageIcon = () => { + switch (storageType) { + case 'cloud': + return ; + case 'local': + return ; + default: + return
; + } + }; + + const getConnectionIcon = () => { + switch (connectionStatus) { + case 'online': + return ; + case 'offline': + return ; + default: + return
; + } + }; + + const getStatusColor = () => { + if (storageType === 'cloud' && connectionStatus === 'online') { + return 'text-green-600 dark:text-green-400'; + } else if (storageType === 'local' || connectionStatus === 'offline') { + return 'text-yellow-600 dark:text-yellow-400'; + } + return 'text-gray-600 dark:text-gray-400'; + }; + + const getStatusText = () => { + if (storageType === 'checking' || connectionStatus === 'checking') { + return 'Checking...'; + } + + if (storageType === 'cloud' && connectionStatus === 'online') { + return 'Cloud Storage'; + } else if (connectionStatus === 'offline') { + return 'Offline Mode'; + } else { + return 'Local Storage'; + } + }; + + const getDetailedStatus = () => { + if (storageType === 'cloud' && connectionStatus === 'online') { + return { + title: 'Cloud Storage Active', + description: 'Your playlists are synced to Cloudflare R2 with CDN acceleration', + icon: + }; + } else if (connectionStatus === 'offline') { + return { + title: 'Offline Mode', + description: 'Working offline. Changes will sync when connection is restored', + icon: + }; + } else { + return { + title: 'Local Storage', + description: 'Data is stored locally in your browser. Cloud sync unavailable', + icon: + }; + } + }; + + const detailedStatus = getDetailedStatus(); + + return ( +
+ setIsExpanded(!isExpanded)} + className={`flex items-center gap-2 px-3 py-2 rounded-lg bg-background/50 hover:bg-background/80 transition-colors ${getStatusColor()}`} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + > + {getStorageIcon()} + {getConnectionIcon()} + {getStatusText()} + + + + {isExpanded && ( + +
+ {detailedStatus.icon} +
+

{detailedStatus.title}

+

+ {detailedStatus.description} +

+ +
+
+ {storageType === 'cloud' ? ( + + ) : ( + + )} + Storage: {storageType === 'cloud' ? 'Cloud' : 'Local'} +
+
+ {connectionStatus === 'online' ? ( + + ) : ( + + )} + Network: {connectionStatus} +
+
+ + {storageType === 'local' && ( +
+ Note: To enable cloud storage, configure Cloudflare R2 credentials in your environment variables. +
+ )} +
+
+
+ )} +
+
+ ); +}; diff --git a/src/components/track-upload-form.tsx b/src/components/track-upload-form.tsx new file mode 100644 index 0000000..944ab3c --- /dev/null +++ b/src/components/track-upload-form.tsx @@ -0,0 +1,244 @@ +// src/components/track-upload-form.tsx +import React, { useState, useRef } from 'react'; +import { toast } from 'sonner'; +import { Upload, Link, Music2 as MusicIcon } from 'lucide-react'; +import { uploadAudioToR2 } from '../services/storage-service'; +import type { TrackSource } from '../types/playlist'; + +interface TrackUploadFormProps { + onUploadComplete: (track: { + name: string; + artist: string; + source: TrackSource; + uri: string; + }) => void; + onCancel: () => void; +} + +export const TrackUploadForm: React.FC = ({ + onUploadComplete, + onCancel +}) => { + const [name, setName] = useState(''); + const [artist, setArtist] = useState(''); + const [source, setSource] = useState('local'); + const [uri, setUri] = useState(''); + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + + const fileInputRef = useRef(null); + + const handleFileSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + // Auto-populate name from filename + const fileName = file.name.replace(/\.[^/.]+$/, ''); + setName(fileName); + + // Create local object URL for preview + const objectUrl = URL.createObjectURL(file); + setUri(objectUrl); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!name.trim()) { + toast.error('Please enter a track name'); + return; + } + + try { + let finalUri = uri; + let finalSource = source; + + // Handle file upload to R2 + if (source === 'local' && fileInputRef.current?.files?.[0]) { + const file = fileInputRef.current.files[0]; + setIsUploading(true); + + try { + const result = await uploadAudioToR2(file, { + onProgress: setUploadProgress + }); + finalUri = result.cdnUrl; + finalSource = 'r2'; + } catch (error) { + console.error('Upload failed:', error); + toast.error('Failed to upload file'); + setIsUploading(false); + return; + } + + setIsUploading(false); + } else if (source === 'spotify') { + // Extract Spotify URI/ID + const spotifyMatch = uri.match(/(?:spotify:track:|open\.spotify\.com\/track\/)([a-zA-Z0-9]{22})/); + finalUri = spotifyMatch ? spotifyMatch[1] : uri; + } else if (source === 'youtube') { + // Extract YouTube video ID + const ytMatch = uri.match(/(?:youtube\.com\/watch\?v=|youtube\.be\/)([a-zA-Z0-9_-]{11})/); + finalUri = ytMatch ? ytMatch[1] : uri; + } + + // Send back the track info to parent + onUploadComplete({ + name: name.trim(), + artist: artist.trim() || 'Unknown', + source: finalSource, + uri: finalUri + }); + + } catch (error) { + console.error('Error:', error); + toast.error('Something went wrong'); + } + }; + + return ( +
+
+ + setName(e.target.value)} + className="w-full p-2 border rounded-lg bg-white/80 dark:bg-gray-800/80" + placeholder="Enter track name" + required + /> +
+ +
+ + setArtist(e.target.value)} + className="w-full p-2 border rounded-lg bg-white/80 dark:bg-gray-800/80" + placeholder="Artist name" + /> +
+ +
+ +
+ + + + + + + +
+
+ + {source === 'local' ? ( +
+ + + + {isUploading && ( +
+
+
+
+

{uploadProgress}% uploaded

+
+ )} +
+ ) : ( +
+ + setUri(e.target.value)} + className="w-full p-2 border rounded-lg bg-white/80 dark:bg-gray-800/80" + placeholder={ + source === 'spotify' ? 'https://open.spotify.com/track/...' : + source === 'youtube' ? 'https://www.youtube.com/watch?v=...' : + 'https://example.com/audio.mp3' + } + required + /> +
+ )} + +
+ + +
+ + ); +}; \ No newline at end of file diff --git a/src/components/unified-player.tsx b/src/components/unified-player.tsx new file mode 100644 index 0000000..9068872 --- /dev/null +++ b/src/components/unified-player.tsx @@ -0,0 +1,401 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { motion } from 'framer-motion'; +import { + Play, + Pause, + SkipBack, + SkipForward, + Volume2, + VolumeX, + Repeat, + Shuffle, +} from 'lucide-react'; +import type { Track } from '../types/playlist'; + +interface UnifiedPlayerProps { + tracks: Track[]; + persistKey?: string; + onTrackChange?: (index: number) => void; +} + +export const UnifiedPlayer: React.FC = ({ + tracks = [], + persistKey, + onTrackChange, +}) => { + // --- Helpers --- + const getSavedPosition = (): number => { + if (!persistKey) return 0; + try + { + const saved = localStorage.getItem(`player_position_${persistKey}`); + return saved ? parseInt(saved, 10) : 0; + } catch + { + return 0; + } + }; + + // --- State --- + const [currentIndex, setCurrentIndex] = useState(getSavedPosition()); + const [isPlaying, setIsPlaying] = useState(false); + const [progress, setProgress] = useState(0); + const [duration, setDuration] = useState(0); + const [volume, setVolume] = useState(0.7); + const [isMuted, setIsMuted] = useState(false); + const [isShuffle, setIsShuffle] = useState(false); + const [isRepeat, setIsRepeat] = useState(false); + const audioRef = useRef(null); + + const noTracks = !tracks || tracks.length === 0; + const currentTrack = !noTracks ? tracks[currentIndex] || tracks[0] : null; + + // --- Hooks --- + useEffect(() => { + if (currentIndex >= tracks.length) + { + setCurrentIndex(0); + } + }, [tracks, currentIndex]); + + useEffect(() => { + if (persistKey) + { + localStorage.setItem(`player_position_${persistKey}`, currentIndex.toString()); + } + if (onTrackChange) + { + onTrackChange(currentIndex); + } + }, [currentIndex, persistKey, onTrackChange]); + + const playNext = () => { + if (tracks.length <= 1) return; + let nextIndex; + if (isShuffle) + { + let randomIndex; + do + { + randomIndex = Math.floor(Math.random() * tracks.length); + } while (tracks.length > 1 && randomIndex === currentIndex); + nextIndex = randomIndex; + } else + { + nextIndex = (currentIndex + 1) % tracks.length; + } + setCurrentIndex(nextIndex); + }; + + const playPrevious = () => { + if (tracks.length <= 1) return; + if (progress > 3 && audioRef.current) + { + audioRef.current.currentTime = 0; + setProgress(0); + return; + } + const prevIndex = (currentIndex - 1 + tracks.length) % tracks.length; + setCurrentIndex(prevIndex); + }; + + useEffect(() => { + const audio = audioRef.current; + if (!audio) return; + + const handleTimeUpdate = () => setProgress(audio.currentTime); + const handleLoadedMetadata = () => { + setDuration(audio.duration); + audio.volume = isMuted ? 0 : volume; + }; + const handleEnded = () => { + if (isRepeat) + { + audio.currentTime = 0; + void audio.play(); + } else + { + playNext(); + } + }; + + audio.addEventListener('timeupdate', handleTimeUpdate); + audio.addEventListener('loadedmetadata', handleLoadedMetadata); + audio.addEventListener('ended', handleEnded); + + return () => { + audio.removeEventListener('timeupdate', handleTimeUpdate); + audio.removeEventListener('loadedmetadata', handleLoadedMetadata); + audio.removeEventListener('ended', handleEnded); + }; + + }, [volume, isMuted, isRepeat]); + + useEffect(() => { + const audio = audioRef.current; + if (!audio || !currentTrack) return; + + + if (['local', 'r2', 'external'].includes(currentTrack.source)) + { + audio.src = currentTrack.uri; + audio.load(); + setProgress(0); + if (isPlaying) void audio.play().catch(console.error); + } + }, [currentTrack, currentIndex, isPlaying]); + + // --- Handlers --- + const togglePlayPause = () => { + if (!currentTrack) return; + + + if (['local', 'r2', 'external'].includes(currentTrack.source)) + { + const audio = audioRef.current; + if (!audio) return; + if (isPlaying) audio.pause(); + else void audio.play().catch(console.error); + } else if (currentTrack.source === 'spotify') + { + window.open( + currentTrack.uri.includes('spotify:track:') + ? `https://open.spotify.com/track/${currentTrack.uri.split(':').pop()}` + : `https://open.spotify.com/track/${currentTrack.uri}`, + '_blank' + ); + } else if (currentTrack.source === 'youtube') + { + window.open(`https://www.youtube.com/watch?v=${currentTrack.uri}`, '_blank'); + } + + setIsPlaying(!isPlaying); + }; + + const handleSeek = (e: React.ChangeEvent) => { + const seekTime = Number(e.target.value); + if (audioRef.current) + { + audioRef.current.currentTime = seekTime; + setProgress(seekTime); + } + }; + + const handleVolumeChange = (e: React.ChangeEvent) => { + const newVolume = Number(e.target.value); + setVolume(newVolume); + if (audioRef.current) audioRef.current.volume = newVolume; + setIsMuted(newVolume === 0); + }; + + const toggleMute = () => { + if (audioRef.current) audioRef.current.volume = isMuted ? volume : 0; + setIsMuted(!isMuted); + }; + + const formatTime = (seconds: number): string => { + if (isNaN(seconds) || !isFinite(seconds)) return '0:00'; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')} `; + }; + + // --- Conditional Render --- + if (noTracks) + { + return (
+ No tracks available to play
+ ); + } + + // --- UI --- + return ( + + {/* Track info */}
+ + {currentTrack?.thumbnail ? ({currentTrack.name} + ) : ( + )} + + + + < div > + + {currentTrack?.name || 'Unknown Track'} + + + + {currentTrack?.artist || 'Unknown Artist'} + + + + + {currentTrack?.source?.toUpperCase() || 'UNKNOWN'} + + +
+
+ + {/* Progress bar */} + { + ['local', 'r2', 'external'].includes(currentTrack?.source ?? '') && ( +
+ +
+ {formatTime(progress)} + {formatTime(duration)} +
+
+ ) + } + + {/* Controls */} +
+
+ setIsShuffle(!isShuffle)} + className={`p-2 rounded-full ${isShuffle + ? 'bg-blue-100 text-blue-600 dark:bg-blue-900 dark:text-blue-300' + : 'text-gray-600 dark:text-gray-400' + }`} + title={isShuffle ? 'Shuffle On' : 'Shuffle Off'} + > + + + + + + + + + {isPlaying ? : } + + + + + + + setIsRepeat(!isRepeat)} + className={`p-2 rounded-full ${isRepeat + ? 'bg-blue-100 text-blue-600 dark:bg-blue-900 dark:text-blue-300' + : 'text-gray-600 dark:text-gray-400' + }`} + title={isRepeat ? 'Repeat On' : 'Repeat Off'} + > + + +
+ +
+ + {isMuted ? : } + + + +
+
+ + { + currentTrack?.source === 'spotify' && ( +

Click play to open in Spotify

+ ) + } + { + currentTrack?.source === 'youtube' && ( +

Click play to watch on YouTube

+ ) + } + +