update https
201
README.md
|
|
@ -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
BIN
cloudflared-v2025.9.1-linux-amd64.tgz
Normal file
75
direct-https.js
Normal 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
1
dist/assets/index-sFswcLb6.css
vendored
Normal file
27
dist/callback.html
vendored
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzAwIiBoZWlnaHQ9IjMwMCIgdmlld0JveD0iMCAwIDMwMCAzMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIzMDAiIGhlaWdodD0iMzAwIiBmaWxsPSIjMjgyODI4Ii8+CjxjaXJjbGUgY3g9IjE1MCIgY3k9IjE1MCIgcj0iNTAiIGZpbGw9IiMxZGI5NTQiLz4KPHN2ZyB4PSIxMjUiIHk9IjEyNSIgd2lkdGg9IjUwIiBoZWlnaHQ9IjUwIiB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9Im5vbmUiPgo8cGF0aCBkPSJNMTIgMkM2LjQ4IDIgMiA2LjQ4IDIgMTJTNi40OCAyMiAxMiAyMlMyMiAxNy41MiAyMiAxMlMxNy41MiAyIDEyIDJaTTEwIDE3TDUgMTJMMTAgN0wxMSAxMkwxNyAxMkwxMCAxN1oiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo8L3N2Zz4K
|
||||
7
env.example
Normal 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
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -1,7 +0,0 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
7653
package-lock.json
generated
61
package.json
|
|
@ -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
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
||||
|
|
@ -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 |
|
|
@ -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
|
|
@ -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 |
|
|
@ -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 |
1
public/placeholder-album.png
Normal file
|
|
@ -0,0 +1 @@
|
|||
data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzAwIiBoZWlnaHQ9IjMwMCIgdmlld0JveD0iMCAwIDMwMCAzMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIzMDAiIGhlaWdodD0iMzAwIiBmaWxsPSIjMjgyODI4Ii8+CjxjaXJjbGUgY3g9IjE1MCIgY3k9IjE1MCIgcj0iNTAiIGZpbGw9IiMxZGI5NTQiLz4KPHN2ZyB4PSIxMjUiIHk9IjEyNSIgd2lkdGg9IjUwIiBoZWlnaHQ9IjUwIiB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9Im5vbmUiPgo8cGF0aCBkPSJNMTIgMkM2LjQ4IDIgMiA2LjQ4IDIgMTJTNi40OCAyMiAxMiAyMlMyMiAxNy41MiAyMiAxMlMxNy41MiAyIDEyIDJaTTEwIDE3TDUgMTJMMTAgN0wxMSAxMkwxNyAxMkwxMCAxN1oiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo8L3N2Zz4K
|
||||
|
|
@ -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 |
|
|
@ -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 |
53
setup.sh
|
|
@ -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
|
|
@ -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
|
|
@ -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;
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
Before Width: | Height: | Size: 25 KiB |
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
134
src/app/page.tsx
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
163
src/components/AnimatedBackground.tsx
Normal 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
|
|
@ -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 };
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
25
src/components/FluidCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
78
src/components/InteractiveBubbles.tsx
Normal 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
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
264
src/pages/LastListenedPage.tsx
Normal 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
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
351
src/pages/MemoryLanePage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
343
src/pages/MixedPlaylistPage.tsx
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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: [],
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
})
|
||||