+
+
How are you feeling?
+
Choose a mood to discover perfect playlists
-
- {moodOptions.map((mood) => (
+
{moodOptions.map((mood) => (
handleMoodClick(mood.value)}
className={`
- inline-flex items-center gap-2 px-4 py-2 rounded-full
- transition-all duration-200 font-medium text-sm
+ group inline-flex items-center gap-3 px-6 py-4 rounded-2xl
+ transition-all duration-300 font-semibold text-sm
+ border-2 hover:scale-105 hover:shadow-lg
${mood.color}
${selectedMood === mood.value
- ? 'ring-2 ring-offset-2 ring-offset-background scale-105'
- : 'scale-100'
+ ? 'ring-4 ring-offset-2 ring-offset-background scale-105 shadow-lg border-transparent'
+ : 'border-transparent hover:border-current/20'
}
`}
>
- {mood.icon}
- {mood.label}
+
+ {mood.icon}
+
+ {mood.label}
))}
diff --git a/src/components/music-player.tsx b/src/components/music-player.tsx
new file mode 100644
index 0000000..5f03d2b
--- /dev/null
+++ b/src/components/music-player.tsx
@@ -0,0 +1,381 @@
+import React, { useRef, useState, useEffect } from 'react';
+import { motion } from 'framer-motion';
+import { useTheme } from '../context/theme-provider';
+
+interface MusicPlayerProps {
+ mood?: string;
+}
+
+const FAVORITES_KEY = 'skybuddy_favorite_tracks';
+
+function getStoredFavorites(): string[] {
+ try {
+ const data = localStorage.getItem(FAVORITES_KEY);
+ return data ? JSON.parse(data) : [];
+ } catch {
+ return [];
+ }
+}
+
+function saveFavorites(favs: string[]) {
+ localStorage.setItem(FAVORITES_KEY, JSON.stringify(favs));
+}
+
+// Dummy track data for now
+const demoTracks = [
+ {
+ title: 'Rainy Mood',
+ artist: 'SkyBuddy',
+ url: '/demo/rainy-mood.mp3',
+ cover: '/demo/rainy.jpg',
+ mood: 'Rain',
+ },
+ {
+ title: 'Sunny Vibes',
+ artist: 'SkyBuddy',
+ url: '/demo/sunny-vibes.mp3',
+ cover: '/demo/sunny.jpg',
+ mood: 'Happy',
+ },
+];
+
+export const MusicPlayer: React.FC
= ({ mood }) => {
+ const { theme } = useTheme?.() || {
+ theme: typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
+ };
+ const isDarkMode = theme === 'dark';
+
+ // Filter tracks by mood if provided
+ const tracks = mood ? demoTracks.filter(t => t.mood === mood) : demoTracks;
+ const [current, setCurrent] = useState(0);
+ const [playing, setPlaying] = useState(false);
+ const [favorites, setFavorites] = useState(() => getStoredFavorites());
+ const [volume, setVolume] = useState(0.7);
+ const [shuffle, setShuffle] = useState(false);
+ const [progress, setProgress] = useState(0);
+ const [duration, setDuration] = useState(0);
+ const audioRef = useRef(null);
+
+ // Favorite/unfavorite current track
+ function handleToggleFavorite() {
+ const trackId = tracks[current]?.title + tracks[current]?.artist;
+ let updated: string[];
+ if (favorites.includes(trackId)) {
+ updated = favorites.filter(f => f !== trackId);
+ } else {
+ updated = [...favorites, trackId];
+ }
+ setFavorites(updated);
+ saveFavorites(updated);
+ }
+
+ // Keep favorites in sync with localStorage
+ useEffect(() => {
+ saveFavorites(favorites);
+ }, [favorites]);
+
+ const playPause = () => {
+ if (playing) {
+ audioRef.current?.pause();
+ } else {
+ audioRef.current?.play();
+ }
+ setPlaying(!playing);
+ };
+
+ const next = () => {
+ if (shuffle) {
+ let nextIdx = Math.floor(Math.random() * tracks.length);
+ // Avoid repeating the same track
+ if (tracks.length > 1 && nextIdx === current) {
+ nextIdx = (nextIdx + 1) % tracks.length;
+ }
+ setCurrent(nextIdx);
+ } else {
+ setCurrent((prev) => (prev + 1) % tracks.length);
+ }
+ setPlaying(false);
+ setProgress(0);
+ };
+
+ const prev = () => {
+ setCurrent((prev) => (prev - 1 + tracks.length) % tracks.length);
+ setPlaying(false);
+ setProgress(0);
+ };
+
+ // Volume control
+ useEffect(() => {
+ if (audioRef.current) {
+ audioRef.current.volume = volume;
+ }
+ }, [volume]);
+
+ // Progress bar animation
+ useEffect(() => {
+ const audio = audioRef.current;
+ if (!audio) return;
+ const updateProgress = () => {
+ setProgress(audio.currentTime);
+ };
+ const setAudioDuration = () => {
+ setDuration(audio.duration || 0);
+ };
+ audio.addEventListener('timeupdate', updateProgress);
+ audio.addEventListener('loadedmetadata', setAudioDuration);
+ return () => {
+ audio.removeEventListener('timeupdate', updateProgress);
+ audio.removeEventListener('loadedmetadata', setAudioDuration);
+ };
+ }, [current]);
+
+ // Seek bar handler
+ const handleSeek = (e: React.ChangeEvent) => {
+ const seekTime = Number(e.target.value);
+ if (audioRef.current) {
+ audioRef.current.currentTime = seekTime;
+ setProgress(seekTime);
+ }
+ };
+
+ // Icon helper for Material Icons
+ const Icon = ({
+ name,
+ className = "",
+ style = {},
+ ...props
+ }: React.HTMLAttributes & { name: string }) => (
+ {name}
+ );
+
+ // Volume icon logic
+ const getVolumeIcon = () => {
+ if (volume === 0) return "volume_off";
+ if (volume < 0.5) return "volume_down";
+ return "volume_up";
+ };
+
+ return (
+
+ {/* Updated glowing background effect that works well in both modes */}
+
+
+ {/* Music card with frosted glass effect */}
+
+
+ {tracks.length > 0 ? (
+ <>
+
+
+
+
+
+
+
+
+ {tracks[current].title}
+
+
+
+
+
+ {tracks[current].artist}
+
+
+
+
+ {/* Animated Progress Bar */}
+
+
+
+ {formatTime(progress)}
+ {formatTime(duration)}
+
+
+
+ {/* Controls */}
+
+ setShuffle(s => !s)}
+ className={`p-2 rounded-full ${
+ shuffle
+ ? 'bg-indigo-500 text-white shadow-lg shadow-indigo-500/30'
+ : 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
+ }`}
+ whileHover={{ scale: 1.15 }}
+ whileTap={{ scale: 0.9 }}
+ title="Shuffle"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setVolume(volume > 0 ? 0 : 0.7)}
+ className="p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full"
+ whileHover={{ scale: 1.15 }}
+ whileTap={{ scale: 0.9 }}
+ title={volume > 0 ? "Mute" : "Unmute"}
+ >
+
+
+
+
+ {/* Volume */}
+
+
+ setVolume(Number(e.target.value))}
+ className="w-full h-1 rounded-full appearance-none cursor-pointer bg-gray-200 dark:bg-gray-700"
+ whileFocus={{ scale: 1.03 }}
+ style={{
+ background: `linear-gradient(90deg,
+ rgba(99, 102, 241, 0.8) ${volume * 100}%,
+ rgba(229, 231, 235, 0.3) ${volume * 100}%)`
+ }}
+ />
+
+
+ >
+ ) : (
+
+
+
No tracks available for this mood.
+
window.history.back()}
+ >
+ Go Back
+
+
+ )}
+
+ );
+
+ // Helper to format time in mm:ss
+ function formatTime(sec: number) {
+ if (!isFinite(sec)) return '0:00';
+ const m = Math.floor(sec / 60);
+ const s = Math.floor(sec % 60);
+ return `${m}:${s.toString().padStart(2, '0')}`;
+ }
+};
diff --git a/src/components/playlist-card.tsx b/src/components/playlist-card.tsx
index 6f3f360..3a8fb4e 100644
--- a/src/components/playlist-card.tsx
+++ b/src/components/playlist-card.tsx
@@ -2,6 +2,7 @@ import { ExternalLink, Music2 } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import type { SpotifyPlaylist } from '@/types/playlist';
import { useState } from 'react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
interface PlaylistCardProps {
playlist: SpotifyPlaylist;
@@ -10,13 +11,23 @@ interface PlaylistCardProps {
const PlaylistCard = ({ playlist }: PlaylistCardProps) => {
const [imageError, setImageError] = useState(false);
+ const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
+ // Try to get mood from search params, fallback to undefined
+ const mood = searchParams.get('mood') || undefined;
+
+ const handleCardClick = (e: React.MouseEvent) => {
+ e.preventDefault();
+ // Navigate to /music?mood=...&playlist=...
+ navigate(`/music?mood=${encodeURIComponent(mood || '')}&playlist=${encodeURIComponent(playlist.id)}`);
+ };
+
return (
{/* Playlist Image - Smaller with fallback */}
diff --git a/src/components/playlist-manager.tsx b/src/components/playlist-manager.tsx
new file mode 100644
index 0000000..7aeffd3
--- /dev/null
+++ b/src/components/playlist-manager.tsx
@@ -0,0 +1,231 @@
+import React, { useState } from 'react';
+import { motion } from 'framer-motion';
+import { Plus, Trash2, Share2, Music } from 'lucide-react';
+import { usePlaylist } from '../hooks/use-playlist';
+import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
+import { Button } from './ui/button';
+import type { Playlist } from '../context/playlist-provider';
+
+interface PlaylistManagerProps {
+ mood?: string;
+ onSelectPlaylist?: (id: string) => void;
+}
+
+export const PlaylistManager: React.FC = ({ mood, onSelectPlaylist }) => {
+ const {
+ playlists,
+ createPlaylist,
+ deletePlaylist,
+ addTrack,
+ sharePlaylist
+ } = usePlaylist();
+
+ const [newName, setNewName] = useState('');
+ const [newDescription, setNewDescription] = useState('');
+ const [trackInputs, setTrackInputs] = useState<{ [playlistId: string]: string }>({});
+
+ // Filter playlists by mood if provided
+ const filteredPlaylists = mood
+ ? playlists.filter((p: Playlist) => p.mood.toLowerCase() === mood.toLowerCase())
+ : playlists;
+
+ // Handle creating new playlist
+ const handleCreatePlaylist = async () => {
+ if (!newName.trim()) return;
+
+ try {
+ await createPlaylist(newName.trim(), mood || 'General', newDescription.trim() || undefined);
+ setNewName('');
+ setNewDescription('');
+ } catch (error) {
+ console.error('Failed to create playlist:', error);
+ }
+ };
+
+ // Handle adding track to playlist
+ const handleAddTrack = (playlistId: string) => {
+ const trackName = trackInputs[playlistId]?.trim();
+ if (!trackName) return;
+
+ try {
+ addTrack(playlistId, trackName);
+ setTrackInputs(prev => ({ ...prev, [playlistId]: '' }));
+ } catch (error) {
+ console.error('Failed to add track:', error);
+ }
+ };
+
+ // Handle sharing
+ const handleShare = async (playlistId: string) => {
+ try {
+ const shareId = await sharePlaylist(playlistId);
+ const shareUrl = `${window.location.origin}/playlist/shared/${shareId}`;
+
+ // Copy to clipboard if available
+ if (navigator.clipboard) {
+ await navigator.clipboard.writeText(shareUrl);
+ alert('Share link copied to clipboard!');
+ } else {
+ prompt('Copy this share link:', shareUrl);
+ }
+ } catch (error) {
+ console.error('Failed to share playlist:', error);
+ alert('Failed to share playlist');
+ }
+ };
+ return (
+
+
+
+
+
+
+
+
+
Your Music Collection
+ {mood &&
{mood} vibes
}
+
+
+
+
+
+ {/* Playlists */}
+ {filteredPlaylists.length === 0 ? (
+
+
+
+
+
No playlists yet
+
+ {mood ? `No playlists found for "${mood}" mood.` : 'Create your first playlist below to get started!'}
+
+
+ ) : (
+ filteredPlaylists.map((playlist: Playlist) => (
+
onSelectPlaylist?.(playlist.id)}
+ initial={{ opacity: 0, y: 20 }}
+ animate={{ opacity: 1, y: 0 }}
+ className="group relative bg-gradient-to-br from-white to-gray-50 dark:from-gray-900 dark:to-gray-800 border border-gray-200 dark:border-gray-700 rounded-2xl p-6 hover:shadow-lg hover:border-purple-200 dark:hover:border-purple-800 transition-all duration-300"
+ >
+ {/* Playlist Header */}
+
+
+
{playlist.name}
+
+ Mood: {playlist.mood}
+ {playlist.description && • {playlist.description} }
+ • {playlist.tracks.length} tracks
+ {playlist.isShared && • Shared }
+
+
+
+
+ handleShare(playlist.id)}
+ >
+
+
+ deletePlaylist(playlist.id)}
+ >
+
+
+
+
+
+ {/* Tracks */}
+
+
Tracks:
+
+ {playlist.tracks.length === 0 ? (
+
No tracks yet.
+ ) : (
+
+ {playlist.tracks.map((track: { id: string; name: string; artist?: string }) => (
+
+
+ {track.name}
+ {track.artist && (
+ - {track.artist}
+ )}
+
+
+ ))}
+
+ )}
+
+ {/* Add Track Input */}
+
+
setTrackInputs(prev => ({ ...prev, [playlist.id]: e.target.value }))}
+ onKeyDown={(e) => { if (e.key === 'Enter') handleAddTrack(playlist.id); }}
+ className="flex-1 p-2 text-sm bg-background rounded border"
+ />
+
handleAddTrack(playlist.id)}
+ disabled={!trackInputs[playlist.id]?.trim()}
+ >
+
+ Add Track
+
+
+
+
+ ))
+ )}
+
{/* Create New Playlist */}
+
+
+
+
+
Create New Playlist
+
+
+
+
+ Playlist Name
+ setNewName(e.target.value)}
+ className="w-full p-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all placeholder:text-gray-400"
+ />
+
+
+
+ Description
+ setNewDescription(e.target.value)}
+ className="w-full p-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all placeholder:text-gray-400"
+ />
+
+
+
+
+ Create Playlist
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/playlist-stats.tsx b/src/components/playlist-stats.tsx
new file mode 100644
index 0000000..5f91666
--- /dev/null
+++ b/src/components/playlist-stats.tsx
@@ -0,0 +1,49 @@
+// src/components/playlist-stats.tsx
+import React from 'react';
+import { Clock, Music, Calendar } from 'lucide-react';
+import type { Track } from '../types/playlist';
+
+interface PlaylistStatsProps {
+ tracks: Track[];
+ createdAt?: number;
+}
+
+export const PlaylistStats: React.FC = ({ tracks, createdAt }) => {
+ // Calculate total duration (for local/r2 tracks with duration)
+ const totalDurationSeconds = tracks.reduce((total, track) => {
+ return total + (track.duration || 0);
+ }, 0);
+
+ const hours = Math.floor(totalDurationSeconds / 3600);
+ const minutes = Math.floor((totalDurationSeconds % 3600) / 60);
+
+ // Format created date
+ const createdDate = createdAt
+ ? new Date(createdAt).toLocaleDateString()
+ : 'Unknown date';
+
+ return (
+
+
+
+ {tracks.length} tracks
+
+
+ {totalDurationSeconds > 0 && (
+
+
+
+ {hours > 0 ? `${hours} hr ${minutes} min` : `${minutes} min`}
+
+
+ )}
+
+ {createdAt && (
+
+
+ Created on {createdDate}
+
+ )}
+
+ );
+};
\ No newline at end of file
diff --git a/src/components/shareable-playlist.tsx b/src/components/shareable-playlist.tsx
new file mode 100644
index 0000000..63334fe
--- /dev/null
+++ b/src/components/shareable-playlist.tsx
@@ -0,0 +1,245 @@
+import React, { useState } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { Share2, Copy, Check, ExternalLink, Import, QrCode } from 'lucide-react';
+import { usePlaylist } from '../hooks/use-playlist';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
+import { Button } from './ui/button';
+import type { Playlist } from '../context/playlist-provider';
+
+interface ShareablePlaylistProps {
+ playlistId?: string;
+ onClose?: () => void;
+}
+
+export const ShareablePlaylist: React.FC = ({ playlistId, onClose }) => {
+ const { playlists, sharePlaylist, importSharedPlaylist } = usePlaylist();
+ const [shareUrl, setShareUrl] = useState('');
+ const [importUrl, setImportUrl] = useState('');
+ const [copied, setCopied] = useState(false);
+ const [isSharing, setIsSharing] = useState(false);
+ const [isImporting, setIsImporting] = useState(false);
+ const [error, setError] = useState('');
+
+ const playlist = playlistId ? playlists.find((p: Playlist) => p.id === playlistId) : null;
+ const handleShare = async () => {
+ if (!playlist) return;
+
+ setIsSharing(true);
+ setError('');
+
+ try {
+ const shareId = await sharePlaylist(playlist.id);
+ const url = `${window.location.origin}/playlist/shared/${shareId}`;
+ setShareUrl(url);
+ } catch (err) {
+ setError('Failed to create shareable link');
+ console.error('Share error:', err);
+ } finally {
+ setIsSharing(false);
+ }
+ };
+
+ const handleCopy = async () => {
+ if (!shareUrl) return;
+
+ try {
+ await navigator.clipboard.writeText(shareUrl);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch {
+ // Fallback for older browsers
+ const textArea = document.createElement('textarea');
+ textArea.value = shareUrl;
+ document.body.appendChild(textArea);
+ textArea.select();
+ document.execCommand('copy');
+ document.body.removeChild(textArea);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }
+ };
+
+ const handleImport = async () => {
+ if (!importUrl.trim()) return;
+
+ setIsImporting(true);
+ setError('');
+
+ try {
+ // Extract share ID from URL
+ const urlParts = importUrl.trim().split('/');
+ const shareId = urlParts[urlParts.length - 1];
+
+ if (!shareId) {
+ throw new Error('Invalid share URL format');
+ }
+ const success = await importSharedPlaylist(shareId);
+ if (success) {
+ setImportUrl('');
+ alert('Playlist imported successfully!');
+ } else {
+ throw new Error('Playlist not found or invalid share ID');
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to import playlist');
+ } finally {
+ setIsImporting(false);
+ }
+ };
+
+ const generateQRCode = () => {
+ if (!shareUrl) return;
+
+ // Using a simple QR code service for demo purposes
+ // In production, you might want to use a proper QR library
+ const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(shareUrl)}`;
+ window.open(qrUrl, '_blank');
+ };
+
+ return (
+
+
+
+
+ Share Playlists
+
+
+ Share your mood playlists with friends or import shared playlists
+
+
+
+
+ {/* Share Section */}
+ {playlist && (
+
+
+
Share "{playlist.name}"
+
+ Mood: {playlist.mood} • {playlist.tracks.length} tracks
+
+
+ {!shareUrl ? (
+
+ {isSharing ? 'Creating Link...' : 'Generate Share Link'}
+
+ ) : (
+
+
+
+
+
+ {copied ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+
+
+ QR Code
+
+ window.open(shareUrl, '_blank')}
+ className="flex-1"
+ >
+
+ Preview
+
+
+
+ )}
+
+
+ )}
+
+ {/* Import Section */}
+
+
+
Import Shared Playlist
+
+ setImportUrl(e.target.value)}
+ className="flex-1 p-2 text-xs bg-background rounded border"
+ />
+
+
+ {isImporting ? 'Importing...' : 'Import'}
+
+
+
+
+
+ {/* Error Display */}
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {/* Info */}
+
+
• Shared playlists include all tracks and metadata
+
• Share links work across devices and browsers
+
• Imported playlists become part of your collection
+
+
+ {onClose && (
+
+ Close
+
+ )}
+
+
+ );
+};
diff --git a/src/components/storage-status.tsx b/src/components/storage-status.tsx
new file mode 100644
index 0000000..a2e5261
--- /dev/null
+++ b/src/components/storage-status.tsx
@@ -0,0 +1,192 @@
+import React, { useState, useEffect } from 'react';
+import { Cloud, HardDrive, Wifi, WifiOff, CheckCircle, AlertCircle } from 'lucide-react';
+import { motion, AnimatePresence } from 'framer-motion';
+
+interface StorageStatusProps {
+ className?: string;
+}
+
+type StorageType = 'cloud' | 'local' | 'checking';
+type ConnectionStatus = 'online' | 'offline' | 'checking';
+
+export const StorageStatus: React.FC = ({ className = '' }) => {
+ const [storageType, setStorageType] = useState('checking');
+ const [connectionStatus, setConnectionStatus] = useState('checking');
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ useEffect(() => {
+ checkStorageStatus();
+ checkConnectionStatus();
+
+ // Set up connection monitoring
+ const handleOnline = () => setConnectionStatus('online');
+ const handleOffline = () => setConnectionStatus('offline');
+
+ window.addEventListener('online', handleOnline);
+ window.addEventListener('offline', handleOffline);
+
+ return () => {
+ window.removeEventListener('online', handleOnline);
+ window.removeEventListener('offline', handleOffline);
+ };
+ }, []);
+
+ const checkStorageStatus = async () => {
+ try {
+ // Check if Cloudflare config is available
+ const hasCloudflareConfig = !!(
+ import.meta.env.VITE_CLOUDFLARE_ACCOUNT_ID &&
+ import.meta.env.VITE_CLOUDFLARE_ACCESS_KEY_ID &&
+ import.meta.env.VITE_CLOUDFLARE_BUCKET_NAME
+ );
+
+ if (hasCloudflareConfig && navigator.onLine) {
+ // Try to ping the storage service
+ await fetch(import.meta.env.VITE_CLOUDFLARE_CDN_URL || '', {
+ method: 'HEAD',
+ mode: 'no-cors'
+ }).catch(() => null);
+
+ setStorageType('cloud');
+ } else {
+ setStorageType('local');
+ }
+ } catch {
+ setStorageType('local');
+ }
+ };
+
+ const checkConnectionStatus = () => {
+ setConnectionStatus(navigator.onLine ? 'online' : 'offline');
+ };
+
+ const getStorageIcon = () => {
+ switch (storageType) {
+ case 'cloud':
+ return ;
+ case 'local':
+ return ;
+ default:
+ return
;
+ }
+ };
+
+ const getConnectionIcon = () => {
+ switch (connectionStatus) {
+ case 'online':
+ return ;
+ case 'offline':
+ return ;
+ default:
+ return
;
+ }
+ };
+
+ const getStatusColor = () => {
+ if (storageType === 'cloud' && connectionStatus === 'online') {
+ return 'text-green-600 dark:text-green-400';
+ } else if (storageType === 'local' || connectionStatus === 'offline') {
+ return 'text-yellow-600 dark:text-yellow-400';
+ }
+ return 'text-gray-600 dark:text-gray-400';
+ };
+
+ const getStatusText = () => {
+ if (storageType === 'checking' || connectionStatus === 'checking') {
+ return 'Checking...';
+ }
+
+ if (storageType === 'cloud' && connectionStatus === 'online') {
+ return 'Cloud Storage';
+ } else if (connectionStatus === 'offline') {
+ return 'Offline Mode';
+ } else {
+ return 'Local Storage';
+ }
+ };
+
+ const getDetailedStatus = () => {
+ if (storageType === 'cloud' && connectionStatus === 'online') {
+ return {
+ title: 'Cloud Storage Active',
+ description: 'Your playlists are synced to Cloudflare R2 with CDN acceleration',
+ icon:
+ };
+ } else if (connectionStatus === 'offline') {
+ return {
+ title: 'Offline Mode',
+ description: 'Working offline. Changes will sync when connection is restored',
+ icon:
+ };
+ } else {
+ return {
+ title: 'Local Storage',
+ description: 'Data is stored locally in your browser. Cloud sync unavailable',
+ icon:
+ };
+ }
+ };
+
+ const detailedStatus = getDetailedStatus();
+
+ return (
+
+
setIsExpanded(!isExpanded)}
+ className={`flex items-center gap-2 px-3 py-2 rounded-lg bg-background/50 hover:bg-background/80 transition-colors ${getStatusColor()}`}
+ whileHover={{ scale: 1.02 }}
+ whileTap={{ scale: 0.98 }}
+ >
+ {getStorageIcon()}
+ {getConnectionIcon()}
+ {getStatusText()}
+
+
+
+ {isExpanded && (
+
+
+ {detailedStatus.icon}
+
+
{detailedStatus.title}
+
+ {detailedStatus.description}
+
+
+
+
+ {storageType === 'cloud' ? (
+
+ ) : (
+
+ )}
+
Storage: {storageType === 'cloud' ? 'Cloud' : 'Local'}
+
+
+ {connectionStatus === 'online' ? (
+
+ ) : (
+
+ )}
+
Network: {connectionStatus}
+
+
+
+ {storageType === 'local' && (
+
+ Note: To enable cloud storage, configure Cloudflare R2 credentials in your environment variables.
+
+ )}
+
+
+
+ )}
+
+
+ );
+};
diff --git a/src/components/track-upload-form.tsx b/src/components/track-upload-form.tsx
new file mode 100644
index 0000000..944ab3c
--- /dev/null
+++ b/src/components/track-upload-form.tsx
@@ -0,0 +1,244 @@
+// src/components/track-upload-form.tsx
+import React, { useState, useRef } from 'react';
+import { toast } from 'sonner';
+import { Upload, Link, Music2 as MusicIcon } from 'lucide-react';
+import { uploadAudioToR2 } from '../services/storage-service';
+import type { TrackSource } from '../types/playlist';
+
+interface TrackUploadFormProps {
+ onUploadComplete: (track: {
+ name: string;
+ artist: string;
+ source: TrackSource;
+ uri: string;
+ }) => void;
+ onCancel: () => void;
+}
+
+export const TrackUploadForm: React.FC = ({
+ onUploadComplete,
+ onCancel
+}) => {
+ const [name, setName] = useState('');
+ const [artist, setArtist] = useState('');
+ const [source, setSource] = useState('local');
+ const [uri, setUri] = useState('');
+ const [isUploading, setIsUploading] = useState(false);
+ const [uploadProgress, setUploadProgress] = useState(0);
+
+ const fileInputRef = useRef(null);
+
+ const handleFileSelect = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ // Auto-populate name from filename
+ const fileName = file.name.replace(/\.[^/.]+$/, '');
+ setName(fileName);
+
+ // Create local object URL for preview
+ const objectUrl = URL.createObjectURL(file);
+ setUri(objectUrl);
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!name.trim()) {
+ toast.error('Please enter a track name');
+ return;
+ }
+
+ try {
+ let finalUri = uri;
+ let finalSource = source;
+
+ // Handle file upload to R2
+ if (source === 'local' && fileInputRef.current?.files?.[0]) {
+ const file = fileInputRef.current.files[0];
+ setIsUploading(true);
+
+ try {
+ const result = await uploadAudioToR2(file, {
+ onProgress: setUploadProgress
+ });
+ finalUri = result.cdnUrl;
+ finalSource = 'r2';
+ } catch (error) {
+ console.error('Upload failed:', error);
+ toast.error('Failed to upload file');
+ setIsUploading(false);
+ return;
+ }
+
+ setIsUploading(false);
+ } else if (source === 'spotify') {
+ // Extract Spotify URI/ID
+ const spotifyMatch = uri.match(/(?:spotify:track:|open\.spotify\.com\/track\/)([a-zA-Z0-9]{22})/);
+ finalUri = spotifyMatch ? spotifyMatch[1] : uri;
+ } else if (source === 'youtube') {
+ // Extract YouTube video ID
+ const ytMatch = uri.match(/(?:youtube\.com\/watch\?v=|youtube\.be\/)([a-zA-Z0-9_-]{11})/);
+ finalUri = ytMatch ? ytMatch[1] : uri;
+ }
+
+ // Send back the track info to parent
+ onUploadComplete({
+ name: name.trim(),
+ artist: artist.trim() || 'Unknown',
+ source: finalSource,
+ uri: finalUri
+ });
+
+ } catch (error) {
+ console.error('Error:', error);
+ toast.error('Something went wrong');
+ }
+ };
+
+ return (
+
+ );
+};
\ No newline at end of file
diff --git a/src/components/unified-player.tsx b/src/components/unified-player.tsx
new file mode 100644
index 0000000..9068872
--- /dev/null
+++ b/src/components/unified-player.tsx
@@ -0,0 +1,401 @@
+import React, { useState, useRef, useEffect } from 'react';
+import { motion } from 'framer-motion';
+import {
+ Play,
+ Pause,
+ SkipBack,
+ SkipForward,
+ Volume2,
+ VolumeX,
+ Repeat,
+ Shuffle,
+} from 'lucide-react';
+import type { Track } from '../types/playlist';
+
+interface UnifiedPlayerProps {
+ tracks: Track[];
+ persistKey?: string;
+ onTrackChange?: (index: number) => void;
+}
+
+export const UnifiedPlayer: React.FC = ({
+ tracks = [],
+ persistKey,
+ onTrackChange,
+}) => {
+ // --- Helpers ---
+ const getSavedPosition = (): number => {
+ if (!persistKey) return 0;
+ try
+ {
+ const saved = localStorage.getItem(`player_position_${persistKey}`);
+ return saved ? parseInt(saved, 10) : 0;
+ } catch
+ {
+ return 0;
+ }
+ };
+
+ // --- State ---
+ const [currentIndex, setCurrentIndex] = useState(getSavedPosition());
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [progress, setProgress] = useState(0);
+ const [duration, setDuration] = useState(0);
+ const [volume, setVolume] = useState(0.7);
+ const [isMuted, setIsMuted] = useState(false);
+ const [isShuffle, setIsShuffle] = useState(false);
+ const [isRepeat, setIsRepeat] = useState(false);
+ const audioRef = useRef(null);
+
+ const noTracks = !tracks || tracks.length === 0;
+ const currentTrack = !noTracks ? tracks[currentIndex] || tracks[0] : null;
+
+ // --- Hooks ---
+ useEffect(() => {
+ if (currentIndex >= tracks.length)
+ {
+ setCurrentIndex(0);
+ }
+ }, [tracks, currentIndex]);
+
+ useEffect(() => {
+ if (persistKey)
+ {
+ localStorage.setItem(`player_position_${persistKey}`, currentIndex.toString());
+ }
+ if (onTrackChange)
+ {
+ onTrackChange(currentIndex);
+ }
+ }, [currentIndex, persistKey, onTrackChange]);
+
+ const playNext = () => {
+ if (tracks.length <= 1) return;
+ let nextIndex;
+ if (isShuffle)
+ {
+ let randomIndex;
+ do
+ {
+ randomIndex = Math.floor(Math.random() * tracks.length);
+ } while (tracks.length > 1 && randomIndex === currentIndex);
+ nextIndex = randomIndex;
+ } else
+ {
+ nextIndex = (currentIndex + 1) % tracks.length;
+ }
+ setCurrentIndex(nextIndex);
+ };
+
+ const playPrevious = () => {
+ if (tracks.length <= 1) return;
+ if (progress > 3 && audioRef.current)
+ {
+ audioRef.current.currentTime = 0;
+ setProgress(0);
+ return;
+ }
+ const prevIndex = (currentIndex - 1 + tracks.length) % tracks.length;
+ setCurrentIndex(prevIndex);
+ };
+
+ useEffect(() => {
+ const audio = audioRef.current;
+ if (!audio) return;
+
+ const handleTimeUpdate = () => setProgress(audio.currentTime);
+ const handleLoadedMetadata = () => {
+ setDuration(audio.duration);
+ audio.volume = isMuted ? 0 : volume;
+ };
+ const handleEnded = () => {
+ if (isRepeat)
+ {
+ audio.currentTime = 0;
+ void audio.play();
+ } else
+ {
+ playNext();
+ }
+ };
+
+ audio.addEventListener('timeupdate', handleTimeUpdate);
+ audio.addEventListener('loadedmetadata', handleLoadedMetadata);
+ audio.addEventListener('ended', handleEnded);
+
+ return () => {
+ audio.removeEventListener('timeupdate', handleTimeUpdate);
+ audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
+ audio.removeEventListener('ended', handleEnded);
+ };
+
+ }, [volume, isMuted, isRepeat]);
+
+ useEffect(() => {
+ const audio = audioRef.current;
+ if (!audio || !currentTrack) return;
+
+
+ if (['local', 'r2', 'external'].includes(currentTrack.source))
+ {
+ audio.src = currentTrack.uri;
+ audio.load();
+ setProgress(0);
+ if (isPlaying) void audio.play().catch(console.error);
+ }
+ }, [currentTrack, currentIndex, isPlaying]);
+
+ // --- Handlers ---
+ const togglePlayPause = () => {
+ if (!currentTrack) return;
+
+
+ if (['local', 'r2', 'external'].includes(currentTrack.source))
+ {
+ const audio = audioRef.current;
+ if (!audio) return;
+ if (isPlaying) audio.pause();
+ else void audio.play().catch(console.error);
+ } else if (currentTrack.source === 'spotify')
+ {
+ window.open(
+ currentTrack.uri.includes('spotify:track:')
+ ? `https://open.spotify.com/track/${currentTrack.uri.split(':').pop()}`
+ : `https://open.spotify.com/track/${currentTrack.uri}`,
+ '_blank'
+ );
+ } else if (currentTrack.source === 'youtube')
+ {
+ window.open(`https://www.youtube.com/watch?v=${currentTrack.uri}`, '_blank');
+ }
+
+ setIsPlaying(!isPlaying);
+ };
+
+ const handleSeek = (e: React.ChangeEvent) => {
+ const seekTime = Number(e.target.value);
+ if (audioRef.current)
+ {
+ audioRef.current.currentTime = seekTime;
+ setProgress(seekTime);
+ }
+ };
+
+ const handleVolumeChange = (e: React.ChangeEvent) => {
+ const newVolume = Number(e.target.value);
+ setVolume(newVolume);
+ if (audioRef.current) audioRef.current.volume = newVolume;
+ setIsMuted(newVolume === 0);
+ };
+
+ const toggleMute = () => {
+ if (audioRef.current) audioRef.current.volume = isMuted ? volume : 0;
+ setIsMuted(!isMuted);
+ };
+
+ const formatTime = (seconds: number): string => {
+ if (isNaN(seconds) || !isFinite(seconds)) return '0:00';
+ const mins = Math.floor(seconds / 60);
+ const secs = Math.floor(seconds % 60);
+ return `${mins}:${secs.toString().padStart(2, '0')} `;
+ };
+
+ // --- Conditional Render ---
+ if (noTracks)
+ {
+ return (
+ No tracks available to play
+ );
+ }
+
+ // --- UI ---
+ return (
+
+ {/* Track info */}
+
+ {currentTrack?.thumbnail ? (
+ ) : (
+ )}
+
+
+
+ < div >
+
+ {currentTrack?.name || 'Unknown Track'}
+
+
+
+ {currentTrack?.artist || 'Unknown Artist'}
+
+
+
+
+ {currentTrack?.source?.toUpperCase() || 'UNKNOWN'}
+
+
+
+
+
+ {/* Progress bar */}
+ {
+ ['local', 'r2', 'external'].includes(currentTrack?.source ?? '') && (
+
+
+
+ {formatTime(progress)}
+ {formatTime(duration)}
+
+
+ )
+ }
+
+ {/* Controls */}
+
+
+
setIsShuffle(!isShuffle)}
+ className={`p-2 rounded-full ${isShuffle
+ ? 'bg-blue-100 text-blue-600 dark:bg-blue-900 dark:text-blue-300'
+ : 'text-gray-600 dark:text-gray-400'
+ }`}
+ title={isShuffle ? 'Shuffle On' : 'Shuffle Off'}
+ >
+
+
+
+
+
+
+
+
+ {isPlaying ? : }
+
+
+
+
+
+
+
setIsRepeat(!isRepeat)}
+ className={`p-2 rounded-full ${isRepeat
+ ? 'bg-blue-100 text-blue-600 dark:bg-blue-900 dark:text-blue-300'
+ : 'text-gray-600 dark:text-gray-400'
+ }`}
+ title={isRepeat ? 'Repeat On' : 'Repeat Off'}
+ >
+
+
+
+
+
+
+ {isMuted ? : }
+
+
+
+
+
+
+ {
+ currentTrack?.source === 'spotify' && (
+
Click play to open in Spotify
+ )
+ }
+ {
+ currentTrack?.source === 'youtube' && (
+
Click play to watch on YouTube
+ )
+ }
+
+
+
+ );
+};
diff --git a/src/config/env.ts b/src/config/env.ts
new file mode 100644
index 0000000..e799e87
--- /dev/null
+++ b/src/config/env.ts
@@ -0,0 +1,10 @@
+// Environment configuration
+export const config = {
+ r2: {
+ // This can be overridden by environment variables in production
+ uploadEndpoint: import.meta.env?.VITE_R2_UPLOAD_ENDPOINT || '/api/upload',
+
+ // Whether to use mock uploads (automatically detected in dev mode)
+ useMockUploads: import.meta.env.MODE !== 'production' || import.meta.env?.DEV
+ }
+};
\ No newline at end of file
diff --git a/src/context/playlist-context.tsx b/src/context/playlist-context.tsx
new file mode 100644
index 0000000..5024000
--- /dev/null
+++ b/src/context/playlist-context.tsx
@@ -0,0 +1,46 @@
+import { createContext } from 'react';
+import type { Playlist } from './playlist-provider';
+
+export interface PlaylistContextType {
+ // State
+ playlists: Playlist[];
+ favorites: string[];
+ currentPlaylist: Playlist | null;
+ isLoading: boolean;
+ error: string | null;
+
+ // Actions
+ createPlaylist: (name: string, mood: string, description?: string) => Promise
;
+ updatePlaylist: (id: string, updates: Partial) => void;
+ deletePlaylist: (id: string) => void;
+
+ // Track management
+ addTrack: (playlistId: string, trackName: string, artist?: string) => void;
+ updateTrack: (playlistId: string, trackId: string, updates: Partial) => void;
+ deleteTrack: (playlistId: string, trackId: string) => void;
+
+ // Favorites
+ toggleFavorite: (trackId: string) => void;
+
+ // Playlist operations
+ setCurrentPlaylist: (playlist: Playlist | null) => void;
+ sharePlaylist: (id: string) => Promise;
+ getSharedPlaylist: (shareId: string) => Promise;
+ importSharedPlaylist: (shareId: string) => Promise;
+ getPlaylistsByMood: (mood: string) => Playlist[];
+
+ // Storage management
+ refreshFromCloud: () => Promise;
+ getStorageStatus: () => Promise<{ cloudConnected: boolean; lastSync: number | null }>;
+}
+
+export interface Track {
+ id: string;
+ name: string;
+ artist?: string;
+ duration?: number;
+ url?: string;
+ isLocal?: boolean;
+}
+
+export const PlaylistContext = createContext(undefined);
diff --git a/src/context/playlist-provider.tsx b/src/context/playlist-provider.tsx
new file mode 100644
index 0000000..e3e0537
--- /dev/null
+++ b/src/context/playlist-provider.tsx
@@ -0,0 +1,478 @@
+import React, { useReducer, useEffect } from 'react';
+import {
+ PlaylistCloudStorage,
+ getStorageService,
+ initializeCloudflareStorage,
+ getCloudflareConfig
+} from '../api/cloudflare-storage';
+import { PlaylistContext, type PlaylistContextType, type Track } from './playlist-context';
+
+export interface Playlist {
+ id: string;
+ name: string;
+ mood: string;
+ tracks: Track[];
+ createdAt: number;
+ updatedAt: number;
+ isShared?: boolean;
+ shareId?: string;
+ description?: string;
+ coverImage?: string;
+}
+
+interface PlaylistState {
+ playlists: Playlist[];
+ favorites: string[];
+ currentPlaylist: Playlist | null;
+ isLoading: boolean;
+ error: string | null;
+}
+
+type PlaylistAction =
+ | { type: 'SET_PLAYLISTS'; payload: Playlist[] }
+ | { type: 'ADD_PLAYLIST'; payload: Playlist }
+ | { type: 'UPDATE_PLAYLIST'; payload: { id: string; updates: Partial } }
+ | { type: 'DELETE_PLAYLIST'; payload: string }
+ | { type: 'ADD_TRACK'; payload: { playlistId: string; track: Track } }
+ | { type: 'UPDATE_TRACK'; payload: { playlistId: string; trackId: string; updates: Partial } }
+ | { type: 'DELETE_TRACK'; payload: { playlistId: string; trackId: string } }
+ | { type: 'SET_FAVORITES'; payload: string[] }
+ | { type: 'TOGGLE_FAVORITE'; payload: string }
+ | { type: 'SET_CURRENT_PLAYLIST'; payload: Playlist | null }
+ | { type: 'SET_LOADING'; payload: boolean }
+ | { type: 'SET_ERROR'; payload: string | null };
+
+// User identification (in production this would come from auth)
+function getUserId(): string {
+ let userId = localStorage.getItem('skybuddy_user_id');
+ if (!userId) {
+ userId = 'user_' + Date.now().toString(36) + Math.random().toString(36).substr(2);
+ localStorage.setItem('skybuddy_user_id', userId);
+ }
+ return userId;
+}
+
+// Helper functions
+function generateId(): string {
+ return Date.now().toString(36) + Math.random().toString(36).substr(2);
+}
+
+function generateShareId(): string {
+ return Math.random().toString(36).substr(2, 8);
+}
+
+// Cloud storage instance
+let cloudStorage: PlaylistCloudStorage | null = null;
+
+// Initialize cloud storage with fallback to localStorage
+async function initializeStorage(): Promise {
+ try {
+ const config = getCloudflareConfig();
+
+ // Check if Cloudflare config is available
+ if (config.accountId && config.accessKeyId && config.bucketName) {
+ initializeCloudflareStorage(config);
+ const service = getStorageService();
+ return new PlaylistCloudStorage(service);
+ } else {
+ console.warn('Cloudflare R2 config not found, falling back to localStorage');
+ return null;
+ }
+ } catch (error) {
+ console.error('Failed to initialize cloud storage:', error);
+ return null;
+ }
+}
+
+// Fallback localStorage functions
+function loadFromLocalStorage(key: string, defaultValue: T): T {
+ try {
+ const data = localStorage.getItem(key);
+ return data ? JSON.parse(data) : defaultValue;
+ } catch {
+ return defaultValue;
+ }
+}
+
+function saveToLocalStorage(key: string, data: T): void {
+ try {
+ localStorage.setItem(key, JSON.stringify(data));
+ } catch (error) {
+ console.error('Failed to save to localStorage:', error);
+ }
+}
+
+// Reducer
+function playlistReducer(state: PlaylistState, action: PlaylistAction): PlaylistState {
+ switch (action.type) {
+ case 'SET_PLAYLISTS':
+ return { ...state, playlists: action.payload };
+
+ case 'ADD_PLAYLIST':
+ return { ...state, playlists: [...state.playlists, action.payload] };
+
+ case 'UPDATE_PLAYLIST':
+ return {
+ ...state,
+ playlists: state.playlists.map(p =>
+ p.id === action.payload.id
+ ? { ...p, ...action.payload.updates, updatedAt: Date.now() }
+ : p
+ )
+ };
+
+ case 'DELETE_PLAYLIST':
+ return {
+ ...state,
+ playlists: state.playlists.filter(p => p.id !== action.payload),
+ currentPlaylist: state.currentPlaylist?.id === action.payload ? null : state.currentPlaylist
+ };
+
+ case 'ADD_TRACK':
+ return {
+ ...state,
+ playlists: state.playlists.map(p =>
+ p.id === action.payload.playlistId
+ ? { ...p, tracks: [...p.tracks, action.payload.track], updatedAt: Date.now() }
+ : p
+ )
+ };
+
+ case 'UPDATE_TRACK':
+ return {
+ ...state,
+ playlists: state.playlists.map(p =>
+ p.id === action.payload.playlistId
+ ? {
+ ...p,
+ tracks: p.tracks.map(t =>
+ t.id === action.payload.trackId ? { ...t, ...action.payload.updates } : t
+ ),
+ updatedAt: Date.now()
+ }
+ : p
+ )
+ };
+
+ case 'DELETE_TRACK':
+ return {
+ ...state,
+ playlists: state.playlists.map(p =>
+ p.id === action.payload.playlistId
+ ? { ...p, tracks: p.tracks.filter(t => t.id !== action.payload.trackId), updatedAt: Date.now() }
+ : p
+ )
+ };
+
+ case 'SET_FAVORITES':
+ return { ...state, favorites: action.payload };
+ case 'TOGGLE_FAVORITE': {
+ const favorites = state.favorites.includes(action.payload)
+ ? state.favorites.filter(f => f !== action.payload)
+ : [...state.favorites, action.payload];
+ return { ...state, favorites };
+ }
+
+ case 'SET_CURRENT_PLAYLIST':
+ return { ...state, currentPlaylist: action.payload };
+
+ case 'SET_LOADING':
+ return { ...state, isLoading: action.payload };
+
+ case 'SET_ERROR':
+ return { ...state, error: action.payload };
+
+ default:
+ return state;
+ }
+}
+
+
+
+// Provider component
+interface PlaylistProviderProps {
+ children: React.ReactNode;
+}
+
+export function PlaylistProvider({ children }: PlaylistProviderProps) {
+ const [state, dispatch] = useReducer(playlistReducer, {
+ playlists: [],
+ favorites: [],
+ currentPlaylist: null,
+ isLoading: false,
+ error: null,
+ });
+ // Initialize cloud storage and load data
+ useEffect(() => {
+ const initializeAndLoad = async () => {
+ dispatch({ type: 'SET_LOADING', payload: true });
+
+ try {
+ // Initialize cloud storage
+ cloudStorage = await initializeStorage();
+ const userId = getUserId();
+
+ if (cloudStorage) {
+ // Load from cloud storage
+ const [playlistsResult, favoritesResult] = await Promise.all([
+ cloudStorage.getPlaylists(userId),
+ cloudStorage.getFavorites(userId)
+ ]);
+
+ if (playlistsResult.success && playlistsResult.data) {
+ dispatch({ type: 'SET_PLAYLISTS', payload: playlistsResult.data });
+ }
+
+ if (favoritesResult.success && favoritesResult.data) {
+ dispatch({ type: 'SET_FAVORITES', payload: favoritesResult.data });
+ }
+ } else {
+ // Fallback to localStorage
+ const playlists = loadFromLocalStorage('skybuddy_playlists_v2', []);
+ const favorites = loadFromLocalStorage('skybuddy_favorite_tracks', []);
+
+ dispatch({ type: 'SET_PLAYLISTS', payload: playlists });
+ dispatch({ type: 'SET_FAVORITES', payload: favorites });
+ }
+ } catch (error) {
+ console.error('Failed to load data:', error);
+ dispatch({ type: 'SET_ERROR', payload: 'Failed to load playlists' });
+
+ // Fallback to localStorage on error
+ const playlists = loadFromLocalStorage('skybuddy_playlists_v2', []);
+ const favorites = loadFromLocalStorage('skybuddy_favorite_tracks', []);
+
+ dispatch({ type: 'SET_PLAYLISTS', payload: playlists });
+ dispatch({ type: 'SET_FAVORITES', payload: favorites });
+ } finally {
+ dispatch({ type: 'SET_LOADING', payload: false });
+ }
+ };
+
+ initializeAndLoad();
+ }, []);
+
+ // Auto-save to cloud storage when state changes
+ useEffect(() => {
+ const saveData = async () => {
+ if (!cloudStorage) {
+ // Fallback to localStorage
+ saveToLocalStorage('skybuddy_playlists_v2', state.playlists);
+ return;
+ }
+
+ try {
+ const userId = getUserId();
+ await cloudStorage.storePlaylists(userId, state.playlists);
+ } catch (error) {
+ console.error('Failed to save playlists to cloud:', error);
+ // Fallback to localStorage
+ saveToLocalStorage('skybuddy_playlists_v2', state.playlists);
+ }
+ };
+
+ if (state.playlists.length > 0) {
+ saveData();
+ }
+ }, [state.playlists]);
+
+ useEffect(() => {
+ const saveFavorites = async () => {
+ if (!cloudStorage) {
+ // Fallback to localStorage
+ saveToLocalStorage('skybuddy_favorite_tracks', state.favorites);
+ return;
+ }
+
+ try {
+ const userId = getUserId();
+ await cloudStorage.storeFavorites(userId, state.favorites);
+ } catch (error) {
+ console.error('Failed to save favorites to cloud:', error);
+ // Fallback to localStorage
+ saveToLocalStorage('skybuddy_favorite_tracks', state.favorites);
+ }
+ };
+
+ saveFavorites();
+ }, [state.favorites]);
+
+ // Actions
+ const actions = {
+ createPlaylist: async (name: string, mood: string, description?: string): Promise => {
+ const playlist: Playlist = {
+ id: generateId(),
+ name: name.trim(),
+ mood,
+ description,
+ tracks: [],
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ dispatch({ type: 'ADD_PLAYLIST', payload: playlist });
+ return playlist;
+ },
+
+ updatePlaylist: (id: string, updates: Partial) => {
+ dispatch({ type: 'UPDATE_PLAYLIST', payload: { id, updates } });
+ },
+
+ deletePlaylist: (id: string) => {
+ dispatch({ type: 'DELETE_PLAYLIST', payload: id });
+ },
+
+ addTrack: (playlistId: string, trackName: string, artist?: string) => {
+ const track: Track = {
+ id: generateId(),
+ name: trackName.trim(),
+ artist: artist?.trim(),
+ isLocal: true,
+ };
+
+ dispatch({ type: 'ADD_TRACK', payload: { playlistId, track } });
+ },
+
+ updateTrack: (playlistId: string, trackId: string, updates: Partial) => {
+ dispatch({ type: 'UPDATE_TRACK', payload: { playlistId, trackId, updates } });
+ },
+
+ deleteTrack: (playlistId: string, trackId: string) => {
+ dispatch({ type: 'DELETE_TRACK', payload: { playlistId, trackId } });
+ },
+
+ toggleFavorite: (trackId: string) => {
+ dispatch({ type: 'TOGGLE_FAVORITE', payload: trackId });
+ },
+
+ setCurrentPlaylist: (playlist: Playlist | null) => {
+ dispatch({ type: 'SET_CURRENT_PLAYLIST', payload: playlist });
+ }, sharePlaylist: async (playlistId: string): Promise => {
+ const playlist = state.playlists.find(p => p.id === playlistId);
+ if (!playlist) throw new Error('Playlist not found');
+
+ const shareId = generateShareId();
+ const shareData = {
+ ...playlist,
+ shareId,
+ isShared: true,
+ };
+
+ try {
+ if (cloudStorage) {
+ // Store in cloud storage
+ const result = await cloudStorage.storeSharedPlaylist(shareId, shareData);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to store shared playlist');
+ }
+ } else {
+ // Fallback to localStorage
+ const sharedPlaylists = loadFromLocalStorage>('skybuddy_shared_playlists', {});
+ sharedPlaylists[shareId] = shareData;
+ saveToLocalStorage('skybuddy_shared_playlists', sharedPlaylists);
+ }
+
+ // Update the original playlist to mark it as shared
+ dispatch({ type: 'UPDATE_PLAYLIST', payload: { id: playlistId, updates: { isShared: true, shareId } } });
+
+ return shareId;
+ } catch (error) {
+ console.error('Failed to share playlist:', error);
+ throw error;
+ }
+ },
+
+ getSharedPlaylist: async (shareId: string): Promise => {
+ try { if (cloudStorage) {
+ // Get from cloud storage
+ const result = await cloudStorage.getSharedPlaylist(shareId);
+ return result.success ? result.data || null : null;
+ } else {
+ // Fallback to localStorage
+ const sharedPlaylists = loadFromLocalStorage>('skybuddy_shared_playlists', {});
+ return sharedPlaylists[shareId] || null;
+ }
+ } catch (error) {
+ console.error('Failed to get shared playlist:', error);
+ return null;
+ }
+ },
+
+ importSharedPlaylist: async (shareId: string): Promise => {
+ try {
+ const sharedPlaylist = await actions.getSharedPlaylist(shareId);
+ if (!sharedPlaylist) return false;
+
+ // Create a new playlist based on the shared one
+ const newPlaylist: Playlist = {
+ ...sharedPlaylist,
+ id: generateId(),
+ name: `${sharedPlaylist.name} (Imported)`,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ isShared: false,
+ shareId: undefined,
+ };
+
+ dispatch({ type: 'ADD_PLAYLIST', payload: newPlaylist });
+ return true;
+ } catch {
+ return false;
+ }
+ },
+
+ getPlaylistsByMood: (mood: string): Playlist[] => {
+ return state.playlists.filter(p => p.mood.toLowerCase() === mood.toLowerCase());
+ },
+ }; const contextValue: PlaylistContextType = {
+ // State
+ playlists: state.playlists,
+ favorites: state.favorites,
+ currentPlaylist: state.currentPlaylist,
+ isLoading: state.isLoading,
+ error: state.error,
+
+ // Actions (mapping from the actions object)
+ createPlaylist: actions.createPlaylist,
+ updatePlaylist: actions.updatePlaylist,
+ deletePlaylist: actions.deletePlaylist,
+ addTrack: actions.addTrack,
+ updateTrack: actions.updateTrack,
+ deleteTrack: actions.deleteTrack,
+ toggleFavorite: actions.toggleFavorite,
+ setCurrentPlaylist: actions.setCurrentPlaylist,
+ sharePlaylist: actions.sharePlaylist,
+ getSharedPlaylist: actions.getSharedPlaylist,
+ importSharedPlaylist: actions.importSharedPlaylist,
+ getPlaylistsByMood: actions.getPlaylistsByMood,
+ refreshFromCloud: async () => {
+ dispatch({ type: 'SET_LOADING', payload: true });
+ try {
+ if (cloudStorage) {
+ const result = await cloudStorage.getPlaylists(getUserId());
+ if (result.success && result.data) {
+ dispatch({ type: 'SET_PLAYLISTS', payload: result.data });
+ }
+ }
+ } catch (error) {
+ console.error('Failed to refresh from cloud:', error);
+ } finally {
+ dispatch({ type: 'SET_LOADING', payload: false });
+ }
+ },
+ getStorageStatus: async () => {
+ return {
+ cloudConnected: cloudStorage !== null,
+ lastSync: null // Could be enhanced to track actual sync times
+ };
+ }
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+
diff --git a/src/hooks/use-favorite.tsx b/src/hooks/use-favorite.tsx
index c908fde..d099254 100644
--- a/src/hooks/use-favorite.tsx
+++ b/src/hooks/use-favorite.tsx
@@ -1,72 +1,40 @@
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { useLocalStorage } from "./use-local-storage";
-
-interface FavoriteCity {
- id:string,
- name:string,
- lat:number,
- lon:number,
- country:string,
- state?:string,
- addedAt:number
-}
-
-export function useFavorites(){ //takes key-value pairs
- const [favorites,setFavorites]=useLocalStorage("favorites",[]);
- const queryClient = useQueryClient();
-
- const favoriteQuery=useQuery({
- queryKey: ['favorites'],
- queryFn: () => favorites,
- initialData: favorites,
- staleTime: Infinity //to not get expired and will always present in localstorage
- });
-
- const addFavorite=useMutation({
- mutationFn:async(city:Omit)=>{
- const newFavorite:FavoriteCity={
- ...city, //we will take whatever provided to us
- id:`${city.lat}-${city.lon}`,
- addedAt:Date.now(),
- };
-
- // to check whether place we are trying to search is a duplicate and we can keep 10 histories at a time
-
- const exists= favorites.some((fav)=>fav.id===newFavorite.id); //compares the id
-
- if(exists) return favorites
-
- const newFavorites=[...favorites,newFavorite].slice(0,10);
-
- setFavorites(newFavorites)
- return newFavorites;
-
- }, //to fetch again or to overwrite the previous data
- onSuccess:()=>{
- queryClient.invalidateQueries({
- queryKey:['favorites'],
- });
- }
- });
-
- const removeFavorite=useMutation({
- mutationFn:async(cityId:string)=>{
- const newFavorites=favorites.filter((city)=> city.id!==cityId);
- setFavorites(newFavorites)
-
- return newFavorites;
- },
- onSuccess:()=>{
- queryClient.invalidateQueries({
- queryKey:['favorites'],
- });
- }
- });
-
- return {
- favorites:favoriteQuery.data,
- addFavorite,
- removeFavorite,
- isFavorite:(lat:number,lon:number)=>favorites.some((city)=>city.lat===lat && city.lon===lon)
+// src/hooks/use-favorites.ts
+import { useState, useEffect } from 'react';
+
+export function useFavorites() {
+ const [favorites, setFavorites] = useState(() => {
+ const saved = localStorage.getItem('skybuddy_favorites');
+ return saved ? JSON.parse(saved) : [];
+ });
+
+ // Save favorites to localStorage when they change
+ useEffect(() => {
+ localStorage.setItem('skybuddy_favorites', JSON.stringify(favorites));
+ }, [favorites]);
+
+ const addFavorite = (trackId: string) => {
+ setFavorites(prev => [...prev, trackId]);
+ };
+
+ const removeFavorite = (trackId: string) => {
+ setFavorites(prev => prev.filter(id => id !== trackId));
+ };
+
+ const toggleFavorite = (trackId: string) => {
+ if (favorites.includes(trackId)) {
+ removeFavorite(trackId);
+ } else {
+ addFavorite(trackId);
}
-}
+ };
+
+ const isFavorite = (trackId: string) => favorites.includes(trackId);
+
+ return {
+ favorites,
+ addFavorite,
+ removeFavorite,
+ toggleFavorite,
+ isFavorite
+ };
+}
\ No newline at end of file
diff --git a/src/hooks/use-playlist.tsx b/src/hooks/use-playlist.tsx
new file mode 100644
index 0000000..444c66c
--- /dev/null
+++ b/src/hooks/use-playlist.tsx
@@ -0,0 +1,11 @@
+import { useContext } from 'react';
+import { PlaylistContext } from '../context/playlist-context';
+
+// Hook to use the playlist context
+export function usePlaylist() {
+ const context = useContext(PlaylistContext);
+ if (context === undefined) {
+ throw new Error('usePlaylist must be used within a PlaylistProvider');
+ }
+ return context;
+}
diff --git a/src/index.css b/src/index.css
index e57f9e3..8de7f2e 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,4 +1,4 @@
-@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@600&display=swap');
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap');
@import "tailwindcss";
@import "tw-animate-css";
@@ -119,5 +119,44 @@
}
body {
@apply bg-background text-foreground;
+ font-family: 'Inter', 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+ font-variation-settings: 'wght' 400;
+ letter-spacing: -0.01em;
+ line-height: 1.6;
+ }
+
+ /* Modern typography hierarchy */
+ h1, h2, h3, h4, h5, h6 {
+ font-family: 'Plus Jakarta Sans', 'Inter', sans-serif;
+ font-weight: 600;
+ letter-spacing: -0.02em;
+ line-height: 1.2;
+ }
+
+ h1 {
+ font-weight: 700;
+ font-size: 2.5rem;
+ }
+
+ h2 {
+ font-weight: 600;
+ font-size: 2rem;
+ }
+
+ h3 {
+ font-weight: 600;
+ font-size: 1.5rem;
+ }
+
+ /* Button and interactive elements */
+ button, .btn {
+ font-family: 'Inter', sans-serif;
+ font-weight: 500;
+ letter-spacing: -0.01em;
+ }
+
+ /* Code and monospace */
+ code, pre {
+ font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
}
}
\ No newline at end of file
diff --git a/src/lib/playlist-data.ts b/src/lib/playlist-data.ts
index 28dccf9..e48351d 100644
--- a/src/lib/playlist-data.ts
+++ b/src/lib/playlist-data.ts
@@ -1,4 +1,4 @@
-import type { WeatherPlaylistMapping, MoodType, SpotifyPlaylist } from '@/types/playlist';
+import type { WeatherPlaylistMapping, MoodType, SpotifyPlaylist } from '../types/playlist';
// Curated Spotify playlists for different weather conditions
export const weatherPlaylistMappings: WeatherPlaylistMapping[] = [
diff --git a/src/pages/music-page.tsx b/src/pages/music-page.tsx
new file mode 100644
index 0000000..82a8808
--- /dev/null
+++ b/src/pages/music-page.tsx
@@ -0,0 +1,172 @@
+import React from 'react';
+import { useSearchParams } from 'react-router-dom';
+import { motion } from 'framer-motion';
+import { ChevronLeft, Music } from 'lucide-react';
+import { useGeolocation } from '../hooks/use-geolocation';
+import { useWeatherQuery } from '../hooks/use-weather';
+import { getRecommendedPlaylists } from '../lib/playlist-data';
+import { usePlaylist } from '../hooks/use-playlist';
+import { MusicPlayer } from '../components/music-player';
+import { UnifiedPlayer } from '../components/unified-player';
+import type { MoodType, Playlist } from '../types/playlist';
+import { PlaylistManager } from '../components/playlist-manager';
+
+// Weather background helper
+const getWeatherBackground = (weather: string) => {
+ switch (weather) {
+ case 'Rain':
+ return 'bg-gradient-to-br from-blue-400 to-gray-700';
+ case 'Snow':
+ return 'bg-gradient-to-br from-blue-200 to-white';
+ case 'Clouds':
+ return 'bg-gradient-to-br from-gray-400 to-gray-700';
+ case 'Clear':
+ return 'bg-gradient-to-br from-yellow-200 to-blue-400';
+ default:
+ return 'bg-gradient-to-br from-gray-200 to-gray-500';
+ }
+};
+
+// Playlist Detail Component
+function PlaylistDetail({ playlist }: { playlist: Playlist }) {
+ if (!playlist) {
+ return (
+
+
+
Playlist not found
+
This playlist may have been deleted or is unavailable
+
+ );
+ }
+
+ return (
+
+
+ {playlist.imageUrl && (
+
+ )}
+
+
+
{playlist.name}
+ {playlist.description && (
+
{playlist.description}
+ )}
+
+
+ {playlist.mood || 'general'}
+
+
+ {playlist.tracks?.length || 0} tracks
+
+
+
+
+
+ {playlist.tracks?.length > 0 ? (
+
+ ) : (
+
+
+ This playlist doesn't have any tracks yet. Add some tracks to get started!
+
+
+ )}
+
+ );
+}
+
+const MusicPage: React.FC = () => {
+ const { coordinates } = useGeolocation();
+ const weatherQuery = useWeatherQuery(coordinates);
+ const weatherMain = weatherQuery.data?.weather?.[0]?.main || 'Clear';
+ const background = getWeatherBackground(weatherMain);
+
+ const [searchParams, setSearchParams] = useSearchParams();
+ const moodParam = searchParams.get('mood');
+ const playlistId = searchParams.get('playlist');
+
+ const mood = moodParam?.toLowerCase() as MoodType | undefined;
+ const { playlists } = usePlaylist();
+
+ // Combine recommended and user playlists
+ const recommendedPlaylists = getRecommendedPlaylists(weatherMain, mood);
+
+ // Find selected playlist (either from recommended or user playlists)
+ const selectedPlaylist = playlistId
+ ? recommendedPlaylists.find((p) => p.id === playlistId) ||
+ playlists.find((p) => p.id === playlistId)
+ : undefined;
+
+ // Handle playlist selection
+ const handleSelectPlaylist = (id: string) => {
+ setSearchParams({ ...Object.fromEntries(searchParams), playlist: id });
+ };
+
+ // Go back to playlist list
+ const handleBack = () => {
+ const params = new URLSearchParams(searchParams);
+ params.delete('playlist');
+ setSearchParams(params);
+ };
+
+ return (
+
+
+
+
+ {mood ? `${mood.charAt(0).toUpperCase() + mood.slice(1)} Vibes` : 'Your Music'}
+
+
+ {mood ? `Perfect tracks to match your ${mood} mood` : 'Discover and manage your playlists'}
+
+
+
+ {selectedPlaylist ? (
+ <>
+
+
+
+ Back to playlists
+
+
+
+
+ >
+ ) : (
+ <>
+
+ {/* Make sure PlaylistManager has the correct props interface */}
+
+ >
+ )}
+
+
+ );
+};
+
+export default MusicPage;
\ No newline at end of file
diff --git a/src/pages/shared-playlist-page.tsx b/src/pages/shared-playlist-page.tsx
new file mode 100644
index 0000000..f16244e
--- /dev/null
+++ b/src/pages/shared-playlist-page.tsx
@@ -0,0 +1,271 @@
+import React, { useEffect, useState } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { motion } from 'framer-motion';
+import { Music, Download, ArrowLeft, Clock } from 'lucide-react';
+import { usePlaylist } from '../hooks/use-playlist';
+import type { Playlist } from '../context/playlist-provider';
+import { Button } from '../components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card';
+
+const SharedPlaylistPage: React.FC = () => {
+ const { shareId } = useParams<{ shareId: string }>();
+ const navigate = useNavigate(); const { getSharedPlaylist, importSharedPlaylist } = usePlaylist();
+ const [playlist, setPlaylist] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [importing, setImporting] = useState(false);
+ const [imported, setImported] = useState(false);
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ const loadSharedPlaylist = async () => {
+ if (!shareId) {
+ setError('Invalid share ID');
+ setLoading(false);
+ return;
+ }
+
+ try {
+ const sharedPlaylist = await getSharedPlaylist(shareId);
+ if (sharedPlaylist) {
+ setPlaylist(sharedPlaylist);
+ } else {
+ setError('Playlist not found or link expired');
+ }
+ } catch (err) {
+ setError('Failed to load shared playlist');
+ console.error('Load error:', err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ loadSharedPlaylist();
+ }, [shareId, getSharedPlaylist]);
+ const handleImport = async () => {
+ if (!shareId) return;
+
+ setImporting(true);
+ try {
+ const success = await importSharedPlaylist(shareId);
+ if (success) {
+ setImported(true);
+ setTimeout(() => {
+ navigate('/music');
+ }, 2000);
+ } else {
+ setError('Failed to import playlist');
+ }
+ } catch (err) {
+ setError('Failed to import playlist');
+ console.error('Import error:', err);
+ } finally {
+ setImporting(false);
+ }
+ };
+
+ const formatDate = (timestamp: number) => {
+ return new Date(timestamp).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric'
+ });
+ };
+
+ if (loading) {
+ return (
+
+
+
+
+ Loading shared playlist...
+
+
+
+ );
+ }
+
+ if (error || !playlist) {
+ return (
+
+
+
+
+
+
+
+ Playlist Not Found
+
+
+ {error || 'The shared playlist could not be found.'}
+
+ navigate('/')} variant="outline">
+
+ Go Home
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {/* Header */}
+
+
navigate('/')}
+ className="flex items-center gap-2"
+ >
+
+ Back to SkyBuddy
+
+
+ {imported && (
+
+ ✓ Imported Successfully
+
+ )}
+
+
+ {/* Playlist Card */}
+
+
+
+
+
+
+ {playlist.name}
+
+
+ A shared mood playlist for you to enjoy
+
+
+
+
+ {playlist.mood}
+
+
+
+
+
+ {/* Playlist Info */}
+
+
+
+ {playlist.tracks.length} tracks
+
+
+
+ Created {formatDate(playlist.createdAt)}
+
+
+
+ {playlist.description && (
+
+
{playlist.description}
+
+ )}
+
+ {/* Track List */}
+
+
Tracks
+
+ {playlist.tracks.length === 0 ? (
+
+ This playlist doesn't have any tracks yet.
+
+ ) : (
+
+ {playlist.tracks.map((track, index) => (
+
+
+ {index + 1}
+
+
+
{track.name}
+ {track.artist && (
+
{track.artist}
+ )}
+
+ {track.isLocal && (
+
+ Local
+
+ )}
+
+ ))}
+
+ )}
+
+
+ {/* Import Button */}
+
+
+ {importing ? (
+ <>
+
+ Importing...
+ >
+ ) : imported ? (
+ <>
+ ✓ Imported to Your Collection
+ >
+ ) : (
+ <>
+
+ Import to My Playlists
+ >
+ )}
+
+
+ {!imported && (
+
+ This will add a copy of this playlist to your collection
+
+ )}
+
+
+
+
+ {/* SkyBuddy Branding */}
+
+
+ Powered by{' '}
+ navigate('/')}
+ className="text-primary hover:underline font-medium"
+ >
+ SkyBuddy
+ {' '}
+ Music for every mood and weather
+
+
+
+
+
+ );
+};
+
+export default SharedPlaylistPage;
diff --git a/src/services/share-service.ts b/src/services/share-service.ts
new file mode 100644
index 0000000..4456f59
--- /dev/null
+++ b/src/services/share-service.ts
@@ -0,0 +1,42 @@
+// src/services/share-service.ts
+import type { Playlist } from '../types/playlist';
+
+export async function generateShareLink(playlist: Playlist): Promise {
+ // This could store the playlist in your database/Cloudflare KV
+ // and return a shareable ID
+
+ try {
+ // Example implementation - would need proper backend
+ const response = await fetch('/api/share-playlist', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ name: playlist.name,
+ description: playlist.description,
+ mood: playlist.mood,
+ tracks: playlist.tracks
+ })
+ });
+
+ const { id } = await response.json();
+
+ // Return shareable URL
+ return `${window.location.origin}/shared/playlist/${id}`;
+ } catch (error) {
+ console.error('Failed to generate share link:', error);
+ throw new Error('Failed to create shared link');
+ }
+}
+
+export async function getSharedPlaylist(id: string): Promise {
+ // Fetch shared playlist from backend
+ const response = await fetch(`/api/shared-playlist/${id}`);
+
+ if (!response.ok) {
+ throw new Error('Playlist not found or no longer available');
+ }
+
+ return response.json();
+}
\ No newline at end of file
diff --git a/src/services/storage-service.ts b/src/services/storage-service.ts
new file mode 100644
index 0000000..44577a7
--- /dev/null
+++ b/src/services/storage-service.ts
@@ -0,0 +1,128 @@
+// src/services/storage-service.ts
+import { toast } from 'sonner';
+
+export interface UploadOptions {
+ onProgress?: (progress: number) => void;
+ folder?: string;
+}
+
+export interface UploadResult {
+ cdnUrl: string;
+ fileSize: number;
+ fileName: string;
+ uploadedAt: string;
+}
+
+// Add this to enable development mode without a real R2 bucket
+const USE_MOCK_UPLOAD = import.meta.env.MODE !== 'production' || import.meta.env?.DEV;
+
+// Add to storage-service.ts
+async function fetchWithRetry(url: string, options: RequestInit, maxRetries = 3) {
+ let lastError;
+
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
+ try {
+ return await fetch(url, options);
+ } catch (error) {
+ console.warn(`Upload attempt ${attempt + 1} failed:`, error);
+ lastError = error;
+ // Wait before retrying (exponential backoff)
+ await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt)));
+ }
+ }
+
+ throw lastError;
+}
+
+export async function uploadAudioToR2(
+ file: File,
+ options: UploadOptions = {}
+): Promise {
+ const { onProgress, folder = 'audio' } = options;
+
+ // Add to uploadAudioToR2 function in storage-service.ts
+ if (!file.type.startsWith('audio/')) {
+ toast.error('Only audio files are supported');
+ throw new Error('Invalid file type. Only audio files are supported.');
+ }
+
+ // Add size limit
+ const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB
+ if (file.size > MAX_FILE_SIZE) {
+ toast.error('File too large (maximum 20MB)');
+ throw new Error('File too large. Maximum size is 20MB.');
+ }
+
+ // Clean the filename to be URL-safe
+ const safeFileName = file.name
+ .replace(/[^a-zA-Z0-9.-]/g, '_')
+ .toLowerCase();
+
+ const uniqueFileName = `${folder}/${Date.now()}-${safeFileName}`;
+
+ // Mock implementation for development
+ if (USE_MOCK_UPLOAD) {
+ // Simulate upload progress
+ if (onProgress) {
+ let progress = 0;
+ const interval = setInterval(() => {
+ progress += Math.floor(Math.random() * 10) + 5;
+ if (progress >= 100) {
+ progress = 100;
+ clearInterval(interval);
+ }
+ onProgress(progress);
+ }, 300);
+ }
+
+ // Return mock result after delay
+ await new Promise(resolve => setTimeout(resolve, 2000));
+
+ return {
+ cdnUrl: URL.createObjectURL(file), // Use local object URL for preview
+ fileSize: file.size,
+ fileName: safeFileName,
+ uploadedAt: new Date().toISOString()
+ };
+ }
+
+ // Real implementation for production
+ const formData = new FormData();
+ formData.append('file', file);
+ formData.append('path', uniqueFileName);
+
+ try {
+ // Upload to your Cloudflare Worker endpoint that handles R2 uploads
+ const response = await fetchWithRetry('/api/upload', { // Use a relative path that your app can proxy
+ method: 'POST',
+ body: formData,
+ // Report upload progress if requested
+ ...(onProgress && {
+ onUploadProgress: (progressEvent: ProgressEvent) => {
+ const percentCompleted = Math.round(
+ (progressEvent.loaded * 100) / progressEvent.total
+ );
+ onProgress(percentCompleted);
+ }
+ })
+ });
+
+ if (!response.ok) {
+ throw new Error(`Upload failed: ${response.statusText}`);
+ }
+
+ const result = await response.json();
+
+ // The worker should return a CDN URL
+ return {
+ cdnUrl: result.cdnUrl, // URL from your CDN
+ fileSize: file.size,
+ fileName: safeFileName,
+ uploadedAt: new Date().toISOString()
+ };
+ } catch (error) {
+ console.error('Upload error:', error);
+ toast.error('Failed to upload audio file');
+ throw error;
+ }
+}
\ No newline at end of file
diff --git a/src/types/playlist.ts b/src/types/playlist.ts
index 94f05a4..8ce7e4c 100644
--- a/src/types/playlist.ts
+++ b/src/types/playlist.ts
@@ -1,35 +1,34 @@
-export type MoodType = 'happy' | 'relaxed' | 'focused' | 'energetic' | 'calm';
+// src/types/playlist.ts
+export type TrackSource = 'local' | 'r2' | 'spotify' | 'youtube' | 'external';
-export type WeatherConditionType =
- | 'Clear'
- | 'Clouds'
- | 'Rain'
- | 'Drizzle'
- | 'Thunderstorm'
- | 'Snow'
- | 'Mist'
- | 'Smoke'
- | 'Haze'
- | 'Dust'
- | 'Fog'
- | 'Sand'
- | 'Ash'
- | 'Squall'
- | 'Tornado';
+export interface Track {
+ id: string;
+ name: string;
+ artist?: string;
+ album?: string;
+ duration?: number;
+ source: TrackSource;
+ uri: string; // file blob URL, CDN URL, spotify URI, youtube video ID, or external URL
+ thumbnail?: string;
+ isLocal?: boolean;
+ metadata?: {
+ fileSize?: number;
+ uploadedAt?: string;
+ format?: string;
+ bitrate?: number;
+ };
+}
-export interface SpotifyPlaylist {
+export interface Playlist {
id: string;
name: string;
- description: string;
+ description?: string;
+ mood?: MoodType;
imageUrl: string;
- spotifyUrl: string;
- trackCount: number;
- genre: string;
+ tracks: Track[];
+ createdAt: number;
+ isShared?: boolean;
+ shareId?: string;
}
-export interface WeatherPlaylistMapping {
- weather: WeatherConditionType;
- emoji: string;
- playlists: SpotifyPlaylist[];
- description: string;
-}
\ No newline at end of file
+export type MoodType = 'happy' | 'sad' | 'energetic' | 'calm' | 'romantic' | 'focus';
\ No newline at end of file
diff --git a/src/workers/upload-workers.js b/src/workers/upload-workers.js
new file mode 100644
index 0000000..67b35b0
--- /dev/null
+++ b/src/workers/upload-workers.js
@@ -0,0 +1,69 @@
+// workers/upload-worker.js
+export default {
+ async fetch(request, env) {
+ // CORS for preflight requests
+ if (request.method === "OPTIONS") {
+ return new Response(null, {
+ headers: {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type",
+ "Access-Control-Max-Age": "86400",
+ },
+ });
+ }
+
+ // Handle actual upload
+ if (request.method === "POST") {
+ try {
+ const formData = await request.formData();
+ const file = formData.get("file");
+ const path = formData.get("path") || `uploads/${Date.now()}-${file.name}`;
+
+ if (!file) {
+ return new Response(JSON.stringify({ error: "No file provided" }), {
+ status: 400,
+ headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }
+ });
+ }
+
+ // Upload to R2
+ await env.AUDIO_BUCKET.put(path, file.stream(), {
+ httpMetadata: {
+ contentType: file.type,
+ },
+ });
+
+ // Return CDN URL (customize with your domain)
+ const cdnUrl = `https://cdn.skybuddy.app/${path}`;
+
+ return new Response(
+ JSON.stringify({
+ success: true,
+ cdnUrl,
+ fileName: file.name,
+ }),
+ {
+ headers: {
+ "Content-Type": "application/json",
+ "Access-Control-Allow-Origin": "*"
+ },
+ }
+ );
+ } catch (error) {
+ return new Response(
+ JSON.stringify({ error: error.message }),
+ {
+ status: 500,
+ headers: {
+ "Content-Type": "application/json",
+ "Access-Control-Allow-Origin": "*"
+ },
+ }
+ );
+ }
+ }
+
+ return new Response("Method not allowed", { status: 405 });
+ }
+};
\ No newline at end of file
diff --git a/tailwind.config.js b/tailwind.config.js
new file mode 100644
index 0000000..3880d82
--- /dev/null
+++ b/tailwind.config.js
@@ -0,0 +1,19 @@
+module.exports = {
+ theme: {
+ extend: {
+ backgroundImage: {
+ 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
+ 'gradient-conic': 'conic-gradient(var(--tw-gradient-stops))',
+ },
+ animation: {
+ 'slow-spin': 'slow-spin 60s linear infinite',
+ },
+ keyframes: {
+ 'slow-spin': {
+ '0%': { transform: 'rotate(0deg)' },
+ '100%': { transform: 'rotate(360deg)' },
+ },
+ },
+ },
+ },
+};
\ No newline at end of file