update https

This commit is contained in:
pschneid 2025-10-14 14:08:06 +02:00
parent 21a6d57368
commit 0f6605a1ce
78 changed files with 7342 additions and 7566 deletions

201
README.md
View File

@ -1,145 +1,120 @@
# Harmony — A Shared Spotify Experience for Two 💕
# 💕 Our Musical Journey
A beautiful, romantic web application that allows two Spotify users to share their musical journey together. Built with Next.js, TypeScript, and Tailwind CSS.
A beautiful, modern web application that connects two hearts through music. Built with React, TypeScript, and the Spotify Web API.
## Features
## 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
### 🎵 Last Listened
- See what your partner is listening to in real-time
- Play their music directly from the app
- View recent listening history
### 🎧 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
### 🎨 Mixed Playlist
- AI-powered playlist generation
- Analyzes both users' music tastes
- Creates perfect blends of musical preferences
- Save playlists to Spotify or locally
### 📊 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
### 💖 Memory Lane
- Create beautiful musical memories together
- Timeline of shared musical experiences
- Track milestones and special moments
- Add custom memories and stories
### ⚙️ Settings
- Customize display names and profile pictures
- Theme selection (Rose, Purple, Blue)
- Secure user authentication with Spotify OAuth
## 🚀 Getting Started
## 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
### Prerequisites
- Node.js 18+ and npm
- Spotify Developer Account
- Two Spotify Premium accounts (for full functionality)
### 2. Database Setup
```bash
# Create PostgreSQL database
createdb spotify_app
### Setup
# 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;"
```
1. **Clone and install dependencies:**
```bash
cd spotify
npm install
```
### 3. Environment Configuration
Copy `.env.local` and update with your credentials:
2. **Set up Spotify App:**
- Go to [Spotify Developer Dashboard](https://developer.spotify.com/dashboard)
- Create a new app
- Add `http://localhost:3000/callback` to redirect URIs
- Copy your Client ID and Client Secret
```env
# Database
DATABASE_URL="postgresql://iu:iu@localhost:5432/spotify_app?schema=public"
3. **Configure environment:**
```bash
cp env.example .env.local
```
Edit `.env.local` with your Spotify credentials:
```
VITE_SPOTIFY_CLIENT_ID=your_client_id
VITE_SPOTIFY_CLIENT_SECRET=your_client_secret
VITE_REDIRECT_URI=http://localhost:3000/callback
```
# NextAuth
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="your-secret-key-here"
4. **Start the development server:**
```bash
npm run dev
```
# Spotify OAuth
SPOTIFY_CLIENT_ID="your-spotify-client-id"
SPOTIFY_CLIENT_SECRET="your-spotify-client-secret"
5. **Open your browser:**
Navigate to `http://localhost:3000`
# Allowed Spotify Users (comma-separated Spotify user IDs)
ALLOWED_SPOTIFY_USERS="your-spotify-user-id,partner-spotify-user-id"
```
## 🎨 Design Features
### 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`
- **Glassmorphism UI** - Modern frosted glass effects
- **Dark Mode** - Beautiful dark theme optimized for music
- **Fluid Animations** - Smooth transitions powered by Framer Motion
- **Responsive Design** - Works perfectly on all devices
- **Spotify Integration** - Seamless music playback and playlist creation
### 5. Installation & Setup
```bash
# Install dependencies
npm install
## 🛠 Tech Stack
# Generate Prisma client
npm run db:generate
- **Frontend:** React 18, TypeScript, Vite
- **Styling:** Tailwind CSS, Custom Glassmorphism
- **Animations:** Framer Motion
- **State Management:** Zustand
- **Music API:** Spotify Web API
- **Icons:** Lucide React
- **Notifications:** React Hot Toast
# Push database schema
npm run db:push
## 📱 Usage
# Start development server
npm run dev
```
1. **Connect Your Account:** Click "Connect with Spotify" and authorize the app
2. **Invite Your Partner:** Share the app with your partner to connect their account
3. **Explore Features:**
- **Last Listened:** See what each other is playing
- **Mixed Playlist:** Generate AI-powered playlists
- **Memory Lane:** Create and view shared musical memories
### 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}`
## 🔒 Privacy & Security
## Usage
- All data is stored locally in your browser
- No personal data is sent to external servers
- Spotify authentication is handled securely
- Playlist data is only shared between connected users
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
## 🚀 Deployment
## API Endpoints
For production deployment:
- `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
1. **Update environment variables** with production URLs
2. **Add production redirect URI** to Spotify app settings
3. **Build the app:**
```bash
npm run build
```
4. **Deploy** to your preferred hosting service (Vercel, Netlify, etc.)
## Security Features
## 💝 Made With Love
- 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
This application was created as a special gift for someone special. Every feature is designed to bring two hearts closer through the universal language of music.
## Design Philosophy
## 📄 License
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! 💕
This project is private and personal. Please respect the privacy and don't redistribute.
---
Made with 💕 for couples who love music together
*"Music is the language of the spirit. It opens the secret of life bringing peace, abolishing strife." - Kahlil Gibran*

BIN
cloudflared-linux-amd64.deb Normal file

Binary file not shown.

Binary file not shown.

75
direct-https.js Normal file
View File

@ -0,0 +1,75 @@
import https from 'https';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const options = {
key: fs.readFileSync('./localhost+2-key.pem'),
cert: fs.readFileSync('./localhost+2.pem')
};
const server = https.createServer(options, (req, res) => {
let filePath = '.' + req.url;
// Handle root path
if (filePath === './') {
filePath = './index.html';
}
// Handle callback.html specifically
if (filePath === './callback.html') {
filePath = './index.html'; // Serve the React app, which will handle the callback
}
// Read and serve the file
fs.readFile(filePath, (err, data) => {
if (err) {
if (err.code === 'ENOENT') {
// File not found, serve index.html for SPA routing
fs.readFile('./index.html', (err, data) => {
if (err) {
res.writeHead(404);
res.end('File not found');
return;
}
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(data);
});
} else {
res.writeHead(500);
res.end('Server Error');
}
return;
}
// Set content type based on file extension
const ext = path.extname(filePath);
const contentType = {
'.html': 'text/html',
'.js': 'application/javascript',
'.mjs': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf'
}[ext] || 'application/octet-stream';
res.writeHead(200, { 'Content-Type': contentType });
res.end(data);
});
});
server.listen(3443, '0.0.0.0', () => {
console.log('Direct HTTPS Server running on https://159.195.9.107:3443');
console.log('This serves the built files directly without proxying');
});

468
dist/assets/index-Sl-nhnNr.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/index-sFswcLb6.css vendored Normal file

File diff suppressed because one or more lines are too long

27
dist/callback.html vendored Normal file
View File

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spotify Callback</title>
</head>
<body>
<script>
// Get the authorization code from URL parameters
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const error = urlParams.get('error');
if (error) {
// Redirect back to main app with error
window.location.href = 'http://localhost:3001/?error=' + encodeURIComponent(error);
} else if (code) {
// Redirect back to main app with code
window.location.href = 'http://localhost:3001/callback?code=' + encodeURIComponent(code);
} else {
// No code or error, redirect to main app
window.location.href = 'http://localhost:3001/';
}
</script>
</body>
</html>

3
dist/heart.svg vendored Normal file
View File

@ -0,0 +1,3 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 28C16 28 3 18 3 11C3 7.68629 5.68629 5 9 5C11.5 5 13.5 6.5 16 8.5C18.5 6.5 20.5 5 23 5C26.3137 5 29 7.68629 29 11C29 18 16 28 16 28Z" fill="#1db954"/>
</svg>

After

Width:  |  Height:  |  Size: 269 B

15
dist/index.html vendored Normal file
View File

@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/heart.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>💕 Our Musical Journey</title>
<meta name="description" content="A private musical journey for two hearts connected through music" />
<script type="module" crossorigin src="/assets/index-Sl-nhnNr.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-sFswcLb6.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

1
dist/placeholder-album.png vendored Normal file
View File

@ -0,0 +1 @@
data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzAwIiBoZWlnaHQ9IjMwMCIgdmlld0JveD0iMCAwIDMwMCAzMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIzMDAiIGhlaWdodD0iMzAwIiBmaWxsPSIjMjgyODI4Ii8+CjxjaXJjbGUgY3g9IjE1MCIgY3k9IjE1MCIgcj0iNTAiIGZpbGw9IiMxZGI5NTQiLz4KPHN2ZyB4PSIxMjUiIHk9IjEyNSIgd2lkdGg9IjUwIiBoZWlnaHQ9IjUwIiB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9Im5vbmUiPgo8cGF0aCBkPSJNMTIgMkM2LjQ4IDIgMiA2LjQ4IDIgMTJTNi40OCAyMiAxMiAyMlMyMiAxNy41MiAyMiAxMlMxNy41MiAyIDEyIDJaTTEwIDE3TDUgMTJMMTAgN0wxMSAxMkwxNyAxMkwxMCAxN1oiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo8L3N2Zz4K

7
env.example Normal file
View File

@ -0,0 +1,7 @@
# Spotify API Configuration
VITE_SPOTIFY_CLIENT_ID=your_spotify_client_id_here
VITE_SPOTIFY_CLIENT_SECRET=your_spotify_client_secret_here
VITE_REDIRECT_URI=http://localhost:3000/callback
# Note: In production, make sure to add your production URL to the Spotify app settings
# VITE_REDIRECT_URI=https://yourdomain.com/callback

View File

@ -1,25 +0,0 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
ignores: [
"node_modules/**",
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
],
},
];
export default eslintConfig;

57
fix-https.js Normal file
View File

@ -0,0 +1,57 @@
import https from 'https';
import http from 'http';
import fs from 'fs';
const options = {
key: fs.readFileSync('./localhost+2-key.pem'),
cert: fs.readFileSync('./localhost+2.pem')
};
const server = https.createServer(options, (req, res) => {
// Handle CORS
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
// Proxy to the development server but modify redirect responses
const proxyReq = http.request({
hostname: 'localhost',
port: 3000,
path: req.url,
method: req.method,
headers: {
...req.headers,
host: 'localhost:3000'
}
}, (proxyRes) => {
// Copy headers but modify location if it's a redirect
const headers = { ...proxyRes.headers };
if (headers.location && headers.location.includes('localhost:3001')) {
headers.location = headers.location.replace('localhost:3001', '159.195.9.107:3443');
console.log('🔄 Fixed redirect:', headers.location);
}
res.writeHead(proxyRes.statusCode, headers);
proxyRes.pipe(res);
});
req.pipe(proxyReq);
proxyReq.on('error', (err) => {
console.error('Proxy error:', err);
res.writeHead(500);
res.end('Proxy Error');
});
});
server.listen(3443, '0.0.0.0', () => {
console.log('Fixed HTTPS Proxy running on https://159.195.9.107:3443');
console.log('This will fix localhost:3001 redirects automatically');
});

14
index.html Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/heart.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>💕 Our Musical Journey</title>
<meta name="description" content="A private musical journey for two hearts connected through music" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

BIN
mkcert-v1.4.4-linux-amd64 Executable file

Binary file not shown.

View File

@ -1,7 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

BIN
ngrok Executable file

Binary file not shown.

7673
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,38 +1,41 @@
{
"name": "harmony",
"version": "0.1.0",
"name": "spotify-couple-gift",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",
"lint": "eslint",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev",
"db:studio": "prisma studio"
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"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",
"spotify-web-api-node": "^5.0.2"
"clsx": "^2.0.0",
"framer-motion": "^10.16.16",
"http-proxy-middleware": "^3.0.5",
"lucide-react": "^0.294.0",
"ogl": "^1.0.11",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1",
"react-router-dom": "^6.20.1",
"spotify-web-api-js": "^1.5.2",
"tailwind-merge": "^2.0.0",
"zustand": "^4.4.7"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.5.4",
"tailwindcss": "^4",
"typescript": "^5"
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"@vitejs/plugin-react": "^4.1.1",
"autoprefixer": "^10.4.16",
"eslint": "^8.53.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"typescript": "^5.2.2",
"vite": "^5.0.0"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -1,5 +0,0 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

View File

@ -1,132 +0,0 @@
// 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")
}

96
production-server.js Normal file
View File

@ -0,0 +1,96 @@
import https from 'https';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const options = {
key: fs.readFileSync('./localhost+2-key.pem'),
cert: fs.readFileSync('./localhost+2.pem')
};
// MIME types mapping
const mimeTypes = {
'.html': 'text/html; charset=utf-8',
'.htm': 'text/html; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.mjs': 'application/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.eot': 'application/vnd.ms-fontobject'
};
const server = https.createServer(options, (req, res) => {
console.log(`📥 ${req.method} ${req.url}`);
let filePath = path.join(__dirname, 'dist', req.url === '/' ? 'index.html' : req.url);
// Security check - prevent directory traversal
if (!filePath.startsWith(path.join(__dirname, 'dist'))) {
res.writeHead(403);
res.end('Forbidden');
return;
}
// Check if file exists
fs.stat(filePath, (err, stats) => {
if (err || !stats.isFile()) {
// File not found, serve index.html for SPA routing
const indexPath = path.join(__dirname, 'dist', 'index.html');
fs.readFile(indexPath, (err, data) => {
if (err) {
res.writeHead(404);
res.end('File not found');
return;
}
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-cache'
});
res.end(data);
});
return;
}
// Read and serve the file
fs.readFile(filePath, (err, data) => {
if (err) {
console.error('Error reading file:', err);
res.writeHead(500);
res.end('Server Error');
return;
}
const ext = path.extname(filePath);
const contentType = mimeTypes[ext] || 'application/octet-stream';
// Set cache headers for static assets
const cacheControl = ext.match(/\.(css|js|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf)$/)
? 'public, max-age=31536000'
: 'no-cache';
res.writeHead(200, {
'Content-Type': contentType,
'Cache-Control': cacheControl
});
res.end(data);
});
});
});
server.listen(3443, '0.0.0.0', () => {
console.log('🚀 Production HTTPS Server running on https://159.195.9.107:3443');
console.log('📁 Serving files from: ./dist/');
console.log('🔒 SSL Certificate: localhost+2.pem');
});

27
public/callback.html Normal file
View File

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spotify Callback</title>
</head>
<body>
<script>
// Get the authorization code from URL parameters
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const error = urlParams.get('error');
if (error) {
// Redirect back to main app with error
window.location.href = 'http://localhost:3001/?error=' + encodeURIComponent(error);
} else if (code) {
// Redirect back to main app with code
window.location.href = 'http://localhost:3001/callback?code=' + encodeURIComponent(code);
} else {
// No code or error, redirect to main app
window.location.href = 'http://localhost:3001/';
}
</script>
</body>
</html>

View File

@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

View File

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

3
public/heart.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 28C16 28 3 18 3 11C3 7.68629 5.68629 5 9 5C11.5 5 13.5 6.5 16 8.5C18.5 6.5 20.5 5 23 5C26.3137 5 29 7.68629 29 11C29 18 16 28 16 28Z" fill="#1db954"/>
</svg>

After

Width:  |  Height:  |  Size: 269 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzAwIiBoZWlnaHQ9IjMwMCIgdmlld0JveD0iMCAwIDMwMCAzMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIzMDAiIGhlaWdodD0iMzAwIiBmaWxsPSIjMjgyODI4Ii8+CjxjaXJjbGUgY3g9IjE1MCIgY3k9IjE1MCIgcj0iNTAiIGZpbGw9IiMxZGI5NTQiLz4KPHN2ZyB4PSIxMjUiIHk9IjEyNSIgd2lkdGg9IjUwIiBoZWlnaHQ9IjUwIiB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9Im5vbmUiPgo8cGF0aCBkPSJNMTIgMkM2LjQ4IDIgMiA2LjQ4IDIgMTJTNi40OCAyMiAxMiAyMlMyMiAxNy41MiAyMiAxMlMxNy41MiAyIDEyIDJaTTEwIDE3TDUgMTJMMTAgN0wxMSAxMkwxNyAxMkwxMCAxN1oiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo8L3N2Zz4K

View File

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

View File

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

View File

@ -1,53 +0,0 @@
#!/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!"

28
simple-https.js Normal file
View File

@ -0,0 +1,28 @@
import https from 'https';
import http from 'http';
import fs from 'fs';
const options = {
key: fs.readFileSync('./localhost+2-key.pem'),
cert: fs.readFileSync('./localhost+2.pem')
};
const server = https.createServer(options, (req, res) => {
// Simple proxy to localhost:3000
const proxyReq = http.request({
hostname: 'localhost',
port: 3000,
path: req.url,
method: req.method,
headers: req.headers
}, (proxyRes) => {
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res);
});
req.pipe(proxyReq);
});
server.listen(3443, '0.0.0.0', () => {
console.log('HTTPS Proxy running on https://159.195.9.107:3443');
});

109
src/App.tsx Normal file
View File

@ -0,0 +1,109 @@
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { useEffect } from 'react';
import { motion } from 'framer-motion';
import { useStore } from './store/useStore';
import DarkVeil, { TestBackground } from './components/DarkVeil';
import { Navbar } from './components/Navbar';
import { LoginPage } from './pages/LoginPage';
import { DashboardPage } from './pages/DashboardPage';
import { CallbackPage } from './pages/CallbackPage';
import { LastListenedPage } from './pages/LastListenedPage';
import { MixedPlaylistPage } from './pages/MixedPlaylistPage';
import { MemoryLanePage } from './pages/MemoryLanePage';
function App() {
const { currentUser, partnerUser, isLoading } = useStore();
const location = useLocation();
console.log('🔍 App - Current location:', location.pathname);
useEffect(() => {
// Check for stored authentication tokens on app load
const storedUser = localStorage.getItem('spotify-user');
const storedPartner = localStorage.getItem('spotify-partner');
if (storedUser) {
try {
const userData = JSON.parse(storedUser);
useStore.getState().setCurrentUser(userData);
} catch (error) {
console.error('Failed to parse stored user data:', error);
localStorage.removeItem('spotify-user');
}
}
if (storedPartner) {
try {
const partnerData = JSON.parse(storedPartner);
useStore.getState().setPartnerUser(partnerData);
} catch (error) {
console.error('Failed to parse stored partner data:', error);
localStorage.removeItem('spotify-partner');
}
}
}, []);
const isAuthenticated = currentUser?.isAuthenticated || false;
const hasPartner = partnerUser?.isAuthenticated || false;
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center relative">
<DarkVeil />
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
className="glass-fluid rounded-3xl p-10 flex flex-col items-center space-y-6 relative z-10"
>
<div className="w-16 h-16 border-4 border-gradient-to-r from-purple-500 to-pink-500 border-t-transparent rounded-full animate-spin"></div>
<p className="text-white font-medium text-lg">Loading your musical journey...</p>
</motion.div>
</div>
);
}
return (
<div className="min-h-screen relative overflow-hidden">
<DarkVeil />
<div className="relative z-10">
{isAuthenticated && <Navbar />}
<Routes>
<Route
path="/"
element={
isAuthenticated ? (
<DashboardPage />
) : (
<LoginPage />
)
}
/>
<Route
path="/callback"
element={<CallbackPage />}
/>
<Route
path="/callback.html"
element={<CallbackPage />}
/>
{isAuthenticated && (
<>
<Route path="/last-listened" element={<LastListenedPage />} />
<Route path="/mixed-playlist" element={<MixedPlaylistPage />} />
<Route path="/memory-lane" element={<MemoryLanePage />} />
</>
)}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</div>
</div>
);
}
export default App;

View File

@ -1,6 +0,0 @@
import NextAuth from "next-auth"
import { authOptions } from "@/lib/auth"
const handler = NextAuth(authOptions)
export { handler as GET, handler as POST }

View File

@ -1,57 +0,0 @@
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

@ -1,114 +0,0 @@
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

@ -1,37 +0,0 @@
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

@ -1,60 +0,0 @@
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

@ -1,65 +0,0 @@
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

@ -1,37 +0,0 @@
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

@ -1,38 +0,0 @@
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

@ -1,124 +0,0 @@
"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>
)
}

View File

@ -1,240 +0,0 @@
"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>
)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@ -1,93 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
@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,37 +0,0 @@
import type { Metadata } from "next"
import { Inter, Poppins } from "next/font/google"
import "./globals.css"
import Providers from "./providers"
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: "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,
}: {
children: React.ReactNode
}) {
return (
<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>
)
}

View File

@ -1,285 +0,0 @@
"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>
)
}

View File

@ -1,305 +0,0 @@
"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,134 +0,0 @@
"use client"
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="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>
<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"
>
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"
>
Connect your Spotify accounts to begin
</motion.p>
</motion.div>
{/* Floating Hearts Animation */}
<FloatingHearts />
</div>
)
}

View File

@ -1,16 +0,0 @@
"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>
)
}

View File

@ -1,238 +0,0 @@
"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>
)
}

View File

@ -1,293 +0,0 @@
"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,163 @@
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { InteractiveBubbles } from './InteractiveBubbles';
interface Particle {
id: number;
x: number;
y: number;
size: number;
speed: number;
delay: number;
}
export const AnimatedBackground = () => {
const [particles, setParticles] = useState<Particle[]>([]);
useEffect(() => {
const createParticles = () => {
const newParticles: Particle[] = [];
for (let i = 0; i < 50; i++) {
newParticles.push({
id: i,
x: Math.random() * window.innerWidth,
y: Math.random() * window.innerHeight,
size: Math.random() * 4 + 2,
speed: Math.random() * 0.5 + 0.1,
delay: Math.random() * 5,
});
}
setParticles(newParticles);
};
createParticles();
const handleResize = () => {
createParticles();
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return (
<div className="fixed inset-0 pointer-events-none overflow-hidden">
{/* Large liquid light cylinders from top */}
<div className="light-cylinder light-cylinder-1"></div>
<div className="light-cylinder light-cylinder-2"></div>
<div className="light-cylinder light-cylinder-3"></div>
{/* Fluid wave bubbles */}
<div className="wave-bubble"></div>
<div className="wave-bubble"></div>
<div className="wave-bubble"></div>
<div className="wave-bubble"></div>
<div className="wave-bubble"></div>
{/* Animated particles */}
{particles.map((particle) => (
<motion.div
key={particle.id}
className="absolute rounded-full bg-gradient-to-br from-purple-500/20 via-blue-500/20 to-pink-500/20"
style={{
width: particle.size,
height: particle.size,
left: particle.x,
top: particle.y,
}}
animate={{
y: [particle.y, particle.y - 100],
opacity: [0, 1, 0],
scale: [0, 1, 0],
}}
transition={{
duration: 10 + particle.speed * 10,
delay: particle.delay,
repeat: Infinity,
ease: "linear",
}}
/>
))}
{/* Interactive bubbles */}
<InteractiveBubbles />
{/* Floating musical notes */}
<motion.div
className="absolute top-20 left-10 text-2xl text-spotify-green/30"
animate={{
y: [0, -20, 0],
rotate: [0, 10, -10, 0],
}}
transition={{
duration: 4,
repeat: Infinity,
ease: "easeInOut",
}}
>
</motion.div>
<motion.div
className="absolute top-40 right-20 text-3xl text-spotify-green/20"
animate={{
y: [0, -30, 0],
rotate: [0, -15, 15, 0],
}}
transition={{
duration: 6,
repeat: Infinity,
ease: "easeInOut",
delay: 1,
}}
>
</motion.div>
<motion.div
className="absolute bottom-32 left-1/4 text-xl text-spotify-green/25"
animate={{
y: [0, -25, 0],
rotate: [0, 20, -20, 0],
}}
transition={{
duration: 5,
repeat: Infinity,
ease: "easeInOut",
delay: 2,
}}
>
</motion.div>
<motion.div
className="absolute bottom-20 right-1/3 text-2xl text-spotify-green/30"
animate={{
y: [0, -35, 0],
rotate: [0, -25, 25, 0],
}}
transition={{
duration: 7,
repeat: Infinity,
ease: "easeInOut",
delay: 3,
}}
>
</motion.div>
{/* Subtle grid pattern */}
<div
className="absolute inset-0 opacity-10"
style={{
backgroundImage: `
linear-gradient(rgba(29, 185, 84, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(29, 185, 84, 0.1) 1px, transparent 1px)
`,
backgroundSize: '50px 50px',
}}
/>
</div>
);
};

286
src/components/DarkVeil.tsx Normal file
View File

@ -0,0 +1,286 @@
import React, { Component, ReactNode, useRef, useEffect } from 'react';
import { Renderer, Program, Mesh, Triangle, Vec2 } from 'ogl';
const vertex = `
attribute vec2 position;
void main(){gl_Position=vec4(position,0.0,1.0);}
`;
const fragment = `
#ifdef GL_ES
precision lowp float;
#endif
uniform vec2 uResolution;
uniform float uTime;
uniform float uHueShift;
uniform float uNoise;
uniform float uScan;
uniform float uScanFreq;
uniform float uWarp;
#define iTime uTime
#define iResolution uResolution
vec4 buf[8];
float rand(vec2 c){return fract(sin(dot(c,vec2(12.9898,78.233)))*43758.5453);}
mat3 rgb2yiq=mat3(0.299,0.587,0.114,0.596,-0.274,-0.322,0.211,-0.523,0.312);
mat3 yiq2rgb=mat3(1.0,0.956,0.621,1.0,-0.272,-0.647,1.0,-1.106,1.703);
vec3 hueShiftRGB(vec3 col,float deg){
vec3 yiq=rgb2yiq*col;
float rad=radians(deg);
float cosh=cos(rad),sinh=sin(rad);
vec3 yiqShift=vec3(yiq.x,yiq.y*cosh-yiq.z*sinh,yiq.y*sinh+yiq.z*cosh);
return clamp(yiq2rgb*yiqShift,0.0,1.0);
}
vec4 sigmoid(vec4 x){return 1./(1.+exp(-x));}
vec4 cppn_fn(vec2 coordinate,float in0,float in1,float in2){
buf[6]=vec4(coordinate.x,coordinate.y,0.3948333106474662+in0,0.36+in1);
buf[7]=vec4(0.14+in2,sqrt(coordinate.x*coordinate.x+coordinate.y*coordinate.y),0.,0.);
buf[0]=mat4(vec4(6.5404263,-3.6126034,0.7590882,-1.13613),vec4(2.4582713,3.1660357,1.2219609,0.06276096),vec4(-5.478085,-6.159632,1.8701609,-4.7742867),vec4(6.039214,-5.542865,-0.90925294,3.251348))*buf[6]+mat4(vec4(0.8473259,-5.722911,3.975766,1.6522468),vec4(-0.24321538,0.5839259,-1.7661959,-5.350116),vec4(0.,0.,0.,0.),vec4(0.,0.,0.,0.))*buf[7]+vec4(0.21808943,1.1243913,-1.7969975,5.0294676);
buf[1]=mat4(vec4(-3.3522482,-6.0612736,0.55641043,-4.4719114),vec4(0.8631464,1.7432913,5.643898,1.6106541),vec4(2.4941394,-3.5012043,1.7184316,6.357333),vec4(3.310376,8.209261,1.1355612,-1.165539))*buf[6]+mat4(vec4(5.24046,-13.034365,0.009859298,15.870829),vec4(2.987511,3.129433,-0.89023495,-1.6822904),vec4(0.,0.,0.,0.),vec4(0.,0.,0.,0.))*buf[7]+vec4(-5.9457836,-6.573602,-0.8812491,1.5436668);
buf[0]=sigmoid(buf[0]);buf[1]=sigmoid(buf[1]);
buf[2]=mat4(vec4(-15.219568,8.095543,-2.429353,-1.9381982),vec4(-5.951362,4.3115187,2.6393783,1.274315),vec4(-7.3145227,6.7297835,5.2473326,5.9411426),vec4(5.0796127,8.979051,-1.7278991,-1.158976))*buf[6]+mat4(vec4(-11.967154,-11.608155,6.1486754,11.237008),vec4(2.124141,-6.263192,-1.7050359,-0.7021966),vec4(0.,0.,0.,0.),vec4(0.,0.,0.,0.))*buf[7]+vec4(-4.17164,-3.2281182,-4.576417,-3.6401186);
buf[3]=mat4(vec4(3.1832156,-13.738922,1.879223,3.233465),vec4(0.64300746,12.768129,1.9141049,0.50990224),vec4(-0.049295485,4.4807224,1.4733979,1.801449),vec4(5.0039253,13.000481,3.3991797,-4.5561905))*buf[6]+mat4(vec4(-0.1285731,7.720628,-3.1425676,4.742367),vec4(0.6393625,3.714393,-0.8108378,-0.39174938),vec4(0.,0.,0.,0.),vec4(0.,0.,0.,0.))*buf[7]+vec4(-1.1811101,-21.621881,0.7851888,1.2329718);
buf[2]=sigmoid(buf[2]);buf[3]=sigmoid(buf[3]);
buf[4]=mat4(vec4(5.214916,-7.183024,2.7228765,2.6592617),vec4(-5.601878,-25.3591,4.067988,0.4602802),vec4(-10.57759,24.286327,21.102104,37.546658),vec4(4.3024497,-1.9625226,2.3458803,-1.372816))*buf[0]+mat4(vec4(-17.6526,-10.507558,2.2587414,12.462782),vec4(6.265566,-502.75443,-12.642513,0.9112289),vec4(-10.983244,20.741234,-9.701768,-0.7635988),vec4(5.383626,1.4819539,-4.1911616,-4.8444734))*buf[1]+mat4(vec4(12.785233,-16.345072,-0.39901125,1.7955981),vec4(-30.48365,-1.8345358,1.4542528,-1.1118771),vec4(19.872723,-7.337935,-42.941723,-98.52709),vec4(8.337645,-2.7312303,-2.2927687,-36.142323))*buf[2]+mat4(vec4(-16.298317,3.5471997,-0.44300047,-9.444417),vec4(57.5077,-35.609753,16.163465,-4.1534753),vec4(-0.07470326,-3.8656476,-7.0901804,3.1523974),vec4(-12.559385,-7.077619,1.490437,-0.8211543))*buf[3]+vec4(-7.67914,15.927437,1.3207729,-1.6686112);
buf[5]=mat4(vec4(-1.4109162,-0.372762,-3.770383,-21.367174),vec4(-6.2103205,-9.35908,0.92529047,8.82561),vec4(11.460242,-22.348068,13.625772,-18.693201),vec4(-0.3429052,-3.9905605,-2.4626114,-0.45033523))*buf[0]+mat4(vec4(7.3481627,-4.3661838,-6.3037653,-3.868115),vec4(1.5462853,6.5488915,1.9701879,-0.58291394),vec4(6.5858274,-2.2180402,3.7127688,-1.3730392),vec4(-5.7973905,10.134961,-2.3395722,-5.965605))*buf[1]+mat4(vec4(-2.5132585,-6.6685553,-1.4029363,-0.16285264),vec4(-0.37908727,0.53738135,4.389061,-1.3024765),vec4(-0.70647055,2.0111287,-5.1659346,-3.728635),vec4(-13.562562,10.487719,-0.9173751,-2.6487076))*buf[2]+mat4(vec4(-8.645013,6.5546675,-6.3944063,-5.5933375),vec4(-0.57783127,-1.077275,36.91025,5.736769),vec4(14.283112,3.7146652,7.1452246,-4.5958776),vec4(2.7192075,3.6021907,-4.366337,-2.3653464))*buf[3]+vec4(-5.9000807,-4.329569,1.2427121,8.59503);
buf[4]=sigmoid(buf[4]);buf[5]=sigmoid(buf[5]);
buf[6]=mat4(vec4(-1.61102,0.7970257,1.4675229,0.20917463),vec4(-28.793737,-7.1390953,1.5025433,4.656581),vec4(-10.94861,39.66238,0.74318546,-10.095605),vec4(-0.7229728,-1.5483948,0.7301322,2.1687684))*buf[0]+mat4(vec4(3.2547753,21.489103,-1.0194173,-3.3100595),vec4(-3.7316632,-3.3792162,-7.223193,-0.23685838),vec4(13.1804495,0.7916005,5.338587,5.687114),vec4(-4.167605,-17.798311,-6.815736,-1.6451967))*buf[1]+mat4(vec4(0.604885,-7.800309,-7.213122,-2.741014),vec4(-3.522382,-0.12359311,-0.5258442,0.43852118),vec4(9.6752825,-22.853785,2.062431,0.099892326),vec4(-4.3196306,-17.730087,2.5184598,5.30267))*buf[2]+mat4(vec4(-6.545563,-15.790176,-6.0438633,-5.415399),vec4(-43.591583,28.551912,-16.00161,18.84728),vec4(4.212382,8.394307,3.0958717,8.657522),vec4(-5.0237565,-4.450633,-4.4768,-5.5010443))*buf[3]+mat4(vec4(1.6985557,-67.05806,6.897715,1.9004834),vec4(1.8680354,2.3915145,2.5231109,4.081538),vec4(11.158006,1.7294737,2.0738268,7.386411),vec4(-4.256034,-306.24686,8.258898,-17.132736))*buf[4]+mat4(vec4(1.6889864,-4.5852966,3.8534803,-6.3482175),vec4(1.3543309,-1.2640043,9.932754,2.9079645),vec4(-5.2770967,0.07150358,-0.13962056,3.3269649),vec4(28.34703,-4.918278,6.1044083,4.085355))*buf[5]+vec4(6.6818056,12.522166,-3.7075126,-4.104386);
buf[7]=mat4(vec4(-8.265602,-4.7027016,5.098234,0.7509808),vec4(8.6507845,-17.15949,16.51939,-8.884479),vec4(-4.036479,-2.3946867,-2.6055532,-1.9866527),vec4(-2.2167742,-1.8135649,-5.9759874,4.8846445))*buf[0]+mat4(vec4(6.7790847,3.5076547,-2.8191125,-2.7028968),vec4(-5.743024,-0.27844876,1.4958696,-5.0517144),vec4(13.122226,15.735168,-2.9397483,-4.101023),vec4(-14.375265,-5.030483,-6.2599335,2.9848232))*buf[1]+mat4(vec4(4.0950394,-0.94011575,-5.674733,4.755022),vec4(4.3809423,4.8310084,1.7425908,-3.437416),vec4(2.117492,0.16342592,-104.56341,16.949184),vec4(-5.22543,-2.994248,3.8350096,-1.9364246))*buf[2]+mat4(vec4(-5.900337,1.7946124,-13.604192,-3.8060522),vec4(6.6583457,31.911177,25.164474,91.81147),vec4(11.840538,4.1503043,-0.7314397,6.768467),vec4(-6.3967767,4.034772,6.1714606,-0.32874924))*buf[3]+mat4(vec4(3.4992442,-196.91893,-8.923708,2.8142626),vec4(3.4806502,-3.1846354,5.1725626,5.1804223),vec4(-2.4009497,15.585794,1.2863957,2.0252278),vec4(-71.25271,-62.441242,-8.138444,0.50670296))*buf[4]+mat4(vec4(-12.291733,-11.176166,-7.3474145,4.390294),vec4(10.805477,5.6337385,-0.9385842,-4.7348723),vec4(-12.869276,-7.039391,5.3029537,7.5436664),vec4(1.4593618,8.91898,3.5101583,5.840625))*buf[5]+vec4(2.2415268,-6.705987,-0.98861027,-2.117676);
buf[6]=sigmoid(buf[6]);buf[7]=sigmoid(buf[7]);
buf[0]=mat4(vec4(1.6794263,1.3817469,2.9625452,0.),vec4(-1.8834411,-1.4806935,-3.5924516,0.),vec4(-1.3279216,-1.0918057,-2.3124623,0.),vec4(0.2662234,0.23235129,0.44178495,0.))*buf[0]+mat4(vec4(-0.6299101,-0.5945583,-0.9125601,0.),vec4(0.17828953,0.18300213,0.18182953,0.),vec4(-2.96544,-2.5819945,-4.9001055,0.),vec4(1.4195864,1.1868085,2.5176322,0.))*buf[1]+mat4(vec4(-1.2584374,-1.0552157,-2.1688404,0.),vec4(-0.7200217,-0.52666044,-1.438251,0.),vec4(0.15345335,0.15196142,0.272854,0.),vec4(0.945728,0.8861938,1.2766753,0.))*buf[2]+mat4(vec4(-2.4218085,-1.968602,-4.35166,0.),vec4(-22.683098,-18.0544,-41.954372,0.),vec4(0.63792,0.5470648,1.1078634,0.),vec4(-1.5489894,-1.3075932,-2.6444845,0.))*buf[3]+mat4(vec4(-0.49252132,-0.39877754,-0.91366625,0.),vec4(0.95609266,0.7923952,1.640221,0.),vec4(0.30616966,0.15693925,0.8639857,0.),vec4(1.1825981,0.94504964,2.176963,0.))*buf[4]+mat4(vec4(0.35446745,0.3293795,0.59547555,0.),vec4(-0.58784515,-0.48177817,-1.0614829,0.),vec4(2.5271258,1.9991658,4.6846647,0.),vec4(0.13042648,0.08864098,0.30187556,0.))*buf[5]+mat4(vec4(-1.7718065,-1.4033192,-3.3355875,0.),vec4(3.1664357,2.638297,5.378702,0.),vec4(-3.1724713,-2.6107926,-5.549295,0.),vec4(-2.851368,-2.249092,-5.3013067,0.))*buf[6]+mat4(vec4(1.5203838,1.2212278,2.8404984,0.),vec4(1.5210563,1.2651345,2.683903,0.),vec4(2.9789467,2.4364579,5.2347264,0.),vec4(2.2270417,1.8825914,3.8028636,0.))*buf[7]+vec4(-1.5468478,-3.6171484,0.24762098,0.);
buf[0]=sigmoid(buf[0]);
return vec4(buf[0].x,buf[0].y,buf[0].z,1.);
}
void mainImage(out vec4 fragColor,in vec2 fragCoord){
vec2 uv=fragCoord/uResolution.xy*2.-1.;
uv.y*=-1.;
uv+=uWarp*vec2(sin(uv.y*6.283+uTime*0.5),cos(uv.x*6.283+uTime*0.5))*0.05;
fragColor=cppn_fn(uv,0.1*sin(0.3*uTime),0.1*sin(0.69*uTime),0.1*sin(0.44*uTime));
}
void main(){
vec4 col;mainImage(col,gl_FragCoord.xy);
col.rgb=hueShiftRGB(col.rgb,uHueShift);
float scanline_val=sin(gl_FragCoord.y*uScanFreq)*0.5+0.5;
col.rgb*=1.-(scanline_val*scanline_val)*uScan;
col.rgb+=(rand(gl_FragCoord.xy+uTime)-0.5)*uNoise;
gl_FragColor=vec4(clamp(col.rgb,0.0,1.0),1.0);
}
`;
// Error Boundary Component
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return this.props.fallback || (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'radial-gradient(circle at center, rgba(0,0,0,0.8) 0%, rgba(0,0,0,1) 100%)',
zIndex: -1,
pointerEvents: 'none'
}}
/>
);
}
return this.props.children;
}
}
type DarkVeilProps = {
hueShift?: number;
noiseIntensity?: number;
scanlineIntensity?: number;
speed?: number;
scanlineFrequency?: number;
warpAmount?: number;
resolutionScale?: number;
};
function DarkVeilCanvas({
hueShift = 30,
noiseIntensity = 0.02,
scanlineIntensity = 0.05,
speed = 0.3,
scanlineFrequency = 0.4,
warpAmount = 0.1,
resolutionScale = 0.5
}: DarkVeilProps) {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = ref.current as HTMLCanvasElement;
if (!canvas) {
console.error('DarkVeil: Canvas not found');
return;
}
let renderer: Renderer;
let program: Program;
let mesh: Mesh;
try {
renderer = new Renderer({
dpr: Math.min(window.devicePixelRatio, 2),
canvas
});
const gl = renderer.gl;
if (!gl) {
console.error('DarkVeil: WebGL context not available');
return;
}
const geometry = new Triangle(gl);
program = new Program(gl, {
vertex,
fragment,
uniforms: {
uTime: { value: 0 },
uResolution: { value: new Vec2() },
uHueShift: { value: hueShift },
uNoise: { value: noiseIntensity },
uScan: { value: scanlineIntensity },
uScanFreq: { value: scanlineFrequency },
uWarp: { value: warpAmount }
}
});
mesh = new Mesh(gl, { geometry, program });
console.log('DarkVeil: Successfully initialized');
} catch (error) {
console.error('DarkVeil: Initialization error:', error);
return;
}
const resize = () => {
const w = window.innerWidth;
const h = window.innerHeight;
renderer.setSize(w * resolutionScale, h * resolutionScale);
program.uniforms.uResolution.value.set(w, h);
};
window.addEventListener('resize', resize);
resize();
const start = performance.now();
let frame = 0;
const loop = () => {
program.uniforms.uTime.value = ((performance.now() - start) / 1000) * speed;
program.uniforms.uHueShift.value = hueShift;
program.uniforms.uNoise.value = noiseIntensity;
program.uniforms.uScan.value = scanlineIntensity;
program.uniforms.uScanFreq.value = scanlineFrequency;
program.uniforms.uWarp.value = warpAmount;
renderer.render({ scene: mesh });
frame = requestAnimationFrame(loop);
};
loop();
return () => {
cancelAnimationFrame(frame);
window.removeEventListener('resize', resize);
};
}, [hueShift, noiseIntensity, scanlineIntensity, speed, scanlineFrequency, warpAmount, resolutionScale]);
return (
<>
{/* Fallback background */}
<div
style={{
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'radial-gradient(circle at center, rgba(20,10,30,0.9) 0%, rgba(0,0,0,1) 100%)',
zIndex: -2,
pointerEvents: 'none'
}}
/>
{/* WebGL Canvas */}
<canvas
ref={ref}
style={{
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: -1,
pointerEvents: 'none'
}}
/>
</>
);
}
// Main exported component with error boundary
export default function DarkVeil(props: DarkVeilProps) {
return (
<ErrorBoundary>
<DarkVeilCanvas {...props} />
</ErrorBoundary>
);
}
// Simple test component to verify rendering
export function TestBackground() {
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
width: '100vw',
height: '100vh',
background: '#ff0000',
zIndex: -1,
pointerEvents: 'none'
}}
/>
);
}
// Also export the ErrorBoundary for use in App.tsx
export { ErrorBoundary };

View File

@ -1,56 +0,0 @@
"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,25 @@
import { motion } from 'framer-motion';
import { ReactNode } from 'react';
import { cn } from '../utils/cn';
interface FluidCardProps {
children: ReactNode;
className?: string;
hover?: boolean;
glow?: boolean;
}
export const FluidCard = ({ children, className, hover = true, glow = false }: FluidCardProps) => {
return (
<motion.div
whileHover={hover ? { y: -4, scale: 1.02 } : {}}
className={cn(
"glass-bubble rounded-3xl p-6 transition-all duration-300",
glow && "shadow-[0_0_30px_rgba(147,51,234,0.3)]",
className
)}
>
{children}
</motion.div>
);
};

View File

@ -0,0 +1,78 @@
import { useState, useEffect, useCallback } from 'react';
interface Bubble {
id: number;
x: number;
y: number;
size: number;
created: number;
}
export const InteractiveBubbles = () => {
const [bubbles, setBubbles] = useState<Bubble[]>([]);
const [nextId, setNextId] = useState(0);
const createBubble = useCallback((x: number, y: number) => {
const newBubble: Bubble = {
id: nextId,
x,
y,
size: Math.random() * 8 + 6, // 6-14px (smaller)
created: Date.now(),
};
setBubbles(prev => [...prev, newBubble]);
setNextId(prev => prev + 1);
// Remove bubble after animation (faster)
setTimeout(() => {
setBubbles(prev => prev.filter(bubble => bubble.id !== newBubble.id));
}, 1500);
}, [nextId]);
const handleMouseMove = useCallback((e: MouseEvent) => {
// Only create bubbles occasionally to avoid too many
if (Math.random() < 0.02) { // 2% chance per mouse move
createBubble(e.clientX, e.clientY);
}
}, [createBubble]);
const handleClick = useCallback((e: MouseEvent) => {
// Create multiple bubbles on click
for (let i = 0; i < 5; i++) {
setTimeout(() => {
createBubble(
e.clientX + (Math.random() - 0.5) * 100,
e.clientY + (Math.random() - 0.5) * 100
);
}, i * 50);
}
}, [createBubble]);
useEffect(() => {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('click', handleClick);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('click', handleClick);
};
}, [handleMouseMove, handleClick]);
return (
<div className="fixed inset-0 pointer-events-none z-10">
{bubbles.map((bubble) => (
<div
key={bubble.id}
className="soap-bubble bubble-small"
style={{
left: bubble.x,
top: bubble.y,
width: bubble.size,
height: bubble.size,
}}
/>
))}
</div>
);
};

162
src/components/Navbar.tsx Normal file
View File

@ -0,0 +1,162 @@
import { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { motion } from 'framer-motion';
import {
Heart,
Music,
PlayCircle,
Sparkles,
LogOut,
User,
Menu,
X
} from 'lucide-react';
import { useStore } from '../store/useStore';
import { cn } from '../utils/cn';
export const Navbar = () => {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const { currentUser, partnerUser, logout } = useStore();
const location = useLocation();
const navigation = [
{ name: 'Dashboard', href: '/', icon: Heart },
{ name: 'Last Listened', href: '/last-listened', icon: Music },
{ name: 'Mixed Playlist', href: '/mixed-playlist', icon: PlayCircle },
{ name: 'Memory Lane', href: '/memory-lane', icon: Sparkles },
];
const handleLogout = () => {
logout();
localStorage.removeItem('spotify-user');
localStorage.removeItem('spotify-partner');
};
return (
<motion.nav
initial={{ y: -100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
className="glass-fluid border-b border-white/10 sticky top-0 z-50"
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo */}
<Link to="/" className="flex items-center space-x-2">
<motion.div
whileHover={{ scale: 1.1, rotate: 5 }}
className="w-8 h-8 bg-gradient-to-br from-pink-500 to-red-500 rounded-full flex items-center justify-center"
>
<Heart className="w-5 h-5 text-white" />
</motion.div>
<span className="text-xl font-bold gradient-text">
Our Musical Journey
</span>
</Link>
{/* Desktop Navigation */}
<div className="hidden md:flex items-center space-x-1">
{navigation.map((item) => {
const isActive = location.pathname === item.href;
return (
<Link
key={item.name}
to={item.href}
className={cn(
"flex items-center space-x-2 px-4 py-2 rounded-lg transition-all duration-300",
isActive
? "bg-spotify-green/20 text-spotify-green border border-spotify-green/30"
: "text-white/70 hover:text-white hover:bg-white/10"
)}
>
<item.icon className="w-4 h-4" />
<span className="font-medium">{item.name}</span>
</Link>
);
})}
</div>
{/* User Info & Actions */}
<div className="hidden md:flex items-center space-x-4">
{partnerUser && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="flex items-center space-x-2 text-sm text-white/70"
>
<User className="w-4 h-4" />
<span>+ {partnerUser.user?.display_name}</span>
</motion.div>
)}
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={handleLogout}
className="flex items-center space-x-2 px-3 py-2 rounded-lg text-white/70 hover:text-white hover:bg-red-500/20 transition-all duration-300"
>
<LogOut className="w-4 h-4" />
<span className="hidden lg:block">Logout</span>
</motion.button>
</div>
{/* Mobile menu button */}
<button
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
className="md:hidden p-2 rounded-lg text-white/70 hover:text-white hover:bg-white/10 transition-all duration-300"
>
{isMobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
</button>
</div>
{/* Mobile Navigation */}
{isMobileMenuOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="md:hidden py-4 border-t border-white/10"
>
<div className="space-y-2">
{navigation.map((item) => {
const isActive = location.pathname === item.href;
return (
<Link
key={item.name}
to={item.href}
onClick={() => setIsMobileMenuOpen(false)}
className={cn(
"flex items-center space-x-3 px-4 py-3 rounded-lg transition-all duration-300",
isActive
? "bg-spotify-green/20 text-spotify-green border border-spotify-green/30"
: "text-white/70 hover:text-white hover:bg-white/10"
)}
>
<item.icon className="w-5 h-5" />
<span className="font-medium">{item.name}</span>
</Link>
);
})}
<div className="pt-4 border-t border-white/10">
{partnerUser && (
<div className="flex items-center space-x-3 px-4 py-2 text-sm text-white/70">
<User className="w-4 h-4" />
<span>Connected with {partnerUser.user?.display_name}</span>
</div>
)}
<button
onClick={handleLogout}
className="w-full flex items-center space-x-3 px-4 py-3 rounded-lg text-white/70 hover:text-white hover:bg-red-500/20 transition-all duration-300"
>
<LogOut className="w-5 h-5" />
<span className="font-medium">Logout</span>
</button>
</div>
</div>
</motion.div>
)}
</div>
</motion.nav>
);
};

View File

@ -1,29 +0,0 @@
"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>
)
}

View File

@ -1,83 +0,0 @@
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"
}
}

View File

@ -1,156 +0,0 @@
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)
}

26
src/main.tsx Normal file
View File

@ -0,0 +1,26 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import App from './App.tsx'
import './styles/globals.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: {
background: 'rgba(255, 255, 255, 0.1)',
backdropFilter: 'blur(20px)',
border: '1px solid rgba(255, 255, 255, 0.2)',
color: '#fff',
},
}}
/>
</BrowserRouter>
</React.StrictMode>,
)

209
src/pages/CallbackPage.tsx Normal file
View File

@ -0,0 +1,209 @@
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { motion } from 'framer-motion';
import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
import toast from 'react-hot-toast';
import { exchangeCodeForToken, fetchUserProfile, fetchRecentlyPlayed, fetchTopTracks } from '../utils/spotify';
import { useStore } from '../store/useStore';
export const CallbackPage = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { setCurrentUser, setPartnerUser, currentUser } = useStore();
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
const [message, setMessage] = useState('');
useEffect(() => {
const handleCallback = async () => {
try {
const code = searchParams.get('code');
const error = searchParams.get('error');
console.log('🔍 CallbackPage - Code:', code);
console.log('🔍 CallbackPage - Error:', error);
if (error) {
throw new Error('Spotify authorization was denied');
}
if (!code) {
throw new Error('No authorization code received');
}
setMessage('Exchanging authorization code...');
// Exchange code for tokens
console.log('🔍 CallbackPage - Exchanging code for tokens...');
const tokenData = await exchangeCodeForToken(code);
console.log('🔍 CallbackPage - Token data received:', !!tokenData.access_token);
setMessage('Fetching your profile...');
// Fetch user profile
console.log('🔍 CallbackPage - Fetching user profile...');
const userProfile = await fetchUserProfile(tokenData.access_token);
console.log('🔍 CallbackPage - User profile received:', userProfile.display_name);
setMessage('Loading your music data...');
// Fetch user's music data
const [recentlyPlayed, topTracks] = await Promise.all([
fetchRecentlyPlayed(tokenData.access_token),
fetchTopTracks(tokenData.access_token),
]);
const userState = {
user: userProfile,
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token,
isAuthenticated: true,
recentlyPlayed,
topTracks,
topArtists: [], // Will be fetched separately if needed
};
// Store user data
localStorage.setItem('spotify-user', JSON.stringify(userState));
// Determine if this is the current user or partner
if (!currentUser) {
setCurrentUser(userState);
setMessage('Welcome! Setting up your musical journey...');
} else {
setPartnerUser(userState);
localStorage.setItem('spotify-partner', JSON.stringify(userState));
setMessage('Partner connected! Your musical journey begins...');
}
setStatus('success');
toast.success(
!currentUser ? 'Successfully connected to Spotify!' : 'Partner connected successfully!',
{ duration: 3000 }
);
// Redirect after a short delay
setTimeout(() => {
navigate('/');
}, 2000);
} catch (error) {
console.error('🔍 CallbackPage - ERROR:', error);
console.error('🔍 CallbackPage - Error details:', {
message: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined
});
setStatus('error');
setMessage(error instanceof Error ? error.message : 'An unexpected error occurred');
toast.error('Failed to connect to Spotify. Please try again.');
setTimeout(() => {
navigate('/');
}, 3000);
}
};
handleCallback();
}, [searchParams, navigate, setCurrentUser, setPartnerUser, currentUser]);
const getStatusIcon = () => {
switch (status) {
case 'loading':
return <Loader2 className="w-12 h-12 text-spotify-green animate-spin" />;
case 'success':
return <CheckCircle className="w-12 h-12 text-green-400" />;
case 'error':
return <XCircle className="w-12 h-12 text-red-400" />;
}
};
const getStatusColor = () => {
switch (status) {
case 'loading':
return 'border-spotify-green/30';
case 'success':
return 'border-green-400/30';
case 'error':
return 'border-red-400/30';
}
};
return (
<div className="min-h-screen flex items-center justify-center px-4 bg-black">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="glass-fluid rounded-3xl p-10 max-w-md w-full text-center"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
className={`w-20 h-20 mx-auto mb-6 rounded-full border-2 flex items-center justify-center ${getStatusColor()}`}
>
{getStatusIcon()}
</motion.div>
<motion.h2
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="text-2xl font-bold text-white mb-4"
>
{status === 'loading' && 'Connecting...'}
{status === 'success' && 'Success!'}
{status === 'error' && 'Connection Failed'}
</motion.h2>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="text-white/70 mb-6"
>
{message}
</motion.p>
{status === 'loading' && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
className="w-full bg-white/10 rounded-full h-2 mb-4"
>
<motion.div
className="bg-spotify-green h-2 rounded-full"
initial={{ width: 0 }}
animate={{ width: '100%' }}
transition={{ duration: 3, ease: "easeInOut" }}
/>
</motion.div>
)}
{status === 'error' && (
<motion.button
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => navigate('/')}
className="bg-spotify-green hover:bg-spotify-green/90 text-white font-semibold py-3 px-6 rounded-lg transition-all duration-300"
>
Try Again
</motion.button>
)}
{status === 'success' && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="text-spotify-green text-sm"
>
Redirecting you to your musical journey...
</motion.div>
)}
</motion.div>
</div>
);
};

234
src/pages/DashboardPage.tsx Normal file
View File

@ -0,0 +1,234 @@
import { motion } from 'framer-motion';
import { Link } from 'react-router-dom';
import {
Music,
PlayCircle,
Sparkles,
Heart,
Clock,
TrendingUp,
User,
Plus
} from 'lucide-react';
import { useStore } from '../store/useStore';
import { formatDate } from '../utils/cn';
export const DashboardPage = () => {
const { currentUser, partnerUser } = useStore();
const features = [
{
title: 'Last Listened',
description: 'See what your partner is listening to right now',
icon: Music,
href: '/last-listened',
color: 'from-blue-500 to-purple-600',
bgColor: 'bg-blue-500/10',
borderColor: 'border-blue-500/30',
},
{
title: 'Mixed Playlist',
description: 'Create AI-powered playlists blending both your tastes',
icon: PlayCircle,
href: '/mixed-playlist',
color: 'from-green-500 to-emerald-600',
bgColor: 'bg-green-500/10',
borderColor: 'border-green-500/30',
},
{
title: 'Memory Lane',
description: 'Your shared musical journey and memories',
icon: Sparkles,
href: '/memory-lane',
color: 'from-pink-500 to-rose-600',
bgColor: 'bg-pink-500/10',
borderColor: 'border-pink-500/30',
},
];
const getLastPlayedTime = () => {
if (!currentUser?.recentlyPlayed?.[0]) return null;
return formatDate(currentUser.recentlyPlayed[0].played_at);
};
const getPartnerLastPlayedTime = () => {
if (!partnerUser?.recentlyPlayed?.[0]) return null;
return formatDate(partnerUser.recentlyPlayed[0].played_at);
};
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<h1 className="text-4xl font-bold text-white mb-2">
Welcome back, <span className="gradient-text">{currentUser?.user?.display_name}</span>
</h1>
<p className="text-white/70 text-lg">
Ready to explore your musical connection together?
</p>
</motion.div>
{/* Connection Status */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="mb-8"
>
{partnerUser ? (
<div className="glass-bubble rounded-3xl p-8 border border-spotify-green/30 glow">
<div className="flex items-center space-x-6">
<div className="w-16 h-16 bg-gradient-to-br from-spotify-green to-green-600 rounded-2xl flex items-center justify-center">
<Heart className="w-8 h-8 text-white" />
</div>
<div>
<h3 className="text-xl font-semibold text-white">
Connected with {partnerUser.user?.display_name}
</h3>
<p className="text-white/70">
Your musical journey is ready to begin! 💕
</p>
</div>
</div>
</div>
) : (
<div className="glass-bubble rounded-3xl p-8 border border-orange-500/30">
<div className="flex items-center space-x-6">
<div className="w-16 h-16 bg-gradient-to-br from-orange-500 to-red-500 rounded-2xl flex items-center justify-center">
<Plus className="w-8 h-8 text-white" />
</div>
<div>
<h3 className="text-xl font-semibold text-white">
Invite your partner
</h3>
<p className="text-white/70">
Share this link so your partner can connect their Spotify account
</p>
<button className="mt-2 bg-orange-500 hover:bg-orange-600 text-white px-4 py-2 rounded-lg transition-colors">
Share Connection Link
</button>
</div>
</div>
</div>
)}
</motion.div>
{/* Quick Stats */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8"
>
<div className="glass-bubble rounded-2xl p-6">
<div className="flex items-center space-x-4">
<div className="w-12 h-12 bg-gradient-to-br from-blue-500/30 to-cyan-500/30 rounded-xl flex items-center justify-center">
<Clock className="w-6 h-6 text-blue-400" />
</div>
<div>
<p className="text-white/70 text-sm">Your last played</p>
<p className="text-white font-semibold">{getLastPlayedTime() || 'No recent plays'}</p>
</div>
</div>
</div>
<div className="glass-bubble rounded-2xl p-6">
<div className="flex items-center space-x-4">
<div className="w-12 h-12 bg-gradient-to-br from-green-500/30 to-emerald-500/30 rounded-xl flex items-center justify-center">
<TrendingUp className="w-6 h-6 text-green-400" />
</div>
<div>
<p className="text-white/70 text-sm">Top tracks analyzed</p>
<p className="text-white font-semibold">{currentUser?.topTracks?.length || 0} songs</p>
</div>
</div>
</div>
<div className="glass-bubble rounded-2xl p-6">
<div className="flex items-center space-x-4">
<div className="w-12 h-12 bg-gradient-to-br from-purple-500/30 to-pink-500/30 rounded-xl flex items-center justify-center">
<User className="w-6 h-6 text-purple-400" />
</div>
<div>
<p className="text-white/70 text-sm">Recently played</p>
<p className="text-white font-semibold">{currentUser?.recentlyPlayed?.length || 0} tracks</p>
</div>
</div>
</div>
</motion.div>
{/* Features Grid */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="grid md:grid-cols-3 gap-6"
>
{features.map((feature, index) => (
<motion.div
key={feature.title}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 + index * 0.1 }}
whileHover={{ y: -5, scale: 1.02 }}
>
<Link to={feature.href}>
<div className={`glass rounded-2xl p-6 h-full border ${feature.borderColor} group cursor-pointer transition-all duration-300 hover:shadow-xl`}>
<div className={`w-12 h-12 ${feature.bgColor} rounded-xl flex items-center justify-center mb-4 group-hover:scale-110 transition-transform duration-300`}>
<feature.icon className="w-6 h-6 text-white" />
</div>
<h3 className="text-xl font-semibold text-white mb-2">{feature.title}</h3>
<p className="text-white/70 text-sm leading-relaxed mb-4">{feature.description}</p>
<div className={`w-full h-1 bg-gradient-to-r ${feature.color} rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-300`} />
</div>
</Link>
</motion.div>
))}
</motion.div>
{/* Recent Activity Preview */}
{currentUser?.recentlyPlayed && currentUser.recentlyPlayed.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 }}
className="mt-8"
>
<h2 className="text-2xl font-bold text-white mb-4">Your Recent Activity</h2>
<div className="glass rounded-2xl p-6">
<div className="space-y-4">
{currentUser.recentlyPlayed.slice(0, 3).map((item, index) => (
<motion.div
key={item.track.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.7 + index * 0.1 }}
className="flex items-center space-x-4 p-3 rounded-lg hover:bg-white/5 transition-colors"
>
<img
src={item.track.album.images[0]?.url || '/placeholder-album.png'}
alt={item.track.album.name}
className="w-12 h-12 rounded-lg object-cover"
/>
<div className="flex-1">
<h4 className="text-white font-medium">{item.track.name}</h4>
<p className="text-white/70 text-sm">{item.track.artists[0]?.name}</p>
</div>
<div className="text-white/50 text-sm">
{formatDate(item.played_at)}
</div>
</motion.div>
))}
</div>
</div>
</motion.div>
)}
</div>
);
};

View File

@ -0,0 +1,264 @@
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import {
Play,
Pause,
Heart,
Clock,
User,
Music,
Volume2,
ExternalLink
} from 'lucide-react';
import { useStore } from '../store/useStore';
import { playTrack, pausePlayback, getCurrentPlayback } from '../utils/spotify';
import { formatDate, formatDuration } from '../utils/cn';
import toast from 'react-hot-toast';
export const LastListenedPage = () => {
const { currentUser, partnerUser, currentTrack, isPlaying, setCurrentTrack, setIsPlaying } = useStore();
const [isLoading, setIsLoading] = useState(false);
const [playingTrackId, setPlayingTrackId] = useState<string | null>(null);
const handlePlayTrack = async (trackUri: string, trackId: string) => {
if (!currentUser?.accessToken) {
toast.error('Not authenticated with Spotify');
return;
}
try {
setIsLoading(true);
setPlayingTrackId(trackId);
if (isPlaying && playingTrackId === trackId) {
await pausePlayback(currentUser.accessToken);
setIsPlaying(false);
setCurrentTrack(null);
setPlayingTrackId(null);
} else {
await playTrack(currentUser.accessToken, trackUri);
setIsPlaying(true);
setCurrentTrack(currentUser.recentlyPlayed?.find(item => item.track.id === trackId)?.track || null);
}
toast.success(isPlaying && playingTrackId === trackId ? 'Paused' : 'Now playing');
} catch (error) {
console.error('Playback error:', error);
toast.error('Failed to play track. Make sure Spotify is open on your device.');
} finally {
setIsLoading(false);
}
};
const UserSection = ({
user,
title,
isPartner = false
}: {
user: any;
title: string;
isPartner?: boolean;
}) => {
if (!user?.recentlyPlayed?.length) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="glass rounded-2xl p-8 text-center"
>
<Music className="w-16 h-16 mx-auto mb-4 text-white/30" />
<h3 className="text-xl font-semibold text-white mb-2">No recent activity</h3>
<p className="text-white/70">
{isPartner ? 'Your partner' : 'You'} haven't played any music recently
</p>
</motion.div>
);
}
const recentTrack = user.recentlyPlayed[0];
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="glass rounded-2xl p-6"
>
<div className="flex items-center space-x-4 mb-6">
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${
isPartner ? 'bg-gradient-to-br from-pink-500 to-purple-600' : 'bg-gradient-to-br from-blue-500 to-cyan-600'
}`}>
<User className="w-6 h-6 text-white" />
</div>
<div>
<h2 className="text-2xl font-bold text-white">{title}</h2>
<p className="text-white/70">
Last played: {formatDate(recentTrack.played_at)}
</p>
</div>
</div>
{/* Current Track Card */}
<motion.div
whileHover={{ scale: 1.02 }}
className="bg-white/5 rounded-xl p-6 mb-6 border border-white/10"
>
<div className="flex items-center space-x-6">
<div className="relative">
<img
src={recentTrack.track.album.images[0]?.url || '/placeholder-album.png'}
alt={recentTrack.track.album.name}
className="w-20 h-20 rounded-lg object-cover"
/>
{isPlaying && playingTrackId === recentTrack.track.id && (
<div className="absolute inset-0 bg-spotify-green/20 rounded-lg flex items-center justify-center">
<Volume2 className="w-6 h-6 text-spotify-green" />
</div>
)}
</div>
<div className="flex-1">
<h3 className="text-xl font-semibold text-white mb-1">
{recentTrack.track.name}
</h3>
<p className="text-white/70 mb-2">
{recentTrack.track.artists.map(artist => artist.name).join(', ')}
</p>
<p className="text-white/50 text-sm">
{recentTrack.track.album.name} {formatDuration(recentTrack.track.duration_ms)}
</p>
</div>
<div className="flex items-center space-x-3">
<button
onClick={() => handlePlayTrack(recentTrack.track.external_urls.spotify, recentTrack.track.id)}
disabled={isLoading}
className={`w-12 h-12 rounded-full flex items-center justify-center transition-all duration-300 ${
isPlaying && playingTrackId === recentTrack.track.id
? 'bg-red-500 hover:bg-red-600 text-white'
: 'bg-spotify-green hover:bg-spotify-green/90 text-white'
} disabled:opacity-50`}
>
{isLoading && playingTrackId === recentTrack.track.id ? (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : isPlaying && playingTrackId === recentTrack.track.id ? (
<Pause className="w-5 h-5" />
) : (
<Play className="w-5 h-5 ml-0.5" />
)}
</button>
<a
href={recentTrack.track.external_urls.spotify}
target="_blank"
rel="noopener noreferrer"
className="w-10 h-10 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center transition-colors"
>
<ExternalLink className="w-4 h-4 text-white" />
</a>
</div>
</div>
</motion.div>
{/* Recent History */}
<div>
<h3 className="text-lg font-semibold text-white mb-4 flex items-center space-x-2">
<Clock className="w-5 h-5" />
<span>Recent History</span>
</h3>
<div className="space-y-3">
{user.recentlyPlayed.slice(0, 5).map((item, index) => (
<motion.div
key={item.track.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
className="flex items-center space-x-4 p-3 rounded-lg hover:bg-white/5 transition-colors group"
>
<img
src={item.track.album.images[0]?.url || '/placeholder-album.png'}
alt={item.track.album.name}
className="w-12 h-12 rounded-lg object-cover"
/>
<div className="flex-1">
<h4 className="text-white font-medium group-hover:text-spotify-green transition-colors">
{item.track.name}
</h4>
<p className="text-white/70 text-sm">
{item.track.artists[0]?.name}
</p>
</div>
<div className="text-white/50 text-sm">
{formatDate(item.played_at)}
</div>
<button
onClick={() => handlePlayTrack(item.track.external_urls.spotify, item.track.id)}
disabled={isLoading}
className="opacity-0 group-hover:opacity-100 w-8 h-8 rounded-full bg-spotify-green hover:bg-spotify-green/90 flex items-center justify-center transition-all disabled:opacity-50"
>
<Play className="w-4 h-4 text-white ml-0.5" />
</button>
</motion.div>
))}
</div>
</div>
</motion.div>
);
};
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<h1 className="text-4xl font-bold text-white mb-2 flex items-center space-x-3">
<Heart className="w-10 h-10 text-spotify-green" />
<span>What's Playing</span>
</h1>
<p className="text-white/70 text-lg">
Discover what you and your partner are listening to right now
</p>
</motion.div>
<div className="grid lg:grid-cols-2 gap-8">
{currentUser && (
<UserSection
user={currentUser}
title={`${currentUser.user?.display_name}'s Music`}
isPartner={false}
/>
)}
{partnerUser && (
<UserSection
user={partnerUser}
title={`${partnerUser.user?.display_name}'s Music`}
isPartner={true}
/>
)}
{!partnerUser && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="glass rounded-2xl p-8 text-center"
>
<Heart className="w-16 h-16 mx-auto mb-4 text-pink-400" />
<h3 className="text-xl font-semibold text-white mb-2">Waiting for your partner</h3>
<p className="text-white/70 mb-4">
Invite your partner to connect their Spotify account to see their music
</p>
<button className="bg-pink-500 hover:bg-pink-600 text-white px-6 py-3 rounded-lg transition-colors">
Send Invitation
</button>
</motion.div>
)}
</div>
</div>
);
};

149
src/pages/LoginPage.tsx Normal file
View File

@ -0,0 +1,149 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import { Heart, Music, Sparkles, ArrowRight, Users } from 'lucide-react';
import { getSpotifyAuthUrl } from '../utils/spotify';
export const LoginPage = () => {
const [isLoading, setIsLoading] = useState(false);
const handleSpotifyLogin = () => {
setIsLoading(true);
window.location.href = getSpotifyAuthUrl();
};
const features = [
{
icon: Music,
title: 'Last Listened',
description: 'Discover what your partner is listening to right now and play it instantly',
},
{
icon: Sparkles,
title: 'Mixed Playlist',
description: 'AI-powered playlists that blend your musical tastes perfectly',
},
{
icon: Heart,
title: 'Memory Lane',
description: 'Create beautiful musical memories and shared experiences together',
},
];
return (
<div className="min-h-screen flex items-center justify-center px-4 bg-black">
<div className="max-w-4xl w-full relative">
{/* Hero Section */}
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8 }}
className="text-center mb-12"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
className="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-pink-500 to-red-500 rounded-full flex items-center justify-center"
>
<Heart className="w-10 h-10 text-white" />
</motion.div>
<h1 className="text-5xl md:text-6xl font-bold mb-4">
<span className="gradient-text">Our Musical</span>
<br />
<span className="text-white">Journey</span>
</h1>
<p className="text-xl text-white/70 max-w-2xl mx-auto leading-relaxed">
A private space where two hearts connect through music.
Discover, share, and create beautiful musical memories together.
</p>
</motion.div>
{/* Features Grid */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4, duration: 0.8 }}
className="grid md:grid-cols-3 gap-6 mb-12"
>
{features.map((feature, index) => (
<motion.div
key={feature.title}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 + index * 0.1 }}
whileHover={{ y: -8, scale: 1.03 }}
className="glass-bubble rounded-3xl p-8 text-center group cursor-pointer"
>
<motion.div
whileHover={{ rotate: 15, scale: 1.15 }}
className="w-16 h-16 mx-auto mb-6 bg-gradient-to-br from-purple-500/30 via-blue-500/30 to-pink-500/30 rounded-2xl flex items-center justify-center backdrop-blur-sm"
>
<feature.icon className="w-8 h-8 text-white" />
</motion.div>
<h3 className="text-2xl font-bold text-white mb-3">{feature.title}</h3>
<p className="text-white/70 text-sm leading-relaxed">{feature.description}</p>
</motion.div>
))}
</motion.div>
{/* Login Section */}
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.8, duration: 0.6 }}
className="glass-fluid rounded-3xl p-10 max-w-lg mx-auto"
>
<div className="text-center mb-8">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 1, type: "spring", stiffness: 200 }}
className="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-purple-500/30 via-blue-500/30 to-pink-500/30 rounded-2xl flex items-center justify-center backdrop-blur-sm"
>
<Users className="w-10 h-10 text-white" />
</motion.div>
<h2 className="text-3xl font-bold text-white mb-3">Begin Your Journey</h2>
<p className="text-white/70 text-lg">
Connect your Spotify account to start your musical love story
</p>
</div>
<motion.button
whileHover={{ scale: 1.05, boxShadow: "0 15px 40px rgba(147, 51, 234, 0.4)" }}
whileTap={{ scale: 0.95 }}
onClick={handleSpotifyLogin}
disabled={isLoading}
className="w-full bg-gradient-to-r from-purple-600 via-blue-600 to-pink-600 hover:from-purple-700 hover:via-blue-700 hover:to-pink-700 text-white font-bold py-5 px-8 rounded-2xl transition-all duration-300 flex items-center justify-center space-x-3 disabled:opacity-50 shadow-2xl"
>
{isLoading ? (
<div className="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<>
<span className="text-lg">Connect with Spotify</span>
<ArrowRight className="w-6 h-6" />
</>
)}
</motion.button>
<p className="text-sm text-white/60 text-center mt-6 leading-relaxed">
We'll only access your listening history and playlist data to create your personalized musical journey
</p>
</motion.div>
{/* Footer */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1, duration: 0.6 }}
className="text-center mt-12"
>
<p className="text-white/40 text-sm">
Made with 💕 for a special someone
</p>
</motion.div>
</div>
</div>
);
};

View File

@ -0,0 +1,351 @@
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import {
Heart,
Calendar,
Music,
Sparkles,
Plus,
Camera,
Star,
Clock,
Users,
MessageCircle
} from 'lucide-react';
import { useStore } from '../store/useStore';
import { formatDate } from '../utils/cn';
import toast from 'react-hot-toast';
export const MemoryLanePage = () => {
const { currentUser, partnerUser, memoryLane, addMemoryLaneItem } = useStore();
const [showAddMemory, setShowAddMemory] = useState(false);
const [newMemory, setNewMemory] = useState({
title: '',
description: '',
type: 'milestone' as 'shared_track' | 'playlist_created' | 'milestone',
});
// Generate some sample memories based on user activity
useEffect(() => {
if (currentUser && partnerUser && memoryLane.length === 0) {
const sampleMemories = [
{
id: '1',
type: 'milestone' as const,
title: 'First Musical Connection',
description: `${currentUser.user?.display_name} and ${partnerUser.user?.display_name} discovered their shared love for music`,
date: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // 7 days ago
users: [currentUser.user?.id || '', partnerUser.user?.id || ''],
},
{
id: '2',
type: 'shared_track' as const,
title: 'Shared Love for This Song',
description: `Both of you have been listening to "${currentUser.recentlyPlayed?.[0]?.track.name || 'Your favorite track'}" recently`,
track: currentUser.recentlyPlayed?.[0]?.track,
date: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000), // 3 days ago
users: [currentUser.user?.id || '', partnerUser.user?.id || ''],
},
{
id: '3',
type: 'playlist_created' as const,
title: 'Our First Mixed Playlist',
description: 'Created a beautiful blend of your musical tastes',
date: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000), // 1 day ago
users: [currentUser.user?.id || '', partnerUser.user?.id || ''],
},
];
sampleMemories.forEach(memory => {
addMemoryLaneItem(memory);
});
}
}, [currentUser, partnerUser, memoryLane.length, addMemoryLaneItem]);
const handleAddMemory = () => {
if (!newMemory.title.trim() || !newMemory.description.trim()) {
toast.error('Please fill in both title and description');
return;
}
const memory = {
id: Date.now().toString(),
...newMemory,
date: new Date(),
users: [currentUser?.user?.id || '', partnerUser?.user?.id || ''],
};
addMemoryLaneItem(memory);
setNewMemory({ title: '', description: '', type: 'milestone' });
setShowAddMemory(false);
toast.success('Memory added to your journey!');
};
const getMemoryIcon = (type: string) => {
switch (type) {
case 'shared_track':
return <Music className="w-5 h-5" />;
case 'playlist_created':
return <Sparkles className="w-5 h-5" />;
case 'milestone':
return <Star className="w-5 h-5" />;
default:
return <Heart className="w-5 h-5" />;
}
};
const getMemoryColor = (type: string) => {
switch (type) {
case 'shared_track':
return 'from-blue-500 to-cyan-500';
case 'playlist_created':
return 'from-purple-500 to-pink-500';
case 'milestone':
return 'from-yellow-500 to-orange-500';
default:
return 'from-pink-500 to-red-500';
}
};
const MemoryCard = ({ memory, index }: { memory: any; index: number }) => (
<motion.div
initial={{ opacity: 0, x: -50 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
className="relative"
>
{/* Timeline line */}
{index < memoryLane.length - 1 && (
<div className="absolute left-8 top-16 w-0.5 h-full bg-gradient-to-b from-white/20 to-transparent" />
)}
<div className="flex items-start space-x-6">
{/* Timeline dot */}
<div className={`w-16 h-16 rounded-full bg-gradient-to-br ${getMemoryColor(memory.type)} flex items-center justify-center relative z-10`}>
{getMemoryIcon(memory.type)}
</div>
{/* Memory content */}
<motion.div
whileHover={{ scale: 1.02 }}
className="glass rounded-2xl p-6 flex-1 border border-white/10"
>
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-xl font-semibold text-white mb-2">{memory.title}</h3>
<p className="text-white/70 leading-relaxed">{memory.description}</p>
</div>
<div className="flex items-center space-x-2 text-white/50 text-sm">
<Calendar className="w-4 h-4" />
<span>{formatDate(memory.date)}</span>
</div>
</div>
{/* Track preview if it's a shared track */}
{memory.track && (
<div className="flex items-center space-x-4 p-4 bg-white/5 rounded-lg">
<img
src={memory.track.album.images[0]?.url || '/placeholder-album.png'}
alt={memory.track.album.name}
className="w-12 h-12 rounded-lg object-cover"
/>
<div className="flex-1">
<h4 className="text-white font-medium">{memory.track.name}</h4>
<p className="text-white/70 text-sm">{memory.track.artists[0]?.name}</p>
</div>
<button className="w-8 h-8 rounded-full bg-spotify-green hover:bg-spotify-green/90 flex items-center justify-center transition-colors">
<Music className="w-4 h-4 text-white" />
</button>
</div>
)}
{/* Memory metadata */}
<div className="flex items-center justify-between mt-4 pt-4 border-t border-white/10">
<div className="flex items-center space-x-4 text-white/50 text-sm">
<div className="flex items-center space-x-1">
<Users className="w-4 h-4" />
<span>Both of you</span>
</div>
<div className="flex items-center space-x-1">
<Clock className="w-4 h-4" />
<span>{new Date(memory.date).toLocaleDateString()}</span>
</div>
</div>
<button className="w-8 h-8 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center transition-colors">
<Heart className="w-4 h-4 text-white" />
</button>
</div>
</motion.div>
</div>
</motion.div>
);
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<div className="flex items-center justify-between">
<div>
<h1 className="text-4xl font-bold text-white mb-2 flex items-center space-x-3">
<Heart className="w-10 h-10 text-pink-400" />
<span>Memory Lane</span>
</h1>
<p className="text-white/70 text-lg">
Your beautiful musical journey together
</p>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => setShowAddMemory(true)}
className="bg-gradient-to-r from-pink-500 to-red-500 hover:from-pink-600 hover:to-red-600 text-white font-semibold py-3 px-6 rounded-xl transition-all duration-300 flex items-center space-x-2"
>
<Plus className="w-5 h-5" />
<span>Add Memory</span>
</motion.button>
</div>
</motion.div>
{/* Add Memory Modal */}
{showAddMemory && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4"
onClick={() => setShowAddMemory(false)}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
onClick={(e) => e.stopPropagation()}
className="glass rounded-2xl p-8 max-w-md w-full"
>
<h3 className="text-2xl font-bold text-white mb-6">Add a New Memory</h3>
<div className="space-y-4">
<div>
<label className="block text-white/70 text-sm font-medium mb-2">Title</label>
<input
type="text"
value={newMemory.title}
onChange={(e) => setNewMemory({ ...newMemory, title: e.target.value })}
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-spotify-green"
placeholder="What's this memory about?"
/>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">Description</label>
<textarea
value={newMemory.description}
onChange={(e) => setNewMemory({ ...newMemory, description: e.target.value })}
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-spotify-green h-24 resize-none"
placeholder="Tell the story of this memory..."
/>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">Type</label>
<select
value={newMemory.type}
onChange={(e) => setNewMemory({ ...newMemory, type: e.target.value as any })}
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white focus:outline-none focus:border-spotify-green"
>
<option value="milestone">Milestone</option>
<option value="shared_track">Shared Track</option>
<option value="playlist_created">Playlist Created</option>
</select>
</div>
</div>
<div className="flex items-center space-x-3 mt-6">
<button
onClick={handleAddMemory}
className="flex-1 bg-spotify-green hover:bg-spotify-green/90 text-white font-semibold py-3 px-4 rounded-lg transition-colors"
>
Add Memory
</button>
<button
onClick={() => setShowAddMemory(false)}
className="px-4 py-3 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors"
>
Cancel
</button>
</div>
</motion.div>
</motion.div>
)}
{/* Timeline */}
<div className="space-y-8">
{memoryLane.length > 0 ? (
memoryLane.map((memory, index) => (
<MemoryCard key={memory.id} memory={memory} index={index} />
))
) : (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="glass rounded-2xl p-12 text-center"
>
<Camera className="w-16 h-16 mx-auto mb-4 text-white/30" />
<h3 className="text-xl font-semibold text-white mb-2">No memories yet</h3>
<p className="text-white/70 mb-6">
Start creating beautiful musical memories together
</p>
<button
onClick={() => setShowAddMemory(true)}
className="bg-gradient-to-r from-pink-500 to-red-500 hover:from-pink-600 hover:to-red-600 text-white font-semibold py-3 px-6 rounded-xl transition-all duration-300"
>
Create First Memory
</button>
</motion.div>
)}
</div>
{/* Stats */}
{memoryLane.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="mt-12 grid grid-cols-1 md:grid-cols-3 gap-6"
>
<div className="glass rounded-2xl p-6 text-center">
<div className="w-12 h-12 mx-auto mb-3 bg-gradient-to-br from-pink-500 to-red-500 rounded-full flex items-center justify-center">
<Heart className="w-6 h-6 text-white" />
</div>
<h4 className="text-2xl font-bold text-white mb-1">{memoryLane.length}</h4>
<p className="text-white/70 text-sm">Memories Created</p>
</div>
<div className="glass rounded-2xl p-6 text-center">
<div className="w-12 h-12 mx-auto mb-3 bg-gradient-to-br from-blue-500 to-cyan-500 rounded-full flex items-center justify-center">
<Music className="w-6 h-6 text-white" />
</div>
<h4 className="text-2xl font-bold text-white mb-1">
{memoryLane.filter(m => m.type === 'shared_track').length}
</h4>
<p className="text-white/70 text-sm">Shared Tracks</p>
</div>
<div className="glass rounded-2xl p-6 text-center">
<div className="w-12 h-12 mx-auto mb-3 bg-gradient-to-br from-purple-500 to-pink-500 rounded-full flex items-center justify-center">
<Sparkles className="w-6 h-6 text-white" />
</div>
<h4 className="text-2xl font-bold text-white mb-1">
{memoryLane.filter(m => m.type === 'playlist_created').length}
</h4>
<p className="text-white/70 text-sm">Playlists Created</p>
</div>
</motion.div>
)}
</div>
);
};

View File

@ -0,0 +1,343 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import {
Sparkles,
Play,
Plus,
Music,
Heart,
Shuffle,
Download,
ExternalLink,
Trash2,
Wand2
} from 'lucide-react';
import { useStore } from '../store/useStore';
import { generateMixedPlaylist, createPlaylist, addTracksToPlaylist } from '../utils/spotify';
import { formatDuration } from '../utils/cn';
import toast from 'react-hot-toast';
export const MixedPlaylistPage = () => {
const { currentUser, partnerUser, mixedPlaylists, addMixedPlaylist, removeMixedPlaylist } = useStore();
const [isGenerating, setIsGenerating] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [newPlaylist, setNewPlaylist] = useState<any>(null);
const handleGeneratePlaylist = async () => {
if (!currentUser?.topTracks || !partnerUser?.topTracks) {
toast.error('Both users need to have their music data loaded');
return;
}
setIsGenerating(true);
try {
// Simulate AI processing time
await new Promise(resolve => setTimeout(resolve, 2000));
const generatedTracks = generateMixedPlaylist(
currentUser.topTracks,
partnerUser.topTracks,
"Our Perfect Mix"
);
setNewPlaylist({
id: Date.now().toString(),
name: "Our Perfect Mix",
description: `A beautiful blend of ${currentUser.user?.display_name} and ${partnerUser.user?.display_name}'s musical tastes`,
tracks: generatedTracks,
createdAt: new Date(),
createdBy: 'AI Magic ✨',
});
toast.success('Playlist generated successfully!');
} catch (error) {
console.error('Playlist generation error:', error);
toast.error('Failed to generate playlist');
} finally {
setIsGenerating(false);
}
};
const handleCreateSpotifyPlaylist = async () => {
if (!newPlaylist || !currentUser?.accessToken || !currentUser?.user) {
toast.error('Unable to create playlist');
return;
}
setIsCreating(true);
try {
// Create playlist on Spotify
const spotifyPlaylist = await createPlaylist(
currentUser.accessToken,
currentUser.user.id,
newPlaylist.name,
newPlaylist.description
);
// Add tracks to playlist
const trackUris = newPlaylist.tracks.map((track: any) => track.external_urls.spotify);
await addTracksToPlaylist(
currentUser.accessToken,
spotifyPlaylist.id,
trackUris
);
// Save to local state
addMixedPlaylist({
...newPlaylist,
spotifyId: spotifyPlaylist.id,
spotifyUrl: spotifyPlaylist.external_urls.spotify,
});
toast.success('Playlist created on Spotify!');
setNewPlaylist(null);
} catch (error) {
console.error('Spotify playlist creation error:', error);
toast.error('Failed to create playlist on Spotify');
} finally {
setIsCreating(false);
}
};
const handleSaveLocally = () => {
if (!newPlaylist) return;
addMixedPlaylist(newPlaylist);
setNewPlaylist(null);
toast.success('Playlist saved locally!');
};
const PlaylistCard = ({ playlist, isNew = false }: { playlist: any; isNew?: boolean }) => (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
whileHover={{ scale: 1.02 }}
className={`glass rounded-2xl p-6 ${isNew ? 'border-spotify-green/50' : 'border-white/10'}`}
>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center space-x-4">
<div className={`w-16 h-16 rounded-xl flex items-center justify-center ${
isNew ? 'bg-gradient-to-br from-spotify-green to-green-600' : 'bg-gradient-to-br from-purple-500 to-pink-500'
}`}>
{isNew ? <Sparkles className="w-8 h-8 text-white" /> : <Music className="w-8 h-8 text-white" />}
</div>
<div>
<h3 className="text-xl font-semibold text-white">{playlist.name}</h3>
<p className="text-white/70 text-sm">{playlist.description}</p>
<p className="text-white/50 text-xs mt-1">
Created {isNew ? 'just now' : new Date(playlist.createdAt).toLocaleDateString()} {playlist.tracks.length} tracks
</p>
</div>
</div>
<div className="flex items-center space-x-2">
{playlist.spotifyUrl && (
<a
href={playlist.spotifyUrl}
target="_blank"
rel="noopener noreferrer"
className="w-8 h-8 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center transition-colors"
>
<ExternalLink className="w-4 h-4 text-white" />
</a>
)}
{!isNew && (
<button
onClick={() => removeMixedPlaylist(playlist.id)}
className="w-8 h-8 rounded-full bg-red-500/20 hover:bg-red-500/30 flex items-center justify-center transition-colors"
>
<Trash2 className="w-4 h-4 text-red-400" />
</button>
)}
</div>
</div>
{/* Track List */}
<div className="space-y-3 max-h-64 overflow-y-auto">
{playlist.tracks.slice(0, 5).map((track: any, index: number) => (
<motion.div
key={track.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
className="flex items-center space-x-3 p-2 rounded-lg hover:bg-white/5 transition-colors"
>
<img
src={track.album.images[0]?.url || '/placeholder-album.png'}
alt={track.album.name}
className="w-10 h-10 rounded-lg object-cover"
/>
<div className="flex-1 min-w-0">
<h4 className="text-white font-medium truncate">{track.name}</h4>
<p className="text-white/70 text-sm truncate">{track.artists[0]?.name}</p>
</div>
<div className="text-white/50 text-sm">
{formatDuration(track.duration_ms)}
</div>
</motion.div>
))}
{playlist.tracks.length > 5 && (
<div className="text-center text-white/50 text-sm py-2">
+{playlist.tracks.length - 5} more tracks
</div>
)}
</div>
{/* Actions */}
{isNew && (
<div className="flex items-center space-x-3 mt-6 pt-4 border-t border-white/10">
<button
onClick={handleCreateSpotifyPlaylist}
disabled={isCreating}
className="flex-1 bg-spotify-green hover:bg-spotify-green/90 text-white font-semibold py-3 px-4 rounded-lg transition-colors flex items-center justify-center space-x-2 disabled:opacity-50"
>
{isCreating ? (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<>
<Plus className="w-4 h-4" />
<span>Create on Spotify</span>
</>
)}
</button>
<button
onClick={handleSaveLocally}
className="px-4 py-3 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors"
>
Save Locally
</button>
</div>
)}
</motion.div>
);
const canGeneratePlaylist = currentUser?.topTracks && partnerUser?.topTracks;
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<h1 className="text-4xl font-bold text-white mb-2 flex items-center space-x-3">
<Sparkles className="w-10 h-10 text-spotify-green" />
<span>Mixed Playlists</span>
</h1>
<p className="text-white/70 text-lg">
AI-powered playlists that perfectly blend your musical tastes together
</p>
</motion.div>
{/* Generate New Playlist Section */}
{canGeneratePlaylist && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="mb-8"
>
<div className="glass rounded-2xl p-8 text-center border border-spotify-green/30">
<div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-spotify-green to-green-600 rounded-full flex items-center justify-center">
<Wand2 className="w-8 h-8 text-white" />
</div>
<h2 className="text-2xl font-bold text-white mb-2">Create Your Perfect Mix</h2>
<p className="text-white/70 mb-6 max-w-2xl mx-auto">
Our AI analyzes both your music tastes and creates a playlist that represents your unique musical connection
</p>
<button
onClick={handleGeneratePlaylist}
disabled={isGenerating}
className="bg-gradient-to-r from-spotify-green to-green-600 hover:from-spotify-green/90 hover:to-green-600/90 text-white font-semibold py-4 px-8 rounded-xl transition-all duration-300 flex items-center space-x-3 mx-auto disabled:opacity-50"
>
{isGenerating ? (
<>
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
<span>Analyzing your music...</span>
</>
) : (
<>
<Sparkles className="w-5 h-5" />
<span>Generate Mixed Playlist</span>
</>
)}
</button>
<div className="mt-4 text-white/50 text-sm">
Analyzing {currentUser.topTracks.length + partnerUser.topTracks.length} tracks from both users
</div>
</div>
</motion.div>
)}
{/* New Generated Playlist */}
{newPlaylist && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="mb-8"
>
<h2 className="text-2xl font-bold text-white mb-4"> Your New Playlist</h2>
<PlaylistCard playlist={newPlaylist} isNew={true} />
</motion.div>
)}
{/* Existing Playlists */}
{mixedPlaylists.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<h2 className="text-2xl font-bold text-white mb-4">Your Mixed Playlists</h2>
<div className="grid gap-6">
{mixedPlaylists.map((playlist) => (
<PlaylistCard key={playlist.id} playlist={playlist} />
))}
</div>
</motion.div>
)}
{/* Empty State */}
{!canGeneratePlaylist && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="glass rounded-2xl p-12 text-center"
>
<Heart className="w-16 h-16 mx-auto mb-4 text-pink-400" />
<h3 className="text-xl font-semibold text-white mb-2">
{!partnerUser ? 'Waiting for your partner' : 'Loading music data'}
</h3>
<p className="text-white/70">
{!partnerUser
? 'Invite your partner to connect their Spotify account to start creating mixed playlists'
: 'We need to analyze both your music tastes to create the perfect mixed playlist'
}
</p>
</motion.div>
)}
{mixedPlaylists.length === 0 && canGeneratePlaylist && !newPlaylist && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="glass rounded-2xl p-12 text-center"
>
<Music className="w-16 h-16 mx-auto mb-4 text-white/30" />
<h3 className="text-xl font-semibold text-white mb-2">No playlists yet</h3>
<p className="text-white/70">
Create your first mixed playlist to start your musical journey together
</p>
</motion.div>
)}
</div>
);
};

106
src/store/useStore.ts Normal file
View File

@ -0,0 +1,106 @@
import { create } from 'zustand';
import { AppState, UserState, SpotifyTrack, MixedPlaylist, MemoryLaneItem } from '../types';
interface StoreActions {
// Authentication actions
setCurrentUser: (user: UserState) => void;
setPartnerUser: (user: UserState) => void;
logout: () => void;
// Loading and error states
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
// Music playback
setCurrentTrack: (track: SpotifyTrack | null) => void;
setIsPlaying: (playing: boolean) => void;
// Mixed playlists
addMixedPlaylist: (playlist: MixedPlaylist) => void;
removeMixedPlaylist: (id: string) => void;
// Memory lane
addMemoryLaneItem: (item: MemoryLaneItem) => void;
removeMemoryLaneItem: (id: string) => void;
// Clear all data
clearAllData: () => void;
}
type Store = AppState & StoreActions & {
mixedPlaylists: MixedPlaylist[];
memoryLane: MemoryLaneItem[];
};
const initialState: AppState = {
currentUser: null,
partnerUser: null,
isLoading: false,
error: null,
currentTrack: null,
isPlaying: false,
};
export const useStore = create<Store>((set, get) => ({
...initialState,
mixedPlaylists: [],
memoryLane: [],
setCurrentUser: (user: UserState) =>
set({ currentUser: user }),
setPartnerUser: (user: UserState) =>
set({ partnerUser: user }),
logout: () =>
set({
currentUser: null,
partnerUser: null,
currentTrack: null,
isPlaying: false,
mixedPlaylists: [],
memoryLane: [],
error: null
}),
setLoading: (loading: boolean) =>
set({ isLoading: loading }),
setError: (error: string | null) =>
set({ error }),
setCurrentTrack: (track: SpotifyTrack | null) =>
set({ currentTrack: track }),
setIsPlaying: (playing: boolean) =>
set({ isPlaying: playing }),
addMixedPlaylist: (playlist: MixedPlaylist) =>
set((state) => ({
mixedPlaylists: [...state.mixedPlaylists, playlist]
})),
removeMixedPlaylist: (id: string) =>
set((state) => ({
mixedPlaylists: state.mixedPlaylists.filter(p => p.id !== id)
})),
addMemoryLaneItem: (item: MemoryLaneItem) =>
set((state) => ({
memoryLane: [...state.memoryLane, item].sort((a, b) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
)
})),
removeMemoryLaneItem: (id: string) =>
set((state) => ({
memoryLane: state.memoryLane.filter(item => item.id !== id)
})),
clearAllData: () =>
set({
...initialState,
mixedPlaylists: [],
memoryLane: [],
}),
}));

418
src/styles/globals.css Normal file
View File

@ -0,0 +1,418 @@
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
:root {
font-family: 'Inter', system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: dark;
color: rgba(255, 255, 255, 0.87);
background: #000000;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
height: 100%;
overflow-x: hidden;
}
body {
background: transparent;
background-attachment: fixed;
min-height: 100vh;
}
#root {
min-height: 100vh;
position: relative;
}
/* Glassmorphism utilities */
.glass {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.glass-dark {
background: rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.glass-green {
background: rgba(29, 185, 84, 0.1);
backdrop-filter: blur(20px);
border: 1px solid rgba(29, 185, 84, 0.3);
box-shadow: 0 8px 32px rgba(29, 185, 84, 0.2);
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: rgba(29, 185, 84, 0.6);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(29, 185, 84, 0.8);
}
/* Animated background particles */
.particles {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: -1;
}
.particle {
position: absolute;
background: radial-gradient(circle, rgba(29, 185, 84, 0.3) 0%, transparent 70%);
border-radius: 50%;
animation: float 20s infinite linear;
}
@keyframes float {
0% {
transform: translateY(100vh) rotate(0deg);
opacity: 0;
}
10% {
opacity: 1;
}
90% {
opacity: 1;
}
100% {
transform: translateY(-100px) rotate(360deg);
opacity: 0;
}
}
/* Fluid wave animations */
.wave-bubble {
position: absolute;
border-radius: 50%;
background: radial-gradient(circle, rgba(147, 51, 234, 0.4) 0%, rgba(59, 130, 246, 0.3) 50%, rgba(236, 72, 153, 0.2) 100%);
filter: blur(40px);
animation: wave-flow 8s ease-in-out infinite;
}
/* Large liquid light cylinders from top */
.light-cylinder {
position: absolute;
top: -30%;
transform-origin: top center;
width: 400px;
height: 150vh;
background: linear-gradient(to bottom,
rgba(147, 51, 234, 0.9) 0%,
rgba(59, 130, 246, 0.7) 15%,
rgba(236, 72, 153, 0.6) 30%,
rgba(255, 255, 255, 0.4) 50%,
rgba(255, 255, 255, 0.2) 70%,
rgba(255, 255, 255, 0.1) 85%,
transparent 100%
);
border-radius: 200px;
animation: liquid-flow 40s ease-in-out infinite;
filter: blur(80px);
}
.light-cylinder-1 {
animation-delay: 0s;
left: 10%;
width: 600px;
height: 160vh;
}
.light-cylinder-2 {
animation-delay: 13s;
left: 45%;
width: 550px;
height: 140vh;
}
.light-cylinder-3 {
animation-delay: 26s;
left: 70%;
width: 580px;
height: 150vh;
}
@keyframes liquid-flow {
0% {
transform: translateX(0px) translateY(0px) rotate(0deg) scale(1);
opacity: 0.7;
filter: blur(80px);
}
20% {
transform: translateX(40px) translateY(15px) rotate(2deg) scale(1.05);
opacity: 0.75;
filter: blur(85px);
}
40% {
transform: translateX(-25px) translateY(-8px) rotate(-1.5deg) scale(0.95);
opacity: 0.65;
filter: blur(82px);
}
60% {
transform: translateX(60px) translateY(25px) rotate(3deg) scale(1.1);
opacity: 0.8;
filter: blur(88px);
}
80% {
transform: translateX(-15px) translateY(10px) rotate(-1deg) scale(0.98);
opacity: 0.75;
filter: blur(84px);
}
100% {
transform: translateX(0px) translateY(0px) rotate(0deg) scale(1);
opacity: 0.7;
filter: blur(80px);
}
}
.wave-bubble:nth-child(1) {
width: 300px;
height: 300px;
top: 10%;
left: 10%;
animation-delay: 0s;
animation-duration: 12s;
}
.wave-bubble:nth-child(2) {
width: 200px;
height: 200px;
top: 60%;
right: 15%;
animation-delay: 2s;
animation-duration: 10s;
}
.wave-bubble:nth-child(3) {
width: 250px;
height: 250px;
bottom: 20%;
left: 20%;
animation-delay: 4s;
animation-duration: 14s;
}
.wave-bubble:nth-child(4) {
width: 180px;
height: 180px;
top: 30%;
right: 40%;
animation-delay: 6s;
animation-duration: 9s;
}
.wave-bubble:nth-child(5) {
width: 320px;
height: 320px;
bottom: 40%;
right: 10%;
animation-delay: 8s;
animation-duration: 16s;
}
@keyframes wave-flow {
0%, 100% {
transform: translateY(0px) translateX(0px) scale(1);
opacity: 0.3;
}
25% {
transform: translateY(-50px) translateX(30px) scale(1.1);
opacity: 0.6;
}
50% {
transform: translateY(-20px) translateX(-20px) scale(0.9);
opacity: 0.4;
}
75% {
transform: translateY(-80px) translateX(40px) scale(1.2);
opacity: 0.7;
}
}
/* Real soap bubbles */
.soap-bubble {
position: absolute;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%,
rgba(255, 255, 255, 0.8) 0%,
rgba(255, 255, 255, 0.4) 20%,
rgba(147, 51, 234, 0.2) 40%,
rgba(59, 130, 246, 0.3) 60%,
rgba(236, 72, 153, 0.2) 80%,
rgba(255, 255, 255, 0.1) 100%
);
border: 2px solid rgba(255, 255, 255, 0.3);
box-shadow:
inset 0 0 20px rgba(255, 255, 255, 0.2),
inset 0 0 40px rgba(147, 51, 234, 0.1),
0 0 30px rgba(255, 255, 255, 0.1);
animation: bubble-float 8s ease-in-out infinite;
}
.bubble-small {
width: 20px;
height: 20px;
animation: bubble-rise 3s ease-out forwards;
}
.bubble-medium {
width: 40px;
height: 40px;
animation: bubble-float 6s ease-in-out infinite;
}
.bubble-large {
width: 80px;
height: 80px;
animation: bubble-float 10s ease-in-out infinite;
}
@keyframes bubble-float {
0%, 100% {
transform: translateY(0px) scale(1);
opacity: 0.6;
}
50% {
transform: translateY(-20px) scale(1.1);
opacity: 0.8;
}
}
@keyframes bubble-rise {
0% {
transform: translateY(0px) scale(1);
opacity: 0.8;
}
100% {
transform: translateY(-150px) scale(0.2);
opacity: 0;
}
}
/* Enhanced glassmorphism */
.glass-fluid {
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(30px);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.glass-bubble {
background: radial-gradient(circle at 30% 30%,
rgba(255, 255, 255, 0.15) 0%,
rgba(255, 255, 255, 0.08) 20%,
rgba(147, 51, 234, 0.1) 40%,
rgba(59, 130, 246, 0.08) 60%,
rgba(236, 72, 153, 0.06) 80%,
rgba(255, 255, 255, 0.03) 100%
);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow:
inset 0 0 20px rgba(255, 255, 255, 0.1),
inset 0 0 40px rgba(147, 51, 234, 0.05),
0 8px 32px rgba(147, 51, 234, 0.1),
0 0 20px rgba(255, 255, 255, 0.05);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.glass-bubble:hover {
background: radial-gradient(circle at 30% 30%,
rgba(255, 255, 255, 0.2) 0%,
rgba(255, 255, 255, 0.12) 20%,
rgba(147, 51, 234, 0.15) 40%,
rgba(59, 130, 246, 0.12) 60%,
rgba(236, 72, 153, 0.1) 80%,
rgba(255, 255, 255, 0.05) 100%
);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow:
inset 0 0 25px rgba(255, 255, 255, 0.15),
inset 0 0 50px rgba(147, 51, 234, 0.08),
0 12px 40px rgba(147, 51, 234, 0.2),
0 0 30px rgba(255, 255, 255, 0.1);
transform: translateY(-2px);
}
/* Spotify-like hover effects */
.spotify-hover {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.spotify-hover:hover {
transform: translateY(-2px);
box-shadow: 0 12px 40px rgba(29, 185, 84, 0.3);
}
/* Gradient text */
.gradient-text {
background: linear-gradient(135deg, #1db954, #1ed760, #1db954);
background-size: 200% 200%;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: gradient-shift 3s ease infinite;
}
@keyframes gradient-shift {
0%, 100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
}
/* Pulse animation for play buttons */
.pulse-play {
animation: pulse-play 2s infinite;
}
@keyframes pulse-play {
0% {
box-shadow: 0 0 0 0 rgba(29, 185, 84, 0.7);
}
70% {
box-shadow: 0 0 0 10px rgba(29, 185, 84, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(29, 185, 84, 0);
}
}

107
src/types/index.ts Normal file
View File

@ -0,0 +1,107 @@
export interface SpotifyTrack {
id: string;
name: string;
artists: SpotifyArtist[];
album: SpotifyAlbum;
duration_ms: number;
external_urls: {
spotify: string;
};
preview_url?: string;
popularity: number;
}
export interface SpotifyArtist {
id: string;
name: string;
external_urls: {
spotify: string;
};
}
export interface SpotifyAlbum {
id: string;
name: string;
images: SpotifyImage[];
external_urls: {
spotify: string;
};
}
export interface SpotifyImage {
url: string;
height: number;
width: number;
}
export interface SpotifyUser {
id: string;
display_name: string;
email: string;
images: SpotifyImage[];
external_urls: {
spotify: string;
};
}
export interface SpotifyPlaylist {
id: string;
name: string;
description: string;
images: SpotifyImage[];
tracks: {
total: number;
items: SpotifyPlaylistTrack[];
};
external_urls: {
spotify: string;
};
}
export interface SpotifyPlaylistTrack {
track: SpotifyTrack;
}
export interface RecentlyPlayedItem {
track: SpotifyTrack;
played_at: string;
}
export interface UserState {
user: SpotifyUser | null;
accessToken: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
recentlyPlayed: RecentlyPlayedItem[];
topTracks: SpotifyTrack[];
topArtists: SpotifyArtist[];
}
export interface AppState {
currentUser: UserState | null;
partnerUser: UserState | null;
isLoading: boolean;
error: string | null;
currentTrack: SpotifyTrack | null;
isPlaying: boolean;
}
export interface MixedPlaylist {
id: string;
name: string;
description: string;
tracks: SpotifyTrack[];
createdAt: Date;
createdBy: string;
}
export interface MemoryLaneItem {
id: string;
type: 'shared_track' | 'playlist_created' | 'milestone';
title: string;
description: string;
track?: SpotifyTrack;
playlist?: SpotifyPlaylist;
date: Date;
users: string[];
}

63
src/utils/cn.ts Normal file
View File

@ -0,0 +1,63 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDuration(ms: number): string {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
export function formatDate(date: string | Date): string {
const d = new Date(date);
const now = new Date();
const diffInHours = (now.getTime() - d.getTime()) / (1000 * 60 * 60);
if (diffInHours < 1) {
const minutes = Math.floor(diffInHours * 60);
return `${minutes}m ago`;
} else if (diffInHours < 24) {
const hours = Math.floor(diffInHours);
return `${hours}h ago`;
} else {
const days = Math.floor(diffInHours / 24);
return `${days}d ago`;
}
}
export function generateRandomId(): string {
return Math.random().toString(36).substr(2, 9);
}
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout;
return (...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
export function getImageUrl(images: Array<{ url: string; width: number; height: number }>, size: 'small' | 'medium' | 'large' = 'medium'): string {
if (!images || images.length === 0) {
return '/placeholder-album.png';
}
const sizeMap = {
small: 64,
medium: 300,
large: 640,
};
const targetSize = sizeMap[size];
const closestImage = images.reduce((prev, curr) => {
return Math.abs(curr.width - targetSize) < Math.abs(prev.width - targetSize) ? curr : prev;
});
return closestImage.url;
}

205
src/utils/spotify.ts Normal file
View File

@ -0,0 +1,205 @@
import SpotifyWebApi from 'spotify-web-api-js';
import { SpotifyTrack, SpotifyUser, RecentlyPlayedItem, SpotifyPlaylist } from '../types';
const SPOTIFY_CLIENT_ID = (import.meta as any).env.VITE_SPOTIFY_CLIENT_ID;
const REDIRECT_URI = (import.meta as any).env.VITE_REDIRECT_URI || 'http://localhost:3000/callback.html';
// Debug: Check what redirect URI is being used
console.log('🔍 Debug - Current redirect URI:', REDIRECT_URI);
console.log('🔍 Debug - Environment VITE_REDIRECT_URI:', (import.meta as any).env.VITE_REDIRECT_URI);
export const spotifyApi = new SpotifyWebApi();
export const getSpotifyAuthUrl = (): string => {
const scopes = [
'user-read-private',
'user-read-email',
'user-read-recently-played',
'user-top-read',
'playlist-read-private',
'playlist-read-collaborative',
'user-read-playback-state',
'user-modify-playback-state',
'user-read-currently-playing',
].join(' ');
const params = new URLSearchParams({
client_id: SPOTIFY_CLIENT_ID,
response_type: 'code',
redirect_uri: REDIRECT_URI,
scope: scopes,
show_dialog: 'true',
});
const authUrl = `https://accounts.spotify.com/authorize?${params.toString()}`;
console.log('🔍 Debug - Generated Spotify Auth URL:', authUrl);
return authUrl;
};
export const exchangeCodeForToken = async (code: string): Promise<{
access_token: string;
refresh_token: string;
expires_in: number;
}> => {
const response = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${btoa(`${SPOTIFY_CLIENT_ID}:${(import.meta as any).env.VITE_SPOTIFY_CLIENT_SECRET}`)}`,
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
}),
});
if (!response.ok) {
throw new Error('Failed to exchange code for token');
}
return response.json();
};
export const refreshAccessToken = async (refreshToken: string): Promise<{
access_token: string;
expires_in: number;
}> => {
const response = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${btoa(`${SPOTIFY_CLIENT_ID}:${(import.meta as any).env.VITE_SPOTIFY_CLIENT_SECRET}`)}`,
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
}),
});
if (!response.ok) {
throw new Error('Failed to refresh access token');
}
return response.json();
};
export const initializeSpotifyApi = (accessToken: string): void => {
spotifyApi.setAccessToken(accessToken);
};
export const fetchUserProfile = async (accessToken: string): Promise<SpotifyUser> => {
spotifyApi.setAccessToken(accessToken);
return spotifyApi.getMe();
};
export const fetchRecentlyPlayed = async (accessToken: string, limit = 20): Promise<RecentlyPlayedItem[]> => {
spotifyApi.setAccessToken(accessToken);
const response = await spotifyApi.getMyRecentlyPlayedTracks({ limit });
return response.items;
};
export const fetchTopTracks = async (accessToken: string, timeRange = 'short_term', limit = 20): Promise<SpotifyTrack[]> => {
spotifyApi.setAccessToken(accessToken);
const response = await spotifyApi.getMyTopTracks({ time_range: timeRange, limit });
return response.items;
};
export const fetchTopArtists = async (accessToken: string, timeRange = 'short_term', limit = 20) => {
spotifyApi.setAccessToken(accessToken);
return spotifyApi.getMyTopArtists({ time_range: timeRange, limit });
};
export const createPlaylist = async (
accessToken: string,
userId: string,
name: string,
description: string
): Promise<SpotifyPlaylist> => {
spotifyApi.setAccessToken(accessToken);
const playlist = await spotifyApi.createPlaylist(userId, {
name,
description,
public: false,
});
return playlist;
};
export const addTracksToPlaylist = async (
accessToken: string,
playlistId: string,
trackUris: string[]
): Promise<void> => {
spotifyApi.setAccessToken(accessToken);
await spotifyApi.addTracksToPlaylist(playlistId, trackUris);
};
export const playTrack = async (accessToken: string, trackUri: string): Promise<void> => {
spotifyApi.setAccessToken(accessToken);
await spotifyApi.play({
uris: [trackUri],
});
};
export const pausePlayback = async (accessToken: string): Promise<void> => {
spotifyApi.setAccessToken(accessToken);
await spotifyApi.pause();
};
export const getCurrentPlayback = async (accessToken: string) => {
spotifyApi.setAccessToken(accessToken);
return spotifyApi.getMyCurrentPlaybackState();
};
// Utility function to extract track features for playlist generation
export const analyzeTrackFeatures = (tracks: SpotifyTrack[]) => {
const features = {
genres: new Set<string>(),
artists: new Set<string>(),
avgPopularity: 0,
totalDuration: 0,
decades: new Set<string>(),
};
tracks.forEach(track => {
// Extract genres from artists (simplified)
track.artists.forEach(artist => features.artists.add(artist.name));
// Calculate average popularity
features.avgPopularity += track.popularity;
// Add duration
features.totalDuration += track.duration_ms;
// Estimate decade from album name or other clues (simplified)
// This would typically use more sophisticated analysis
});
features.avgPopularity /= tracks.length;
return features;
};
// Generate mixed playlist based on both users' music
export const generateMixedPlaylist = (
user1Tracks: SpotifyTrack[],
user2Tracks: SpotifyTrack[],
playlistName: string = "Our Mixed Vibes"
): SpotifyTrack[] => {
const allTracks = [...user1Tracks, ...user2Tracks];
// Remove duplicates based on track ID
const uniqueTracks = allTracks.filter((track, index, self) =>
index === self.findIndex(t => t.id === track.id)
);
// Sort by popularity and mix them
const sortedByPopularity = uniqueTracks.sort((a, b) => b.popularity - a.popularity);
// Take top tracks and shuffle them for variety
const topTracks = sortedByPopularity.slice(0, 30);
const shuffled = topTracks.sort(() => Math.random() - 0.5);
return shuffled.slice(0, 25); // Return 25 tracks for the playlist
};

59
tailwind.config.js Normal file
View File

@ -0,0 +1,59 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
spotify: {
green: '#1db954',
dark: '#191414',
gray: '#282828',
lightgray: '#b3b3b3',
},
glass: {
white: 'rgba(255, 255, 255, 0.1)',
black: 'rgba(0, 0, 0, 0.2)',
border: 'rgba(255, 255, 255, 0.2)',
}
},
backdropBlur: {
xs: '2px',
},
animation: {
'float': 'float 6s ease-in-out infinite',
'pulse-slow': 'pulse 4s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'bounce-slow': 'bounce 3s infinite',
'glow': 'glow 2s ease-in-out infinite alternate',
'wave-flow': 'wave-flow 8s ease-in-out infinite',
'bubble-float': 'bubble-float 6s ease-in-out infinite',
},
keyframes: {
float: {
'0%, 100%': { transform: 'translateY(0px)' },
'50%': { transform: 'translateY(-20px)' },
},
glow: {
'0%': { boxShadow: '0 0 20px rgba(29, 185, 84, 0.3)' },
'100%': { boxShadow: '0 0 30px rgba(29, 185, 84, 0.6)' },
},
'wave-flow': {
'0%, 100%': { transform: 'translateY(0px) translateX(0px) scale(1)', opacity: '0.3' },
'25%': { transform: 'translateY(-50px) translateX(30px) scale(1.1)', opacity: '0.6' },
'50%': { transform: 'translateY(-20px) translateX(-20px) scale(0.9)', opacity: '0.4' },
'75%': { transform: 'translateY(-80px) translateX(40px) scale(1.2)', opacity: '0.7' },
},
'bubble-float': {
'0%, 100%': { transform: 'translateY(0px) scale(1)', opacity: '0.5' },
'50%': { transform: 'translateY(-20px) scale(1.1)', opacity: '0.8' },
}
},
fontFamily: {
'spotify': ['Circular', 'Helvetica', 'Arial', 'sans-serif'],
}
},
},
plugins: [],
}

View File

@ -1,27 +1,25 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
tsconfig.node.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

11
vite.config.ts Normal file
View File

@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
host: true
}
})