From 21a6d5736816d53f55c1b9fa9e8edb7c38ae450e Mon Sep 17 00:00:00 2001 From: GulfGulfinson Date: Fri, 10 Oct 2025 10:25:45 +0200 Subject: [PATCH] init commit --- .gitignore | 2 + README.md | 151 +++- package-lock.json | 782 +++++++++++++++++- package.json | 23 +- prisma/schema.prisma | 132 +++ setup.sh | 53 ++ src/app/api/auth/[...nextauth]/route.ts | 6 + src/app/api/spotify/create-playlist/route.ts | 57 ++ .../api/spotify/currently-playing/route.ts | 114 +++ src/app/api/spotify/harmony/route.ts | 37 + src/app/api/spotify/recently-played/route.ts | 60 ++ src/app/api/spotify/recommendations/route.ts | 65 ++ src/app/api/timeline/route.ts | 37 + src/app/api/users/route.ts | 38 + src/app/auth/signin/page.tsx | 124 +++ src/app/dashboard/page.tsx | 240 ++++++ src/app/globals.css | 109 ++- src/app/layout.tsx | 53 +- src/app/live/page.tsx | 285 +++++++ src/app/mix/page.tsx | 305 +++++++ src/app/page.tsx | 225 ++--- src/app/providers.tsx | 16 + src/app/settings/page.tsx | 238 ++++++ src/app/timeline/page.tsx | 293 +++++++ src/components/FloatingHearts.tsx | 56 ++ src/components/WaveAnimation.tsx | 29 + src/lib/auth.ts | 83 ++ src/lib/spotify.ts | 156 ++++ 28 files changed, 3572 insertions(+), 197 deletions(-) create mode 100644 prisma/schema.prisma create mode 100755 setup.sh create mode 100644 src/app/api/auth/[...nextauth]/route.ts create mode 100644 src/app/api/spotify/create-playlist/route.ts create mode 100644 src/app/api/spotify/currently-playing/route.ts create mode 100644 src/app/api/spotify/harmony/route.ts create mode 100644 src/app/api/spotify/recently-played/route.ts create mode 100644 src/app/api/spotify/recommendations/route.ts create mode 100644 src/app/api/timeline/route.ts create mode 100644 src/app/api/users/route.ts create mode 100644 src/app/auth/signin/page.tsx create mode 100644 src/app/dashboard/page.tsx create mode 100644 src/app/live/page.tsx create mode 100644 src/app/mix/page.tsx create mode 100644 src/app/providers.tsx create mode 100644 src/app/settings/page.tsx create mode 100644 src/app/timeline/page.tsx create mode 100644 src/components/FloatingHearts.tsx create mode 100644 src/components/WaveAnimation.tsx create mode 100644 src/lib/auth.ts create mode 100644 src/lib/spotify.ts diff --git a/.gitignore b/.gitignore index 5ef6a52..f390d12 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +/src/generated/prisma diff --git a/README.md b/README.md index e215bc4..bc89f08 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,145 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Harmony — A Shared Spotify Experience for Two 💕 -## Getting Started +A beautiful, romantic web application that allows two Spotify users to share their musical journey together. Built with Next.js, TypeScript, and Tailwind CSS. -First, run the development server: +## Features +### 🎵 Shared Timeline +- View recently played songs from both users in a beautiful timeline +- See which songs you both have listened to with special heart markers 💕 +- Track your musical journey together over time + +### 🎧 Mix Generator +- AI-powered playlist creation based on both users' musical tastes +- Uses Spotify's Recommendations API to find perfect songs for both of you +- One-click playlist creation and saving to Spotify + +### 📊 Live Dashboard +- Real-time display of what both users are currently listening to +- Harmony percentage calculation based on audio features (BPM, energy, valence) +- Beautiful animated progress bars and album covers + +### ⚙️ Settings +- Customize display names and profile pictures +- Theme selection (Rose, Purple, Blue) +- Secure user authentication with Spotify OAuth + +## Tech Stack + +- **Frontend**: Next.js 15, React 19, TypeScript, Tailwind CSS +- **Animations**: Framer Motion +- **Icons**: Lucide React +- **Backend**: Next.js API Routes +- **Database**: PostgreSQL with Prisma ORM +- **Authentication**: NextAuth.js with Spotify OAuth +- **Music API**: Spotify Web API + +## Setup Instructions + +### 1. Prerequisites +- Node.js 18+ +- PostgreSQL database +- Spotify Developer Account + +### 2. Database Setup ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +# Create PostgreSQL database +createdb spotify_app + +# Set up database user (optional, can use default) +psql -c "CREATE USER iu WITH PASSWORD 'iu';" +psql -c "GRANT ALL PRIVILEGES ON DATABASE spotify_app TO iu;" ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +### 3. Environment Configuration +Copy `.env.local` and update with your credentials: -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +```env +# Database +DATABASE_URL="postgresql://iu:iu@localhost:5432/spotify_app?schema=public" -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +# NextAuth +NEXTAUTH_URL="http://localhost:3000" +NEXTAUTH_SECRET="your-secret-key-here" -## Learn More +# Spotify OAuth +SPOTIFY_CLIENT_ID="your-spotify-client-id" +SPOTIFY_CLIENT_SECRET="your-spotify-client-secret" -To learn more about Next.js, take a look at the following resources: +# Allowed Spotify Users (comma-separated Spotify user IDs) +ALLOWED_SPOTIFY_USERS="your-spotify-user-id,partner-spotify-user-id" +``` -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +### 4. Spotify App Setup +1. Go to [Spotify Developer Dashboard](https://developer.spotify.com/dashboard) +2. Create a new app +3. Add `http://localhost:3000/api/auth/callback/spotify` to Redirect URIs +4. Copy Client ID and Client Secret to your `.env.local` -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +### 5. Installation & Setup +```bash +# Install dependencies +npm install -## Deploy on Vercel +# Generate Prisma client +npm run db:generate -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +# Push database schema +npm run db:push -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +# Start development server +npm run dev +``` + +### 6. Get Spotify User IDs +To find your Spotify user IDs: +1. Go to your Spotify profile +2. Click "Share" → "Copy link to profile" +3. The user ID is in the URL: `https://open.spotify.com/user/{USER_ID}` + +## Usage + +1. **Sign In**: Both users need to sign in with their Spotify accounts +2. **Dashboard**: View overview of both users' activity +3. **Timeline**: See shared listening history with heart markers for mutual songs +4. **Mix Generator**: Create AI-powered playlists based on both users' tastes +5. **Live Dashboard**: Real-time view of current listening activity and harmony matching +6. **Settings**: Customize your experience + +## API Endpoints + +- `GET /api/users` - Get all connected users +- `GET /api/timeline` - Get shared listening timeline +- `GET /api/spotify/recently-played` - Get user's recently played tracks +- `GET /api/spotify/currently-playing` - Get real-time listening data +- `POST /api/spotify/recommendations` - Generate music recommendations +- `POST /api/spotify/create-playlist` - Create Spotify playlist +- `POST /api/spotify/harmony` - Calculate harmony percentage + +## Security Features + +- User whitelist: Only specified Spotify users can access the app +- Secure token storage in PostgreSQL database +- Automatic token refresh handling +- Protected API routes with NextAuth session validation + +## Design Philosophy + +Harmony is designed with romance and elegance in mind: +- **Pastel color palette**: Rose, pink, and purple tones +- **Glassmorphism effects**: Beautiful frosted glass cards +- **Smooth animations**: Framer Motion for fluid interactions +- **Romantic touches**: Heart animations, floating elements, gradient text +- **Responsive design**: Works perfectly on all devices + +## Contributing + +This is a personal project, but feel free to fork and customize for your own use! + +## License + +MIT License - feel free to use this for your own romantic musical adventures! 💕 + +--- + +Made with 💕 for couples who love music together \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index de4ceff..657074c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,16 @@ "name": "harmony", "version": "0.1.0", "dependencies": { + "@prisma/client": "^6.17.0", + "@types/spotify-web-api-node": "^5.0.11", + "framer-motion": "^12.23.22", + "lucide-react": "^0.545.0", "next": "15.5.4", + "next-auth": "^4.24.11", + "prisma": "^6.17.0", "react": "19.1.0", - "react-dom": "19.1.0" + "react-dom": "19.1.0", + "spotify-web-api-node": "^5.0.2" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -36,6 +43,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@emnapi/core": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", @@ -900,6 +915,86 @@ "node": ">=12.4.0" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@prisma/client": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.17.0.tgz", + "integrity": "sha512-b42mTLOdLEZ6e/igu8CLdccAUX9AwHknQQ1+pHOftnzDP2QoyZyFvcANqSLs5ockimFKJnV7Ljf+qrhNYf6oAg==", + "hasInstallScript": true, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.17.0.tgz", + "integrity": "sha512-k8tuChKpkO/Vj7ZEzaQMNflNGbaW4X0r8+PC+W2JaqVRdiS2+ORSv1SrDwNxsb8YyzIQJucXqLGZbgxD97ZhsQ==", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.16.12", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.17.0.tgz", + "integrity": "sha512-eE2CB32nr1hRqyLVnOAVY6c//iSJ/PN+Yfoa/2sEzLGpORaCg61d+nvdAkYSh+6Y2B8L4BVyzkRMANLD6nnC2g==" + }, + "node_modules/@prisma/engines": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.17.0.tgz", + "integrity": "sha512-XhE9v3hDQTNgCYMjogcCYKi7HCEkZf9WwTGuXy8cmY8JUijvU0ap4M7pGLx4pBblkp5EwUsYzw1YLtH7yi0GZw==", + "hasInstallScript": true, + "dependencies": { + "@prisma/debug": "6.17.0", + "@prisma/engines-version": "6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a", + "@prisma/fetch-engine": "6.17.0", + "@prisma/get-platform": "6.17.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a.tgz", + "integrity": "sha512-G0VU4uFDreATgTz4sh3dTtU2C+jn+J6c060ixavWZaUaSRZsNQhSPW26lbfez7GHzR02RGCdqs5UcSuGBC3yLw==" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.17.0.tgz", + "integrity": "sha512-YSl5R3WIAPrmshYPkaaszOsBIWRAovOCHn3y7gkTNGG51LjYW4pi6PFNkGouW6CA06qeTjTbGrDRCgFjnmVWDg==", + "dependencies": { + "@prisma/debug": "6.17.0", + "@prisma/engines-version": "6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a", + "@prisma/get-platform": "6.17.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.17.0.tgz", + "integrity": "sha512-3tEKChrnlmLXPd870oiVfRvj7vVKuxqP349hYaMDsbV4TZd3+lFqw8KTI2Tbq5DopamfNuNqhVCj+R6ZxKKYGQ==", + "dependencies": { + "@prisma/debug": "6.17.0" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -912,6 +1007,11 @@ "integrity": "sha512-2ih5qGw5SZJ+2fLZxP6Lr6Na2NTIgPRL/7Kmyuw0uIyBQnuhQ8fi8fzUTd38eIQmqp+GYLC00cI6WgtqHxBwmw==", "dev": true }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1236,6 +1336,19 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/spotify-api": { + "version": "0.0.25", + "resolved": "https://registry.npmjs.org/@types/spotify-api/-/spotify-api-0.0.25.tgz", + "integrity": "sha512-okhoy0U9fPWtwqCfbDyW8VxamhqvXE0gXIVeMOh5HcvEFQvWW2X0VsvdiX/OyiGQpZbZiOJXIGrbnIPfK0AIpA==" + }, + "node_modules/@types/spotify-web-api-node": { + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/@types/spotify-web-api-node/-/spotify-web-api-node-5.0.11.tgz", + "integrity": "sha512-RS3IkSqH9geC61e8qd+Oy7giOTtiY7ywm0Z4bu5uYuc7XuOcLfDwKjmle85IbpTEdazeCgmIbo8nMLg7WDVvgw==", + "dependencies": { + "@types/spotify-api": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.46.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.0.tgz", @@ -1993,6 +2106,11 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -2054,6 +2172,33 @@ "node": ">=8" } }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -2076,7 +2221,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" @@ -2089,7 +2233,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" @@ -2145,6 +2288,20 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -2154,6 +2311,14 @@ "node": ">=18" } }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "dependencies": { + "consola": "^3.2.3" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -2177,12 +2342,57 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, + "node_modules/confbox": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", + "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2264,7 +2474,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "dependencies": { "ms": "^2.1.3" }, @@ -2283,6 +2492,14 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -2317,6 +2534,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==" + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2338,11 +2573,21 @@ "node": ">=0.10.0" } }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", @@ -2352,12 +2597,29 @@ "node": ">= 0.4" } }, + "node_modules/effect": { + "version": "3.16.12", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.16.12.tgz", + "integrity": "sha512-N39iBk0K71F9nb442TLbTkjl24FLUzuvx2i1I2RsEAQsdAdUTuUoW0vlfUXgkMTUOnYqKnWcFfqw4hK4Pw27hg==", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "engines": { + "node": ">=14" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", @@ -2443,7 +2705,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -2452,7 +2713,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -2488,7 +2748,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "dependencies": { "es-errors": "^1.3.0" }, @@ -2500,7 +2759,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", @@ -2956,6 +3214,32 @@ "node": ">=0.10.0" } }, + "node_modules/exsolve": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", + "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3002,6 +3286,11 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -3085,11 +3374,60 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/form-data": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz", + "integrity": "sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.6.tgz", + "integrity": "sha512-KcpbcpuLNOwrEjnbpMC0gS+X8ciDoZE1kkqzat4a8vrprf+s9pKNQ/QIwWfbfs4ltgmFl3MD177SNTkve3BwGQ==", + "deprecated": "Please upgrade to latest, formidable@v2 or formidable@v3! Check these notes: https://bit.ly/2ZEqIau", + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/framer-motion": { + "version": "12.23.22", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.22.tgz", + "integrity": "sha512-ZgGvdxXCw55ZYvhoZChTlG6pUuehecgvEAJz0BHoC5pQKW1EC5xf1Mul1ej5+ai+pVY0pylyFfdl45qnM1/GsA==", + "dependencies": { + "motion-dom": "^12.23.21", + "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/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -3136,7 +3474,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", @@ -3160,7 +3497,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" @@ -3198,6 +3534,22 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3242,7 +3594,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -3314,7 +3665,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -3326,7 +3676,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "dependencies": { "has-symbols": "^1.0.3" }, @@ -3341,7 +3690,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -3383,6 +3731,11 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -3806,11 +4159,18 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, "bin": { "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4175,6 +4535,30 @@ "loose-envify": "cli.js" } }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-cache/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/lucide-react": { + "version": "0.545.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.545.0.tgz", + "integrity": "sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw==", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.19", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", @@ -4188,7 +4572,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -4202,6 +4585,14 @@ "node": ">= 8" } }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -4215,6 +4606,36 @@ "node": ">=8.6" } }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -4257,11 +4678,23 @@ "node": ">= 18" } }, + "node_modules/motion-dom": { + "version": "12.23.21", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.21.tgz", + "integrity": "sha512-5xDXx/AbhrfgsQmSE7YESMn4Dpo6x5/DTZ4Iyy4xqDvVHWvFVoV+V2Ri2S/ksx+D40wrZ7gPYiMWshkdoqNgNQ==", + "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==" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/nanoid": { "version": "3.3.11", @@ -4352,6 +4785,37 @@ } } }, + "node_modules/next-auth": { + "version": "4.24.11", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.11.tgz", + "integrity": "sha512-pCFXzIDQX7xmHFs4KVH4luCjaCbuPRtZ9oBUjUhOk84mZ9WVPf94n87TxYI4rSRf9HmfHEF8Yep3JrYDVOo3Cw==", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@panva/hkdf": "^1.0.2", + "cookie": "^0.7.0", + "jose": "^4.15.5", + "oauth": "^0.9.15", + "openid-client": "^5.4.0", + "preact": "^10.6.3", + "preact-render-to-string": "^5.1.19", + "uuid": "^8.3.2" + }, + "peerDependencies": { + "@auth/core": "0.34.2", + "next": "^12.2.5 || ^13 || ^14 || ^15", + "nodemailer": "^6.6.5", + "react": "^17.0.2 || ^18 || ^19", + "react-dom": "^17.0.2 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@auth/core": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -4379,6 +4843,34 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==" + }, + "node_modules/nypm": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", + "integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.2", + "pathe": "^2.0.3", + "pkg-types": "^2.3.0", + "tinyexec": "^1.0.1" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": "^14.16.0 || >=16.10.0" + } + }, + "node_modules/oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -4388,11 +4880,18 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -4494,6 +4993,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==" + }, + "node_modules/oidc-token-hash": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.1.tgz", + "integrity": "sha512-D7EmwxJV6DsEB6vOFLrBM2OzsVgQzgPWyHlV2OOAVj772n+WTXpudC9e9u5BVKQnYwaD30Ivhi9b+4UeBcGu9g==", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4594,6 +5120,16 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4611,6 +5147,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -4648,6 +5194,26 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.27.2", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz", + "integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz", + "integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4657,6 +5223,35 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==" + }, + "node_modules/prisma": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.17.0.tgz", + "integrity": "sha512-rcvldz98r+2bVCs0MldQCBaaVJRCj9Ew4IqphLATF89OJcSzwRQpwnKXR+W2+2VjK7/o2x3ffu5+2N3Muu6Dbw==", + "hasInstallScript": true, + "dependencies": { + "@prisma/config": "6.17.0", + "@prisma/engines": "6.17.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -4677,6 +5272,35 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ] + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -4697,6 +5321,15 @@ } ] }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, "node_modules/react": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", @@ -4722,6 +5355,31 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -4854,6 +5512,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -4896,7 +5573,6 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "devOptional": true, "bin": { "semver": "bin/semver.js" }, @@ -5017,7 +5693,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", @@ -5036,7 +5711,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" @@ -5052,7 +5726,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -5070,7 +5743,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -5093,6 +5765,14 @@ "node": ">=0.10.0" } }, + "node_modules/spotify-web-api-node": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/spotify-web-api-node/-/spotify-web-api-node-5.0.2.tgz", + "integrity": "sha512-r82dRWU9PMimHvHEzL0DwEJrzFk+SMCVfq249SLt3I7EFez7R+jeoKQd+M1//QcnjqlXPs2am4DFsGk8/GCsrA==", + "dependencies": { + "superagent": "^6.1.0" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -5112,6 +5792,14 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -5262,6 +5950,28 @@ } } }, + "node_modules/superagent": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-6.1.0.tgz", + "integrity": "sha512-OUDHEssirmplo3F+1HWKUrUjvnQuA+nZI6i/JJBdXb5eq9IyEQwPyPpqND+SSsxf6TygpBEkUjISVRN4/VOpeg==", + "deprecated": "Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net", + "dependencies": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.2", + "debug": "^4.1.1", + "fast-safe-stringify": "^2.0.7", + "form-data": "^3.0.0", + "formidable": "^1.2.2", + "methods": "^1.1.2", + "mime": "^2.4.6", + "qs": "^6.9.4", + "readable-stream": "^3.6.0", + "semver": "^7.3.2" + }, + "engines": { + "node": ">= 7.0.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -5321,6 +6031,11 @@ "node": ">=18" } }, + "node_modules/tinyexec": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", + "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -5497,7 +6212,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5573,6 +6288,19 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 5bba168..0f2c797 100644 --- a/package.json +++ b/package.json @@ -6,22 +6,33 @@ "dev": "next dev --turbopack", "build": "next build --turbopack", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "db:generate": "prisma generate", + "db:push": "prisma db push", + "db:migrate": "prisma migrate dev", + "db:studio": "prisma studio" }, "dependencies": { + "@prisma/client": "^6.17.0", + "@types/spotify-web-api-node": "^5.0.11", + "framer-motion": "^12.23.22", + "lucide-react": "^0.545.0", + "next": "15.5.4", + "next-auth": "^4.24.11", + "prisma": "^6.17.0", "react": "19.1.0", "react-dom": "19.1.0", - "next": "15.5.4" + "spotify-web-api-node": "^5.0.2" }, "devDependencies": { - "typescript": "^5", + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", - "@tailwindcss/postcss": "^4", - "tailwindcss": "^4", "eslint": "^9", "eslint-config-next": "15.5.4", - "@eslint/eslintrc": "^3" + "tailwindcss": "^4", + "typescript": "^5" } } diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..ad1bd67 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,132 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(cuid()) + spotifyId String @unique + displayName String + email String + profileImage String? + accessToken String + refreshToken String + tokenExpiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relationships + recentlyPlayed RecentlyPlayed[] + currentlyPlaying CurrentlyPlaying[] + topTracks TopTrack[] + topArtists TopArtist[] + playlists Playlist[] + + @@map("users") +} + +model RecentlyPlayed { + id String @id @default(cuid()) + userId String + trackId String + trackName String + artistName String + albumName String + albumImage String? + playedAt DateTime + duration Int + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, trackId, playedAt]) + @@map("recently_played") +} + +model CurrentlyPlaying { + id String @id @default(cuid()) + userId String + trackId String? + trackName String? + artistName String? + albumName String? + albumImage String? + isPlaying Boolean @default(false) + progressMs Int? + durationMs Int? + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("currently_playing") +} + +model TopTrack { + id String @id @default(cuid()) + userId String + trackId String + trackName String + artistName String + albumName String + albumImage String? + popularity Int + timeRange String // short_term, medium_term, long_term + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("top_tracks") +} + +model TopArtist { + id String @id @default(cuid()) + userId String + artistId String + artistName String + artistImage String? + popularity Int + timeRange String // short_term, medium_term, long_term + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("top_artists") +} + +model Playlist { + id String @id @default(cuid()) + userId String + spotifyId String? // Spotify playlist ID if created + name String + description String? + imageUrl String? + isShared Boolean @default(false) + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + tracks PlaylistTrack[] + + @@map("playlists") +} + +model PlaylistTrack { + id String @id @default(cuid()) + playlistId String + trackId String + trackName String + artistName String + albumName String + albumImage String? + addedAt DateTime @default(now()) + + playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade) + + @@map("playlist_tracks") +} \ No newline at end of file diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..7ed9f8b --- /dev/null +++ b/setup.sh @@ -0,0 +1,53 @@ +#!/bin/bash + +echo "🎵 Setting up Harmony - A Shared Spotify Experience for Two" +echo "==========================================================" + +# Check if Node.js is installed +if ! command -v node &> /dev/null; then + echo "❌ Node.js is not installed. Please install Node.js 18+ first." + exit 1 +fi + +# Check if PostgreSQL is installed +if ! command -v psql &> /dev/null; then + echo "❌ PostgreSQL is not installed. Please install PostgreSQL first." + exit 1 +fi + +echo "✅ Prerequisites check passed" + +# Install dependencies +echo "📦 Installing dependencies..." +npm install + +# Generate Prisma client +echo "🔧 Generating Prisma client..." +npm run db:generate + +# Check if database exists +echo "🗄️ Setting up database..." +DB_EXISTS=$(psql -lqt | cut -d \| -f 1 | grep -w spotify_app | wc -l) + +if [ $DB_EXISTS -eq 0 ]; then + echo "Creating database 'spotify_app'..." + createdb spotify_app + echo "✅ Database created" +else + echo "✅ Database already exists" +fi + +# Push database schema +echo "📊 Pushing database schema..." +npm run db:push + +echo "" +echo "🎉 Setup complete!" +echo "" +echo "Next steps:" +echo "1. Copy .env.local and update with your Spotify credentials" +echo "2. Get your Spotify user IDs and add them to ALLOWED_SPOTIFY_USERS" +echo "3. Run 'npm run dev' to start the development server" +echo "4. Visit http://localhost:3000 to see your beautiful Harmony app!" +echo "" +echo "💕 Enjoy your musical journey together!" diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..b6149fb --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,6 @@ +import NextAuth from "next-auth" +import { authOptions } from "@/lib/auth" + +const handler = NextAuth(authOptions) + +export { handler as GET, handler as POST } diff --git a/src/app/api/spotify/create-playlist/route.ts b/src/app/api/spotify/create-playlist/route.ts new file mode 100644 index 0000000..9430843 --- /dev/null +++ b/src/app/api/spotify/create-playlist/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from "next/server" +import { getServerSession } from "next-auth" +import { authOptions } from "@/lib/auth" +import { getSpotifyService } from "@/lib/spotify" +import { PrismaClient } from "@prisma/client" + +const prisma = new PrismaClient() + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { name, description, trackIds } = await request.json() + + if (!name || !trackIds || !Array.isArray(trackIds)) { + return NextResponse.json({ error: "Missing required fields" }, { status: 400 }) + } + + // Get the current user + const user = await prisma.user.findUnique({ + where: { spotifyId: session.spotifyId } + }) + + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }) + } + + // Create playlist using Spotify API + const spotify = await getSpotifyService(user.id) + const playlist = await spotify.createPlaylist(user.spotifyId, name, description) + + // Add tracks to playlist + const trackUris = trackIds.map((id: string) => `spotify:track:${id}`) + await spotify.addTracksToPlaylist(playlist.id, trackUris) + + // Store playlist in database + const dbPlaylist = await prisma.playlist.create({ + data: { + userId: user.id, + spotifyId: playlist.id, + name: playlist.name, + description: playlist.description, + imageUrl: playlist.images[0]?.url, + isShared: true + } + }) + + return NextResponse.json({ playlist }) + } catch (error) { + console.error("Error creating playlist:", error) + return NextResponse.json({ error: "Internal server error" }, { status: 500 }) + } +} diff --git a/src/app/api/spotify/currently-playing/route.ts b/src/app/api/spotify/currently-playing/route.ts new file mode 100644 index 0000000..cf77d1e --- /dev/null +++ b/src/app/api/spotify/currently-playing/route.ts @@ -0,0 +1,114 @@ +import { NextRequest, NextResponse } from "next/server" +import { getServerSession } from "next-auth" +import { authOptions } from "@/lib/auth" +import { getSpotifyService } from "@/lib/spotify" +import { PrismaClient } from "@prisma/client" + +const prisma = new PrismaClient() + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + // Get all users and their currently playing tracks + const users = await prisma.user.findMany({ + include: { + currentlyPlaying: true + } + }) + + const currentlyPlayingTracks = [] + + for (const user of users) { + try { + const spotify = await getSpotifyService(user.id) + const currentlyPlaying = await spotify.getCurrentlyPlaying() + + if (currentlyPlaying && currentlyPlaying.item) { + // Update database with current track + await prisma.currentlyPlaying.upsert({ + where: { userId: user.id }, + update: { + trackId: currentlyPlaying.item.id, + trackName: currentlyPlaying.item.name, + artistName: currentlyPlaying.item.artists[0]?.name || "Unknown Artist", + albumName: currentlyPlaying.item.album.name, + albumImage: currentlyPlaying.item.album.images[0]?.url, + isPlaying: currentlyPlaying.is_playing, + progressMs: currentlyPlaying.progress_ms || 0, + durationMs: currentlyPlaying.item.duration_ms + }, + create: { + userId: user.id, + trackId: currentlyPlaying.item.id, + trackName: currentlyPlaying.item.name, + artistName: currentlyPlaying.item.artists[0]?.name || "Unknown Artist", + albumName: currentlyPlaying.item.album.name, + albumImage: currentlyPlaying.item.album.images[0]?.url, + isPlaying: currentlyPlaying.is_playing, + progressMs: currentlyPlaying.progress_ms || 0, + durationMs: currentlyPlaying.item.duration_ms + } + }) + + currentlyPlayingTracks.push({ + id: currentlyPlaying.item.id, + trackName: currentlyPlaying.item.name, + artistName: currentlyPlaying.item.artists[0]?.name || "Unknown Artist", + albumName: currentlyPlaying.item.album.name, + albumImage: currentlyPlaying.item.album.images[0]?.url, + isPlaying: currentlyPlaying.is_playing, + progressMs: currentlyPlaying.progress_ms || 0, + durationMs: currentlyPlaying.item.duration_ms, + user: { + displayName: user.displayName, + profileImage: user.profileImage + } + }) + } else { + // No track currently playing + currentlyPlayingTracks.push({ + id: `no-track-${user.id}`, + trackName: "No track playing", + artistName: "", + albumName: "", + albumImage: null, + isPlaying: false, + progressMs: 0, + durationMs: 0, + user: { + displayName: user.displayName, + profileImage: user.profileImage + } + }) + } + } catch (error) { + console.error(`Error fetching currently playing for user ${user.id}:`, error) + // Add placeholder for this user + currentlyPlayingTracks.push({ + id: `error-${user.id}`, + trackName: "Unable to fetch", + artistName: "", + albumName: "", + albumImage: null, + isPlaying: false, + progressMs: 0, + durationMs: 0, + user: { + displayName: user.displayName, + profileImage: user.profileImage + } + }) + } + } + + return NextResponse.json({ tracks: currentlyPlayingTracks }) + } catch (error) { + console.error("Error fetching currently playing:", error) + return NextResponse.json({ error: "Internal server error" }, { status: 500 }) + } +} diff --git a/src/app/api/spotify/harmony/route.ts b/src/app/api/spotify/harmony/route.ts new file mode 100644 index 0000000..f29ad51 --- /dev/null +++ b/src/app/api/spotify/harmony/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from "next/server" +import { getServerSession } from "next-auth" +import { authOptions } from "@/lib/auth" +import { getSpotifyService } from "@/lib/spotify" +import { PrismaClient } from "@prisma/client" + +const prisma = new PrismaClient() + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { track1Id, track2Id } = await request.json() + + if (!track1Id || !track2Id) { + return NextResponse.json({ error: "Missing track IDs" }, { status: 400 }) + } + + // Get the first user to use their Spotify service + const user = await prisma.user.findFirst() + if (!user) { + return NextResponse.json({ error: "No users found" }, { status: 404 }) + } + + const spotify = await getSpotifyService(user.id) + const harmonyPercentage = await spotify.calculateHarmony(track1Id, track2Id) + + return NextResponse.json({ harmonyPercentage }) + } catch (error) { + console.error("Error calculating harmony:", error) + return NextResponse.json({ error: "Internal server error" }, { status: 500 }) + } +} diff --git a/src/app/api/spotify/recently-played/route.ts b/src/app/api/spotify/recently-played/route.ts new file mode 100644 index 0000000..5e471d2 --- /dev/null +++ b/src/app/api/spotify/recently-played/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from "next/server" +import { getServerSession } from "next-auth" +import { authOptions } from "@/lib/auth" +import { getSpotifyService } from "@/lib/spotify" +import { PrismaClient } from "@prisma/client" + +const prisma = new PrismaClient() + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const user = await prisma.user.findUnique({ + where: { spotifyId: session.spotifyId } + }) + + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }) + } + + const spotify = await getSpotifyService(user.id) + const recentlyPlayed = await spotify.getRecentlyPlayed(50) + + // Store recently played tracks in database + const tracksToStore = recentlyPlayed.map((item: any) => ({ + userId: user.id, + trackId: item.track.id, + trackName: item.track.name, + artistName: item.track.artists[0]?.name || "Unknown Artist", + albumName: item.track.album.name, + albumImage: item.track.album.images[0]?.url, + playedAt: new Date(item.played_at), + duration: item.track.duration_ms + })) + + // Upsert recently played tracks + for (const track of tracksToStore) { + await prisma.recentlyPlayed.upsert({ + where: { + userId_trackId_playedAt: { + userId: track.userId, + trackId: track.trackId, + playedAt: track.playedAt + } + }, + update: track, + create: track + }) + } + + return NextResponse.json({ tracks: recentlyPlayed }) + } catch (error) { + console.error("Error fetching recently played:", error) + return NextResponse.json({ error: "Internal server error" }, { status: 500 }) + } +} diff --git a/src/app/api/spotify/recommendations/route.ts b/src/app/api/spotify/recommendations/route.ts new file mode 100644 index 0000000..1e801dd --- /dev/null +++ b/src/app/api/spotify/recommendations/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from "next/server" +import { getServerSession } from "next-auth" +import { authOptions } from "@/lib/auth" +import { getSpotifyService } from "@/lib/spotify" +import { PrismaClient } from "@prisma/client" + +const prisma = new PrismaClient() + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + // Get all users' top tracks and artists + const users = await prisma.user.findMany({ + include: { + topTracks: { + where: { timeRange: "medium_term" }, + take: 10 + }, + topArtists: { + where: { timeRange: "medium_term" }, + take: 10 + } + } + }) + + if (users.length < 2) { + return NextResponse.json({ error: "Need at least 2 users for recommendations" }, { status: 400 }) + } + + // Collect all seed tracks and artists + const allSeedTracks: string[] = [] + const allSeedArtists: string[] = [] + + users.forEach(user => { + user.topTracks.forEach(track => { + if (!allSeedTracks.includes(track.trackId)) { + allSeedTracks.push(track.trackId) + } + }) + user.topArtists.forEach(artist => { + if (!allSeedArtists.includes(artist.artistId)) { + allSeedArtists.push(artist.artistId) + } + }) + }) + + // Get recommendations using the first user's Spotify service + const spotify = await getSpotifyService(users[0].id) + const recommendations = await spotify.getRecommendations( + allSeedTracks.slice(0, 5), // Max 5 seed tracks + allSeedArtists.slice(0, 5), // Max 5 seed artists + 20 // Get 20 recommendations + ) + + return NextResponse.json({ tracks: recommendations }) + } catch (error) { + console.error("Error generating recommendations:", error) + return NextResponse.json({ error: "Internal server error" }, { status: 500 }) + } +} diff --git a/src/app/api/timeline/route.ts b/src/app/api/timeline/route.ts new file mode 100644 index 0000000..39fd57c --- /dev/null +++ b/src/app/api/timeline/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from "next/server" +import { getServerSession } from "next-auth" +import { authOptions } from "@/lib/auth" +import { PrismaClient } from "@prisma/client" + +const prisma = new PrismaClient() + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + // Get all recently played tracks from all users, ordered by playedAt + const tracks = await prisma.recentlyPlayed.findMany({ + include: { + user: { + select: { + displayName: true, + profileImage: true + } + } + }, + orderBy: { + playedAt: 'desc' + }, + take: 100 // Limit to last 100 tracks + }) + + return NextResponse.json({ tracks }) + } catch (error) { + console.error("Error fetching timeline:", error) + return NextResponse.json({ error: "Internal server error" }, { status: 500 }) + } +} diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts new file mode 100644 index 0000000..e1057ea --- /dev/null +++ b/src/app/api/users/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server" +import { getServerSession } from "next-auth" +import { authOptions } from "@/lib/auth" +import { PrismaClient } from "@prisma/client" + +const prisma = new PrismaClient() + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + // Get all users with their currently playing info + const users = await prisma.user.findMany({ + select: { + id: true, + displayName: true, + profileImage: true, + currentlyPlaying: { + select: { + trackName: true, + artistName: true, + albumImage: true, + isPlaying: true + } + } + } + }) + + return NextResponse.json({ users }) + } catch (error) { + console.error("Error fetching users:", error) + return NextResponse.json({ error: "Internal server error" }, { status: 500 }) + } +} diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx new file mode 100644 index 0000000..636df42 --- /dev/null +++ b/src/app/auth/signin/page.tsx @@ -0,0 +1,124 @@ +"use client" + +import { signIn, getSession } from "next-auth/react" +import { useEffect, useState } from "react" +import { motion } from "framer-motion" +import { Heart, Music, ArrowRight } from "lucide-react" +import { useRouter } from "next/navigation" + +export default function SignInPage() { + const [isLoading, setIsLoading] = useState(false) + const router = useRouter() + + useEffect(() => { + // Check if user is already signed in + getSession().then((session) => { + if (session) { + router.push("/dashboard") + } + }) + }, [router]) + + const handleSignIn = async () => { + setIsLoading(true) + try { + await signIn("spotify", { callbackUrl: "/dashboard" }) + } catch (error) { + console.error("Sign in error:", error) + } finally { + setIsLoading(false) + } + } + + return ( +
+ + {/* Logo */} + +
+
+ +
+ + + +
+
+ +

+ Welcome to Harmony +

+

+ Connect your Spotify account to start sharing beautiful musical moments + with your loved one. +

+ + + {isLoading ? ( + + ) : ( + <> + + Sign in with Spotify + + + )} + + +

+ Only authorized users can access this app +

+
+ + {/* Background decoration */} +
+ {[...Array(8)].map((_, i) => ( + + + + ))} +
+
+ ) +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..ba6e33a --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -0,0 +1,240 @@ +"use client" + +import { useSession } from "next-auth/react" +import { useEffect, useState } from "react" +import { motion } from "framer-motion" +import { useRouter } from "next/navigation" +import { Heart, Music, Users, Sparkles, Settings } from "lucide-react" +import Link from "next/link" + +interface User { + id: string + displayName: string + profileImage?: string + currentlyPlaying?: { + trackName: string + artistName: string + albumImage?: string + isPlaying: boolean + } +} + +export default function DashboardPage() { + const { data: session, status } = useSession() + const router = useRouter() + const [users, setUsers] = useState([]) + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + if (status === "unauthenticated") { + router.push("/auth/signin") + return + } + + if (session) { + fetchUsers() + } + }, [session, status, router]) + + const fetchUsers = async () => { + try { + const response = await fetch("/api/users") + const data = await response.json() + setUsers(data.users || []) + } catch (error) { + console.error("Error fetching users:", error) + } finally { + setIsLoading(false) + } + } + + if (status === "loading" || isLoading) { + return ( +
+ +
+ ) + } + + if (!session) { + return null + } + + return ( +
+ {/* Header */} + +
+
+

+ Welcome back, {session.user?.name}! +

+

+ Ready to discover your musical harmony together? +

+
+ + + + + +
+
+ + {/* Quick Stats */} + +
+
+
+ +
+
+

{users.length}

+

Connected Users

+
+
+
+ +
+
+
+ +
+
+

0

+

Shared Playlists

+
+
+
+ +
+
+
+ +
+
+

0%

+

Current Harmony

+
+
+
+
+ + {/* Main Navigation */} + + {/* Shared Timeline */} + + +
+ +
+

Shared Timeline

+

+ Discover what you both have been listening to in a beautiful timeline +

+
+ + + {/* Mix Generator */} + + +
+ +
+

Mix Generator

+

+ Create the perfect playlist for both of you +

+
+ + + {/* Live Dashboard */} + + +
+ +
+

Live Dashboard

+

+ See what you're both listening to in real-time +

+
+ +
+ + {/* Currently Playing Section */} + +

Currently Playing

+
+ {users.map((user, index) => ( + +
+ {user.profileImage ? ( + {user.displayName} + ) : ( + + )} +
+
+

{user.displayName}

+

+ {user.currentlyPlaying?.isPlaying + ? `Now playing: ${user.currentlyPlaying.trackName} by ${user.currentlyPlaying.artistName}` + : "Not currently playing" + } +

+
+
+ ))} +
+
+
+ ) +} diff --git a/src/app/globals.css b/src/app/globals.css index a2dc41e..d26ce9d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,26 +1,93 @@ -@import "tailwindcss"; +@tailwind base; +@tailwind components; +@tailwind utilities; -:root { - --background: #ffffff; - --foreground: #171717; -} - -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); -} - -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; +@layer base { + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground; } } -body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; +@layer components { + .glass-card { + @apply bg-white/20 backdrop-blur-md border border-white/30 rounded-2xl shadow-xl; + } + + .glass-card-hover { + @apply hover:bg-white/30 hover:backdrop-blur-lg transition-all duration-300 ease-out; + } + + .gradient-text { + @apply bg-gradient-to-r from-rose-400 via-pink-500 to-purple-500 bg-clip-text text-transparent; + } + + .floating-heart { + animation: float 3s ease-in-out infinite; + } + + .pulse-glow { + animation: pulse-glow 2s ease-in-out infinite; + } + + .wave-animation { + animation: wave 1.5s ease-in-out infinite; + } } + +@keyframes float { + 0%, 100% { + transform: translateY(0px); + } + 50% { + transform: translateY(-10px); + } +} + +@keyframes pulse-glow { + 0%, 100% { + box-shadow: 0 0 20px rgba(236, 72, 153, 0.3); + } + 50% { + box-shadow: 0 0 40px rgba(236, 72, 153, 0.6); + } +} + +@keyframes wave { + 0%, 100% { + transform: scaleY(1); + } + 50% { + transform: scaleY(1.5); + } +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + @apply bg-white/20 rounded-full; +} + +::-webkit-scrollbar-thumb { + @apply bg-gradient-to-b from-rose-300 to-pink-400 rounded-full; +} + +::-webkit-scrollbar-thumb:hover { + @apply from-rose-400 to-pink-500; +} + +/* Smooth transitions for all interactive elements */ +* { + transition: all 0.2s ease-out; +} + +/* Focus styles */ +*:focus { + @apply outline-none ring-2 ring-rose-300 ring-opacity-50; +} \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f7fa87e..bb3dc47 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,34 +1,37 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import "./globals.css"; +import type { Metadata } from "next" +import { Inter, Poppins } from "next/font/google" +import "./globals.css" +import Providers from "./providers" -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); +const inter = Inter({ subsets: ["latin"], variable: "--font-inter" }) +const poppins = Poppins({ + subsets: ["latin"], + weight: ["300", "400", "500", "600", "700"], + variable: "--font-poppins" +}) export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", -}; + title: "Harmony — A Shared Spotify Experience for Two", + description: "A beautiful, romantic web app for sharing Spotify experiences with your loved one", + keywords: ["spotify", "music", "couple", "romantic", "playlist", "harmony"], + authors: [{ name: "Harmony App" }], + viewport: "width=device-width, initial-scale=1", +} export default function RootLayout({ children, -}: Readonly<{ - children: React.ReactNode; -}>) { +}: { + children: React.ReactNode +}) { return ( - - - {children} + + +
+ + {children} + +
- ); -} + ) +} \ No newline at end of file diff --git a/src/app/live/page.tsx b/src/app/live/page.tsx new file mode 100644 index 0000000..cb234e0 --- /dev/null +++ b/src/app/live/page.tsx @@ -0,0 +1,285 @@ +"use client" + +import { useSession } from "next-auth/react" +import { useEffect, useState } from "react" +import { motion } from "framer-motion" +import { useRouter } from "next/navigation" +import { Heart, Music, ArrowLeft, Play, Pause, Volume2, Activity } from "lucide-react" +import Link from "next/link" +import Image from "next/image" +import WaveAnimation from "@/components/WaveAnimation" + +interface CurrentlyPlaying { + id: string + trackName: string + artistName: string + albumName: string + albumImage?: string + isPlaying: boolean + progressMs: number + durationMs: number + user: { + displayName: string + profileImage?: string + } +} + +export default function LivePage() { + const { data: session, status } = useSession() + const router = useRouter() + const [currentlyPlaying, setCurrentlyPlaying] = useState([]) + const [harmonyPercentage, setHarmonyPercentage] = useState(0) + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + if (status === "unauthenticated") { + router.push("/auth/signin") + return + } + + if (session) { + fetchCurrentlyPlaying() + // Set up polling for real-time updates + const interval = setInterval(fetchCurrentlyPlaying, 5000) // Poll every 5 seconds + return () => clearInterval(interval) + } + }, [session, status, router]) + + const fetchCurrentlyPlaying = async () => { + try { + const response = await fetch("/api/spotify/currently-playing") + const data = await response.json() + setCurrentlyPlaying(data.tracks || []) + + // Calculate harmony percentage if both users are playing + if (data.tracks && data.tracks.length === 2 && data.tracks.every((t: CurrentlyPlaying) => t.isPlaying)) { + const harmony = await calculateHarmony(data.tracks[0], data.tracks[1]) + setHarmonyPercentage(harmony) + } else { + setHarmonyPercentage(0) + } + } catch (error) { + console.error("Error fetching currently playing:", error) + } finally { + setIsLoading(false) + } + } + + const calculateHarmony = async (track1: CurrentlyPlaying, track2: CurrentlyPlaying) => { + try { + const response = await fetch("/api/spotify/harmony", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + track1Id: track1.id, + track2Id: track2.id + }) + }) + const data = await response.json() + return data.harmonyPercentage || 0 + } catch (error) { + console.error("Error calculating harmony:", error) + return 0 + } + } + + const formatTime = (ms: number) => { + const minutes = Math.floor(ms / 60000) + const seconds = Math.floor((ms % 60000) / 1000) + return `${minutes}:${seconds.toString().padStart(2, '0')}` + } + + const formatProgress = (progress: number, duration: number) => { + return (progress / duration) * 100 + } + + if (status === "loading" || isLoading) { + return ( +
+ +
+ ) + } + + if (!session) { + return null + } + + return ( +
+ {/* Header */} + +
+ + + + + +
+

Live Dashboard

+

+ See what you're both listening to in real-time +

+
+
+
+ + {/* Harmony Match */} + {harmonyPercentage > 0 && ( + +
+ +
+

Perfect Harmony!

+

+ {harmonyPercentage}% Musical Match +

+

+ You're both listening to music right now! Your tastes are perfectly aligned. +

+
+ )} + + {/* Currently Playing Cards */} + + {currentlyPlaying.map((track, index) => ( + + {/* User Info */} +
+
+ {track.user.profileImage ? ( + {track.user.displayName} + ) : ( + + )} +
+
+

{track.user.displayName}

+
+
+ + {track.isPlaying ? 'Now Playing' : 'Paused'} + +
+
+
+ + {/* Track Info */} +
+ {track.albumImage ? ( + {track.albumName} + ) : ( +
+ +
+ )} + +

{track.trackName}

+

{track.artistName}

+

{track.albumName}

+
+ + {/* Progress Bar */} + {track.isPlaying && ( +
+
+ {formatTime(track.progressMs)} + {formatTime(track.durationMs)} +
+
+ +
+
+ )} + + {/* Play/Pause Icon */} +
+
+ {track.isPlaying ? ( + + ) : ( + + )} +
+
+ + ))} + + + {/* No Active Listening */} + {currentlyPlaying.length === 0 && ( + +
+ +
+

No active listening

+

+ Start playing music on Spotify to see your live listening activity here. +

+
+ )} + + {/* Wave Animation Background */} + +
+ ) +} diff --git a/src/app/mix/page.tsx b/src/app/mix/page.tsx new file mode 100644 index 0000000..202ebcc --- /dev/null +++ b/src/app/mix/page.tsx @@ -0,0 +1,305 @@ +"use client" + +import { useSession } from "next-auth/react" +import { useEffect, useState } from "react" +import { motion } from "framer-motion" +import { useRouter } from "next/navigation" +import { Heart, Music, ArrowLeft, Play, Plus, ExternalLink, Sparkles } from "lucide-react" +import Link from "next/link" +import Image from "next/image" + +interface Track { + id: string + name: string + artists: Array<{ name: string }> + album: { + name: string + images: Array<{ url: string }> + } + external_urls: { + spotify: string + } +} + +interface Playlist { + id: string + name: string + description: string + external_urls: { + spotify: string + } +} + +export default function MixPage() { + const { data: session, status } = useSession() + const router = useRouter() + const [recommendations, setRecommendations] = useState([]) + const [isGenerating, setIsGenerating] = useState(false) + const [playlist, setPlaylist] = useState(null) + const [isCreatingPlaylist, setIsCreatingPlaylist] = useState(false) + + useEffect(() => { + if (status === "unauthenticated") { + router.push("/auth/signin") + return + } + }, [session, status, router]) + + const generateMix = async () => { + setIsGenerating(true) + try { + const response = await fetch("/api/spotify/recommendations", { + method: "POST" + }) + const data = await response.json() + setRecommendations(data.tracks || []) + } catch (error) { + console.error("Error generating mix:", error) + } finally { + setIsGenerating(false) + } + } + + const createPlaylist = async () => { + if (recommendations.length === 0) return + + setIsCreatingPlaylist(true) + try { + const response = await fetch("/api/spotify/create-playlist", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + name: "Harmony Mix 💕", + description: "A beautiful mix created for you both by Harmony", + trackIds: recommendations.map(track => track.id) + }) + }) + const data = await response.json() + setPlaylist(data.playlist) + } catch (error) { + console.error("Error creating playlist:", error) + } finally { + setIsCreatingPlaylist(false) + } + } + + if (status === "loading") { + return ( +
+ +
+ ) + } + + if (!session) { + return null + } + + return ( +
+ {/* Header */} + +
+ + + + + +
+

Mix Generator

+

+ Create the perfect playlist for both of you +

+
+
+
+ + {/* Generate Button */} + + + {isGenerating ? ( + <> + + Creating Your Mix... + + ) : ( + <> + + Make us a Mix 💽 + + + )} + + + + {/* Recommendations */} + {recommendations.length > 0 && ( + +
+

+ Your Perfect Mix ({recommendations.length} songs) +

+ {!playlist && ( + + {isCreatingPlaylist ? ( + <> + + Creating... + + ) : ( + <> + + Create Playlist + + )} + + )} +
+ + {playlist && ( + +
+ +
+

Playlist Created!

+

Your mix is ready to enjoy together

+ + Open in Spotify + + +
+ )} + +
+ {recommendations.map((track, index) => ( + +
+ {/* Album Cover */} +
+ {track.album.images[0] ? ( + {track.album.name} + ) : ( +
+ +
+ )} + + + +
+ + {/* Track Info */} +
+

+ {track.name} +

+

+ {track.artists.map(artist => artist.name).join(", ")} +

+

{track.album.name}

+
+ + {/* Spotify Link */} + + + +
+
+ ))} +
+
+ )} + + {recommendations.length === 0 && !isGenerating && ( + +
+ +
+

Ready to create your mix?

+

+ Click the button above to generate a personalized playlist based on both of your musical tastes. +

+
+ )} +
+ ) +} diff --git a/src/app/page.tsx b/src/app/page.tsx index a932894..d6c69c3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,103 +1,134 @@ -import Image from "next/image"; +"use client" -export default function Home() { +import { motion } from "framer-motion" +import { Heart, Music, Users, Sparkles } from "lucide-react" +import Link from "next/link" +import FloatingHearts from "@/components/FloatingHearts" + +export default function HomePage() { return ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - src/app/page.tsx - - . -
  2. -
  3. - Save and see your changes instantly. -
  4. -
+
+ {/* Hero Section */} + + +
+
+ +
+ + + +
+
- -
- + Connect your Spotify accounts to begin + + + + {/* Floating Hearts Animation */} +
- ); -} + ) +} \ No newline at end of file diff --git a/src/app/providers.tsx b/src/app/providers.tsx new file mode 100644 index 0000000..49786cc --- /dev/null +++ b/src/app/providers.tsx @@ -0,0 +1,16 @@ +"use client" + +import { SessionProvider } from "next-auth/react" +import { ReactNode } from "react" + +interface ProvidersProps { + children: ReactNode +} + +export default function Providers({ children }: ProvidersProps) { + return ( + + {children} + + ) +} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx new file mode 100644 index 0000000..28724c5 --- /dev/null +++ b/src/app/settings/page.tsx @@ -0,0 +1,238 @@ +"use client" + +import { useSession } from "next-auth/react" +import { useEffect, useState } from "react" +import { motion } from "framer-motion" +import { useRouter } from "next/navigation" +import { Heart, Music, ArrowLeft, User, Palette, Save } from "lucide-react" +import Link from "next/link" +import Image from "next/image" + +export default function SettingsPage() { + const { data: session, status } = useSession() + const router = useRouter() + const [isLoading, setIsLoading] = useState(true) + const [isSaving, setIsSaving] = useState(false) + const [settings, setSettings] = useState({ + displayName: "", + profileImage: "", + themeColor: "rose" + }) + + useEffect(() => { + if (status === "unauthenticated") { + router.push("/auth/signin") + return + } + + if (session) { + setSettings({ + displayName: session.user?.name || "", + profileImage: session.user?.image || "", + themeColor: "rose" + }) + setIsLoading(false) + } + }, [session, status, router]) + + const handleSave = async () => { + setIsSaving(true) + try { + // TODO: Implement settings save API + console.log("Saving settings:", settings) + // Simulate API call + await new Promise(resolve => setTimeout(resolve, 1000)) + } catch (error) { + console.error("Error saving settings:", error) + } finally { + setIsSaving(false) + } + } + + if (status === "loading" || isLoading) { + return ( +
+ +
+ ) + } + + if (!session) { + return null + } + + return ( +
+ {/* Header */} + +
+ + + + + +
+

Settings

+

+ Customize your Harmony experience +

+
+
+
+ + {/* Settings Form */} + + {/* Profile Section */} +
+
+
+ +
+

Profile

+
+ +
+ {/* Profile Image */} +
+
+ {settings.profileImage ? ( + Profile + ) : ( +
+ +
+ )} +
+
+

Profile picture from Spotify

+

+ This is automatically synced with your Spotify account +

+
+
+ + {/* Display Name */} +
+ + setSettings({ ...settings, displayName: e.target.value })} + className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-rose-300 focus:border-transparent transition-all duration-200" + placeholder="Enter your display name" + /> +
+
+
+ + {/* Theme Section */} +
+
+
+ +
+

Theme

+
+ +
+

Choose your preferred color theme

+
+ {[ + { name: "Rose", value: "rose", color: "from-rose-400 to-pink-500" }, + { name: "Purple", value: "purple", color: "from-purple-400 to-pink-500" }, + { name: "Blue", value: "blue", color: "from-blue-400 to-purple-500" } + ].map((theme) => ( + setSettings({ ...settings, themeColor: theme.value })} + className={`p-4 rounded-xl border-2 transition-all duration-200 ${ + settings.themeColor === theme.value + ? 'border-rose-300 bg-rose-50' + : 'border-gray-200 hover:border-gray-300' + }`} + > +
+

{theme.name}

+ + ))} +
+
+
+ + {/* Save Button */} + + {isSaving ? ( + <> + + Saving... + + ) : ( + <> + + Save Settings + + )} + + + + {/* App Info */} + +
+
+ +
+

Harmony

+

+ A Shared Spotify Experience for Two +

+

+ Made with 💕 for couples who love music together +

+
+
+
+ ) +} diff --git a/src/app/timeline/page.tsx b/src/app/timeline/page.tsx new file mode 100644 index 0000000..743be09 --- /dev/null +++ b/src/app/timeline/page.tsx @@ -0,0 +1,293 @@ +"use client" + +import { useSession } from "next-auth/react" +import { useEffect, useState } from "react" +import { motion } from "framer-motion" +import { useRouter } from "next/navigation" +import { Heart, Music, ArrowLeft, Calendar } from "lucide-react" +import Link from "next/link" +import Image from "next/image" + +interface Track { + id: string + trackName: string + artistName: string + albumName: string + albumImage?: string + playedAt: string + duration: number + userId: string + user: { + displayName: string + profileImage?: string + } +} + +export default function TimelinePage() { + const { data: session, status } = useSession() + const router = useRouter() + const [tracks, setTracks] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [sharedTracks, setSharedTracks] = useState>(new Set()) + + useEffect(() => { + if (status === "unauthenticated") { + router.push("/auth/signin") + return + } + + if (session) { + fetchTimeline() + } + }, [session, status, router]) + + const fetchTimeline = async () => { + try { + const response = await fetch("/api/timeline") + const data = await response.json() + setTracks(data.tracks || []) + + // Find shared tracks (songs both users have played) + const trackCounts = new Map() + data.tracks?.forEach((track: Track) => { + const key = `${track.trackName}-${track.artistName}` + trackCounts.set(key, (trackCounts.get(key) || 0) + 1) + }) + + const shared = new Set() + trackCounts.forEach((count, key) => { + if (count > 1) { + shared.add(key) + } + }) + setSharedTracks(shared) + } catch (error) { + console.error("Error fetching timeline:", error) + } finally { + setIsLoading(false) + } + } + + const formatDate = (dateString: string) => { + const date = new Date(dateString) + const now = new Date() + const diffInHours = (now.getTime() - date.getTime()) / (1000 * 60 * 60) + + if (diffInHours < 1) { + return "Just now" + } else if (diffInHours < 24) { + return `${Math.floor(diffInHours)}h ago` + } else if (diffInHours < 48) { + return "Yesterday" + } else { + return date.toLocaleDateString() + } + } + + const formatDuration = (ms: number) => { + const minutes = Math.floor(ms / 60000) + const seconds = Math.floor((ms % 60000) / 1000) + return `${minutes}:${seconds.toString().padStart(2, '0')}` + } + + if (status === "loading" || isLoading) { + return ( +
+ +
+ ) + } + + if (!session) { + return null + } + + return ( +
+ {/* Header */} + +
+ + + + + +
+

Shared Timeline

+

+ Your musical journey together +

+
+
+ + {/* Stats */} +
+
+
+
+ +
+
+

{tracks.length}

+

Total Tracks

+
+
+
+ +
+
+
+ +
+
+

{sharedTracks.size}

+

Shared Songs

+
+
+
+ +
+
+
+ +
+
+

+ {new Set(tracks.map(t => new Date(t.playedAt).toDateString())).size} +

+

Active Days

+
+
+
+
+
+ + {/* Timeline */} + + {tracks.map((track, index) => { + const isShared = sharedTracks.has(`${track.trackName}-${track.artistName}`) + + return ( + +
+ {/* Album Cover */} +
+ {track.albumImage ? ( + {track.albumName} + ) : ( +
+ +
+ )} + {isShared && ( + + + + )} +
+ + {/* Track Info */} +
+
+

+ {track.trackName} +

+ {isShared && ( + + 💕 + + )} +
+

{track.artistName}

+

{track.albumName}

+
+ + {/* User Info */} +
+
+ {track.user.profileImage ? ( + {track.user.displayName} + ) : ( + + )} +
+
+

{track.user.displayName}

+

{formatDate(track.playedAt)}

+
+
+ + {/* Duration */} +
+ {formatDuration(track.duration)} +
+
+
+ ) + })} +
+ + {tracks.length === 0 && ( + +
+ +
+

No tracks yet

+

+ Start listening to music on Spotify to see your shared timeline here. +

+
+ )} +
+ ) +} diff --git a/src/components/FloatingHearts.tsx b/src/components/FloatingHearts.tsx new file mode 100644 index 0000000..8e7ef24 --- /dev/null +++ b/src/components/FloatingHearts.tsx @@ -0,0 +1,56 @@ +"use client" + +import { motion } from "framer-motion" +import { Heart } from "lucide-react" +import { useEffect, useState } from "react" + +export default function FloatingHearts() { + const [hearts, setHearts] = useState>([]) + + useEffect(() => { + const createHeart = () => { + const newHeart = { + id: Date.now() + Math.random(), + x: Math.random() * (typeof window !== 'undefined' ? window.innerWidth : 1000), + y: typeof window !== 'undefined' ? window.innerHeight + 50 : 800, + size: Math.random() * 0.5 + 0.5 + } + + setHearts(prev => [...prev.slice(-10), newHeart]) // Keep only last 10 hearts + } + + const interval = setInterval(createHeart, 2000) + return () => clearInterval(interval) + }, []) + + return ( +
+ {hearts.map((heart) => ( + { + setHearts(prev => prev.filter(h => h.id !== heart.id)) + }} + > + + + ))} +
+ ) +} diff --git a/src/components/WaveAnimation.tsx b/src/components/WaveAnimation.tsx new file mode 100644 index 0000000..b0ba053 --- /dev/null +++ b/src/components/WaveAnimation.tsx @@ -0,0 +1,29 @@ +"use client" + +import { motion } from "framer-motion" + +export default function WaveAnimation() { + return ( +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ ) +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..95cc391 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,83 @@ +import { NextAuthOptions } from "next-auth" +import SpotifyProvider from "next-auth/providers/spotify" +import { PrismaClient } from "@prisma/client" + +const prisma = new PrismaClient() + +export const authOptions: NextAuthOptions = { + providers: [ + SpotifyProvider({ + clientId: process.env.SPOTIFY_CLIENT_ID!, + clientSecret: process.env.SPOTIFY_CLIENT_SECRET!, + authorization: { + params: { + scope: [ + "user-read-email", + "user-read-private", + "user-read-recently-played", + "user-read-currently-playing", + "user-top-read", + "playlist-read-private", + "playlist-modify-public", + "playlist-modify-private", + "user-library-read" + ].join(" ") + } + } + }) + ], + callbacks: { + async signIn({ user, account, profile }) { + // Check if user is in the allowed list + const allowedUsers = process.env.ALLOWED_SPOTIFY_USERS?.split(",") || [] + const spotifyId = profile?.id as string + + if (!allowedUsers.includes(spotifyId)) { + return false + } + + // Store or update user in database + if (account && account.access_token) { + await prisma.user.upsert({ + where: { spotifyId }, + update: { + accessToken: account.access_token, + refreshToken: account.refresh_token || "", + tokenExpiresAt: new Date(Date.now() + (account.expires_in || 3600) * 1000), + displayName: user.name || "", + email: user.email || "", + profileImage: user.image || null + }, + create: { + spotifyId, + accessToken: account.access_token, + refreshToken: account.refresh_token || "", + tokenExpiresAt: new Date(Date.now() + (account.expires_in || 3600) * 1000), + displayName: user.name || "", + email: user.email || "", + profileImage: user.image || null + } + }) + } + + return true + }, + async jwt({ token, account, user }) { + if (account && user) { + token.accessToken = account.access_token + token.refreshToken = account.refresh_token + token.spotifyId = user.id + } + return token + }, + async session({ session, token }) { + session.accessToken = token.accessToken as string + session.refreshToken = token.refreshToken as string + session.spotifyId = token.spotifyId as string + return session + } + }, + pages: { + signIn: "/auth/signin" + } +} diff --git a/src/lib/spotify.ts b/src/lib/spotify.ts new file mode 100644 index 0000000..2dffd29 --- /dev/null +++ b/src/lib/spotify.ts @@ -0,0 +1,156 @@ +import SpotifyWebApi from "spotify-web-api-node" +import { PrismaClient } from "@prisma/client" + +const prisma = new PrismaClient() + +export class SpotifyService { + private spotify: SpotifyWebApi + + constructor(accessToken: string) { + this.spotify = new SpotifyWebApi({ + accessToken + }) + } + + // Get user's recently played tracks + async getRecentlyPlayed(limit = 50) { + try { + const response = await this.spotify.getMyRecentlyPlayedTracks({ limit }) + return response.body.items + } catch (error) { + console.error("Error fetching recently played:", error) + throw error + } + } + + // Get user's currently playing track + async getCurrentlyPlaying() { + try { + const response = await this.spotify.getMyCurrentPlayingTrack() + return response.body + } catch (error) { + console.error("Error fetching currently playing:", error) + throw error + } + } + + // Get user's top tracks + async getTopTracks(timeRange: "short_term" | "medium_term" | "long_term" = "medium_term", limit = 50) { + try { + const response = await this.spotify.getMyTopTracks({ time_range: timeRange, limit }) + return response.body.items + } catch (error) { + console.error("Error fetching top tracks:", error) + throw error + } + } + + // Get user's top artists + async getTopArtists(timeRange: "short_term" | "medium_term" | "long_term" = "medium_term", limit = 50) { + try { + const response = await this.spotify.getMyTopArtists({ time_range: timeRange, limit }) + return response.body.items + } catch (error) { + console.error("Error fetching top artists:", error) + throw error + } + } + + // Get track audio features + async getTrackFeatures(trackId: string) { + try { + const response = await this.spotify.getAudioFeaturesForTrack(trackId) + return response.body + } catch (error) { + console.error("Error fetching track features:", error) + throw error + } + } + + // Get recommendations based on seed tracks/artists + async getRecommendations(seedTracks: string[], seedArtists: string[], limit = 20) { + try { + const response = await this.spotify.getRecommendations({ + seed_tracks: seedTracks.slice(0, 5), // Max 5 seed tracks + seed_artists: seedArtists.slice(0, 5), // Max 5 seed artists + limit, + target_energy: 0.5, + target_valence: 0.5, + target_danceability: 0.5 + }) + return response.body.tracks + } catch (error) { + console.error("Error fetching recommendations:", error) + throw error + } + } + + // Create a playlist + async createPlaylist(userId: string, name: string, description?: string) { + try { + const response = await this.spotify.createPlaylist(userId, { + name, + description, + public: false + }) + return response.body + } catch (error) { + console.error("Error creating playlist:", error) + throw error + } + } + + // Add tracks to playlist + async addTracksToPlaylist(playlistId: string, trackUris: string[]) { + try { + const response = await this.spotify.addTracksToPlaylist(playlistId, trackUris) + return response.body + } catch (error) { + console.error("Error adding tracks to playlist:", error) + throw error + } + } + + // Calculate harmony percentage between two tracks + async calculateHarmony(track1Id: string, track2Id: string) { + try { + const [features1, features2] = await Promise.all([ + this.getTrackFeatures(track1Id), + this.getTrackFeatures(track2Id) + ]) + + // Calculate similarity based on audio features + const energyDiff = Math.abs(features1.energy - features2.energy) + const valenceDiff = Math.abs(features1.valence - features2.valence) + const danceabilityDiff = Math.abs(features1.danceability - features2.danceability) + const tempoDiff = Math.abs(features1.tempo - features2.tempo) / 200 // Normalize tempo difference + + // Calculate harmony percentage (higher is more harmonious) + const harmony = Math.max(0, 100 - (energyDiff + valenceDiff + danceabilityDiff + tempoDiff) * 25) + + return Math.round(harmony) + } catch (error) { + console.error("Error calculating harmony:", error) + return 0 + } + } +} + +// Helper function to get Spotify service for a user +export async function getSpotifyService(userId: string) { + const user = await prisma.user.findUnique({ + where: { id: userId } + }) + + if (!user) { + throw new Error("User not found") + } + + // Check if token is expired and refresh if needed + if (user.tokenExpiresAt < new Date()) { + // TODO: Implement token refresh logic + throw new Error("Token expired") + } + + return new SpotifyService(user.accessToken) +}