init commit

This commit is contained in:
GulfGulfinson 2025-10-10 10:25:45 +02:00
parent 7f9f878362
commit 21a6d57368
28 changed files with 3572 additions and 197 deletions

2
.gitignore vendored
View File

@ -39,3 +39,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
/src/generated/prisma

151
README.md
View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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
View 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
View 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!"

View 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 }

View 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 })
}
}

View 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 })
}
}

View 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 })
}
}

View 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 })
}
}

View 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 })
}
}

View 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 })
}
}

View 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 })
}
}

View 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
View 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>
)
}

View File

@ -1,26 +1,93 @@
@import "tailwindcss";
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
@layer components {
.glass-card {
@apply bg-white/20 backdrop-blur-md border border-white/30 rounded-2xl shadow-xl;
}
.glass-card-hover {
@apply hover:bg-white/30 hover:backdrop-blur-lg transition-all duration-300 ease-out;
}
.gradient-text {
@apply bg-gradient-to-r from-rose-400 via-pink-500 to-purple-500 bg-clip-text text-transparent;
}
.floating-heart {
animation: float 3s ease-in-out infinite;
}
.pulse-glow {
animation: pulse-glow 2s ease-in-out infinite;
}
.wave-animation {
animation: wave 1.5s ease-in-out infinite;
}
}
@keyframes float {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-10px);
}
}
@keyframes pulse-glow {
0%, 100% {
box-shadow: 0 0 20px rgba(236, 72, 153, 0.3);
}
50% {
box-shadow: 0 0 40px rgba(236, 72, 153, 0.6);
}
}
@keyframes wave {
0%, 100% {
transform: scaleY(1);
}
50% {
transform: scaleY(1.5);
}
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
@apply bg-white/20 rounded-full;
}
::-webkit-scrollbar-thumb {
@apply bg-gradient-to-b from-rose-300 to-pink-400 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply from-rose-400 to-pink-500;
}
/* Smooth transitions for all interactive elements */
* {
transition: all 0.2s ease-out;
}
/* Focus styles */
*:focus {
@apply outline-none ring-2 ring-rose-300 ring-opacity-50;
}

View File

@ -1,34 +1,37 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import type { Metadata } from "next"
import { Inter, Poppins } from "next/font/google"
import "./globals.css"
import Providers from "./providers"
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" })
const poppins = Poppins({
subsets: ["latin"],
weight: ["300", "400", "500", "600", "700"],
variable: "--font-poppins"
})
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
title: "Harmony — A Shared Spotify Experience for Two",
description: "A beautiful, romantic web app for sharing Spotify experiences with your loved one",
keywords: ["spotify", "music", "couple", "romantic", "playlist", "harmony"],
authors: [{ name: "Harmony App" }],
viewport: "width=device-width, initial-scale=1",
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
}: {
children: React.ReactNode
}) {
return (
<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
View 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
View 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>
)
}

View File

@ -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
View 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
View 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
View 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>
)
}

View 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>
)
}

View 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
View 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
View 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)
}