spotify/src/pages/MixedPlaylistPage.tsx
2025-10-21 15:43:52 +02:00

690 lines
26 KiB
TypeScript

import { useState, useEffect } 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 { apiPost, apiGet, apiDelete, API_BASE } from '../utils/api';
import { formatDuration } from '../utils/cn';
import { getThemeClasses } from '../utils/theme';
import toast from 'react-hot-toast';
// PlaylistImage component to handle image loading with fallback
const PlaylistImage = ({ imageUrl, alt, fallbackGradient }: { imageUrl: string; alt: string; fallbackGradient: string }) => {
const [src, setSrc] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
if (imageUrl.startsWith('http')) {
// Direct HTTP/HTTPS URL
setSrc(imageUrl);
setLoading(false);
} else {
// API endpoint - fetch as base64 via same-protocol API_BASE to avoid mixed content
const fetchImage = async () => {
try {
const response = await fetch(`${API_BASE}${imageUrl}`);
if (response.ok) {
const data = await response.json();
setSrc(data.dataUrl);
} else {
setError(true);
}
} catch (e) {
setError(true);
} finally {
setLoading(false);
}
};
fetchImage();
}
}, [imageUrl]);
if (loading) {
return (
<div className="w-full h-full bg-gradient-to-br from-gray-500 to-gray-600 flex items-center justify-center">
<Music className="w-8 h-8 text-white animate-pulse" />
</div>
);
}
if (error || !src) {
return (
<div className={`w-full h-full flex items-center justify-center ${fallbackGradient}`}>
<Music className="w-8 h-8 text-white" />
</div>
);
}
return (
<img
src={src}
alt={alt}
className="w-full h-full object-cover"
onError={() => setError(true)}
/>
);
};
// Generate beautiful gradients based on playlist vibe and genres
function getPlaylistGradient(playlist: any): string {
const vibe = playlist.vibe?.toLowerCase();
const genres = playlist.genres || [];
// Vibe-based gradients
if (vibe) {
switch (vibe) {
case 'energetic':
return 'bg-gradient-to-br from-red-500 to-orange-500';
case 'chill':
return 'bg-gradient-to-br from-blue-500 to-teal-500';
case 'romantic':
return 'bg-gradient-to-br from-pink-500 to-rose-500';
case 'party':
return 'bg-gradient-to-br from-purple-500 to-pink-500';
case 'workout':
return 'bg-gradient-to-br from-green-500 to-lime-500';
case 'study':
return 'bg-gradient-to-br from-indigo-500 to-blue-500';
case 'sad':
return 'bg-gradient-to-br from-gray-500 to-slate-500';
case 'happy':
return 'bg-gradient-to-br from-yellow-500 to-orange-500';
}
}
// Genre-based gradients
if (genres.length > 0) {
const primaryGenre = genres[0].toLowerCase();
switch (primaryGenre) {
case 'pop':
return 'bg-gradient-to-br from-pink-500 to-purple-500';
case 'rock':
return 'bg-gradient-to-br from-red-500 to-black';
case 'hip-hop':
case 'rap':
return 'bg-gradient-to-br from-yellow-500 to-red-500';
case 'electronic':
case 'edm':
return 'bg-gradient-to-br from-cyan-500 to-blue-500';
case 'jazz':
return 'bg-gradient-to-br from-amber-500 to-orange-500';
case 'classical':
return 'bg-gradient-to-br from-slate-500 to-gray-600';
case 'country':
return 'bg-gradient-to-br from-green-600 to-yellow-600';
case 'r&b':
return 'bg-gradient-to-br from-purple-600 to-pink-600';
case 'indie':
return 'bg-gradient-to-br from-teal-500 to-green-500';
case 'alternative':
return 'bg-gradient-to-br from-gray-600 to-purple-600';
}
}
// Default gradient
return 'bg-gradient-to-br from-purple-500 to-pink-500';
}
export const MixedPlaylistPage = () => {
const { currentUser, partnerUser, mixedPlaylists, addMixedPlaylist, removeMixedPlaylist, setMixedPlaylists, theme } = useStore();
const themeClasses = getThemeClasses(theme);
const [isGenerating, setIsGenerating] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [newPlaylist, setNewPlaylist] = useState<any>(null);
const [vibe, setVibe] = useState<string>('');
const [genresText, setGenresText] = useState<string>('');
const [includeKnown, setIncludeKnown] = useState<boolean>(true);
const [createForBoth, setCreateForBoth] = useState<boolean>(false);
const parsedGenres = genresText
.split(',')
.map(g => g.trim())
.filter(g => g.length > 0);
// Load playlists from database on component mount
useEffect(() => {
const loadPlaylists = async () => {
if (!currentUser?.jwt) return;
try {
console.log('Loading playlists from database...');
const response = await apiGet<{ playlists: any[] }>('/playlists/mixed', currentUser.jwt);
console.log('Loaded playlists:', response.playlists);
// Convert database playlists to frontend format
const convertedPlaylists = response.playlists.map((dbPlaylist: any) => {
const trackUris = dbPlaylist.track_uris ? JSON.parse(dbPlaylist.track_uris) : [];
console.log('Processing playlist:', dbPlaylist.name, 'track_uris count:', trackUris.length, 'raw track_uris:', dbPlaylist.track_uris?.substring(0, 100) + '...');
return {
id: dbPlaylist.id,
name: dbPlaylist.name,
description: dbPlaylist.description,
tracks: [], // We don't store full track objects in DB, just URIs
createdAt: new Date(dbPlaylist.created_at),
createdBy: 'AI Magic ✨',
spotifyId: dbPlaylist.creator_spotify_id,
spotifyUrl: dbPlaylist.creator_spotify_url,
partnerSpotifyId: dbPlaylist.partner_spotify_id,
partnerSpotifyUrl: dbPlaylist.partner_spotify_url,
vibe: dbPlaylist.vibe,
genres: dbPlaylist.genres ? JSON.parse(dbPlaylist.genres) : [],
trackUris: trackUris,
spotifyImageUrl: dbPlaylist.creator_spotify_image_url,
};
});
// Update store with loaded playlists (replace existing ones)
setMixedPlaylists(convertedPlaylists);
} catch (error) {
console.error('Failed to load playlists:', error);
// Don't show error toast for this - it's not critical
}
};
loadPlaylists();
}, [currentUser?.jwt]);
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?.user?.id) {
toast.error('Unable to create playlist');
return;
}
if (!currentUser?.jwt) {
toast.error('Please login again');
return;
}
setIsCreating(true);
try {
const body: any = {
partnerId: partnerUser?.user?.id || currentUser.user.id,
createForBoth: false,
includeKnown: true,
name: newPlaylist.name,
description: newPlaylist.description,
};
const result = await apiPost('/playlists/mixed', body, currentUser.jwt);
addMixedPlaylist({
...newPlaylist,
spotifyId: result?.createdFor?.creator?.playlistId,
spotifyUrl: result?.createdFor?.creator?.url,
});
toast.success('Playlist created on Spotify!');
setNewPlaylist(null);
} catch (e) {
console.error('Spotify playlist creation error:', e);
toast.error('Failed to create playlist on Spotify');
} finally {
setIsCreating(false);
}
};
const handleCreateEnhanced = async () => {
if (!currentUser?.user?.id || !partnerUser?.user?.id) {
toast.error('Missing users');
return;
}
if (!currentUser?.jwt) {
toast.error('Please login again');
return;
}
setIsCreating(true);
try {
const body: any = {
partnerId: partnerUser.user.id,
createForBoth,
includeKnown,
vibe: vibe || undefined,
genres: parsedGenres.length ? parsedGenres : undefined,
// Don't pass name - let server generate unique name
// name: newPlaylist?.name || 'Our Enhanced Mix',
description: newPlaylist?.description || 'An AI-blended mix with fresh recommendations',
};
const result = await apiPost('/playlists/mixed', body, currentUser.jwt);
// Show appropriate success/error messages
if (createForBoth && result.createdFor.partnerError) {
toast.success('Your playlist created! Partner sync failed: ' + result.createdFor.partnerError);
} else if (createForBoth && result.createdFor.partner) {
toast.success('Enhanced playlist created on both accounts!');
} else {
toast.success('Enhanced playlist created!');
}
// Create playlist object for local store
const playlistData = {
id: result.id,
name: result.name, // Use name from server response
description: body.description,
tracks: [],
createdAt: new Date(),
createdBy: 'AI Magic ✨',
spotifyId: result.createdFor.creator.playlistId,
spotifyUrl: result.createdFor.creator.url,
partnerSpotifyId: result.createdFor.partner?.playlistId,
partnerSpotifyUrl: result.createdFor.partner?.url,
vibe: body.vibe,
genres: body.genres || [],
trackUris: result.trackUris || [], // Use track URIs from server response
spotifyImageUrl: result.spotifyImageUrl, // Use image URL from server response
};
addMixedPlaylist(playlistData);
setNewPlaylist(null);
} catch (e) {
console.error(e);
toast.error('Failed to create enhanced playlist');
} 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 overflow-hidden flex-shrink-0 ${
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" />
) : playlist.spotifyImageUrl ? (
<PlaylistImage
imageUrl={playlist.spotifyImageUrl}
alt={playlist.name}
fallbackGradient={getPlaylistGradient(playlist)}
/>
) : null}
{!isNew && (
<div
className={`w-full h-full flex items-center justify-center ${
playlist.spotifyImageUrl
? 'bg-gradient-to-br from-purple-500 to-pink-500'
: getPlaylistGradient(playlist)
}`}
style={{ display: playlist.spotifyImageUrl ? 'none' : 'flex' }}
>
<Music className="w-8 h-8 text-white" />
</div>
)}
</div>
<div>
<h3 className="text-xl font-semibold text-white">{playlist.name}</h3>
<p className="text-white/70 text-sm">{playlist.description}</p>
<div className="flex flex-wrap gap-1 mt-1 mb-1">
{playlist.vibe && (
<span className="px-2 py-0.5 bg-blue-500/20 text-blue-300 text-xs rounded-full">
{playlist.vibe}
</span>
)}
{playlist.genres && playlist.genres.length > 0 && playlist.genres.slice(0, 2).map((genre: string, index: number) => (
<span key={index} className="px-2 py-0.5 bg-green-500/20 text-green-300 text-xs rounded-full">
{genre}
</span>
))}
</div>
<p className="text-white/50 text-xs">
Created {isNew ? 'just now' : new Date(playlist.createdAt).toLocaleDateString()} {playlist.tracks.length || playlist.trackUris?.length || 0} 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"
title="Open in Spotify"
>
<ExternalLink className="w-4 h-4 text-white" />
</a>
)}
{playlist.partnerSpotifyUrl && (
<a
href={playlist.partnerSpotifyUrl}
target="_blank"
rel="noopener noreferrer"
className="w-8 h-8 rounded-full bg-pink-500/20 hover:bg-pink-500/30 flex items-center justify-center transition-colors"
title="Partner's playlist in Spotify"
>
<Heart className="w-4 h-4 text-pink-400" />
</a>
)}
{!isNew && (
<button
onClick={async () => {
try {
if (currentUser?.jwt) {
await apiDelete(`/playlists/mixed/${playlist.id}`, currentUser.jwt);
}
removeMixedPlaylist(playlist.id);
toast.success('Playlist deleted successfully');
} catch (error) {
console.error('Failed to delete playlist:', error);
toast.error('Failed to delete playlist');
}
}}
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.length > 0 ? (
// Show full track objects if available
<>
{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 || 'Unknown Album'}
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 || 'Unknown Artist'}</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>
)}
</>
) : playlist.trackUris && playlist.trackUris.length > 0 ? (
// Show empty space when we only have URIs
<div className="text-center py-4">
{/* Empty - no text needed */}
</div>
) : (
// No tracks available
<div className="text-center text-white/50 text-sm py-4">
No tracks available
</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 || createForBoth}
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>{createForBoth ? 'Disabled (using Create for both)' : '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"
style={{ color: themeClasses.cssVars?.primary || '#1db954' }}
/>
<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"
style={{ borderColor: `${themeClasses.cssVars?.primary || '#1db954'}30` }}
>
<div
className="w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center"
style={{
background: `linear-gradient(135deg, ${themeClasses.cssVars?.primary || '#1db954'} 0%, ${themeClasses.cssVars?.secondary || '#1ed760'} 100%)`,
}}
>
<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>
{/* Mix Generator removed as requested */}
<div className="mt-4 text-white/50 text-sm">
Analyzing {currentUser.topTracks.length + partnerUser.topTracks.length} tracks from both users
</div>
{/* Enhanced controls */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-left mt-8">
<div>
<label className="block text-white/80 text-sm mb-1">Vibe</label>
<select
className="w-full bg-white/10 text-white rounded-lg p-2 appearance-none focus:outline-none focus:ring-2 focus:ring-spotify-green"
value={vibe}
onChange={(e) => setVibe(e.target.value)}
style={{
backgroundColor: 'rgba(255, 255, 255, 0.1)',
color: 'rgba(255, 255, 255, 0.9)'
}}
>
<option value="" style={{ backgroundColor: 'rgba(30, 30, 30, 0.95)', color: 'rgba(255, 255, 255, 0.9)' }}>Auto</option>
<option value="energetic" style={{ backgroundColor: 'rgba(30, 30, 30, 0.95)', color: 'rgba(255, 255, 255, 0.9)' }}>Energetic</option>
<option value="chill" style={{ backgroundColor: 'rgba(30, 30, 30, 0.95)', color: 'rgba(255, 255, 255, 0.9)' }}>Chill</option>
<option value="happy" style={{ backgroundColor: 'rgba(30, 30, 30, 0.95)', color: 'rgba(255, 255, 255, 0.9)' }}>Happy</option>
<option value="sad" style={{ backgroundColor: 'rgba(30, 30, 30, 0.95)', color: 'rgba(255, 255, 255, 0.9)' }}>Sad</option>
<option value="party" style={{ backgroundColor: 'rgba(30, 30, 30, 0.95)', color: 'rgba(255, 255, 255, 0.9)' }}>Party</option>
<option value="focus" style={{ backgroundColor: 'rgba(30, 30, 30, 0.95)', color: 'rgba(255, 255, 255, 0.9)' }}>Focus</option>
</select>
</div>
<div>
<label className="block text-white/80 text-sm mb-1">Genres (comma-separated)</label>
<input
className="w-full bg-white/10 text-white rounded-lg p-2 placeholder-white/60 focus:outline-none focus:ring-2 focus:ring-spotify-green"
placeholder="pop, rock, edm"
value={genresText}
onChange={(e) => setGenresText(e.target.value)}
/>
</div>
<label className="flex items-center space-x-2 text-white/80">
<input type="checkbox" checked={includeKnown} onChange={(e) => setIncludeKnown(e.target.checked)} />
<span>Include songs we already know</span>
</label>
<label className="flex items-center space-x-2 text-white/80">
<input type="checkbox" checked={createForBoth} onChange={(e) => setCreateForBoth(e.target.checked)} />
<span>Create playlist on both accounts</span>
{createForBoth && (
<span className="text-xs text-pink-400 ml-2"> Synced</span>
)}
</label>
</div>
<div className="flex items-center justify-center gap-3 mt-6">
<button
onClick={handleCreateEnhanced}
disabled={isCreating}
className="bg-white/10 hover:bg-white/20 text-white font-semibold py-3 px-4 rounded-lg transition-colors disabled:opacity-50"
>
Create Enhanced Playlist
</button>
</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>
);
};