init commit
This commit is contained in:
parent
7f9f878362
commit
21a6d57368
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -39,3 +39,5 @@ yarn-error.log*
|
|||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
/src/generated/prisma
|
||||
|
|
|
|||
151
README.md
151
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
|
||||
782
package-lock.json
generated
782
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
132
prisma/schema.prisma
Normal file
132
prisma/schema.prisma
Normal file
|
|
@ -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")
|
||||
}
|
||||
53
setup.sh
Executable file
53
setup.sh
Executable file
|
|
@ -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!"
|
||||
6
src/app/api/auth/[...nextauth]/route.ts
Normal file
6
src/app/api/auth/[...nextauth]/route.ts
Normal file
|
|
@ -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 }
|
||||
57
src/app/api/spotify/create-playlist/route.ts
Normal file
57
src/app/api/spotify/create-playlist/route.ts
Normal file
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
114
src/app/api/spotify/currently-playing/route.ts
Normal file
114
src/app/api/spotify/currently-playing/route.ts
Normal file
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
37
src/app/api/spotify/harmony/route.ts
Normal file
37
src/app/api/spotify/harmony/route.ts
Normal file
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
60
src/app/api/spotify/recently-played/route.ts
Normal file
60
src/app/api/spotify/recently-played/route.ts
Normal file
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
65
src/app/api/spotify/recommendations/route.ts
Normal file
65
src/app/api/spotify/recommendations/route.ts
Normal file
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
37
src/app/api/timeline/route.ts
Normal file
37
src/app/api/timeline/route.ts
Normal file
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
38
src/app/api/users/route.ts
Normal file
38
src/app/api/users/route.ts
Normal file
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
124
src/app/auth/signin/page.tsx
Normal file
124
src/app/auth/signin/page.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center p-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="glass-card p-12 max-w-md w-full text-center"
|
||||
>
|
||||
{/* Logo */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<div className="relative inline-block">
|
||||
<div className="w-20 h-20 bg-gradient-to-br from-rose-400 to-pink-500 rounded-full flex items-center justify-center shadow-2xl pulse-glow">
|
||||
<Heart className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 20, repeat: Infinity, ease: "linear" }}
|
||||
className="absolute -top-2 -right-2 w-6 h-6 bg-gradient-to-br from-purple-400 to-pink-400 rounded-full flex items-center justify-center"
|
||||
>
|
||||
<Music className="w-3 h-3 text-white" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<h1 className="text-4xl font-bold gradient-text mb-4">
|
||||
Welcome to Harmony
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-8 leading-relaxed">
|
||||
Connect your Spotify account to start sharing beautiful musical moments
|
||||
with your loved one.
|
||||
</p>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={handleSignIn}
|
||||
disabled={isLoading}
|
||||
className="w-full bg-gradient-to-r from-green-500 to-green-600 text-white py-4 px-8 rounded-full text-lg font-semibold shadow-xl hover:shadow-2xl transition-all duration-300 flex items-center justify-center gap-3 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
className="w-5 h-5 border-2 border-white border-t-transparent rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Music className="w-5 h-5" />
|
||||
Sign in with Spotify
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
|
||||
<p className="text-sm text-gray-500 mt-6">
|
||||
Only authorized users can access this app
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Background decoration */}
|
||||
<div className="fixed inset-0 pointer-events-none overflow-hidden">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="absolute text-rose-200/30"
|
||||
initial={{
|
||||
x: Math.random() * (typeof window !== 'undefined' ? window.innerWidth : 1000),
|
||||
y: Math.random() * (typeof window !== 'undefined' ? window.innerHeight : 800),
|
||||
scale: Math.random() * 0.5 + 0.5
|
||||
}}
|
||||
animate={{
|
||||
y: [null, -50],
|
||||
opacity: [0.3, 0, 0.3]
|
||||
}}
|
||||
transition={{
|
||||
duration: Math.random() * 8 + 8,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
>
|
||||
<Heart className="w-6 h-6" />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
240
src/app/dashboard/page.tsx
Normal file
240
src/app/dashboard/page.tsx
Normal file
|
|
@ -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<User[]>([])
|
||||
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 (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
||||
className="w-16 h-16 border-4 border-rose-300 border-t-rose-500 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-8">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="mb-12"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold gradient-text mb-2">
|
||||
Welcome back, {session.user?.name}!
|
||||
</h1>
|
||||
<p className="text-gray-600 text-lg">
|
||||
Ready to discover your musical harmony together?
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/settings">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="glass-card glass-card-hover p-3 rounded-full"
|
||||
>
|
||||
<Settings className="w-6 h-6 text-gray-600" />
|
||||
</motion.button>
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="grid md:grid-cols-3 gap-6 mb-12"
|
||||
>
|
||||
<div className="glass-card glass-card-hover p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-rose-300 to-pink-400 rounded-xl flex items-center justify-center">
|
||||
<Users className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-800">{users.length}</p>
|
||||
<p className="text-gray-600">Connected Users</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-card glass-card-hover p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-purple-300 to-pink-400 rounded-xl flex items-center justify-center">
|
||||
<Music className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-800">0</p>
|
||||
<p className="text-gray-600">Shared Playlists</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-card glass-card-hover p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-pink-300 to-rose-400 rounded-xl flex items-center justify-center">
|
||||
<Heart className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-800">0%</p>
|
||||
<p className="text-gray-600">Current Harmony</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Main Navigation */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.4 }}
|
||||
className="grid md:grid-cols-3 gap-8 mb-12"
|
||||
>
|
||||
{/* Shared Timeline */}
|
||||
<Link href="/timeline">
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.02, y: -5 }}
|
||||
className="glass-card glass-card-hover p-8 text-center group cursor-pointer"
|
||||
>
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-rose-300 to-pink-400 rounded-2xl flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||
<Users className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold text-gray-800 mb-4">Shared Timeline</h3>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
Discover what you both have been listening to in a beautiful timeline
|
||||
</p>
|
||||
</motion.div>
|
||||
</Link>
|
||||
|
||||
{/* Mix Generator */}
|
||||
<Link href="/mix">
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.02, y: -5 }}
|
||||
className="glass-card glass-card-hover p-8 text-center group cursor-pointer"
|
||||
>
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-purple-300 to-pink-400 rounded-2xl flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||
<Music className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold text-gray-800 mb-4">Mix Generator</h3>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
Create the perfect playlist for both of you
|
||||
</p>
|
||||
</motion.div>
|
||||
</Link>
|
||||
|
||||
{/* Live Dashboard */}
|
||||
<Link href="/live">
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.02, y: -5 }}
|
||||
className="glass-card glass-card-hover p-8 text-center group cursor-pointer"
|
||||
>
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-pink-300 to-rose-400 rounded-2xl flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||
<Sparkles className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold text-gray-800 mb-4">Live Dashboard</h3>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
See what you're both listening to in real-time
|
||||
</p>
|
||||
</motion.div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
{/* Currently Playing Section */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.6 }}
|
||||
className="glass-card p-8"
|
||||
>
|
||||
<h2 className="text-2xl font-semibold text-gray-800 mb-6">Currently Playing</h2>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{users.map((user, index) => (
|
||||
<motion.div
|
||||
key={user.id}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.8 + index * 0.1 }}
|
||||
className="flex items-center gap-4 p-4 bg-white/20 rounded-xl"
|
||||
>
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-rose-300 to-pink-400 rounded-full flex items-center justify-center">
|
||||
{user.profileImage ? (
|
||||
<img
|
||||
src={user.profileImage}
|
||||
alt={user.displayName}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Users className="w-6 h-6 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-gray-800">{user.displayName}</p>
|
||||
<p className="text-gray-600">
|
||||
{user.currentlyPlaying?.isPlaying
|
||||
? `Now playing: ${user.currentlyPlaying.trackName} by ${user.currentlyPlaying.artistName}`
|
||||
: "Not currently playing"
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,26 +1,93 @@
|
|||
@import "tailwindcss";
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
@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;
|
||||
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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" })
|
||||
const poppins = Poppins({
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
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 (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<html lang="en" className={`${inter.variable} ${poppins.variable}`}>
|
||||
<body className="font-poppins bg-gradient-to-br from-rose-50 via-pink-50 to-purple-50 min-h-screen">
|
||||
<div className="min-h-screen bg-gradient-to-br from-rose-50/80 via-pink-50/80 to-purple-50/80 backdrop-blur-sm">
|
||||
<Providers>
|
||||
{children}
|
||||
</Providers>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
)
|
||||
}
|
||||
285
src/app/live/page.tsx
Normal file
285
src/app/live/page.tsx
Normal file
|
|
@ -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<CurrentlyPlaying[]>([])
|
||||
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 (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
||||
className="w-16 h-16 border-4 border-rose-300 border-t-rose-500 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-8">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link href="/dashboard">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="glass-card glass-card-hover p-3 rounded-full"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-gray-600" />
|
||||
</motion.button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold gradient-text mb-2">Live Dashboard</h1>
|
||||
<p className="text-gray-600 text-lg">
|
||||
See what you're both listening to in real-time
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Harmony Match */}
|
||||
{harmonyPercentage > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="glass-card glass-card-hover p-8 mb-8 text-center"
|
||||
>
|
||||
<div className="w-20 h-20 bg-gradient-to-br from-rose-400 to-pink-500 rounded-full flex items-center justify-center mx-auto mb-4 pulse-glow">
|
||||
<Heart className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-800 mb-2">Perfect Harmony!</h2>
|
||||
<p className="text-2xl font-semibold gradient-text mb-4">
|
||||
{harmonyPercentage}% Musical Match
|
||||
</p>
|
||||
<p className="text-gray-600">
|
||||
You're both listening to music right now! Your tastes are perfectly aligned.
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Currently Playing Cards */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="grid md:grid-cols-2 gap-8"
|
||||
>
|
||||
{currentlyPlaying.map((track, index) => (
|
||||
<motion.div
|
||||
key={track.id}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.6, delay: index * 0.1 }}
|
||||
className="glass-card glass-card-hover p-8"
|
||||
>
|
||||
{/* User Info */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-rose-300 to-pink-400 rounded-full flex items-center justify-center">
|
||||
{track.user.profileImage ? (
|
||||
<Image
|
||||
src={track.user.profileImage}
|
||||
alt={track.user.displayName}
|
||||
width={48}
|
||||
height={48}
|
||||
className="rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Music className="w-6 h-6 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-gray-800">{track.user.displayName}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${track.isPlaying ? 'bg-green-500' : 'bg-gray-400'}`} />
|
||||
<span className="text-sm text-gray-600">
|
||||
{track.isPlaying ? 'Now Playing' : 'Paused'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Track Info */}
|
||||
<div className="text-center mb-6">
|
||||
{track.albumImage ? (
|
||||
<Image
|
||||
src={track.albumImage}
|
||||
alt={track.albumName}
|
||||
width={200}
|
||||
height={200}
|
||||
className="rounded-2xl shadow-2xl mx-auto mb-4"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-48 h-48 bg-gradient-to-br from-gray-300 to-gray-400 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<Music className="w-16 h-16 text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h4 className="text-2xl font-bold text-gray-800 mb-2">{track.trackName}</h4>
|
||||
<p className="text-xl text-gray-600 mb-1">{track.artistName}</p>
|
||||
<p className="text-gray-500">{track.albumName}</p>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{track.isPlaying && (
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between text-sm text-gray-500 mb-2">
|
||||
<span>{formatTime(track.progressMs)}</span>
|
||||
<span>{formatTime(track.durationMs)}</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<motion.div
|
||||
className="bg-gradient-to-r from-rose-400 to-pink-500 h-2 rounded-full"
|
||||
style={{
|
||||
width: `${formatProgress(track.progressMs, track.durationMs)}%`
|
||||
}}
|
||||
animate={{
|
||||
width: `${formatProgress(track.progressMs, track.durationMs)}%`
|
||||
}}
|
||||
transition={{ duration: 1 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Play/Pause Icon */}
|
||||
<div className="flex justify-center">
|
||||
<div className={`w-16 h-16 rounded-full flex items-center justify-center ${
|
||||
track.isPlaying
|
||||
? 'bg-gradient-to-br from-rose-400 to-pink-500'
|
||||
: 'bg-gray-300'
|
||||
}`}>
|
||||
{track.isPlaying ? (
|
||||
<Pause className="w-8 h-8 text-white" />
|
||||
) : (
|
||||
<Play className="w-8 h-8 text-white ml-1" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* No Active Listening */}
|
||||
{currentlyPlaying.length === 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-center py-16"
|
||||
>
|
||||
<div className="w-24 h-24 bg-gradient-to-br from-rose-300 to-pink-400 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<Activity className="w-12 h-12 text-white" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold text-gray-800 mb-4">No active listening</h3>
|
||||
<p className="text-gray-600 max-w-md mx-auto">
|
||||
Start playing music on Spotify to see your live listening activity here.
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Wave Animation Background */}
|
||||
<WaveAnimation />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
305
src/app/mix/page.tsx
Normal file
305
src/app/mix/page.tsx
Normal file
|
|
@ -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<Track[]>([])
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [playlist, setPlaylist] = useState<Playlist | null>(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 (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
||||
className="w-16 h-16 border-4 border-rose-300 border-t-rose-500 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-8">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link href="/dashboard">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="glass-card glass-card-hover p-3 rounded-full"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-gray-600" />
|
||||
</motion.button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold gradient-text mb-2">Mix Generator</h1>
|
||||
<p className="text-gray-600 text-lg">
|
||||
Create the perfect playlist for both of you
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Generate Button */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={generateMix}
|
||||
disabled={isGenerating}
|
||||
className="bg-gradient-to-r from-rose-400 to-pink-500 text-white px-12 py-6 rounded-full text-2xl font-semibold shadow-2xl hover:shadow-3xl transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-4 mx-auto"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
className="w-8 h-8 border-2 border-white border-t-transparent rounded-full"
|
||||
/>
|
||||
Creating Your Mix...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Music className="w-8 h-8" />
|
||||
Make us a Mix 💽
|
||||
<Sparkles className="w-8 h-8" />
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
|
||||
{/* Recommendations */}
|
||||
{recommendations.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.4 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-semibold text-gray-800">
|
||||
Your Perfect Mix ({recommendations.length} songs)
|
||||
</h2>
|
||||
{!playlist && (
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={createPlaylist}
|
||||
disabled={isCreatingPlaylist}
|
||||
className="bg-gradient-to-r from-green-500 to-green-600 text-white px-6 py-3 rounded-full font-semibold shadow-lg hover:shadow-xl transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isCreatingPlaylist ? (
|
||||
<>
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
className="w-4 h-4 border-2 border-white border-t-transparent rounded-full"
|
||||
/>
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create Playlist
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{playlist && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="glass-card glass-card-hover p-6 mb-6 text-center"
|
||||
>
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-green-400 to-green-500 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Play className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-2">Playlist Created!</h3>
|
||||
<p className="text-gray-600 mb-4">Your mix is ready to enjoy together</p>
|
||||
<a
|
||||
href={playlist.external_urls.spotify}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 bg-gradient-to-r from-green-500 to-green-600 text-white px-6 py-3 rounded-full font-semibold shadow-lg hover:shadow-xl transition-all duration-300"
|
||||
>
|
||||
Open in Spotify
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4">
|
||||
{recommendations.map((track, index) => (
|
||||
<motion.div
|
||||
key={track.id}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.6, delay: index * 0.05 }}
|
||||
className="glass-card glass-card-hover p-6"
|
||||
>
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Album Cover */}
|
||||
<div className="relative">
|
||||
{track.album.images[0] ? (
|
||||
<Image
|
||||
src={track.album.images[0].url}
|
||||
alt={track.album.name}
|
||||
width={80}
|
||||
height={80}
|
||||
className="rounded-xl shadow-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-20 h-20 bg-gradient-to-br from-gray-300 to-gray-400 rounded-xl flex items-center justify-center">
|
||||
<Music className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
)}
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.1, 1] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
className="absolute -top-2 -right-2 w-6 h-6 bg-gradient-to-br from-rose-400 to-pink-500 rounded-full flex items-center justify-center shadow-lg"
|
||||
>
|
||||
<Heart className="w-3 h-3 text-white" />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Track Info */}
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-1">
|
||||
{track.name}
|
||||
</h3>
|
||||
<p className="text-gray-600 text-lg mb-1">
|
||||
{track.artists.map(artist => artist.name).join(", ")}
|
||||
</p>
|
||||
<p className="text-gray-500">{track.album.name}</p>
|
||||
</div>
|
||||
|
||||
{/* Spotify Link */}
|
||||
<a
|
||||
href={track.external_urls.spotify}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="glass-card glass-card-hover p-3 rounded-full"
|
||||
>
|
||||
<ExternalLink className="w-5 h-5 text-gray-600" />
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{recommendations.length === 0 && !isGenerating && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-center py-16"
|
||||
>
|
||||
<div className="w-24 h-24 bg-gradient-to-br from-rose-300 to-pink-400 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<Music className="w-12 h-12 text-white" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold text-gray-800 mb-4">Ready to create your mix?</h3>
|
||||
<p className="text-gray-600 max-w-md mx-auto">
|
||||
Click the button above to generate a personalized playlist based on both of your musical tastes.
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
223
src/app/page.tsx
223
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 (
|
||||
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
|
||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
|
||||
<li className="mb-2 tracking-[-.01em]">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
|
||||
src/app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li className="tracking-[-.01em]">
|
||||
Save and see your changes instantly.
|
||||
</li>
|
||||
</ol>
|
||||
<div className="min-h-screen flex flex-col items-center justify-center p-8">
|
||||
{/* Hero Section */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<div className="relative inline-block">
|
||||
<div className="w-24 h-24 bg-gradient-to-br from-rose-400 to-pink-500 rounded-full flex items-center justify-center shadow-2xl pulse-glow">
|
||||
<Heart className="w-12 h-12 text-white" />
|
||||
</div>
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 20, repeat: Infinity, ease: "linear" }}
|
||||
className="absolute -top-2 -right-2 w-8 h-8 bg-gradient-to-br from-purple-400 to-pink-400 rounded-full flex items-center justify-center"
|
||||
>
|
||||
<Music className="w-4 h-4 text-white" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<h1 className="text-6xl md:text-7xl font-bold gradient-text mb-6">
|
||||
Harmony
|
||||
</h1>
|
||||
<p className="text-xl md:text-2xl text-gray-600 mb-4 font-light">
|
||||
A Shared Spotify Experience for Two
|
||||
</p>
|
||||
<p className="text-lg text-gray-500 max-w-2xl mx-auto leading-relaxed">
|
||||
Create beautiful musical memories together. Share your favorite songs,
|
||||
discover new music, and find your perfect harmony.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Features Grid */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.4 }}
|
||||
className="grid md:grid-cols-3 gap-8 max-w-6xl w-full mb-16"
|
||||
>
|
||||
{/* Shared Timeline */}
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05, y: -5 }}
|
||||
className="glass-card glass-card-hover p-8 text-center group"
|
||||
>
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-rose-300 to-pink-400 rounded-2xl flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||
<Users className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold text-gray-800 mb-4">Shared Timeline</h3>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
See what you both have been listening to in a beautiful, romantic timeline.
|
||||
Discover songs you both love with special heart markers.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Mix Generator */}
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05, y: -5 }}
|
||||
className="glass-card glass-card-hover p-8 text-center group"
|
||||
>
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-purple-300 to-pink-400 rounded-2xl flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||
<Music className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold text-gray-800 mb-4">Mix Generator</h3>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
Let our AI create the perfect playlist for both of you based on your
|
||||
musical tastes and preferences.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Live Dashboard */}
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05, y: -5 }}
|
||||
className="glass-card glass-card-hover p-8 text-center group"
|
||||
>
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-pink-300 to-rose-400 rounded-2xl flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||
<Sparkles className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold text-gray-800 mb-4">Live Dashboard</h3>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
See what you're both listening to in real-time and discover your
|
||||
musical harmony percentage.
|
||||
</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* CTA Section */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.6 }}
|
||||
className="text-center"
|
||||
>
|
||||
<Link href="/auth/signin">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="bg-gradient-to-r from-rose-400 to-pink-500 text-white px-12 py-4 rounded-full text-xl font-semibold shadow-2xl hover:shadow-3xl transition-all duration-300"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
Start Your Musical Journey Together
|
||||
</motion.button>
|
||||
</Link>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 1 }}
|
||||
className="text-gray-500 mt-6 text-sm"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
Connect your Spotify accounts to begin
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
{/* Floating Hearts Animation */}
|
||||
<FloatingHearts />
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
16
src/app/providers.tsx
Normal file
16
src/app/providers.tsx
Normal file
|
|
@ -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 (
|
||||
<SessionProvider>
|
||||
{children}
|
||||
</SessionProvider>
|
||||
)
|
||||
}
|
||||
238
src/app/settings/page.tsx
Normal file
238
src/app/settings/page.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
||||
className="w-16 h-16 border-4 border-rose-300 border-t-rose-500 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-8">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link href="/dashboard">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="glass-card glass-card-hover p-3 rounded-full"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-gray-600" />
|
||||
</motion.button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold gradient-text mb-2">Settings</h1>
|
||||
<p className="text-gray-600 text-lg">
|
||||
Customize your Harmony experience
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Settings Form */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="max-w-2xl"
|
||||
>
|
||||
{/* Profile Section */}
|
||||
<div className="glass-card glass-card-hover p-8 mb-8">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-rose-300 to-pink-400 rounded-xl flex items-center justify-center">
|
||||
<User className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold text-gray-800">Profile</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Profile Image */}
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="relative">
|
||||
{settings.profileImage ? (
|
||||
<Image
|
||||
src={settings.profileImage}
|
||||
alt="Profile"
|
||||
width={80}
|
||||
height={80}
|
||||
className="rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-20 h-20 bg-gradient-to-br from-rose-300 to-pink-400 rounded-full flex items-center justify-center">
|
||||
<User className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-600 mb-2">Profile picture from Spotify</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
This is automatically synced with your Spotify account
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Display Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.displayName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme Section */}
|
||||
<div className="glass-card glass-card-hover p-8 mb-8">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-purple-300 to-pink-400 rounded-xl flex items-center justify-center">
|
||||
<Palette className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold text-gray-800">Theme</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600">Choose your preferred color theme</p>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[
|
||||
{ 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) => (
|
||||
<motion.button
|
||||
key={theme.value}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => 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'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-8 h-8 bg-gradient-to-r ${theme.color} rounded-full mx-auto mb-2`} />
|
||||
<p className="text-sm font-medium text-gray-700">{theme.name}</p>
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="w-full bg-gradient-to-r from-rose-400 to-pink-500 text-white py-4 px-8 rounded-full text-lg font-semibold shadow-xl hover:shadow-2xl transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-3"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
className="w-5 h-5 border-2 border-white border-t-transparent rounded-full"
|
||||
/>
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-5 h-5" />
|
||||
Save Settings
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
|
||||
{/* App Info */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.4 }}
|
||||
className="mt-12 text-center"
|
||||
>
|
||||
<div className="glass-card glass-card-hover p-6 max-w-md mx-auto">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-rose-300 to-pink-400 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Heart className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-2">Harmony</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
A Shared Spotify Experience for Two
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Made with 💕 for couples who love music together
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
293
src/app/timeline/page.tsx
Normal file
293
src/app/timeline/page.tsx
Normal file
|
|
@ -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<Track[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [sharedTracks, setSharedTracks] = useState<Set<string>>(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<string, number>()
|
||||
data.tracks?.forEach((track: Track) => {
|
||||
const key = `${track.trackName}-${track.artistName}`
|
||||
trackCounts.set(key, (trackCounts.get(key) || 0) + 1)
|
||||
})
|
||||
|
||||
const shared = new Set<string>()
|
||||
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 (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
||||
className="w-16 h-16 border-4 border-rose-300 border-t-rose-500 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-8">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link href="/dashboard">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="glass-card glass-card-hover p-3 rounded-full"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-gray-600" />
|
||||
</motion.button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold gradient-text mb-2">Shared Timeline</h1>
|
||||
<p className="text-gray-600 text-lg">
|
||||
Your musical journey together
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
<div className="glass-card glass-card-hover p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-rose-300 to-pink-400 rounded-xl flex items-center justify-center">
|
||||
<Music className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-800">{tracks.length}</p>
|
||||
<p className="text-gray-600">Total Tracks</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-card glass-card-hover p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-purple-300 to-pink-400 rounded-xl flex items-center justify-center">
|
||||
<Heart className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-800">{sharedTracks.size}</p>
|
||||
<p className="text-gray-600">Shared Songs</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-card glass-card-hover p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-pink-300 to-rose-400 rounded-xl flex items-center justify-center">
|
||||
<Calendar className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-800">
|
||||
{new Set(tracks.map(t => new Date(t.playedAt).toDateString())).size}
|
||||
</p>
|
||||
<p className="text-gray-600">Active Days</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Timeline */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
{tracks.map((track, index) => {
|
||||
const isShared = sharedTracks.has(`${track.trackName}-${track.artistName}`)
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={`${track.id}-${track.playedAt}`}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.6, delay: index * 0.05 }}
|
||||
className={`glass-card glass-card-hover p-6 ${
|
||||
isShared ? 'ring-2 ring-rose-300 ring-opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Album Cover */}
|
||||
<div className="relative">
|
||||
{track.albumImage ? (
|
||||
<Image
|
||||
src={track.albumImage}
|
||||
alt={track.albumName}
|
||||
width={80}
|
||||
height={80}
|
||||
className="rounded-xl shadow-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-20 h-20 bg-gradient-to-br from-gray-300 to-gray-400 rounded-xl flex items-center justify-center">
|
||||
<Music className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
)}
|
||||
{isShared && (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="absolute -top-2 -right-2 w-6 h-6 bg-gradient-to-br from-rose-400 to-pink-500 rounded-full flex items-center justify-center shadow-lg"
|
||||
>
|
||||
<Heart className="w-3 h-3 text-white" />
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Track Info */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-xl font-semibold text-gray-800">
|
||||
{track.trackName}
|
||||
</h3>
|
||||
{isShared && (
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.2, 1] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
className="text-rose-500"
|
||||
>
|
||||
💕
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-600 text-lg mb-1">{track.artistName}</p>
|
||||
<p className="text-gray-500">{track.albumName}</p>
|
||||
</div>
|
||||
|
||||
{/* User Info */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-rose-300 to-pink-400 rounded-full flex items-center justify-center">
|
||||
{track.user.profileImage ? (
|
||||
<Image
|
||||
src={track.user.profileImage}
|
||||
alt={track.user.displayName}
|
||||
width={40}
|
||||
height={40}
|
||||
className="rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Music className="w-5 h-5 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium text-gray-800">{track.user.displayName}</p>
|
||||
<p className="text-sm text-gray-500">{formatDate(track.playedAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div className="text-gray-500 text-sm">
|
||||
{formatDuration(track.duration)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</motion.div>
|
||||
|
||||
{tracks.length === 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-center py-16"
|
||||
>
|
||||
<div className="w-24 h-24 bg-gradient-to-br from-rose-300 to-pink-400 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<Music className="w-12 h-12 text-white" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold text-gray-800 mb-4">No tracks yet</h3>
|
||||
<p className="text-gray-600 max-w-md mx-auto">
|
||||
Start listening to music on Spotify to see your shared timeline here.
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
src/components/FloatingHearts.tsx
Normal file
56
src/components/FloatingHearts.tsx
Normal file
|
|
@ -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<Array<{ id: number; x: number; y: number; size: number }>>([])
|
||||
|
||||
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 (
|
||||
<div className="fixed inset-0 pointer-events-none overflow-hidden z-0">
|
||||
{hearts.map((heart) => (
|
||||
<motion.div
|
||||
key={heart.id}
|
||||
className="absolute text-rose-300/30"
|
||||
initial={{
|
||||
x: heart.x,
|
||||
y: heart.y,
|
||||
scale: heart.size,
|
||||
opacity: 0.3
|
||||
}}
|
||||
animate={{
|
||||
y: -50,
|
||||
opacity: [0.3, 0.8, 0],
|
||||
rotate: 360
|
||||
}}
|
||||
transition={{
|
||||
duration: Math.random() * 8 + 8,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
onAnimationComplete={() => {
|
||||
setHearts(prev => prev.filter(h => h.id !== heart.id))
|
||||
}}
|
||||
>
|
||||
<Heart className="w-6 h-6" />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
src/components/WaveAnimation.tsx
Normal file
29
src/components/WaveAnimation.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"use client"
|
||||
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
export default function WaveAnimation() {
|
||||
return (
|
||||
<div className="fixed inset-0 pointer-events-none overflow-hidden z-0">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="absolute bottom-0 left-0 w-full h-32 bg-gradient-to-t from-rose-200/20 to-transparent"
|
||||
animate={{
|
||||
scaleY: [1, 1.5, 1],
|
||||
opacity: [0.3, 0.6, 0.3]
|
||||
}}
|
||||
transition={{
|
||||
duration: 2 + i * 0.5,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
style={{
|
||||
left: `${i * 20}%`,
|
||||
transformOrigin: "bottom"
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
83
src/lib/auth.ts
Normal file
83
src/lib/auth.ts
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
156
src/lib/spotify.ts
Normal file
156
src/lib/spotify.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user