Initial commit: ACE-Step UI - Open source music generation interface
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
interface AlbumCoverProps {
|
||||
seed: string;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
// Seeded random number generator for consistent results
|
||||
class SeededRandom {
|
||||
private seed: number;
|
||||
|
||||
constructor(seed: string) {
|
||||
this.seed = this.hashString(seed);
|
||||
}
|
||||
|
||||
private hashString(str: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
return Math.abs(hash) || 1;
|
||||
}
|
||||
|
||||
next(): number {
|
||||
this.seed = (this.seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return this.seed / 0x7fffffff;
|
||||
}
|
||||
|
||||
range(min: number, max: number): number {
|
||||
return min + this.next() * (max - min);
|
||||
}
|
||||
|
||||
int(min: number, max: number): number {
|
||||
return Math.floor(this.range(min, max));
|
||||
}
|
||||
|
||||
pick<T>(arr: T[]): T {
|
||||
return arr[this.int(0, arr.length)];
|
||||
}
|
||||
}
|
||||
|
||||
// Curated color palettes - music-themed combinations
|
||||
const palettes = [
|
||||
// Sunset Vibes
|
||||
{ colors: ['#FF6B6B', '#FEC89A', '#FFD93D', '#C9184A'], bg: '#1a1a2e' },
|
||||
// Ocean Depths
|
||||
{ colors: ['#0077B6', '#00B4D8', '#90E0EF', '#CAF0F8'], bg: '#03045E' },
|
||||
// Forest Night
|
||||
{ colors: ['#2D6A4F', '#40916C', '#52B788', '#95D5B2'], bg: '#1B4332' },
|
||||
// Neon Dreams
|
||||
{ colors: ['#F72585', '#7209B7', '#3A0CA3', '#4CC9F0'], bg: '#10002B' },
|
||||
// Golden Hour
|
||||
{ colors: ['#FF9500', '#FF5400', '#FFBD00', '#FFE066'], bg: '#2D1B00' },
|
||||
// Arctic Aurora
|
||||
{ colors: ['#48CAE4', '#00F5D4', '#9B5DE5', '#F15BB5'], bg: '#0A0A1A' },
|
||||
// Lavender Haze
|
||||
{ colors: ['#E0AAFF', '#C77DFF', '#9D4EDD', '#7B2CBF'], bg: '#240046' },
|
||||
// Cherry Blossom
|
||||
{ colors: ['#FFCCD5', '#FFB3C1', '#FF758F', '#C9184A'], bg: '#2B0A14' },
|
||||
// Cyber Punk
|
||||
{ colors: ['#00FF87', '#60EFFF', '#FF00E5', '#FFE500'], bg: '#0D0D0D' },
|
||||
// Deep Space
|
||||
{ colors: ['#7400B8', '#5E60CE', '#4EA8DE', '#56CFE1'], bg: '#03071E' },
|
||||
// Warm Ember
|
||||
{ colors: ['#FFBA08', '#FAA307', '#F48C06', '#E85D04'], bg: '#370617' },
|
||||
// Cool Mint
|
||||
{ colors: ['#64DFDF', '#72EFDD', '#80FFDB', '#5EEAD4'], bg: '#0D3B3B' },
|
||||
// Velvet Rose
|
||||
{ colors: ['#9D174D', '#BE185D', '#DB2777', '#EC4899'], bg: '#1C0A14' },
|
||||
// Electric Blue
|
||||
{ colors: ['#0EA5E9', '#38BDF8', '#7DD3FC', '#E0F2FE'], bg: '#0C1929' },
|
||||
// Jungle Fever
|
||||
{ colors: ['#84CC16', '#A3E635', '#BEF264', '#ECFCCB'], bg: '#1A2E05' },
|
||||
];
|
||||
|
||||
type PatternType = 'aurora' | 'mesh' | 'orbs' | 'rays' | 'waves' | 'geometric' | 'nebula' | 'gradient' | 'rings' | 'crystal';
|
||||
|
||||
const generatePattern = (rng: SeededRandom, palette: typeof palettes[0]): React.CSSProperties => {
|
||||
const patterns: PatternType[] = ['aurora', 'mesh', 'orbs', 'rays', 'waves', 'geometric', 'nebula', 'gradient', 'rings', 'crystal'];
|
||||
const pattern = rng.pick(patterns);
|
||||
const colors = palette.colors;
|
||||
const bg = palette.bg;
|
||||
|
||||
switch (pattern) {
|
||||
case 'aurora': {
|
||||
const angle1 = rng.int(0, 360);
|
||||
const angle2 = rng.int(0, 360);
|
||||
return {
|
||||
background: `
|
||||
linear-gradient(${angle1}deg, ${colors[0]}00 0%, ${colors[0]}88 25%, ${colors[1]}88 50%, ${colors[2]}88 75%, ${colors[3]}00 100%),
|
||||
linear-gradient(${angle2}deg, ${colors[2]}00 0%, ${colors[3]}66 30%, ${colors[0]}66 70%, ${colors[1]}00 100%),
|
||||
radial-gradient(ellipse at ${rng.int(20, 80)}% ${rng.int(60, 100)}%, ${colors[1]}44 0%, transparent 50%),
|
||||
linear-gradient(180deg, ${bg} 0%, ${colors[3]}22 100%)
|
||||
`,
|
||||
backgroundColor: bg,
|
||||
};
|
||||
}
|
||||
|
||||
case 'mesh': {
|
||||
const points = [
|
||||
{ x: rng.int(0, 40), y: rng.int(0, 40) },
|
||||
{ x: rng.int(60, 100), y: rng.int(0, 40) },
|
||||
{ x: rng.int(0, 40), y: rng.int(60, 100) },
|
||||
{ x: rng.int(60, 100), y: rng.int(60, 100) },
|
||||
];
|
||||
return {
|
||||
background: `
|
||||
radial-gradient(at ${points[0].x}% ${points[0].y}%, ${colors[0]} 0%, transparent 50%),
|
||||
radial-gradient(at ${points[1].x}% ${points[1].y}%, ${colors[1]} 0%, transparent 50%),
|
||||
radial-gradient(at ${points[2].x}% ${points[2].y}%, ${colors[2]} 0%, transparent 50%),
|
||||
radial-gradient(at ${points[3].x}% ${points[3].y}%, ${colors[3]} 0%, transparent 50%)
|
||||
`,
|
||||
backgroundColor: bg,
|
||||
};
|
||||
}
|
||||
|
||||
case 'orbs': {
|
||||
const orbCount = rng.int(3, 6);
|
||||
const orbs = Array.from({ length: orbCount }, (_, i) => {
|
||||
const size = rng.int(30, 70);
|
||||
const x = rng.int(10, 90);
|
||||
const y = rng.int(10, 90);
|
||||
const color = colors[i % colors.length];
|
||||
const blur = rng.int(20, 40);
|
||||
return `radial-gradient(circle ${size}% at ${x}% ${y}%, ${color}99 0%, ${color}44 ${blur}%, transparent 70%)`;
|
||||
});
|
||||
return {
|
||||
background: [...orbs, `linear-gradient(135deg, ${bg} 0%, ${colors[0]}11 100%)`].join(', '),
|
||||
backgroundColor: bg,
|
||||
};
|
||||
}
|
||||
|
||||
case 'rays': {
|
||||
const centerX = rng.int(30, 70);
|
||||
const centerY = rng.int(30, 70);
|
||||
const rayCount = rng.int(6, 12);
|
||||
const rays = Array.from({ length: rayCount }, (_, i) => {
|
||||
const angle = (360 / rayCount) * i + rng.int(-10, 10);
|
||||
const color = colors[i % colors.length];
|
||||
return `linear-gradient(${angle}deg, transparent 0%, transparent 45%, ${color}66 48%, ${color}66 52%, transparent 55%, transparent 100%)`;
|
||||
});
|
||||
return {
|
||||
background: [
|
||||
`radial-gradient(circle at ${centerX}% ${centerY}%, ${colors[0]} 0%, transparent 30%)`,
|
||||
...rays,
|
||||
].join(', '),
|
||||
backgroundColor: bg,
|
||||
};
|
||||
}
|
||||
|
||||
case 'waves': {
|
||||
const waveAngle = rng.int(0, 180);
|
||||
const waveSize = rng.int(8, 20);
|
||||
return {
|
||||
background: `
|
||||
repeating-linear-gradient(
|
||||
${waveAngle}deg,
|
||||
${colors[0]}44 0px,
|
||||
${colors[1]}44 ${waveSize}px,
|
||||
${colors[2]}44 ${waveSize * 2}px,
|
||||
${colors[3]}44 ${waveSize * 3}px,
|
||||
${colors[0]}44 ${waveSize * 4}px
|
||||
),
|
||||
radial-gradient(ellipse at 50% 0%, ${colors[0]}66 0%, transparent 70%),
|
||||
radial-gradient(ellipse at 50% 100%, ${colors[2]}66 0%, transparent 70%)
|
||||
`,
|
||||
backgroundColor: bg,
|
||||
};
|
||||
}
|
||||
|
||||
case 'geometric': {
|
||||
const angle = rng.int(0, 90);
|
||||
return {
|
||||
background: `
|
||||
conic-gradient(from ${angle}deg at 50% 50%, ${colors[0]}, ${colors[1]}, ${colors[2]}, ${colors[3]}, ${colors[0]}),
|
||||
repeating-conic-gradient(from 0deg at 50% 50%, ${bg}00 0deg, ${bg}88 ${90/rng.int(2,6)}deg)
|
||||
`,
|
||||
backgroundBlendMode: 'overlay',
|
||||
backgroundColor: bg,
|
||||
};
|
||||
}
|
||||
|
||||
case 'nebula': {
|
||||
const x1 = rng.int(20, 80);
|
||||
const y1 = rng.int(20, 80);
|
||||
const x2 = rng.int(20, 80);
|
||||
const y2 = rng.int(20, 80);
|
||||
return {
|
||||
background: `
|
||||
radial-gradient(ellipse ${rng.int(60, 100)}% ${rng.int(40, 80)}% at ${x1}% ${y1}%, ${colors[0]}88 0%, transparent 50%),
|
||||
radial-gradient(ellipse ${rng.int(40, 80)}% ${rng.int(60, 100)}% at ${x2}% ${y2}%, ${colors[1]}88 0%, transparent 50%),
|
||||
radial-gradient(ellipse ${rng.int(50, 90)}% ${rng.int(50, 90)}% at ${100-x1}% ${100-y1}%, ${colors[2]}66 0%, transparent 60%),
|
||||
radial-gradient(ellipse ${rng.int(30, 60)}% ${rng.int(30, 60)}% at ${100-x2}% ${100-y2}%, ${colors[3]}44 0%, transparent 70%),
|
||||
linear-gradient(${rng.int(0, 360)}deg, ${bg} 0%, ${colors[0]}22 50%, ${bg} 100%)
|
||||
`,
|
||||
backgroundColor: bg,
|
||||
};
|
||||
}
|
||||
|
||||
case 'gradient': {
|
||||
const angle = rng.int(0, 360);
|
||||
const type = rng.int(0, 3);
|
||||
if (type === 0) {
|
||||
return {
|
||||
background: `linear-gradient(${angle}deg, ${colors[0]} 0%, ${colors[1]} 33%, ${colors[2]} 66%, ${colors[3]} 100%)`,
|
||||
};
|
||||
} else if (type === 1) {
|
||||
return {
|
||||
background: `
|
||||
radial-gradient(circle at ${rng.int(30, 70)}% ${rng.int(30, 70)}%, ${colors[0]} 0%, ${colors[1]} 30%, ${colors[2]} 60%, ${colors[3]} 100%)
|
||||
`,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
background: `
|
||||
linear-gradient(${angle}deg, ${colors[0]} 0%, ${colors[0]} 25%, transparent 25%, transparent 75%, ${colors[2]} 75%),
|
||||
linear-gradient(${angle + 90}deg, ${colors[1]} 0%, ${colors[1]} 25%, transparent 25%, transparent 75%, ${colors[3]} 75%),
|
||||
linear-gradient(${angle}deg, ${colors[2]} 0%, ${colors[3]} 100%)
|
||||
`,
|
||||
backgroundBlendMode: 'multiply, screen, normal',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
case 'rings': {
|
||||
const centerX = rng.int(30, 70);
|
||||
const centerY = rng.int(30, 70);
|
||||
return {
|
||||
background: `
|
||||
repeating-radial-gradient(circle at ${centerX}% ${centerY}%,
|
||||
${colors[0]}66 0px, ${colors[0]}66 2px,
|
||||
transparent 2px, transparent ${rng.int(15, 25)}px,
|
||||
${colors[1]}66 ${rng.int(15, 25)}px, ${colors[1]}66 ${rng.int(17, 27)}px,
|
||||
transparent ${rng.int(17, 27)}px, transparent ${rng.int(35, 50)}px
|
||||
),
|
||||
radial-gradient(circle at ${centerX}% ${centerY}%, ${colors[2]}88 0%, transparent 60%),
|
||||
linear-gradient(${rng.int(0, 180)}deg, ${colors[3]}44, ${colors[0]}44)
|
||||
`,
|
||||
backgroundColor: bg,
|
||||
};
|
||||
}
|
||||
|
||||
case 'crystal': {
|
||||
const facets = rng.int(4, 8);
|
||||
const gradients = Array.from({ length: facets }, (_, i) => {
|
||||
const startAngle = (360 / facets) * i;
|
||||
const color = colors[i % colors.length];
|
||||
return `conic-gradient(from ${startAngle}deg at ${50 + rng.int(-20, 20)}% ${50 + rng.int(-20, 20)}%, ${color}88 0deg, transparent ${360/facets}deg)`;
|
||||
});
|
||||
return {
|
||||
background: [
|
||||
...gradients,
|
||||
`radial-gradient(circle at 50% 50%, ${colors[0]}44 0%, transparent 70%)`,
|
||||
].join(', '),
|
||||
backgroundColor: bg,
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
background: `linear-gradient(135deg, ${colors[0]}, ${colors[1]})`,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const AlbumCover: React.FC<AlbumCoverProps> = ({ seed, size = 'md', className = '', children }) => {
|
||||
const coverStyle = useMemo(() => {
|
||||
const rng = new SeededRandom(seed);
|
||||
const palette = rng.pick(palettes);
|
||||
return generatePattern(rng, palette);
|
||||
}, [seed]);
|
||||
|
||||
const sizeClasses: Record<string, string> = {
|
||||
xs: 'w-8 h-8',
|
||||
sm: 'w-10 h-10',
|
||||
md: 'w-12 h-12',
|
||||
lg: 'w-14 h-14',
|
||||
xl: 'w-48 h-48',
|
||||
full: 'w-full h-full',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${sizeClasses[size]} rounded-md shadow-lg flex-shrink-0 overflow-hidden relative ${className}`}
|
||||
style={coverStyle}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlbumCover;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,307 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { X, Camera, Image as ImageIcon, Upload, Loader2 } from 'lucide-react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { usersApi, UserProfile } from '../services/api';
|
||||
|
||||
interface EditProfileModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
export const EditProfileModal: React.FC<EditProfileModalProps> = ({ isOpen, onClose, onSaved }) => {
|
||||
const { user, token, refreshUser, updateUsername } = useAuth();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
|
||||
const [editUsername, setEditUsername] = useState('');
|
||||
const [editBio, setEditBio] = useState('');
|
||||
const [editAvatarUrl, setEditAvatarUrl] = useState('');
|
||||
const [editBannerUrl, setEditBannerUrl] = useState('');
|
||||
const [usernameError, setUsernameError] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [bannerFile, setBannerFile] = useState<File | null>(null);
|
||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||
const [bannerPreview, setBannerPreview] = useState<string | null>(null);
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
const [uploadingBanner, setUploadingBanner] = useState(false);
|
||||
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||||
const bannerInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && user && token) {
|
||||
loadProfile();
|
||||
}
|
||||
}, [isOpen, user, token]);
|
||||
|
||||
const loadProfile = async () => {
|
||||
if (!user || !token) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await usersApi.getProfile(user.username, token);
|
||||
setProfile(res.user);
|
||||
setEditUsername(res.user.username || '');
|
||||
setEditBio(res.user.bio || '');
|
||||
setEditAvatarUrl(res.user.avatar_url || '');
|
||||
setEditBannerUrl(res.user.banner_url || '');
|
||||
setUsernameError('');
|
||||
} catch (error) {
|
||||
console.error('Failed to load profile:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setAvatarFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => setAvatarPreview(ev.target?.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBannerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setBannerFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => setBannerPreview(ev.target?.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
if (!token || !profile) return;
|
||||
setIsSaving(true);
|
||||
setUsernameError('');
|
||||
|
||||
try {
|
||||
// Update username if changed
|
||||
if (editUsername && editUsername !== profile.username) {
|
||||
const sanitized = editUsername.trim().replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
if (sanitized.length < 2) {
|
||||
setUsernameError('Username must be at least 2 characters');
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateUsername(sanitized);
|
||||
} catch (err: unknown) {
|
||||
const error = err as Error & { message?: string };
|
||||
if (error.message?.includes('taken')) {
|
||||
setUsernameError('Username is already taken');
|
||||
} else {
|
||||
setUsernameError('Failed to update username');
|
||||
}
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (avatarFile) {
|
||||
setUploadingAvatar(true);
|
||||
const avatarRes = await usersApi.uploadAvatar(avatarFile, token);
|
||||
setEditAvatarUrl(avatarRes.url);
|
||||
setUploadingAvatar(false);
|
||||
}
|
||||
|
||||
if (bannerFile) {
|
||||
setUploadingBanner(true);
|
||||
const bannerRes = await usersApi.uploadBanner(bannerFile, token);
|
||||
setEditBannerUrl(bannerRes.url);
|
||||
setUploadingBanner(false);
|
||||
}
|
||||
|
||||
const updates: Record<string, string> = { bio: editBio };
|
||||
if (!avatarFile && editAvatarUrl !== profile.avatar_url) {
|
||||
updates.avatarUrl = editAvatarUrl;
|
||||
}
|
||||
if (!bannerFile && editBannerUrl !== profile.banner_url) {
|
||||
updates.bannerUrl = editBannerUrl;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await usersApi.updateProfile(updates, token);
|
||||
}
|
||||
|
||||
await refreshUser();
|
||||
handleClose();
|
||||
onSaved?.();
|
||||
} catch (error) {
|
||||
console.error('Failed to update profile:', error);
|
||||
alert('Failed to update profile');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setUploadingAvatar(false);
|
||||
setUploadingBanner(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAvatarFile(null);
|
||||
setBannerFile(null);
|
||||
setAvatarPreview(null);
|
||||
setBannerPreview(null);
|
||||
setUsernameError('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
|
||||
<div className="w-full max-w-lg bg-zinc-900 border border-zinc-800 rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="px-6 py-4 border-b border-zinc-800 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-white">Edit Profile</h2>
|
||||
<button onClick={handleClose} className="text-zinc-400 hover:text-white transition-colors">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="p-12 flex items-center justify-center">
|
||||
<Loader2 size={32} className="animate-spin text-zinc-400" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Username Input */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-300">Username</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-zinc-500">@</span>
|
||||
<input
|
||||
type="text"
|
||||
value={editUsername}
|
||||
onChange={(e) => {
|
||||
setEditUsername(e.target.value);
|
||||
setUsernameError('');
|
||||
}}
|
||||
placeholder="username"
|
||||
maxLength={50}
|
||||
className="flex-1 bg-black border border-zinc-800 rounded-lg px-3 py-2 text-white placeholder-zinc-600 focus:outline-none focus:border-indigo-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
{usernameError && (
|
||||
<p className="text-sm text-red-500">{usernameError}</p>
|
||||
)}
|
||||
<p className="text-xs text-zinc-500">Letters, numbers, underscores, and hyphens only</p>
|
||||
</div>
|
||||
|
||||
{/* Avatar Upload */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-300">Avatar Image</label>
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="w-20 h-20 rounded-full bg-zinc-800 border-2 border-zinc-700 border-dashed overflow-hidden flex-shrink-0 relative">
|
||||
{(avatarPreview || editAvatarUrl) ? (
|
||||
<img
|
||||
src={avatarPreview || editAvatarUrl}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => (e.currentTarget.style.display = 'none')}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-zinc-500">
|
||||
<Camera size={24} />
|
||||
</div>
|
||||
)}
|
||||
{uploadingAvatar && (
|
||||
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
|
||||
<Loader2 size={20} className="animate-spin text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
onChange={handleAvatarChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
<Upload size={16} />
|
||||
Upload Avatar
|
||||
</button>
|
||||
<p className="text-xs text-zinc-500">JPG, PNG, WebP, GIF - Max 5MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Banner Upload */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-300">Banner Image</label>
|
||||
<div
|
||||
onClick={() => bannerInputRef.current?.click()}
|
||||
className="relative w-full h-32 rounded-lg bg-zinc-800 border-2 border-zinc-700 border-dashed overflow-hidden cursor-pointer hover:border-zinc-600 transition-colors"
|
||||
>
|
||||
{(bannerPreview || editBannerUrl) ? (
|
||||
<img
|
||||
src={bannerPreview || editBannerUrl}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => (e.currentTarget.style.display = 'none')}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center text-zinc-500 gap-2">
|
||||
<ImageIcon size={32} />
|
||||
<span className="text-sm">Click to upload banner</span>
|
||||
</div>
|
||||
)}
|
||||
{uploadingBanner && (
|
||||
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
|
||||
<Loader2 size={24} className="animate-spin text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={bannerInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
onChange={handleBannerChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<p className="text-xs text-zinc-500">Recommended: 1500x500px - JPG, PNG, WebP, GIF - Max 5MB</p>
|
||||
</div>
|
||||
|
||||
{/* Bio Input */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-300">Bio</label>
|
||||
<textarea
|
||||
value={editBio}
|
||||
onChange={(e) => setEditBio(e.target.value)}
|
||||
placeholder="Tell us about yourself..."
|
||||
rows={4}
|
||||
className="w-full bg-black border border-zinc-800 rounded-lg px-3 py-2 text-white placeholder-zinc-600 focus:outline-none focus:border-indigo-500 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 bg-black/20 border-t border-zinc-800 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 text-sm font-medium text-zinc-300 hover:text-white transition-colors"
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveProfile}
|
||||
disabled={isSaving || uploadingAvatar || uploadingBanner}
|
||||
className="px-6 py-2 bg-white text-black hover:bg-zinc-200 rounded-full text-sm font-bold transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isSaving && <Loader2 size={16} className="animate-spin" />}
|
||||
{uploadingAvatar ? 'Uploading Avatar...' : uploadingBanner ? 'Uploading Banner...' : isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Song, Playlist } from '../types';
|
||||
import { Heart, Plus, Music, Play } from 'lucide-react';
|
||||
import { AlbumCover } from './AlbumCover';
|
||||
|
||||
interface LibraryViewProps {
|
||||
likedSongs: Song[];
|
||||
playlists: Playlist[];
|
||||
onPlaySong: (song: Song, list?: Song[]) => void;
|
||||
onCreatePlaylist: () => void;
|
||||
onSelectPlaylist: (playlist: Playlist) => void;
|
||||
}
|
||||
|
||||
export const LibraryView: React.FC<LibraryViewProps> = ({
|
||||
likedSongs,
|
||||
playlists,
|
||||
onPlaySong,
|
||||
onCreatePlaylist,
|
||||
onSelectPlaylist
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<'playlists' | 'liked'>('liked');
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-white dark:bg-black overflow-y-auto custom-scrollbar p-6 lg:p-10 pb-32 transition-colors duration-300">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1 className="text-3xl font-bold text-zinc-900 dark:text-white">Your Library</h1>
|
||||
<button
|
||||
onClick={onCreatePlaylist}
|
||||
className="flex items-center gap-2 bg-zinc-900 dark:bg-zinc-800 hover:bg-zinc-800 dark:hover:bg-zinc-700 text-white px-4 py-2 rounded-full font-medium transition-colors shadow-lg shadow-zinc-900/10 dark:shadow-none"
|
||||
>
|
||||
<Plus size={18} />
|
||||
<span>New Playlist</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-4 mb-8 border-b border-zinc-200 dark:border-white/10 pb-1">
|
||||
<button
|
||||
onClick={() => setActiveTab('liked')}
|
||||
className={`pb-3 text-sm font-bold transition-colors relative ${activeTab === 'liked' ? 'text-zinc-900 dark:text-white' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-white'}`}
|
||||
>
|
||||
Liked Songs
|
||||
{activeTab === 'liked' && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-green-500 rounded-full"></div>}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('playlists')}
|
||||
className={`pb-3 text-sm font-bold transition-colors relative ${activeTab === 'playlists' ? 'text-zinc-900 dark:text-white' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-white'}`}
|
||||
>
|
||||
Playlists
|
||||
{activeTab === 'playlists' && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-green-500 rounded-full"></div>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{activeTab === 'liked' ? (
|
||||
<div>
|
||||
<div className="bg-gradient-to-b from-indigo-500/10 to-zinc-50 dark:from-indigo-800/50 dark:to-zinc-900/50 p-6 rounded-xl flex items-end gap-6 mb-8 cursor-pointer hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors group border border-zinc-200 dark:border-white/5" onClick={() => likedSongs.length > 0 && onPlaySong(likedSongs[0], likedSongs)}>
|
||||
<div className="w-40 h-40 bg-gradient-to-br from-indigo-500 to-purple-400 rounded shadow-2xl flex items-center justify-center">
|
||||
<Heart fill="white" size={64} className="text-white" />
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<h2 className="text-sm font-bold uppercase text-zinc-500 dark:text-white mb-2">Playlist</h2>
|
||||
<h1 className="text-5xl font-extrabold text-zinc-900 dark:text-white mb-4">Liked Songs</h1>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-300 font-medium">
|
||||
{likedSongs.length} songs
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto mb-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="w-14 h-14 rounded-full bg-green-500 flex items-center justify-center shadow-lg hover:scale-105 transition-transform">
|
||||
<Play fill="black" className="text-black ml-1" size={28} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{likedSongs.map((song, idx) => (
|
||||
<div key={song.id} className="group flex items-center gap-4 p-2 rounded hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors" onClick={() => onPlaySong(song, likedSongs)}>
|
||||
<span className="text-zinc-400 dark:text-zinc-500 w-6 text-center group-hover:hidden">{idx + 1}</span>
|
||||
<span className="text-zinc-900 dark:text-white w-6 text-center hidden group-hover:block"><Play size={14} fill="currentColor" /></span>
|
||||
|
||||
{song.coverUrl ? (
|
||||
<img src={song.coverUrl} className="w-10 h-10 rounded object-cover shadow-sm" alt="" onError={(e) => { e.currentTarget.style.display = 'none'; }} />
|
||||
) : (
|
||||
<AlbumCover seed={song.id || song.title} size="sm" className="w-10 h-10" />
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-zinc-900 dark:text-white font-medium truncate">{song.title}</div>
|
||||
<div className="text-zinc-500 dark:text-zinc-400 text-xs">{song.style}</div>
|
||||
</div>
|
||||
|
||||
<div className="text-zinc-500 dark:text-zinc-400 text-sm font-mono">{song.duration}</div>
|
||||
<div className="text-green-500"><Heart fill="#22c55e" size={16} /></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
|
||||
{playlists.map((playlist) => (
|
||||
<div key={playlist.id} className="bg-white dark:bg-zinc-900/40 p-4 rounded-lg border border-zinc-200 dark:border-white/5 hover:border-zinc-300 dark:hover:border-white/10 hover:shadow-lg dark:hover:bg-zinc-900 transition-all group cursor-pointer" onClick={() => onSelectPlaylist(playlist)}>
|
||||
<div className="relative aspect-square mb-4 rounded-md overflow-hidden bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center">
|
||||
{playlist.coverUrl ? (
|
||||
<img src={playlist.coverUrl} className="w-full h-full object-cover" alt={playlist.name} onError={(e) => { e.currentTarget.style.display = 'none'; }} />
|
||||
) : (
|
||||
<AlbumCover seed={playlist.id || playlist.name} size="full" className="w-full h-full" />
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-bold text-zinc-900 dark:text-white truncate">{playlist.name}</h3>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 line-clamp-2">{playlist.description || `By You`}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import React, { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface MobileDrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
position: 'left' | 'right';
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function MobileDrawer({ isOpen, onClose, position, children, title }: MobileDrawerProps): React.ReactElement | null {
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const drawerRef = useRef<HTMLDivElement>(null);
|
||||
const previousActiveElement = useRef<HTMLElement | null>(null);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
setIsClosing(false);
|
||||
onClose();
|
||||
}, 300);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
previousActiveElement.current = document.activeElement as HTMLElement;
|
||||
drawerRef.current?.focus();
|
||||
} else if (previousActiveElement.current) {
|
||||
previousActiveElement.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && isOpen && !isClosing) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen, isClosing, handleClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !drawerRef.current) return;
|
||||
|
||||
const drawer = drawerRef.current;
|
||||
const focusableElements = drawer.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
|
||||
const handleTabKey = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Tab') return;
|
||||
|
||||
if (event.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
event.preventDefault();
|
||||
lastElement?.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastElement) {
|
||||
event.preventDefault();
|
||||
firstElement?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
drawer.addEventListener('keydown', handleTabKey);
|
||||
return () => drawer.removeEventListener('keydown', handleTabKey);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleBackdropClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (event.target === event.currentTarget && !isClosing) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen && !isClosing) return null;
|
||||
|
||||
const slideAnimation = isClosing
|
||||
? position === 'left' ? 'drawer-slide-out-left' : 'drawer-slide-out-right'
|
||||
: position === 'left' ? 'drawer-slide-in-left' : 'drawer-slide-in-right';
|
||||
|
||||
const backdropAnimation = isClosing ? 'drawer-backdrop-out' : 'drawer-backdrop-in';
|
||||
|
||||
const positionClasses = position === 'left' ? 'left-0' : 'right-0';
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={`fixed inset-0 z-50 ${backdropAnimation}`}
|
||||
onClick={handleBackdropClick}
|
||||
role="presentation"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/70 backdrop-blur-mobile" />
|
||||
<div
|
||||
ref={drawerRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title ? 'drawer-title' : undefined}
|
||||
tabIndex={-1}
|
||||
className={`fixed top-0 ${positionClasses} h-full w-80 max-w-[85vw] bg-zinc-900 shadow-2xl border-white/10 ${slideAnimation} safe-area-inset-y safe-area-inset-${position} flex flex-col`}
|
||||
style={{ borderWidth: position === 'left' ? '0 1px 0 0' : '0 0 0 1px' }}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/10 shrink-0">
|
||||
{title && (
|
||||
<h2 id="drawer-title" className="text-lg font-semibold text-white">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{!title && <div />}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2 hover:bg-white/10 rounded-lg transition-colors text-zinc-400 hover:text-white tap-highlight-none"
|
||||
aria-label="Close drawer"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain scroll-touch">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(content, document.body);
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { Song } from '../types';
|
||||
import { Play, Pause, SkipBack, SkipForward, Repeat, Shuffle, Download, Heart, MoreVertical, Volume2, VolumeX, Maximize2, Repeat1, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useResponsive } from '../context/ResponsiveContext';
|
||||
import { SongDropdownMenu } from './SongDropdownMenu';
|
||||
import { ShareModal } from './ShareModal';
|
||||
import { AlbumCover } from './AlbumCover';
|
||||
|
||||
interface PlayerProps {
|
||||
currentSong: Song | null;
|
||||
isPlaying: boolean;
|
||||
onTogglePlay: () => void;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
onSeek: (time: number) => void;
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
volume: number;
|
||||
onVolumeChange: (val: number) => void;
|
||||
isShuffle: boolean;
|
||||
onToggleShuffle: () => void;
|
||||
repeatMode: 'none' | 'all' | 'one';
|
||||
onToggleRepeat: () => void;
|
||||
isLiked: boolean;
|
||||
onToggleLike: () => void;
|
||||
onNavigateToSong?: (songId: string) => void;
|
||||
onOpenVideo?: () => void;
|
||||
onReusePrompt?: () => void;
|
||||
onAddToPlaylist?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
export const Player: React.FC<PlayerProps> = ({
|
||||
currentSong,
|
||||
isPlaying,
|
||||
onTogglePlay,
|
||||
currentTime,
|
||||
duration,
|
||||
onSeek,
|
||||
onNext,
|
||||
onPrevious,
|
||||
volume,
|
||||
onVolumeChange,
|
||||
isShuffle,
|
||||
onToggleShuffle,
|
||||
repeatMode,
|
||||
onToggleRepeat,
|
||||
isLiked,
|
||||
onToggleLike,
|
||||
onNavigateToSong,
|
||||
onOpenVideo,
|
||||
onReusePrompt,
|
||||
onAddToPlaylist,
|
||||
onDelete
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const { isMobile } = useResponsive();
|
||||
const progressBarRef = useRef<HTMLDivElement>(null);
|
||||
const fullscreenProgressRef = useRef<HTMLDivElement>(null);
|
||||
const [isHoveringVolume, setIsHoveringVolume] = useState(false);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
|
||||
// Close fullscreen on Escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isFullscreen) {
|
||||
setIsFullscreen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isFullscreen]);
|
||||
|
||||
if (!currentSong) return null;
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
if (isNaN(time)) return "0:00";
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = Math.floor(time % 60);
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleSeekInteraction = (e: React.MouseEvent<HTMLDivElement>, ref: React.RefObject<HTMLDivElement>) => {
|
||||
if (!ref.current || !duration) return;
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const width = rect.width;
|
||||
const percentage = Math.max(0, Math.min(1, x / width));
|
||||
onSeek(percentage * duration);
|
||||
};
|
||||
|
||||
const progressPercent = duration ? (currentTime / duration) * 100 : 0;
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!currentSong?.audioUrl) return;
|
||||
try {
|
||||
const response = await fetch(currentSong.audioUrl);
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${currentSong.title || 'song'}.mp3`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
if (isFullscreen) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-gradient-to-b from-zinc-100 to-zinc-50 dark:from-zinc-900 dark:to-black flex flex-col safe-area-inset-top safe-area-inset-bottom transition-colors duration-300">
|
||||
{/* Header with close button */}
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<button
|
||||
onClick={() => setIsFullscreen(false)}
|
||||
className="p-2 text-zinc-600 dark:text-white/70 tap-highlight-none"
|
||||
>
|
||||
<ChevronDown size={28} />
|
||||
</button>
|
||||
<span className="text-xs text-zinc-500 dark:text-white/50 uppercase tracking-wider">Now Playing</span>
|
||||
<div className="w-11" />
|
||||
</div>
|
||||
|
||||
{/* Album Art */}
|
||||
<div className="flex-1 flex items-center justify-center px-8 py-4">
|
||||
<div className="w-full max-w-[280px] aspect-square rounded-lg overflow-hidden shadow-2xl">
|
||||
{currentSong.coverUrl ? (
|
||||
<img
|
||||
src={currentSong.coverUrl}
|
||||
className="w-full h-full object-cover"
|
||||
alt="cover"
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; e.currentTarget.nextElementSibling?.classList.remove('hidden'); }}
|
||||
/>
|
||||
) : null}
|
||||
<AlbumCover seed={currentSong.id || currentSong.title} size="full" className={`w-full h-full ${currentSong.coverUrl ? 'hidden' : ''}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Song Info */}
|
||||
<div className="px-6 mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0 mr-4">
|
||||
<h2
|
||||
onClick={() => {
|
||||
setIsFullscreen(false);
|
||||
onNavigateToSong?.(currentSong.id);
|
||||
}}
|
||||
className="text-xl font-bold text-zinc-900 dark:text-white truncate"
|
||||
>
|
||||
{currentSong.title}
|
||||
</h2>
|
||||
<p className="text-sm text-zinc-500 dark:text-white/60 truncate mt-1">
|
||||
{currentSong.creator || 'Unknown Artist'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggleLike}
|
||||
className={`p-2 tap-highlight-none ${isLiked ? 'text-pink-600 dark:text-pink-500' : 'text-zinc-400 dark:text-white/50'}`}
|
||||
>
|
||||
<Heart size={24} fill={isLiked ? "currentColor" : "none"} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="px-6 mb-2">
|
||||
<div
|
||||
ref={fullscreenProgressRef}
|
||||
className="w-full h-1.5 bg-zinc-300 dark:bg-white/20 rounded-full cursor-pointer relative"
|
||||
onClick={(e) => handleSeekInteraction(e, fullscreenProgressRef)}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-zinc-900 dark:bg-white rounded-full relative"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
>
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-4 h-4 bg-zinc-900 dark:bg-white rounded-full shadow-lg -mr-2" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between mt-2 text-xs text-zinc-500 dark:text-white/50 font-mono">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration || 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Controls */}
|
||||
<div className="flex items-center justify-center gap-8 py-4">
|
||||
<button
|
||||
onClick={onToggleShuffle}
|
||||
className={`p-2 tap-highlight-none ${isShuffle ? 'text-pink-600 dark:text-pink-500' : 'text-zinc-400 dark:text-white/50'}`}
|
||||
>
|
||||
<Shuffle size={22} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onPrevious}
|
||||
className="p-2 text-zinc-800 dark:text-white tap-highlight-none"
|
||||
>
|
||||
<SkipBack size={32} fill="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onTogglePlay}
|
||||
className="w-16 h-16 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-black flex items-center justify-center shadow-lg tap-highlight-none"
|
||||
>
|
||||
{isPlaying ? <Pause size={32} fill="currentColor" /> : <Play size={32} fill="currentColor" className="ml-1" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onNext}
|
||||
className="p-2 text-zinc-800 dark:text-white tap-highlight-none"
|
||||
>
|
||||
<SkipForward size={32} fill="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggleRepeat}
|
||||
className={`p-2 tap-highlight-none relative ${repeatMode !== 'none' ? 'text-pink-600 dark:text-pink-500' : 'text-zinc-400 dark:text-white/50'}`}
|
||||
>
|
||||
{repeatMode === 'one' ? <Repeat1 size={22} /> : <Repeat size={22} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Volume Control */}
|
||||
<div className="flex items-center gap-3 px-6 py-4">
|
||||
<button
|
||||
onClick={() => onVolumeChange(volume === 0 ? 0.8 : 0)}
|
||||
className="text-zinc-400 dark:text-white/50 tap-highlight-none"
|
||||
>
|
||||
{volume === 0 ? <VolumeX size={20} /> : <Volume2 size={20} />}
|
||||
</button>
|
||||
<div className="flex-1 h-1 bg-zinc-300 dark:bg-white/20 rounded-full relative">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={volume}
|
||||
onChange={(e) => onVolumeChange(parseFloat(e.target.value))}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-zinc-700 dark:bg-white/70 rounded-full"
|
||||
style={{ width: `${volume * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Extra Actions */}
|
||||
<div className="flex items-center justify-center gap-6 px-6 pb-6 text-zinc-400 dark:text-white/50">
|
||||
{onOpenVideo && (
|
||||
<button onClick={onOpenVideo} className="p-3 tap-highlight-none">
|
||||
<Maximize2 size={20} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-3 tap-highlight-none"
|
||||
title="Download Audio"
|
||||
>
|
||||
<Download size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className="p-3 tap-highlight-none relative"
|
||||
>
|
||||
<MoreVertical size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showDropdown && (
|
||||
<div className="absolute bottom-24 left-1/2 -translate-x-1/2">
|
||||
<SongDropdownMenu
|
||||
song={currentSong}
|
||||
isOpen={showDropdown}
|
||||
onClose={() => setShowDropdown(false)}
|
||||
isOwner={user?.id === currentSong.userId}
|
||||
position="center"
|
||||
direction="up"
|
||||
onCreateVideo={onOpenVideo}
|
||||
onReusePrompt={onReusePrompt}
|
||||
onAddToPlaylist={onAddToPlaylist}
|
||||
onDelete={onDelete}
|
||||
onShare={() => setShareModalOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-black/95 backdrop-blur border-t border-zinc-200 dark:border-white/10 flex flex-col z-50 transition-colors duration-300 safe-area-inset-bottom">
|
||||
{/* Progress Bar - taller for touch */}
|
||||
<div
|
||||
ref={progressBarRef}
|
||||
className="w-full h-1 bg-zinc-200 dark:bg-zinc-800 cursor-pointer relative"
|
||||
onClick={(e) => handleSeekInteraction(e, progressBarRef)}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-pink-600 dark:bg-pink-500"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main content: Song info left, controls right */}
|
||||
<div className="flex items-center px-3 py-2 gap-3">
|
||||
{/* Song Info - takes available space, tap to expand */}
|
||||
<div
|
||||
className="flex items-center gap-3 flex-1 min-w-0"
|
||||
onClick={() => setIsFullscreen(true)}
|
||||
>
|
||||
<div className="w-11 h-11 rounded bg-zinc-200 dark:bg-zinc-800 overflow-hidden shadow-sm flex-shrink-0 relative">
|
||||
{currentSong.coverUrl ? (
|
||||
<img src={currentSong.coverUrl} className="w-full h-full object-cover" alt="cover" onError={(e) => { e.currentTarget.style.display = 'none'; }} />
|
||||
) : null}
|
||||
{!currentSong.coverUrl && <AlbumCover seed={currentSong.id || currentSong.title} size="full" className="w-full h-full" />}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20 opacity-0 active:opacity-100 transition-opacity">
|
||||
<ChevronUp size={20} className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden flex-1 min-w-0">
|
||||
<h4 className="text-sm font-semibold text-zinc-900 dark:text-white truncate">
|
||||
{currentSong.title}
|
||||
</h4>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 truncate">
|
||||
{currentSong.creator || 'Unknown Artist'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Controls - compact */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={onToggleLike}
|
||||
className={`p-2 tap-highlight-none ${isLiked ? 'text-pink-600 dark:text-pink-500' : 'text-zinc-400'}`}
|
||||
>
|
||||
<Heart size={20} fill={isLiked ? "currentColor" : "none"} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onPrevious}
|
||||
className="p-2 text-zinc-700 dark:text-zinc-300 tap-highlight-none"
|
||||
>
|
||||
<SkipBack size={22} fill="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onTogglePlay}
|
||||
className="w-11 h-11 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-black flex items-center justify-center shadow-lg tap-highlight-none"
|
||||
>
|
||||
{isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" className="ml-0.5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onNext}
|
||||
className="p-2 text-zinc-700 dark:text-zinc-300 tap-highlight-none"
|
||||
>
|
||||
<SkipForward size={22} fill="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop fullscreen mode
|
||||
if (isFullscreen) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-gradient-to-b from-zinc-100 to-zinc-50 dark:from-zinc-900 dark:to-black flex flex-col transition-colors duration-300"
|
||||
onClick={() => setIsFullscreen(false)}
|
||||
>
|
||||
{/* Header with close button */}
|
||||
<div className="flex items-center justify-between px-6 py-4" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => setIsFullscreen(false)}
|
||||
className="p-2 text-zinc-600 dark:text-white/70 hover:bg-zinc-200 dark:hover:bg-white/10 rounded-full transition-colors"
|
||||
>
|
||||
<ChevronDown size={28} />
|
||||
</button>
|
||||
<span className="text-sm text-zinc-500 dark:text-white/50 uppercase tracking-wider font-medium">Now Playing</span>
|
||||
<div className="w-11" />
|
||||
</div>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 flex items-center justify-center px-8 py-4 overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex flex-col lg:flex-row items-center gap-8 lg:gap-16 max-w-5xl w-full">
|
||||
{/* Album Art */}
|
||||
<div className="w-full max-w-[320px] lg:max-w-[400px] aspect-square rounded-lg overflow-hidden shadow-2xl flex-shrink-0">
|
||||
{currentSong.coverUrl ? (
|
||||
<img
|
||||
src={currentSong.coverUrl}
|
||||
className="w-full h-full object-cover"
|
||||
alt="cover"
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; e.currentTarget.nextElementSibling?.classList.remove('hidden'); }}
|
||||
/>
|
||||
) : null}
|
||||
<AlbumCover seed={currentSong.id || currentSong.title} size="full" className={`w-full h-full ${currentSong.coverUrl ? 'hidden' : ''}`} />
|
||||
</div>
|
||||
|
||||
{/* Right side: Song info and controls */}
|
||||
<div className="flex flex-col items-center lg:items-start gap-6 flex-1 min-w-0 max-w-lg">
|
||||
{/* Song Info */}
|
||||
<div className="text-center lg:text-left w-full">
|
||||
<h2
|
||||
onClick={() => {
|
||||
setIsFullscreen(false);
|
||||
onNavigateToSong?.(currentSong.id);
|
||||
}}
|
||||
className="text-2xl lg:text-3xl font-bold text-zinc-900 dark:text-white truncate cursor-pointer hover:underline"
|
||||
>
|
||||
{currentSong.title}
|
||||
</h2>
|
||||
<p className="text-base lg:text-lg text-zinc-500 dark:text-white/60 truncate mt-2">
|
||||
{currentSong.creator || 'Unknown Artist'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="w-full">
|
||||
<div
|
||||
ref={fullscreenProgressRef}
|
||||
className="w-full h-2 bg-zinc-300 dark:bg-white/20 rounded-full cursor-pointer relative group"
|
||||
onClick={(e) => handleSeekInteraction(e, fullscreenProgressRef)}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-zinc-900 dark:bg-white rounded-full relative group-hover:bg-pink-600 dark:group-hover:bg-pink-500 transition-colors"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
>
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-4 h-4 bg-zinc-900 dark:bg-white group-hover:bg-pink-600 dark:group-hover:bg-pink-500 rounded-full shadow-lg -mr-2 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between mt-2 text-sm text-zinc-500 dark:text-white/50 font-mono">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration || 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Controls */}
|
||||
<div className="flex items-center justify-center gap-8 py-2 w-full">
|
||||
<button
|
||||
onClick={onToggleShuffle}
|
||||
className={`p-2 transition-colors ${isShuffle ? 'text-pink-600 dark:text-pink-500' : 'text-zinc-400 hover:text-zinc-900 dark:hover:text-white'}`}
|
||||
>
|
||||
<Shuffle size={22} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onPrevious}
|
||||
className="p-2 text-zinc-800 dark:text-white hover:scale-110 transition-transform"
|
||||
>
|
||||
<SkipBack size={36} fill="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onTogglePlay}
|
||||
className="w-18 h-18 p-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-black flex items-center justify-center shadow-lg hover:scale-105 transition-transform"
|
||||
>
|
||||
{isPlaying ? <Pause size={36} fill="currentColor" /> : <Play size={36} fill="currentColor" className="ml-1" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onNext}
|
||||
className="p-2 text-zinc-800 dark:text-white hover:scale-110 transition-transform"
|
||||
>
|
||||
<SkipForward size={36} fill="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggleRepeat}
|
||||
className={`p-2 transition-colors relative ${repeatMode !== 'none' ? 'text-pink-600 dark:text-pink-500' : 'text-zinc-400 hover:text-zinc-900 dark:hover:text-white'}`}
|
||||
>
|
||||
{repeatMode === 'one' ? <Repeat1 size={22} /> : <Repeat size={22} />}
|
||||
{repeatMode !== 'none' && <div className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 bg-current rounded-full"></div>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Volume Control */}
|
||||
<div className="flex items-center gap-4 w-full max-w-xs">
|
||||
<button
|
||||
onClick={() => onVolumeChange(volume === 0 ? 0.8 : 0)}
|
||||
className="text-zinc-500 dark:text-white/50 hover:text-zinc-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
{volume === 0 ? <VolumeX size={22} /> : <Volume2 size={22} />}
|
||||
</button>
|
||||
<div className="flex-1 h-1.5 bg-zinc-300 dark:bg-white/20 rounded-full relative">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={volume}
|
||||
onChange={(e) => onVolumeChange(parseFloat(e.target.value))}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-zinc-700 dark:bg-white/70 rounded-full"
|
||||
style={{ width: `${volume * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Extra Actions */}
|
||||
<div className="flex items-center justify-center gap-4 text-zinc-400 dark:text-white/50">
|
||||
<button
|
||||
onClick={onToggleLike}
|
||||
className={`p-3 rounded-full hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors ${isLiked ? 'text-pink-600 dark:text-pink-500' : ''}`}
|
||||
>
|
||||
<Heart size={22} fill={isLiked ? "currentColor" : "none"} />
|
||||
</button>
|
||||
{onOpenVideo && (
|
||||
<button
|
||||
onClick={onOpenVideo}
|
||||
className="p-3 rounded-full hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<Maximize2 size={20} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-3 rounded-full hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors"
|
||||
title="Download Audio"
|
||||
>
|
||||
<Download size={20} />
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className="p-3 rounded-full hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<MoreVertical size={20} />
|
||||
</button>
|
||||
{showDropdown && (
|
||||
<SongDropdownMenu
|
||||
song={currentSong}
|
||||
isOpen={showDropdown}
|
||||
onClose={() => setShowDropdown(false)}
|
||||
isOwner={user?.id === currentSong.userId}
|
||||
position="center"
|
||||
direction="up"
|
||||
onCreateVideo={onOpenVideo}
|
||||
onReusePrompt={onReusePrompt}
|
||||
onAddToPlaylist={onAddToPlaylist}
|
||||
onDelete={onDelete}
|
||||
onShare={() => setShareModalOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ShareModal
|
||||
isOpen={shareModalOpen}
|
||||
onClose={() => setShareModalOpen(false)}
|
||||
song={currentSong}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-20 lg:h-24 bg-white dark:bg-black/95 backdrop-blur border-t border-zinc-200 dark:border-white/10 flex flex-col z-50 transition-colors duration-300 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.1)] dark:shadow-none">
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div
|
||||
ref={progressBarRef}
|
||||
className="w-full h-1 lg:h-1.5 bg-zinc-200 dark:bg-zinc-800 cursor-pointer group relative"
|
||||
onClick={(e) => handleSeekInteraction(e, progressBarRef)}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-zinc-900 dark:bg-white relative group-hover:bg-pink-600 dark:group-hover:bg-pink-500 transition-colors"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
>
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-zinc-900 dark:bg-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity shadow-lg scale-150"></div>
|
||||
</div>
|
||||
{/* Hit area for easier clicking */}
|
||||
<div className="absolute top-1/2 -translate-y-1/2 w-full h-4 -z-10"></div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center justify-between px-2 sm:px-4 lg:px-6 gap-2 sm:gap-4">
|
||||
|
||||
{/* Song Info */}
|
||||
<div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-1 max-w-[30%] lg:max-w-[33%]">
|
||||
<div className="w-10 h-10 lg:w-12 lg:h-12 rounded bg-zinc-200 dark:bg-zinc-800 overflow-hidden shadow-sm flex-shrink-0">
|
||||
{currentSong.coverUrl ? (
|
||||
<img src={currentSong.coverUrl} className="w-full h-full object-cover" alt="cover" onError={(e) => { e.currentTarget.style.display = 'none'; }} />
|
||||
) : null}
|
||||
{!currentSong.coverUrl && <AlbumCover seed={currentSong.id || currentSong.title} size="full" className="w-full h-full" />}
|
||||
</div>
|
||||
<div className="overflow-hidden min-w-0">
|
||||
<h4
|
||||
onClick={() => onNavigateToSong?.(currentSong.id)}
|
||||
className="text-xs sm:text-sm font-bold text-zinc-900 dark:text-white truncate cursor-pointer hover:underline"
|
||||
>
|
||||
{currentSong.title}
|
||||
</h4>
|
||||
<p className="text-[10px] sm:text-xs text-zinc-500 dark:text-zinc-400 truncate hover:underline cursor-pointer">{currentSong.creator || 'Unknown Artist'}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggleLike}
|
||||
className={`ml-1 sm:ml-2 transition-colors flex-shrink-0 hidden sm:block ${isLiked ? 'text-pink-600 dark:text-pink-500' : 'text-zinc-400 hover:text-zinc-900 dark:hover:text-white'}`}
|
||||
>
|
||||
<Heart size={18} fill={isLiked ? "currentColor" : "none"} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex flex-col items-center justify-center flex-shrink-0">
|
||||
<div className="flex items-center gap-2 sm:gap-4 lg:gap-6">
|
||||
<button
|
||||
onClick={onToggleShuffle}
|
||||
className={`transition-colors hidden sm:block ${isShuffle ? 'text-pink-600 dark:text-pink-500' : 'text-zinc-400 hover:text-zinc-900 dark:hover:text-white'}`}
|
||||
>
|
||||
<Shuffle size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onPrevious}
|
||||
className="text-zinc-700 dark:text-zinc-300 hover:text-black dark:hover:text-white transition-colors"
|
||||
>
|
||||
<SkipBack size={18} className="sm:w-[22px] sm:h-[22px]" fill="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onTogglePlay}
|
||||
className="w-9 h-9 sm:w-10 sm:h-10 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-black flex items-center justify-center hover:scale-105 transition-transform shadow-lg"
|
||||
>
|
||||
{isPlaying ? <Pause size={18} className="sm:w-5 sm:h-5" fill="currentColor" /> : <Play size={18} className="sm:w-5 sm:h-5 ml-0.5" fill="currentColor" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onNext}
|
||||
className="text-zinc-700 dark:text-zinc-300 hover:text-black dark:hover:text-white transition-colors"
|
||||
>
|
||||
<SkipForward size={18} className="sm:w-[22px] sm:h-[22px]" fill="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggleRepeat}
|
||||
className={`transition-colors hidden sm:block ${repeatMode !== 'none' ? 'text-pink-600 dark:text-pink-500' : 'text-zinc-400 hover:text-zinc-900 dark:hover:text-white'} relative`}
|
||||
>
|
||||
{repeatMode === 'one' ? <Repeat1 size={16} /> : <Repeat size={16} />}
|
||||
{repeatMode !== 'none' && <div className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 w-1 h-1 bg-current rounded-full"></div>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Volume & Extras */}
|
||||
<div className="flex items-center justify-end gap-1 sm:gap-2 lg:gap-3 min-w-0 flex-1 max-w-[30%] lg:max-w-[33%] text-zinc-500 dark:text-zinc-400">
|
||||
<span className="text-[10px] sm:text-xs font-mono text-right text-zinc-600 dark:text-zinc-400 hidden md:block">
|
||||
{formatTime(currentTime)} / {formatTime(duration || 0)}
|
||||
</span>
|
||||
|
||||
<div
|
||||
className="items-center gap-2 relative group hidden md:flex"
|
||||
onMouseEnter={() => setIsHoveringVolume(true)}
|
||||
onMouseLeave={() => setIsHoveringVolume(false)}
|
||||
>
|
||||
<button
|
||||
onClick={() => onVolumeChange(volume === 0 ? 0.8 : 0)}
|
||||
className="p-1.5 lg:p-2 hover:bg-zinc-100 dark:hover:bg-white/10 rounded-full transition-colors"
|
||||
>
|
||||
{volume === 0 ? <VolumeX size={18} /> : <Volume2 size={18} />}
|
||||
</button>
|
||||
|
||||
{/* Volume Slider */}
|
||||
<div className={`h-1.5 bg-zinc-200 dark:bg-zinc-700 rounded-full cursor-pointer overflow-hidden transition-all duration-200 ${isHoveringVolume ? 'opacity-100 w-16 lg:w-24 mx-1 lg:mx-2' : 'opacity-0 w-0 mx-0 pointer-events-none'}`}>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={volume}
|
||||
onChange={(e) => onVolumeChange(parseFloat(e.target.value))}
|
||||
className="w-full h-full opacity-0 cursor-pointer absolute z-10"
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-zinc-900 dark:bg-white rounded-full"
|
||||
style={{ width: `${volume * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-1.5 lg:p-2 hover:bg-zinc-100 dark:hover:bg-white/10 rounded-full transition-colors hidden lg:block"
|
||||
title="Download Audio"
|
||||
>
|
||||
<Download size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsFullscreen(true)}
|
||||
className="p-1.5 lg:p-2 hover:bg-zinc-100 dark:hover:bg-white/10 rounded-full transition-colors"
|
||||
>
|
||||
<Maximize2 size={16} />
|
||||
</button>
|
||||
<div className="relative hidden sm:block">
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className="p-1.5 lg:p-2 hover:bg-zinc-100 dark:hover:bg-white/10 rounded-full transition-colors"
|
||||
>
|
||||
<MoreVertical size={18} />
|
||||
</button>
|
||||
<SongDropdownMenu
|
||||
song={currentSong}
|
||||
isOpen={showDropdown}
|
||||
onClose={() => setShowDropdown(false)}
|
||||
isOwner={user?.id === currentSong.userId}
|
||||
position="right"
|
||||
direction="up"
|
||||
onCreateVideo={onOpenVideo}
|
||||
onReusePrompt={onReusePrompt}
|
||||
onAddToPlaylist={onAddToPlaylist}
|
||||
onDelete={onDelete}
|
||||
onShare={() => setShareModalOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ShareModal
|
||||
isOpen={shareModalOpen}
|
||||
onClose={() => setShareModalOpen(false)}
|
||||
song={currentSong}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,292 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Song, Playlist, playlistsApi, songsApi, getAudioUrl } from '../services/api';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { ArrowLeft, Play, MoreHorizontal, Clock, Calendar, Shuffle, Trash2, Mic2, Music } from 'lucide-react';
|
||||
|
||||
interface PlaylistDetailProps {
|
||||
playlistId: string;
|
||||
onBack: () => void;
|
||||
onPlaySong: (song: Song, list?: Song[]) => void;
|
||||
onSelect: (song: Song) => void;
|
||||
onNavigateToProfile: (username: string) => void;
|
||||
}
|
||||
|
||||
export const PlaylistDetail: React.FC<PlaylistDetailProps> = ({ playlistId, onBack, onPlaySong, onSelect, onNavigateToProfile }) => {
|
||||
const { user: currentUser, token } = useAuth();
|
||||
const [playlist, setPlaylist] = useState<Playlist & { creator_avatar?: string } | null>(null);
|
||||
const [songs, setSongs] = useState<Song[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadPlaylist();
|
||||
}, [playlistId]);
|
||||
|
||||
const loadPlaylist = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await playlistsApi.getPlaylist(playlistId, token);
|
||||
// res.playlist comes from DB row, which now includes creator_avatar
|
||||
setPlaylist(res.playlist as any);
|
||||
|
||||
const mappedSongs: Song[] = res.songs.map((s: any) => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
lyrics: s.lyrics,
|
||||
style: s.style,
|
||||
coverUrl: s.cover_url || s.coverUrl || `https://picsum.photos/seed/${s.id}/400/400`,
|
||||
audioUrl: getAudioUrl(s.audio_url || s.audioUrl, s.id),
|
||||
duration: s.duration,
|
||||
bpm: s.bpm,
|
||||
tags: s.tags || [],
|
||||
isPublic: s.is_public || false,
|
||||
likeCount: s.like_count || 0,
|
||||
viewCount: s.view_count || 0,
|
||||
creator: s.creator,
|
||||
created_at: s.created_at,
|
||||
addedAt: s.added_at
|
||||
}));
|
||||
|
||||
setSongs(mappedSongs);
|
||||
} catch (error) {
|
||||
console.error('Failed to load playlist:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ... (retaining methods handleRemove, handleDelete) ...
|
||||
const handleRemoveSong = async (songId: string) => {
|
||||
if (!token || !playlist) return;
|
||||
try {
|
||||
await playlistsApi.removeSong(playlist.id, songId, token);
|
||||
setSongs(prev => prev.filter(s => s.id !== songId));
|
||||
} catch (error) {
|
||||
console.error('Failed to remove song:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePlaylist = async () => {
|
||||
if (!token || !playlist) return;
|
||||
if (!confirm('Are you sure you want to delete this playlist?')) return;
|
||||
try {
|
||||
await playlistsApi.delete(playlist.id, token);
|
||||
onBack();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete playlist:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return (
|
||||
<div className="flex items-center justify-center h-full bg-black">
|
||||
<div className="text-zinc-400 gap-2 flex items-center">
|
||||
<div className="w-4 h-4 border-2 border-zinc-400 border-t-transparent rounded-full animate-spin"></div>
|
||||
Loading playlist...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!playlist) return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 bg-black">
|
||||
<div className="text-zinc-400">Playlist not found</div>
|
||||
<button onClick={onBack} className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 rounded-lg text-white">
|
||||
Go Back
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const isOwner = currentUser?.id === playlist.user_id;
|
||||
|
||||
// Gradient based on ID/Name
|
||||
const gradients = [
|
||||
'from-purple-900 to-black',
|
||||
'from-blue-900 to-black',
|
||||
'from-indigo-900 to-black',
|
||||
'from-rose-900 to-black',
|
||||
];
|
||||
const bgGradient = gradients[playlist.name.length % gradients.length];
|
||||
|
||||
return (
|
||||
<div className={`w-full h-full flex flex-col bg-gradient-to-b ${bgGradient} overflow-hidden`}>
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 p-4 md:p-8 pt-12 md:pt-8 flex flex-col md:flex-row gap-4 md:gap-8 items-center md:items-end bg-black/20 backdrop-blur-lg border-b border-white/10">
|
||||
{/* Cover */}
|
||||
<div className="w-32 h-32 md:w-52 md:h-52 shadow-2xl rounded-lg bg-zinc-800 flex items-center justify-center overflow-hidden flex-shrink-0 group relative">
|
||||
{playlist.cover_url ? (
|
||||
<img src={playlist.cover_url} alt={playlist.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-br from-zinc-700 to-zinc-900 flex items-center justify-center">
|
||||
<Music size={40} className="text-white/20 md:hidden" />
|
||||
<Music size={64} className="text-white/20 hidden md:block" />
|
||||
<span className="text-4xl md:text-6xl font-bold text-white/10">{playlist.name[0].toUpperCase()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 space-y-2 md:space-y-4 text-center md:text-left">
|
||||
<span className="text-xs font-bold tracking-wider uppercase text-white/80">Playlist</span>
|
||||
<h1 className="text-2xl md:text-5xl lg:text-7xl font-bold text-white tracking-tight leading-none drop-shadow-lg">
|
||||
{playlist.name}
|
||||
</h1>
|
||||
{playlist.description && (
|
||||
<p className="text-zinc-300 text-sm max-w-2xl hidden md:block">{playlist.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-center md:justify-start gap-2 text-sm text-white font-medium flex-wrap">
|
||||
{playlist.creator && (
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer hover:underline"
|
||||
onClick={() => onNavigateToProfile(playlist.creator!)}
|
||||
>
|
||||
{playlist.creator_avatar ? (
|
||||
<img src={playlist.creator_avatar} alt={playlist.creator} className="w-5 h-5 md:w-6 md:h-6 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-5 h-5 md:w-6 md:h-6 rounded-full bg-gradient-to-r from-green-400 to-blue-500"></div>
|
||||
)}
|
||||
<span>{playlist.creator}</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="w-1 h-1 rounded-full bg-white/50"></span>
|
||||
<span>{songs.length} songs</span>
|
||||
<span className="w-1 h-1 rounded-full bg-white/50 hidden md:block"></span>
|
||||
<span className="text-zinc-400 hidden md:block">
|
||||
{songs.reduce((acc, s) => acc + (s.duration ? (typeof s.duration === 'string' ? 0 : s.duration) : 0), 0) > 0
|
||||
? Math.floor(songs.reduce((acc, s) => acc + (s.duration as number || 0), 0) / 60) + " min"
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions Bar */}
|
||||
<div className="px-4 md:px-8 py-3 md:py-4 bg-black/20 flex items-center gap-3 md:gap-4">
|
||||
<button
|
||||
onClick={() => songs.length > 0 && onPlaySong(songs[0], songs)}
|
||||
className="w-12 h-12 md:w-14 md:h-14 rounded-full bg-green-500 hover:scale-105 transition-transform flex items-center justify-center text-black shadow-lg"
|
||||
>
|
||||
<Play size={24} fill="currentColor" className="ml-1" />
|
||||
</button>
|
||||
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={handleDeletePlaylist}
|
||||
className="text-zinc-400 hover:text-red-500 transition-colors p-2"
|
||||
title="Delete Playlist"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex-1"></div>
|
||||
|
||||
<div className="text-zinc-400 text-xs md:text-sm">
|
||||
{playlist.is_public ? 'Public' : 'Private'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Song List */}
|
||||
<div className="flex-1 overflow-y-auto bg-black/40">
|
||||
<div className="px-2 md:px-8 py-2 md:py-4">
|
||||
{/* Desktop Header */}
|
||||
<div className="hidden md:grid grid-cols-[16px_4fr_3fr_2fr_minmax(120px,1fr)] gap-4 px-4 py-2 border-b border-white/10 text-sm font-medium text-zinc-400 mb-2 sticky top-0 bg-[#121212] z-10">
|
||||
<span>#</span>
|
||||
<span>Title</span>
|
||||
<span>Artist</span>
|
||||
<span>Date Added</span>
|
||||
<span className="text-right"><Clock size={16} className="inline" /></span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{songs.map((song, index) => (
|
||||
<div
|
||||
key={song.id}
|
||||
className="group flex md:grid md:grid-cols-[16px_4fr_3fr_2fr_minmax(120px,1fr)] gap-3 md:gap-4 px-2 md:px-4 py-3 rounded-md hover:bg-white/10 items-center transition-colors text-sm text-zinc-400 hover:text-white cursor-pointer"
|
||||
onClick={() => {
|
||||
onSelect(song);
|
||||
onPlaySong(song, songs);
|
||||
}}
|
||||
>
|
||||
{/* Index - hidden on mobile */}
|
||||
<span className="hidden md:block group-hover:text-white">{index + 1}</span>
|
||||
|
||||
{/* Cover + Title */}
|
||||
<div className="flex items-center gap-3 overflow-hidden flex-1 md:flex-none">
|
||||
<div className="w-12 h-12 md:w-10 md:h-10 rounded bg-zinc-800 flex-shrink-0 overflow-hidden relative group/img">
|
||||
<img src={song.coverUrl} alt="" className="w-full h-full object-cover" />
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPlaySong(song, songs);
|
||||
}}
|
||||
className="absolute inset-0 bg-black/50 flex md:hidden group-hover/img:flex items-center justify-center text-white"
|
||||
>
|
||||
<Play size={16} fill="white" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col truncate min-w-0">
|
||||
<span className="font-medium text-white truncate">{song.title}</span>
|
||||
<span className="text-xs text-zinc-500 group-hover:text-zinc-400 truncate">
|
||||
{song.creator || 'Unknown'} <span className="md:hidden">• {song.duration ? `${Math.floor(song.duration / 60)}:${String(Math.floor(song.duration % 60)).padStart(2, '0')}` : '0:00'}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Artist - hidden on mobile */}
|
||||
<span className="hidden md:block hover:underline cursor-pointer truncate" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
song.creator && onNavigateToProfile(song.creator);
|
||||
}}>
|
||||
{song.creator || 'Unknown'}
|
||||
</span>
|
||||
|
||||
{/* Date Added - hidden on mobile */}
|
||||
<span className="hidden md:block">
|
||||
{song.addedAt ? new Date(song.addedAt).toLocaleDateString() : 'Just now'}
|
||||
</span>
|
||||
|
||||
{/* Duration + Actions */}
|
||||
<div className="hidden md:flex items-center justify-end gap-4">
|
||||
<span className="font-mono text-xs">
|
||||
{song.duration ? `${Math.floor(song.duration / 60)}:${String(Math.floor(song.duration % 60)).padStart(2, '0')}` : '0:00'}
|
||||
</span>
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveSong(song.id);
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 text-zinc-500 hover:text-white transition-opacity"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile delete button */}
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveSong(song.id);
|
||||
}}
|
||||
className="md:hidden text-zinc-500 hover:text-white p-2"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Back button absolute */}
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="absolute top-6 left-6 z-50 w-8 h-8 rounded-full bg-black/50 flex items-center justify-center text-white hover:bg-black/70 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Plus, Music } from 'lucide-react';
|
||||
import { Playlist } from '../types';
|
||||
|
||||
interface CreatePlaylistModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreate: (name: string, description: string) => void;
|
||||
}
|
||||
|
||||
export const CreatePlaylistModal: React.FC<CreatePlaylistModalProps> = ({ isOpen, onClose, onCreate }) => {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (name.trim()) {
|
||||
onCreate(name, description);
|
||||
setName('');
|
||||
setDescription('');
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 dark:bg-black/80 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 rounded-xl w-full max-w-md p-6 shadow-2xl animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-bold text-zinc-900 dark:text-white">Create Playlist</h2>
|
||||
<button onClick={onClose} className="text-zinc-400 hover:text-zinc-900 dark:hover:text-white">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-zinc-50 dark:bg-black/50 border border-zinc-200 dark:border-white/10 rounded-lg p-3 text-zinc-900 dark:text-white focus:outline-none focus:border-pink-500 focus:ring-1 focus:ring-pink-500 placeholder-zinc-400 dark:placeholder-zinc-600"
|
||||
placeholder="My Awesome Playlist"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase mb-1">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full bg-zinc-50 dark:bg-black/50 border border-zinc-200 dark:border-white/10 rounded-lg p-3 text-zinc-900 dark:text-white focus:outline-none focus:border-pink-500 focus:ring-1 focus:ring-pink-500 resize-none h-24 placeholder-zinc-400 dark:placeholder-zinc-600"
|
||||
placeholder="Vibes for coding..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-zinc-600 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-white hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!name.trim()}
|
||||
className="px-4 py-2 rounded-lg text-sm font-bold bg-zinc-900 dark:bg-white text-white dark:text-black hover:scale-105 transition-transform disabled:opacity-50 disabled:hover:scale-100 shadow-lg"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface AddToPlaylistModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
playlists: Playlist[];
|
||||
onSelect: (playlistId: string) => void;
|
||||
onCreateNew?: () => void;
|
||||
}
|
||||
|
||||
export const AddToPlaylistModal: React.FC<AddToPlaylistModalProps> = ({ isOpen, onClose, playlists, onSelect, onCreateNew }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 dark:bg-black/80 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 rounded-xl w-full max-w-sm p-6 shadow-2xl animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">Add to Playlist</h2>
|
||||
<button onClick={onClose} className="text-zinc-400 hover:text-zinc-900 dark:hover:text-white">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create New Button - Always visible at top */}
|
||||
<button
|
||||
onClick={onCreateNew}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-lg hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors group mb-3 border border-dashed border-zinc-300 dark:border-white/20 hover:border-zinc-400 dark:hover:border-white/40"
|
||||
>
|
||||
<div className="w-10 h-10 bg-zinc-100 dark:bg-zinc-800/50 rounded flex items-center justify-center text-zinc-600 dark:text-white/70 group-hover:text-zinc-900 dark:group-hover:text-white">
|
||||
<Plus size={20} />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold text-zinc-700 dark:text-white/90 group-hover:text-zinc-900 dark:group-hover:text-white">Create New Playlist</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="h-px bg-zinc-100 dark:bg-white/10 my-2"></div>
|
||||
|
||||
<div className="space-y-1 max-h-60 overflow-y-auto custom-scrollbar">
|
||||
{playlists.length === 0 ? (
|
||||
<div className="text-center py-6 text-zinc-500 text-sm italic">
|
||||
No existing playlists.
|
||||
</div>
|
||||
) : (
|
||||
playlists.map(playlist => (
|
||||
<button
|
||||
key={playlist.id}
|
||||
onClick={() => {
|
||||
onSelect(playlist.id);
|
||||
onClose();
|
||||
}}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-lg hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors group"
|
||||
>
|
||||
<div className="w-10 h-10 bg-zinc-200 dark:bg-zinc-800 rounded flex items-center justify-center text-zinc-500 group-hover:text-zinc-900 dark:group-hover:text-white flex-shrink-0 overflow-hidden">
|
||||
{playlist.coverUrl ? (
|
||||
<img src={playlist.coverUrl} className="w-full h-full object-cover" alt="" />
|
||||
) : (
|
||||
<Music size={18} />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-left overflow-hidden">
|
||||
<div className="font-medium text-zinc-900 dark:text-white truncate">{playlist.name}</div>
|
||||
<div className="text-xs text-zinc-500">{playlist.song_count || playlist.songIds?.length || 0} songs</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,312 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Song } from '../types';
|
||||
import { Heart, Share2, Play, MoreHorizontal, X, Copy, Wand2, MoreVertical, Download, Repeat, Video, Music, Link as LinkIcon, Sparkles, Globe, Lock, Trash2, Edit3, Layers } from 'lucide-react';
|
||||
import { songsApi } from '../services/api';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { SongDropdownMenu } from './SongDropdownMenu';
|
||||
import { ShareModal } from './ShareModal';
|
||||
import { AlbumCover } from './AlbumCover';
|
||||
|
||||
interface RightSidebarProps {
|
||||
song: Song | null;
|
||||
onClose?: () => void;
|
||||
onOpenVideo?: () => void;
|
||||
onReuse?: (song: Song) => void;
|
||||
onSongUpdate?: (song: Song) => void;
|
||||
onNavigateToProfile?: (username: string) => void;
|
||||
onNavigateToSong?: (songId: string) => void;
|
||||
isLiked?: boolean;
|
||||
onToggleLike?: (song: Song) => void;
|
||||
onDelete?: (song: Song) => void;
|
||||
onAddToPlaylist?: (song: Song) => void;
|
||||
}
|
||||
|
||||
export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpenVideo, onReuse, onSongUpdate, onNavigateToProfile, onNavigateToSong, isLiked, onToggleLike, onDelete, onAddToPlaylist }) => {
|
||||
const { token, user } = useAuth();
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const [isOwner, setIsOwner] = useState(false);
|
||||
const [tagsExpanded, setTagsExpanded] = useState(false);
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
const [copiedStyle, setCopiedStyle] = useState(false);
|
||||
const [copiedLyrics, setCopiedLyrics] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (song) {
|
||||
setIsOwner(user?.id === song.userId);
|
||||
}
|
||||
}, [song, user]);
|
||||
|
||||
|
||||
if (!song) return (
|
||||
<div className="w-full h-full bg-zinc-50 dark:bg-suno-panel border-l border-zinc-200 dark:border-white/5 flex items-center justify-center text-zinc-400 dark:text-zinc-500 text-sm transition-colors duration-300">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Music size={40} className="text-zinc-300 dark:text-zinc-700" />
|
||||
<p>Select a song to view details</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full bg-zinc-50 dark:bg-suno-panel flex flex-col border-l border-zinc-200 dark:border-white/5 relative transition-colors duration-300">
|
||||
|
||||
{/* Header */}
|
||||
<div className="h-14 flex items-center justify-between px-4 border-b border-zinc-200 dark:border-white/5 flex-shrink-0 bg-zinc-50/50 dark:bg-suno-panel/50 backdrop-blur-md z-10">
|
||||
<span className="font-semibold text-sm text-zinc-900 dark:text-white">Song Details</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 hover:bg-zinc-200 dark:hover:bg-white/10 rounded-full text-zinc-500 dark:text-zinc-400 transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||
<div className="p-5 space-y-6">
|
||||
|
||||
{/* Cover Art */}
|
||||
<div className="group relative aspect-square w-full rounded-xl overflow-hidden shadow-2xl bg-zinc-200 dark:bg-zinc-800 ring-1 ring-black/5 dark:ring-white/10">
|
||||
{song.coverUrl ? (
|
||||
<img src={song.coverUrl} alt={song.title} className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105" onError={(e) => { e.currentTarget.style.display = 'none'; }} />
|
||||
) : null}
|
||||
{!song.coverUrl && <AlbumCover seed={song.id || song.title} size="full" className="w-full h-full" />}
|
||||
|
||||
{/* Overlay Gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-60"></div>
|
||||
|
||||
<div className="absolute bottom-4 left-4 right-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-white">
|
||||
<Play size={16} fill="currentColor" />
|
||||
<span className="text-xs font-bold font-mono">{song.viewCount || 0}</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-black bg-white/90 px-1.5 py-0.5 rounded backdrop-blur-sm">
|
||||
{song.duration}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title & Artist Block */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-start gap-2">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<h2
|
||||
onClick={() => onNavigateToSong?.(song.id)}
|
||||
className="text-2xl font-bold text-zinc-900 dark:text-white leading-tight tracking-tight cursor-pointer hover:underline"
|
||||
>
|
||||
{song.title}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowMenu(!showMenu);
|
||||
}}
|
||||
className="text-zinc-400 hover:text-black dark:hover:text-white p-1"
|
||||
>
|
||||
<MoreVertical size={20} />
|
||||
</button>
|
||||
<SongDropdownMenu
|
||||
song={song}
|
||||
isOpen={showMenu}
|
||||
onClose={() => setShowMenu(false)}
|
||||
isOwner={isOwner}
|
||||
onCreateVideo={onOpenVideo}
|
||||
onReusePrompt={() => onReuse?.(song)}
|
||||
onDelete={() => onDelete?.(song)}
|
||||
onAddToPlaylist={() => onAddToPlaylist?.(song)}
|
||||
onShare={() => setShareModalOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-xs font-bold text-white shadow-sm ring-2 ring-white dark:ring-black">
|
||||
{song.creator ? song.creator[0].toUpperCase() : 'A'}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span
|
||||
onClick={() => song.creator && onNavigateToProfile?.(song.creator)}
|
||||
className="text-sm font-semibold text-zinc-900 dark:text-white hover:underline cursor-pointer"
|
||||
>
|
||||
{song.creator || 'Anonymous'}
|
||||
</span>
|
||||
<span className="text-xs text-zinc-500 dark:text-zinc-400">Created {new Date(song.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Actions */}
|
||||
<div className="flex items-center justify-between px-3 py-2.5 bg-zinc-200/80 dark:bg-black/40 backdrop-blur-sm rounded-2xl border border-zinc-300/50 dark:border-white/5">
|
||||
<button
|
||||
onClick={onOpenVideo}
|
||||
title="Create Video"
|
||||
className="p-3 text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white hover:bg-zinc-300/50 dark:hover:bg-white/10 rounded-xl transition-all duration-200"
|
||||
>
|
||||
<Video size={18} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!song?.audioUrl) return;
|
||||
const audioUrl = song.audioUrl.startsWith('http') ? song.audioUrl : `${window.location.origin}${song.audioUrl}`;
|
||||
window.open(`/editor?audioUrl=${encodeURIComponent(audioUrl)}`, '_blank');
|
||||
}}
|
||||
title="Open in Editor"
|
||||
className="p-3 text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white hover:bg-zinc-300/50 dark:hover:bg-white/10 rounded-xl transition-all duration-200"
|
||||
>
|
||||
<Edit3 size={18} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onReuse && onReuse(song)}
|
||||
title="Reuse Prompt"
|
||||
className="p-3 text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white hover:bg-zinc-300/50 dark:hover:bg-white/10 rounded-xl transition-all duration-200"
|
||||
>
|
||||
<Repeat size={18} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!song?.audioUrl) return;
|
||||
const baseUrl = window.location.port === '3000'
|
||||
? `${window.location.protocol}//${window.location.hostname}:3001`
|
||||
: window.location.origin;
|
||||
const audioUrl = song.audioUrl.startsWith('http') ? song.audioUrl : `${baseUrl}${song.audioUrl}`;
|
||||
window.open(`${baseUrl}/demucs-web/?audioUrl=${encodeURIComponent(audioUrl)}`, '_blank');
|
||||
}}
|
||||
title="Extract Stems"
|
||||
className="p-3 text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white hover:bg-zinc-300/50 dark:hover:bg-white/10 rounded-xl transition-all duration-200"
|
||||
>
|
||||
<Layers size={18} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Icon Actions Row */}
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<div className="flex items-center gap-6">
|
||||
<ActionButton
|
||||
icon={<Heart size={22} fill={isLiked ? 'currentColor' : 'none'} />}
|
||||
label={String(song.likeCount || 0)}
|
||||
active={isLiked}
|
||||
onClick={() => onToggleLike?.(song)}
|
||||
/>
|
||||
<ActionButton icon={<Share2 size={22} />} onClick={() => setShareModalOpen(true)} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="p-2 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors"
|
||||
title="Download Audio"
|
||||
onClick={async () => {
|
||||
if (!song.audioUrl) return;
|
||||
try {
|
||||
const response = await fetch(song.audioUrl);
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${song.title || 'song'}.mp3`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Download size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-zinc-200 dark:bg-white/5 w-full"></div>
|
||||
|
||||
{/* Tags / Style */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-bold text-zinc-500 dark:text-zinc-500 uppercase tracking-wider">Style & Tags</h3>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const allTags = song.tags && song.tags.length > 0
|
||||
? song.tags.join(', ')
|
||||
: song.style;
|
||||
navigator.clipboard.writeText(allTags);
|
||||
setCopiedStyle(true);
|
||||
setTimeout(() => setCopiedStyle(false), 2000);
|
||||
}}
|
||||
className={`flex items-center gap-1 text-[10px] font-medium transition-colors ${copiedStyle ? 'text-green-500' : 'text-zinc-500 hover:text-black dark:hover:text-white'}`}
|
||||
title="Copy all tags"
|
||||
>
|
||||
<Copy size={12} /> {copiedStyle ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setTagsExpanded(!tagsExpanded)}
|
||||
className={`flex flex-wrap gap-1.5 cursor-pointer relative ${!tagsExpanded ? 'max-h-[22px] overflow-hidden' : ''}`}
|
||||
>
|
||||
{Array.isArray(song.tags) && song.tags.length > 0 ? (
|
||||
song.tags.map(tag => (
|
||||
<span key={tag} className="px-2 py-0.5 bg-zinc-100 dark:bg-white/5 hover:bg-zinc-200 dark:hover:bg-white/10 border border-zinc-200 dark:border-white/10 rounded text-[11px] font-medium text-zinc-600 dark:text-zinc-300 transition-colors">
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
song.style.split(',').map((tag, idx) => (
|
||||
<span key={idx} className="px-2 py-0.5 bg-zinc-100 dark:bg-white/5 hover:bg-zinc-200 dark:hover:bg-white/10 border border-zinc-200 dark:border-white/10 rounded text-[11px] font-medium text-zinc-600 dark:text-zinc-300 transition-colors">
|
||||
{tag.trim()}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
{!tagsExpanded && (
|
||||
<span className="absolute right-0 top-0 px-2 py-0.5 bg-zinc-200 dark:bg-zinc-700 rounded text-[11px] font-medium text-zinc-600 dark:text-zinc-300">
|
||||
+more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lyrics Section */}
|
||||
<div className="bg-white dark:bg-black/20 rounded-xl border border-zinc-200 dark:border-white/5 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-zinc-100 dark:border-white/5 flex items-center justify-between bg-zinc-50 dark:bg-white/5">
|
||||
<h3 className="text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Lyrics</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (song.lyrics) {
|
||||
navigator.clipboard.writeText(song.lyrics);
|
||||
setCopiedLyrics(true);
|
||||
setTimeout(() => setCopiedLyrics(false), 2000);
|
||||
}
|
||||
}}
|
||||
className={`flex items-center gap-1 text-[10px] font-medium transition-colors ${copiedLyrics ? 'text-green-500' : 'text-zinc-500 hover:text-black dark:hover:text-white'}`}
|
||||
>
|
||||
<Copy size={12} /> {copiedLyrics ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 max-h-[300px] overflow-y-auto custom-scrollbar">
|
||||
<div className="text-sm text-zinc-700 dark:text-zinc-300 font-mono whitespace-pre-wrap leading-relaxed opacity-90">
|
||||
{song.lyrics || <div className="text-zinc-400 dark:text-zinc-600 italic text-center py-8">Instrumental<br /><span className="text-xs not-italic">No lyrics generated</span></div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{song && (
|
||||
<ShareModal
|
||||
isOpen={shareModalOpen}
|
||||
onClose={() => setShareModalOpen(false)}
|
||||
song={song}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ActionButton: React.FC<{ icon: React.ReactNode; label?: string; active?: boolean; onClick?: () => void }> = ({ icon, label, active, onClick }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-1.5 ${active ? 'text-pink-600 dark:text-pink-500' : 'text-zinc-400'} hover:text-black dark:hover:text-white transition-colors`}
|
||||
>
|
||||
{icon}
|
||||
{label && <span className="text-xs font-semibold">{label}</span>}
|
||||
</button>
|
||||
);
|
||||
@@ -0,0 +1,611 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Search, Play, Pause, Heart, ChevronRight, ChevronLeft, Copy, Check, X, Loader2 } from 'lucide-react';
|
||||
import { Song, Playlist } from '../types';
|
||||
import { songsApi, usersApi, playlistsApi, searchApi, UserProfile, getAudioUrl } from '../services/api';
|
||||
|
||||
interface SearchPageProps {
|
||||
onPlaySong?: (song: Song, list?: Song[]) => void;
|
||||
currentSong?: Song | null;
|
||||
isPlaying?: boolean;
|
||||
onNavigateToProfile?: (username: string) => void;
|
||||
onNavigateToSong?: (songId: string) => void;
|
||||
onNavigateToPlaylist?: (playlistId: string) => void;
|
||||
}
|
||||
|
||||
const GENRES = [
|
||||
'Pop', 'Rock', 'Electronic', 'Hip Hop', 'Country', 'Latin', 'Heavy Metal', 'Disco',
|
||||
'K-Pop', 'EDM', 'R&B', 'Indie', 'Folk', 'Funk', 'Jazz', 'Alternative Pop',
|
||||
'House', 'Afrobeats', 'Reggaeton', 'Rap', 'Blues', 'Gospel', 'Reggae',
|
||||
'Synthwave', 'J-Pop', 'Punk', 'Soul', 'Techno', 'Classical', 'Bossa Nova',
|
||||
'Ska', 'Bluegrass', 'Indie Surf', 'Lo-Fi Beats', 'Trap', 'Grunge', 'Chillhop',
|
||||
'New Wave', 'Drum And Bass', 'Acoustic Cover', 'Cinematic Dubstep', 'Modern Bollywood',
|
||||
'Opera', 'Ambient', 'Focus', 'A Capella', 'Meditation', 'Sleep'
|
||||
];
|
||||
|
||||
const MAX_RESULTS = 20;
|
||||
|
||||
interface ExtendedSong extends Song {
|
||||
creator_avatar?: string | null;
|
||||
}
|
||||
|
||||
export const SearchPage: React.FC<SearchPageProps> = ({
|
||||
onPlaySong,
|
||||
currentSong,
|
||||
isPlaying,
|
||||
onNavigateToProfile,
|
||||
onNavigateToSong,
|
||||
onNavigateToPlaylist,
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [featuredSongs, setFeaturedSongs] = useState<ExtendedSong[]>([]);
|
||||
const [featuredCreators, setFeaturedCreators] = useState<Array<UserProfile & { song_count?: number }>>([]);
|
||||
const [featuredPlaylists, setFeaturedPlaylists] = useState<Array<Playlist & { creator?: string; creator_avatar?: string; song_count?: number }>>([]);
|
||||
const [searchResults, setSearchResults] = useState<{
|
||||
songs: ExtendedSong[];
|
||||
creators: Array<UserProfile & { song_count?: number }>;
|
||||
playlists: Array<Playlist & { creator?: string; creator_avatar?: string; song_count?: number }>;
|
||||
} | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [copiedTag, setCopiedTag] = useState<string | null>(null);
|
||||
|
||||
const songsScrollRef = useRef<HTMLDivElement>(null);
|
||||
const creatorsScrollRef = useRef<HTMLDivElement>(null);
|
||||
const playlistsScrollRef = useRef<HTMLDivElement>(null);
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadFeaturedContent();
|
||||
}, []);
|
||||
|
||||
const transformSong = (s: any): ExtendedSong => ({
|
||||
...s,
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
lyrics: s.lyrics || '',
|
||||
style: s.style || s.caption || '',
|
||||
coverUrl: s.cover_url || s.coverUrl || `https://picsum.photos/seed/${s.id}/400/400`,
|
||||
duration: s.duration ? (typeof s.duration === 'string' ? s.duration : `${Math.floor(s.duration / 60)}:${String(Math.floor(s.duration % 60)).padStart(2, '0')}`) : '0:00',
|
||||
createdAt: new Date(s.created_at || s.createdAt),
|
||||
tags: s.tags || [],
|
||||
audioUrl: getAudioUrl(s.audio_url || s.audioUrl, s.id),
|
||||
isPublic: s.is_public ?? s.isPublic,
|
||||
likeCount: s.like_count || s.likeCount || 0,
|
||||
viewCount: s.view_count || s.viewCount || 0,
|
||||
creator: s.creator,
|
||||
creator_avatar: s.creator_avatar || s.creatorAvatar || null,
|
||||
});
|
||||
|
||||
// Shuffle array randomly
|
||||
const shuffleArray = <T,>(array: T[]): T[] => {
|
||||
const shuffled = [...array];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
};
|
||||
|
||||
const loadFeaturedContent = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [songsRes, creatorsRes, playlistsRes] = await Promise.allSettled([
|
||||
songsApi.getFeaturedSongs(),
|
||||
usersApi.getFeaturedCreators().catch(() => ({ creators: [] })),
|
||||
playlistsApi.getFeaturedPlaylists().catch(() => ({ playlists: [] })),
|
||||
]);
|
||||
|
||||
if (songsRes.status === 'fulfilled') {
|
||||
const songs = songsRes.value.songs.map(transformSong);
|
||||
setFeaturedSongs(shuffleArray(songs).slice(0, MAX_RESULTS));
|
||||
}
|
||||
|
||||
if (creatorsRes.status === 'fulfilled' && creatorsRes.value.creators?.length > 0) {
|
||||
setFeaturedCreators(creatorsRes.value.creators.slice(0, MAX_RESULTS));
|
||||
} else if (songsRes.status === 'fulfilled' && songsRes.value.songs?.length > 0) {
|
||||
const uniqueCreators = new Map<string, UserProfile & { song_count?: number }>();
|
||||
songsRes.value.songs.forEach((song: any) => {
|
||||
if (song.creator && !uniqueCreators.has(song.creator)) {
|
||||
uniqueCreators.set(song.creator, {
|
||||
id: song.user_id || song.userId || song.creator,
|
||||
username: song.creator,
|
||||
email: '',
|
||||
created_at: song.created_at || song.createdAt,
|
||||
avatar_url: song.creator_avatar || song.creatorAvatar || null,
|
||||
});
|
||||
}
|
||||
});
|
||||
setFeaturedCreators(Array.from(uniqueCreators.values()).slice(0, MAX_RESULTS));
|
||||
}
|
||||
|
||||
if (playlistsRes.status === 'fulfilled') {
|
||||
setFeaturedPlaylists((playlistsRes.value.playlists || []).slice(0, MAX_RESULTS));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load featured content:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const performSearch = useCallback(async (query: string) => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearching(true);
|
||||
try {
|
||||
const results = await searchApi.search(query);
|
||||
setSearchResults({
|
||||
songs: (results.songs || []).slice(0, MAX_RESULTS).map(transformSong),
|
||||
creators: (results.creators || []).slice(0, MAX_RESULTS),
|
||||
playlists: (results.playlists || []).slice(0, MAX_RESULTS),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
setSearchResults({ songs: [], creators: [], playlists: [] });
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchQuery(value);
|
||||
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (!value.trim()) {
|
||||
setSearchResults(null);
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
performSearch(value);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleGenreClick = (genre: string) => {
|
||||
setSearchQuery(genre);
|
||||
performSearch(genre);
|
||||
};
|
||||
|
||||
const handleCopyTag = (tag: string) => {
|
||||
navigator.clipboard.writeText(tag);
|
||||
setCopiedTag(tag);
|
||||
setTimeout(() => setCopiedTag(null), 2000);
|
||||
};
|
||||
|
||||
const formatNumber = (count: number | undefined): string => {
|
||||
if (!count) return '0';
|
||||
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
|
||||
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
|
||||
return count.toString();
|
||||
};
|
||||
|
||||
|
||||
const scroll = (ref: React.RefObject<HTMLDivElement | null>, direction: 'left' | 'right') => {
|
||||
if (ref.current) {
|
||||
const scrollAmount = 400;
|
||||
ref.current.scrollBy({
|
||||
left: direction === 'left' ? -scrollAmount : scrollAmount,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const displaySongs = searchResults?.songs || featuredSongs;
|
||||
const displayCreators = searchResults?.creators || featuredCreators;
|
||||
const displayPlaylists = searchResults?.playlists || featuredPlaylists;
|
||||
const isSearching = searchQuery.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-zinc-50 dark:bg-[#0a0a0a] h-full overflow-y-auto custom-scrollbar">
|
||||
<div className="max-w-[1400px] mx-auto px-6 py-6">
|
||||
{/* Search Input */}
|
||||
<div className="mb-8">
|
||||
<div className="relative max-w-3xl">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-400" size={20} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search for songs, playlists, creators, or genres"
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
className="w-full h-11 pl-12 pr-12 bg-white dark:bg-zinc-900/80 border border-zinc-200 dark:border-white/10 rounded-full text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:border-pink-500 dark:focus:border-pink-500 focus:ring-2 focus:ring-pink-500/20 transition-all"
|
||||
/>
|
||||
{searching ? (
|
||||
<Loader2 className="absolute right-4 top-1/2 -translate-y-1/2 text-pink-500 animate-spin" size={18} />
|
||||
) : searchQuery && (
|
||||
<button
|
||||
onClick={() => { setSearchQuery(''); setSearchResults(null); }}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600 dark:hover:text-white"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Songs Section */}
|
||||
<section className="mb-10">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">
|
||||
{isSearching ? `Songs matching "${searchQuery}"` : 'Featured Songs'}
|
||||
{isSearching && displaySongs.length > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-zinc-500">({displaySongs.length})</span>
|
||||
)}
|
||||
</h2>
|
||||
{!isSearching && displaySongs.length > 4 && (
|
||||
<button
|
||||
onClick={() => scroll(songsScrollRef, 'right')}
|
||||
className="p-1.5 rounded-full hover:bg-zinc-100 dark:hover:bg-white/5 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="animate-pulse">
|
||||
<div className="bg-zinc-200 dark:bg-zinc-800 rounded-lg h-[72px]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : displaySongs.length > 0 ? (
|
||||
<div
|
||||
ref={songsScrollRef}
|
||||
className={isSearching
|
||||
? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3"
|
||||
: "grid grid-cols-2 lg:grid-cols-4 gap-3 auto-rows-max"
|
||||
}
|
||||
>
|
||||
{displaySongs.slice(0, isSearching ? MAX_RESULTS : 8).map((song) => (
|
||||
<FeaturedSongCard
|
||||
key={song.id}
|
||||
song={song}
|
||||
isPlaying={currentSong?.id === song.id && isPlaying}
|
||||
onPlay={() => onPlaySong?.(song, displaySongs)}
|
||||
onNavigateToProfile={onNavigateToProfile}
|
||||
onCopyTag={handleCopyTag}
|
||||
copiedTag={copiedTag}
|
||||
formatNumber={formatNumber}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : isSearching ? (
|
||||
<div className="text-center py-8 text-zinc-500 text-sm">
|
||||
No songs found matching "{searchQuery}"
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* Creators Section */}
|
||||
<section className="mb-10">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">
|
||||
{isSearching ? `Creators matching "${searchQuery}"` : 'Featured Creators'}
|
||||
{isSearching && displayCreators.length > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-zinc-500">({displayCreators.length})</span>
|
||||
)}
|
||||
</h2>
|
||||
{!isSearching && displayCreators.length > 6 && (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => scroll(creatorsScrollRef, 'left')}
|
||||
className="p-1.5 rounded-full hover:bg-zinc-100 dark:hover:bg-white/5 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => scroll(creatorsScrollRef, 'right')}
|
||||
className="p-1.5 rounded-full hover:bg-zinc-100 dark:hover:bg-white/5 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex gap-5 overflow-x-auto pb-2">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex-shrink-0 w-[110px] animate-pulse">
|
||||
<div className="w-[90px] h-[90px] mx-auto rounded-full bg-zinc-200 dark:bg-zinc-800 mb-2" />
|
||||
<div className="h-4 bg-zinc-200 dark:bg-zinc-800 rounded mx-2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : displayCreators.length > 0 ? (
|
||||
<div
|
||||
ref={creatorsScrollRef}
|
||||
className={isSearching
|
||||
? "grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-4"
|
||||
: "flex gap-5 overflow-x-auto pb-2 scrollbar-hide"
|
||||
}
|
||||
style={isSearching ? {} : { scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{displayCreators.map((creator) => (
|
||||
<CreatorCard
|
||||
key={creator.id}
|
||||
creator={creator}
|
||||
onNavigateToProfile={onNavigateToProfile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-zinc-500 text-sm">
|
||||
{isSearching ? `No creators found matching "${searchQuery}"` : 'No creators yet. Be the first to share your music!'}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Playlists Section */}
|
||||
<section className="mb-10">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">
|
||||
{isSearching ? `Playlists matching "${searchQuery}"` : 'Featured Playlists'}
|
||||
{isSearching && displayPlaylists.length > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-zinc-500">({displayPlaylists.length})</span>
|
||||
)}
|
||||
</h2>
|
||||
{!isSearching && displayPlaylists.length > 5 && (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => scroll(playlistsScrollRef, 'left')}
|
||||
className="p-1.5 rounded-full hover:bg-zinc-100 dark:hover:bg-white/5 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => scroll(playlistsScrollRef, 'right')}
|
||||
className="p-1.5 rounded-full hover:bg-zinc-100 dark:hover:bg-white/5 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex gap-4 overflow-x-auto pb-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="flex-shrink-0 w-[140px] animate-pulse">
|
||||
<div className="aspect-square rounded-lg bg-zinc-200 dark:bg-zinc-800 mb-2" />
|
||||
<div className="h-4 bg-zinc-200 dark:bg-zinc-800 rounded mb-1" />
|
||||
<div className="h-3 bg-zinc-200 dark:bg-zinc-800 rounded w-2/3" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : displayPlaylists.length > 0 ? (
|
||||
<div
|
||||
ref={playlistsScrollRef}
|
||||
className={isSearching
|
||||
? "grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4"
|
||||
: "flex gap-4 overflow-x-auto pb-2 scrollbar-hide"
|
||||
}
|
||||
style={isSearching ? {} : { scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{displayPlaylists.map((playlist) => (
|
||||
<PlaylistCard
|
||||
key={playlist.id}
|
||||
playlist={playlist}
|
||||
onNavigateToPlaylist={onNavigateToPlaylist}
|
||||
onNavigateToProfile={onNavigateToProfile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-zinc-500 text-sm">
|
||||
{isSearching ? `No playlists found matching "${searchQuery}"` : 'No public playlists yet. Create one and share your favorites!'}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Genres */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">Genres</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{GENRES.map((genre) => (
|
||||
<button
|
||||
key={genre}
|
||||
onClick={() => handleGenreClick(genre)}
|
||||
className={`px-3 py-1.5 border rounded-full text-sm transition-all duration-200 group flex items-center gap-1.5 ${
|
||||
searchQuery === genre
|
||||
? 'bg-pink-500 border-pink-500 text-white'
|
||||
: 'bg-zinc-100 dark:bg-zinc-800/60 border-zinc-200 dark:border-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700/60 hover:border-pink-500/30 hover:text-pink-600 dark:hover:text-pink-400'
|
||||
}`}
|
||||
>
|
||||
{genre}
|
||||
<Copy
|
||||
size={12}
|
||||
className={`opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer ${searchQuery === genre ? 'text-white/70' : ''}`}
|
||||
onClick={(e) => { e.stopPropagation(); handleCopyTag(genre); }}
|
||||
/>
|
||||
{copiedTag === genre && <Check size={12} className="text-green-500" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface FeaturedSongCardProps {
|
||||
song: ExtendedSong;
|
||||
isPlaying?: boolean;
|
||||
onPlay: () => void;
|
||||
onNavigateToProfile?: (username: string) => void;
|
||||
onCopyTag: (tag: string) => void;
|
||||
copiedTag: string | null;
|
||||
formatNumber: (n: number | undefined) => string;
|
||||
}
|
||||
|
||||
const FeaturedSongCard: React.FC<FeaturedSongCardProps> = ({
|
||||
song,
|
||||
isPlaying,
|
||||
onPlay,
|
||||
onNavigateToProfile,
|
||||
onCopyTag,
|
||||
copiedTag,
|
||||
formatNumber,
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const tags = song.style?.split(',').map(t => t.trim()).filter(Boolean).slice(0, 2) || [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-3 p-2 bg-white dark:bg-zinc-900/40 rounded-xl border border-zinc-100 dark:border-white/5 hover:border-pink-500/30 hover:bg-zinc-50 dark:hover:bg-zinc-800/40 transition-all cursor-pointer group"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="relative w-12 h-12 rounded-lg overflow-hidden flex-shrink-0" onClick={onPlay}>
|
||||
<img
|
||||
src={song.coverUrl}
|
||||
alt={song.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className={`absolute inset-0 bg-black/50 flex items-center justify-center transition-opacity ${isHovered || isPlaying ? 'opacity-100' : 'opacity-0'}`}>
|
||||
{isPlaying ? (
|
||||
<Pause size={16} className="text-white" fill="white" />
|
||||
) : (
|
||||
<Play size={16} className="text-white ml-0.5" fill="white" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<span className="font-semibold text-zinc-900 dark:text-white text-sm truncate max-w-[140px]">{song.title}</span>
|
||||
{song.isPublic !== false && (
|
||||
<span className="flex-shrink-0 text-[8px] font-bold text-white bg-gradient-to-r from-pink-500 to-purple-500 px-1 py-0.5 rounded">
|
||||
v5
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-500 dark:text-zinc-400 truncate mb-1">
|
||||
{tags.map((tag, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={(e) => { e.stopPropagation(); onCopyTag(tag); }}
|
||||
className="hover:text-pink-500 dark:hover:text-pink-400 transition-colors"
|
||||
>
|
||||
{tag}{i < tags.length - 1 ? ', ' : ''}
|
||||
{copiedTag === tag && <Check size={10} className="inline ml-0.5 text-green-500" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px] text-zinc-400">
|
||||
{song.creator && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onNavigateToProfile?.(song.creator!); }}
|
||||
className="flex items-center gap-1 hover:text-pink-500 transition-colors max-w-[80px]"
|
||||
>
|
||||
{song.creator_avatar ? (
|
||||
<img
|
||||
src={song.creator_avatar}
|
||||
alt={song.creator}
|
||||
className="w-3.5 h-3.5 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-3.5 h-3.5 rounded-full bg-gradient-to-br from-pink-500 to-purple-500 flex items-center justify-center text-[7px] text-white font-bold flex-shrink-0">
|
||||
{song.creator.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="truncate">{song.creator}</span>
|
||||
</button>
|
||||
)}
|
||||
<span className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<Play size={9} /> {formatNumber(song.viewCount)}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<Heart size={9} /> {formatNumber(song.likeCount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CreatorCardProps {
|
||||
creator: UserProfile & { song_count?: number };
|
||||
onNavigateToProfile?: (username: string) => void;
|
||||
}
|
||||
|
||||
const CreatorCard: React.FC<CreatorCardProps> = ({
|
||||
creator,
|
||||
onNavigateToProfile,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="flex-shrink-0 w-[110px] text-center cursor-pointer group"
|
||||
onClick={() => onNavigateToProfile?.(creator.username)}
|
||||
>
|
||||
<div className="w-[90px] h-[90px] mx-auto rounded-full overflow-hidden mb-2 ring-2 ring-transparent group-hover:ring-pink-500 transition-all shadow-lg">
|
||||
<img
|
||||
src={creator.avatar_url || `https://api.dicebear.com/7.x/avataaars/svg?seed=${creator.username}`}
|
||||
alt={creator.username}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="font-semibold text-zinc-900 dark:text-white text-sm truncate group-hover:text-pink-500 transition-colors px-1">
|
||||
{creator.username}
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-500 truncate px-1">@{creator.username.toLowerCase().replace(/\s/g, '')}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface PlaylistCardProps {
|
||||
playlist: Playlist & { creator?: string; creator_avatar?: string; song_count?: number };
|
||||
onNavigateToPlaylist?: (playlistId: string) => void;
|
||||
onNavigateToProfile?: (username: string) => void;
|
||||
}
|
||||
|
||||
const PlaylistCard: React.FC<PlaylistCardProps> = ({
|
||||
playlist,
|
||||
onNavigateToPlaylist,
|
||||
onNavigateToProfile,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="flex-shrink-0 w-[140px] cursor-pointer group"
|
||||
onClick={() => onNavigateToPlaylist?.(playlist.id)}
|
||||
>
|
||||
<div className="aspect-square rounded-lg overflow-hidden mb-2 shadow-md relative bg-zinc-200 dark:bg-zinc-800">
|
||||
<img
|
||||
src={playlist.cover_url || `https://picsum.photos/seed/${playlist.id}/400/400`}
|
||||
alt={playlist.name}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
<div className="font-semibold text-zinc-900 dark:text-white text-sm truncate group-hover:text-pink-500 transition-colors">
|
||||
{playlist.name}
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-500 mb-1">{playlist.song_count || 0} songs</div>
|
||||
{playlist.creator && (
|
||||
<div
|
||||
className="flex items-center gap-1.5 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNavigateToProfile?.(playlist.creator!);
|
||||
}}
|
||||
>
|
||||
<div className="w-4 h-4 rounded-full overflow-hidden flex-shrink-0 bg-zinc-300 dark:bg-zinc-700">
|
||||
<img
|
||||
src={playlist.creator_avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${playlist.creator}`}
|
||||
alt={playlist.creator}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[11px] text-zinc-500 hover:text-pink-500 transition-colors truncate">
|
||||
{playlist.creator}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,182 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, User as UserIcon, Palette, Info, Edit3, ExternalLink } from 'lucide-react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { EditProfileModal } from './EditProfileModal';
|
||||
|
||||
interface SettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
theme: 'light' | 'dark';
|
||||
onToggleTheme: () => void;
|
||||
onNavigateToProfile?: (username: string) => void;
|
||||
}
|
||||
|
||||
export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, theme, onToggleTheme, onNavigateToProfile }) => {
|
||||
const { user } = useAuth();
|
||||
const [isEditProfileOpen, setIsEditProfileOpen] = useState(false);
|
||||
|
||||
if (!isOpen || !user) {
|
||||
if (isEditProfileOpen && user) {
|
||||
return (
|
||||
<EditProfileModal
|
||||
isOpen={isEditProfileOpen}
|
||||
onClose={() => setIsEditProfileOpen(false)}
|
||||
onSaved={() => setIsEditProfileOpen(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div
|
||||
className="bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-zinc-200 dark:border-white/5">
|
||||
<h2 className="text-2xl font-bold text-zinc-900 dark:text-white">Settings</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-zinc-100 dark:hover:bg-white/5 rounded-full transition-colors"
|
||||
>
|
||||
<X size={20} className="text-zinc-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-8">
|
||||
{/* User Profile Section */}
|
||||
<div className="bg-zinc-50 dark:bg-zinc-800/50 rounded-xl p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-2xl font-bold text-white shadow-lg overflow-hidden">
|
||||
{user.avatar_url ? (
|
||||
<img src={user.avatar_url} alt={user.username} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
user.username[0].toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-bold text-zinc-900 dark:text-white">@{user.username}</h3>
|
||||
<p className="text-xs text-zinc-400 dark:text-zinc-500 mt-1">
|
||||
Member since {new Date(user.createdAt).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
onClose();
|
||||
setIsEditProfileOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
<Edit3 size={16} />
|
||||
Edit Profile
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onNavigateToProfile?.(user.username);
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-zinc-200 dark:bg-zinc-700 text-zinc-900 dark:text-white rounded-lg text-sm font-medium hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
View Profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-zinc-900 dark:text-white">
|
||||
<UserIcon size={20} />
|
||||
<h3 className="font-semibold">Account</h3>
|
||||
</div>
|
||||
<div className="pl-7 space-y-3">
|
||||
<div>
|
||||
<label className="text-sm text-zinc-500 dark:text-zinc-400">Username</label>
|
||||
<p className="text-zinc-900 dark:text-white font-medium">@{user.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-zinc-900 dark:text-white">
|
||||
<Palette size={20} />
|
||||
<h3 className="font-semibold">Appearance</h3>
|
||||
</div>
|
||||
<div className="pl-7 space-y-3">
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={theme === 'dark' ? onToggleTheme : undefined}
|
||||
className={`flex-1 py-3 px-4 rounded-lg border-2 font-medium transition-colors ${theme === 'light'
|
||||
? 'border-indigo-500 bg-indigo-50 text-indigo-700'
|
||||
: 'border-zinc-300 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-600'
|
||||
}`}
|
||||
>
|
||||
Light
|
||||
</button>
|
||||
<button
|
||||
onClick={theme === 'light' ? onToggleTheme : undefined}
|
||||
className={`flex-1 py-3 px-4 rounded-lg border-2 font-medium transition-colors ${theme === 'dark'
|
||||
? 'border-indigo-500 bg-indigo-950 text-indigo-300'
|
||||
: 'border-zinc-300 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-600'
|
||||
}`}
|
||||
>
|
||||
Dark
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* About Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-zinc-900 dark:text-white">
|
||||
<Info size={20} />
|
||||
<h3 className="font-semibold">About</h3>
|
||||
</div>
|
||||
<div className="pl-7 space-y-3 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<p>Version 1.0.0</p>
|
||||
<p>ACE-Step UI - Local AI Music Generator</p>
|
||||
<p className="text-xs text-zinc-400 dark:text-zinc-500 mt-2">
|
||||
Powered by ACE-Step 1.5. Open source and free to use.
|
||||
</p>
|
||||
<div className="pt-3 border-t border-zinc-200 dark:border-zinc-700/50 mt-4">
|
||||
<p className="text-zinc-900 dark:text-white font-medium mb-2">Created by Ambsd</p>
|
||||
<a
|
||||
href="https://x.com/AmbsdOP"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-black dark:bg-white text-white dark:text-black rounded-lg text-sm font-medium hover:bg-zinc-800 dark:hover:bg-zinc-200 transition-colors"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
Follow @AmbsdOP
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-zinc-200 dark:border-white/5 p-6 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 bg-zinc-900 dark:bg-white text-white dark:text-black font-semibold rounded-lg hover:bg-zinc-800 dark:hover:bg-zinc-200 transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EditProfileModal
|
||||
isOpen={isEditProfileOpen}
|
||||
onClose={() => setIsEditProfileOpen(false)}
|
||||
onSaved={() => setIsEditProfileOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,226 @@
|
||||
import React, { useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { X, Link, Check } from 'lucide-react';
|
||||
import { Song } from '../types';
|
||||
|
||||
interface ShareModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
song: Song;
|
||||
}
|
||||
|
||||
const XIcon = () => (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const RedditIcon = () => (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const FacebookIcon = () => (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const WhatsAppIcon = () => (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const TelegramIcon = () => (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const LinkedInIcon = () => (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const EmailIcon = () => (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ShareModal: React.FC<ShareModalProps> = ({ isOpen, onClose, song }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const shareUrl = `${window.location.origin}/song/${song.id}`;
|
||||
|
||||
// Platform-specific share text for better engagement
|
||||
const defaultShareText = `🎵 "${song.title}" ${song.style ? `(${song.style})` : ''} - Made with ACE-Step UI`;
|
||||
const twitterText = `🔥 Just created "${song.title}" with ACE-Step UI - local AI music generation! ${song.style ? `#${song.style.replace(/\s+/g, '')}` : ''} #AIMusic #ACEStep`;
|
||||
const redditTitle = `[AI Music] ${song.title} - ${song.style || 'Original'} | Created with ACE-Step UI`;
|
||||
const whatsAppText = `🎧 Listen to this AI-generated song!\n\n"${song.title}" by ${song.creator || 'Unknown Artist'}\n${song.style ? `Genre: ${song.style}` : ''}\n\nMade with ACE-Step UI - free and open source!`;
|
||||
const telegramText = `🎵 "${song.title}" by ${song.creator || 'Unknown Artist'}\n${song.style ? `🎸 ${song.style}` : ''}\n\n🤖 Made with ACE-Step UI`;
|
||||
const linkedInText = `Check out this AI-generated music: "${song.title}" - Created locally with ACE-Step. #AIMusic #MusicTech #OpenSource`;
|
||||
|
||||
const handleShareX = () => {
|
||||
const url = `https://twitter.com/intent/tweet?text=${encodeURIComponent(twitterText)}&url=${encodeURIComponent(shareUrl)}`;
|
||||
window.open(url, '_blank', 'width=550,height=420');
|
||||
};
|
||||
|
||||
const handleShareReddit = () => {
|
||||
const url = `https://reddit.com/submit?url=${encodeURIComponent(shareUrl)}&title=${encodeURIComponent(redditTitle)}`;
|
||||
window.open(url, '_blank', 'width=800,height=600');
|
||||
};
|
||||
|
||||
const handleShareFacebook = () => {
|
||||
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}"e=${encodeURIComponent(defaultShareText)}`;
|
||||
window.open(url, '_blank', 'width=550,height=420');
|
||||
};
|
||||
|
||||
const handleShareWhatsApp = () => {
|
||||
const url = `https://wa.me/?text=${encodeURIComponent(`${whatsAppText}\n\n${shareUrl}`)}`;
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
const handleShareTelegram = () => {
|
||||
const url = `https://t.me/share/url?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(telegramText)}`;
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
const handleShareLinkedIn = () => {
|
||||
const url = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`;
|
||||
window.open(url, '_blank', 'width=550,height=420');
|
||||
};
|
||||
|
||||
const handleShareEmail = () => {
|
||||
const subject = encodeURIComponent(`🎵 Check out this AI song: ${song.title}`);
|
||||
const body = encodeURIComponent(`Hey!\n\nI created this AI-generated song and thought you'd love it:\n\n"${song.title}" by ${song.creator || 'Unknown Artist'}\n${song.style ? `Genre: ${song.style}` : ''}\n\n🎧 Listen here: ${shareUrl}\n\n🤖 Made with ACE-Step UI - free and open source local AI music generation!`);
|
||||
window.location.href = `mailto:?subject=${subject}&body=${body}`;
|
||||
};
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
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 modalContent = (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 dark:bg-black/80 backdrop-blur-sm p-4"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 rounded-xl w-full max-w-sm p-6 shadow-2xl animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">Share Song</h2>
|
||||
<button onClick={onClose} className="text-zinc-400 hover:text-zinc-900 dark:hover:text-white">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6 p-3 bg-zinc-50 dark:bg-black/30 rounded-lg">
|
||||
<img
|
||||
src={song.coverUrl}
|
||||
alt={song.title}
|
||||
className="w-12 h-12 rounded object-cover"
|
||||
/>
|
||||
<div className="overflow-hidden">
|
||||
<div className="font-medium text-zinc-900 dark:text-white truncate">{song.title}</div>
|
||||
<div className="text-sm text-zinc-500 truncate">{song.style}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3 mb-4">
|
||||
<button
|
||||
onClick={handleShareX}
|
||||
className="flex flex-col items-center gap-1.5 p-3 rounded-lg bg-black text-white hover:bg-zinc-800 transition-colors"
|
||||
title="Share on X"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="text-xs font-medium">X</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleShareFacebook}
|
||||
className="flex flex-col items-center gap-1.5 p-3 rounded-lg bg-[#1877F2] text-white hover:bg-[#166FE5] transition-colors"
|
||||
title="Share on Facebook"
|
||||
>
|
||||
<FacebookIcon />
|
||||
<span className="text-xs font-medium">Facebook</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleShareWhatsApp}
|
||||
className="flex flex-col items-center gap-1.5 p-3 rounded-lg bg-[#25D366] text-white hover:bg-[#22C55E] transition-colors"
|
||||
title="Share on WhatsApp"
|
||||
>
|
||||
<WhatsAppIcon />
|
||||
<span className="text-xs font-medium">WhatsApp</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleShareTelegram}
|
||||
className="flex flex-col items-center gap-1.5 p-3 rounded-lg bg-[#0088CC] text-white hover:bg-[#0077B5] transition-colors"
|
||||
title="Share on Telegram"
|
||||
>
|
||||
<TelegramIcon />
|
||||
<span className="text-xs font-medium">Telegram</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleShareReddit}
|
||||
className="flex flex-col items-center gap-1.5 p-3 rounded-lg bg-[#FF4500] text-white hover:bg-[#FF5722] transition-colors"
|
||||
title="Share on Reddit"
|
||||
>
|
||||
<RedditIcon />
|
||||
<span className="text-xs font-medium">Reddit</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleShareLinkedIn}
|
||||
className="flex flex-col items-center gap-1.5 p-3 rounded-lg bg-[#0A66C2] text-white hover:bg-[#004182] transition-colors"
|
||||
title="Share on LinkedIn"
|
||||
>
|
||||
<LinkedInIcon />
|
||||
<span className="text-xs font-medium">LinkedIn</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleShareEmail}
|
||||
className="flex flex-col items-center gap-1.5 p-3 rounded-lg bg-zinc-600 dark:bg-zinc-700 text-white hover:bg-zinc-700 dark:hover:bg-zinc-600 transition-colors"
|
||||
title="Share via Email"
|
||||
>
|
||||
<EmailIcon />
|
||||
<span className="text-xs font-medium">Email</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleCopyLink}
|
||||
className="flex flex-col items-center gap-1.5 p-3 rounded-lg border border-zinc-200 dark:border-white/10 text-zinc-900 dark:text-white hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors"
|
||||
title="Copy Link"
|
||||
>
|
||||
{copied ? <Check size={20} className="text-green-500" /> : <Link size={20} />}
|
||||
<span className="text-xs font-medium">{copied ? 'Copied!' : 'Copy'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return ReactDOM.createPortal(modalContent, document.body);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import React from 'react';
|
||||
import { Library, Disc, Search, User, LogIn, LogOut, Sun, Moon } from 'lucide-react';
|
||||
import { View } from '../types';
|
||||
|
||||
interface SidebarProps {
|
||||
currentView: View;
|
||||
onNavigate: (view: View) => void;
|
||||
theme: 'light' | 'dark';
|
||||
onToggleTheme: () => void;
|
||||
user?: { username: string; isAdmin?: boolean; avatar_url?: string } | null;
|
||||
onLogin?: () => void;
|
||||
onLogout?: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({
|
||||
currentView,
|
||||
onNavigate,
|
||||
theme,
|
||||
onToggleTheme,
|
||||
user,
|
||||
onLogin,
|
||||
onLogout,
|
||||
onOpenSettings,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white dark:bg-suno-sidebar border-r border-zinc-200 dark:border-white/5 flex-shrink-0 w-[72px] items-center py-4 z-30 transition-colors duration-300 overflow-y-auto scrollbar-hide">
|
||||
{/* Logo */}
|
||||
<div
|
||||
className="w-10 h-10 rounded-full bg-gradient-to-br from-pink-500 to-purple-600 flex items-center justify-center mb-8 cursor-pointer shadow-lg hover:scale-105 transition-transform"
|
||||
onClick={() => onNavigate('create')}
|
||||
title="ACE-Step UI"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 text-white">
|
||||
<path d="M12 2L2 7L12 12L22 7L12 2Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M2 17L12 22L22 17" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M2 12L12 17L22 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 flex flex-col gap-4 w-full px-3">
|
||||
<NavItem
|
||||
icon={<Disc size={24} />}
|
||||
label="Create"
|
||||
active={currentView === 'create'}
|
||||
onClick={() => onNavigate('create')}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<Library size={24} />}
|
||||
label="Library"
|
||||
active={currentView === 'library'}
|
||||
onClick={() => onNavigate('library')}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<Search size={24} />}
|
||||
label="Search"
|
||||
active={currentView === 'search'}
|
||||
onClick={() => onNavigate('search')}
|
||||
/>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-4">
|
||||
<button
|
||||
onClick={onToggleTheme}
|
||||
className="w-10 h-10 rounded-full hover:bg-zinc-100 dark:hover:bg-white/10 flex items-center justify-center text-zinc-500 dark:text-zinc-400 hover:text-black dark:hover:text-white transition-colors mx-auto"
|
||||
title={theme === 'dark' ? 'Light Mode' : 'Dark Mode'}
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={20} /> : <Moon size={20} />}
|
||||
</button>
|
||||
|
||||
{user ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div
|
||||
onClick={onOpenSettings}
|
||||
className="w-8 h-8 rounded-full bg-gradient-to-br from-pink-500 to-purple-600 flex items-center justify-center text-white text-xs font-bold cursor-pointer border border-white/20 hover:scale-110 transition-transform overflow-hidden"
|
||||
title={`${user.username} - Settings`}
|
||||
>
|
||||
{user.avatar_url ? (
|
||||
<img src={user.avatar_url} alt={user.username} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
user.username.charAt(0).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="w-8 h-8 rounded-full hover:bg-red-500/20 flex items-center justify-center text-zinc-500 hover:text-red-500 transition-colors"
|
||||
title="Sign Out"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={onLogin}
|
||||
className="w-10 h-10 rounded-full hover:bg-zinc-100 dark:hover:bg-white/10 flex items-center justify-center text-zinc-500 dark:text-zinc-400 hover:text-pink-500 transition-colors mx-auto"
|
||||
title="Sign In"
|
||||
>
|
||||
<LogIn size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface NavItemProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
active?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const NavItem: React.FC<NavItemProps> = ({ icon, label, active, onClick }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`
|
||||
w-full aspect-square rounded-xl flex flex-col items-center justify-center gap-1 transition-all duration-200 group relative
|
||||
${active ? 'bg-zinc-100 dark:bg-white/10 text-black dark:text-white' : 'text-zinc-500 hover:text-black dark:hover:text-white hover:bg-zinc-100 dark:hover:bg-white/5'}
|
||||
`}
|
||||
title={label}
|
||||
>
|
||||
{active && <div className="absolute left-0 top-1/2 -translate-y-1/2 h-8 w-1 bg-pink-500 rounded-r-full"></div>}
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
@@ -0,0 +1,227 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Song } from '../types';
|
||||
import {
|
||||
Video,
|
||||
Edit3,
|
||||
Layers,
|
||||
Repeat,
|
||||
ListPlus,
|
||||
Download,
|
||||
Trash2,
|
||||
Share2
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SongDropdownMenuProps {
|
||||
song: Song;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
isOwner?: boolean;
|
||||
position?: 'left' | 'right';
|
||||
direction?: 'up' | 'down';
|
||||
onCreateVideo?: () => void;
|
||||
onEditAudio?: () => void;
|
||||
onExtractStems?: () => void;
|
||||
onReusePrompt?: () => void;
|
||||
onAddToPlaylist?: () => void;
|
||||
onDownload?: () => void;
|
||||
onShare?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
interface MenuItemProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const MenuItem: React.FC<MenuItemProps> = ({ icon, label, onClick, danger, disabled }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`w-full px-3 py-2 text-left text-sm flex items-center gap-3 transition-colors
|
||||
${danger
|
||||
? 'text-red-400 hover:bg-red-500/10 hover:text-red-300'
|
||||
: 'text-zinc-300 hover:bg-white/5 hover:text-white'}
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
||||
`}
|
||||
>
|
||||
<span className="w-4 h-4 flex items-center justify-center opacity-70">{icon}</span>
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
const MenuDivider: React.FC = () => (
|
||||
<div className="h-px bg-white/10 my-1 mx-2" />
|
||||
);
|
||||
|
||||
export const SongDropdownMenu: React.FC<SongDropdownMenuProps> = ({
|
||||
song,
|
||||
isOpen,
|
||||
onClose,
|
||||
isOwner = false,
|
||||
position = 'right',
|
||||
direction = 'down',
|
||||
onCreateVideo,
|
||||
onEditAudio,
|
||||
onExtractStems,
|
||||
onReusePrompt,
|
||||
onAddToPlaylist,
|
||||
onDownload,
|
||||
onShare,
|
||||
onDelete
|
||||
}) => {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleAction = (action?: () => void) => {
|
||||
if (action) {
|
||||
action();
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleEditAudio = () => {
|
||||
if (!song.audioUrl) return;
|
||||
const audioUrl = song.audioUrl.startsWith('http')
|
||||
? song.audioUrl
|
||||
: `${window.location.origin}${song.audioUrl}`;
|
||||
window.open(`/editor?audioUrl=${encodeURIComponent(audioUrl)}`, '_blank');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleExtractStems = () => {
|
||||
if (!song.audioUrl) return;
|
||||
const baseUrl = window.location.port === '3000'
|
||||
? `${window.location.protocol}//${window.location.hostname}:3001`
|
||||
: window.location.origin;
|
||||
const audioUrl = song.audioUrl.startsWith('http')
|
||||
? song.audioUrl
|
||||
: `${baseUrl}${song.audioUrl}`;
|
||||
window.open(`${baseUrl}/demucs-web/?audioUrl=${encodeURIComponent(audioUrl)}`, '_blank');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!song.audioUrl) return;
|
||||
try {
|
||||
// Fetch as blob to handle cross-origin
|
||||
const response = await fetch(song.audioUrl);
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${song.title || 'song'}.mp3`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Clean up blob URL
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const positionClasses = position === 'left' ? 'left-0' : 'right-0';
|
||||
const directionClasses = direction === 'up'
|
||||
? 'bottom-full mb-2'
|
||||
: 'top-full mt-2';
|
||||
const animationClasses = direction === 'up'
|
||||
? 'animate-in fade-in slide-in-from-bottom-2'
|
||||
: 'animate-in fade-in slide-in-from-top-2';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={`absolute ${positionClasses} ${directionClasses} w-52
|
||||
bg-zinc-900 rounded-xl shadow-2xl border border-white/10 py-1.5 z-50
|
||||
${animationClasses} duration-150`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Creative Actions */}
|
||||
<MenuItem
|
||||
icon={<Video size={14} />}
|
||||
label="Create Video"
|
||||
onClick={() => handleAction(onCreateVideo)}
|
||||
/>
|
||||
{isOwner && (
|
||||
<MenuItem
|
||||
icon={<Edit3 size={14} />}
|
||||
label="Edit Audio"
|
||||
onClick={onEditAudio ? () => handleAction(onEditAudio) : handleEditAudio}
|
||||
/>
|
||||
)}
|
||||
<MenuItem
|
||||
icon={<Layers size={14} />}
|
||||
label="Extract Stems"
|
||||
onClick={onExtractStems ? () => handleAction(onExtractStems) : handleExtractStems}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={<Repeat size={14} />}
|
||||
label="Reuse Prompt"
|
||||
onClick={() => handleAction(onReusePrompt)}
|
||||
/>
|
||||
|
||||
<MenuDivider />
|
||||
|
||||
{/* Library Actions */}
|
||||
<MenuItem
|
||||
icon={<ListPlus size={14} />}
|
||||
label="Add to Playlist"
|
||||
onClick={() => handleAction(onAddToPlaylist)}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={<Download size={14} />}
|
||||
label="Download"
|
||||
onClick={onDownload ? () => handleAction(onDownload) : handleDownload}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={<Share2 size={14} />}
|
||||
label="Share"
|
||||
onClick={() => handleAction(onShare)}
|
||||
/>
|
||||
|
||||
{/* Owner-only Actions */}
|
||||
{isOwner && (
|
||||
<>
|
||||
<MenuDivider />
|
||||
<MenuItem
|
||||
icon={<Trash2 size={14} />}
|
||||
label="Delete Song"
|
||||
onClick={() => handleAction(onDelete)}
|
||||
danger
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,456 @@
|
||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { Song } from '../types';
|
||||
import { Play, MoreHorizontal, Heart, ThumbsDown, ListPlus, Pause, Search, Filter, Check, Globe, Lock, Loader2, ThumbsUp, Share2, Video, Info, Clock } from 'lucide-react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { SongDropdownMenu } from './SongDropdownMenu';
|
||||
import { ShareModal } from './ShareModal';
|
||||
import { AlbumCover } from './AlbumCover';
|
||||
|
||||
interface SongListProps {
|
||||
songs: Song[];
|
||||
currentSong: Song | null;
|
||||
selectedSong: Song | null;
|
||||
likedSongIds: Set<string>;
|
||||
isPlaying: boolean;
|
||||
onPlay: (song: Song) => void;
|
||||
onSelect: (song: Song) => void;
|
||||
onToggleLike: (songId: string) => void;
|
||||
onAddToPlaylist: (song: Song) => void;
|
||||
onOpenVideo?: (song: Song) => void;
|
||||
onShowDetails?: (song: Song) => void;
|
||||
onNavigateToProfile?: (username: string) => void;
|
||||
onReusePrompt?: (song: Song) => void;
|
||||
onDelete?: (song: Song) => void;
|
||||
}
|
||||
|
||||
// ... existing code ...
|
||||
|
||||
|
||||
|
||||
// Define Filter Types
|
||||
type FilterType = 'liked' | 'public' | 'private' | 'generating';
|
||||
|
||||
const FILTERS: { id: FilterType; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: 'liked', label: 'Liked', icon: <ThumbsUp size={16} /> },
|
||||
{ id: 'public', label: 'Public', icon: <Globe size={16} /> },
|
||||
{ id: 'private', label: 'Private', icon: <Lock size={16} /> },
|
||||
{ id: 'generating', label: 'Generating', icon: <Loader2 size={16} /> },
|
||||
];
|
||||
|
||||
export const SongList: React.FC<SongListProps> = ({
|
||||
songs,
|
||||
currentSong,
|
||||
selectedSong,
|
||||
likedSongIds,
|
||||
isPlaying,
|
||||
onPlay,
|
||||
onSelect,
|
||||
onToggleLike,
|
||||
onAddToPlaylist,
|
||||
onOpenVideo,
|
||||
onShowDetails,
|
||||
onNavigateToProfile,
|
||||
onReusePrompt,
|
||||
onDelete
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeFilters, setActiveFilters] = useState<Set<FilterType>>(new Set());
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||
const filterRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close filter dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (filterRef.current && !filterRef.current.contains(event.target as Node)) {
|
||||
setIsFilterOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const toggleFilter = (filterId: FilterType) => {
|
||||
setActiveFilters(prev => {
|
||||
const newFilters = new Set(prev);
|
||||
if (newFilters.has(filterId)) {
|
||||
newFilters.delete(filterId);
|
||||
} else {
|
||||
newFilters.add(filterId);
|
||||
}
|
||||
return newFilters;
|
||||
});
|
||||
};
|
||||
|
||||
const filteredSongs = useMemo(() => {
|
||||
return songs.filter(song => {
|
||||
// 1. Search Logic
|
||||
const matchesSearch =
|
||||
song.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
song.style.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
song.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
|
||||
if (!matchesSearch) return false;
|
||||
|
||||
// 2. Filter Logic
|
||||
if (activeFilters.size === 0) return true;
|
||||
|
||||
if (activeFilters.has('liked') && !likedSongIds.has(song.id)) return false;
|
||||
if (activeFilters.has('public') && !song.isPublic) return false;
|
||||
if (activeFilters.has('private') && song.isPublic) return false;
|
||||
if (activeFilters.has('generating') && !song.isGenerating) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [songs, searchQuery, activeFilters, likedSongIds]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-white dark:bg-black h-full overflow-y-auto custom-scrollbar p-6 pb-32 transition-colors duration-300">
|
||||
<div className="max-w-5xl mx-auto w-full"> {/* Container constraint */}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-6 mb-8">
|
||||
<div className="flex items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
<span className="hover:text-black dark:hover:text-white cursor-pointer transition-colors">Workspaces</span>
|
||||
<span className="text-zinc-400 dark:text-zinc-600">›</span>
|
||||
<span className="text-zinc-900 dark:text-white font-medium">My Workspace</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative group flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search your songs..."
|
||||
className="w-full bg-zinc-100 dark:bg-[#121214] border border-zinc-200 dark:border-white/10 rounded-lg pl-10 pr-4 py-2.5 text-sm text-zinc-900 dark:text-white focus:outline-none focus:border-zinc-400 dark:focus:border-white/20 placeholder-zinc-500 dark:placeholder-zinc-600 transition-colors"
|
||||
/>
|
||||
<Search className="w-4 h-4 text-zinc-500 absolute left-3 top-3 group-focus-within:text-black dark:group-focus-within:text-white transition-colors" />
|
||||
</div>
|
||||
|
||||
<div className="relative" ref={filterRef}>
|
||||
<button
|
||||
onClick={() => setIsFilterOpen(!isFilterOpen)}
|
||||
className={`
|
||||
border text-xs font-bold px-4 py-2.5 rounded-lg flex items-center gap-2 transition-all select-none
|
||||
${isFilterOpen || activeFilters.size > 0
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-black border-transparent'
|
||||
: 'bg-zinc-100 dark:bg-[#121214] hover:bg-zinc-200 dark:hover:bg-white/5 border-zinc-200 dark:border-white/10 text-zinc-700 dark:text-white'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Filter size={14} fill={activeFilters.size > 0 ? "currentColor" : "none"} />
|
||||
<span>Filters {activeFilters.size > 0 && `(${activeFilters.size})`}</span>
|
||||
</button>
|
||||
|
||||
{/* Filter Dropdown */}
|
||||
{isFilterOpen && (
|
||||
<div className="absolute right-0 top-full mt-2 w-56 bg-white dark:bg-[#18181b] border border-zinc-200 dark:border-white/10 rounded-xl shadow-2xl overflow-hidden py-1 z-50 animate-in fade-in zoom-in-95 duration-100 origin-top-right">
|
||||
<div className="px-3 py-2 text-[10px] font-bold text-zinc-500 uppercase tracking-wider">
|
||||
Refine By
|
||||
</div>
|
||||
{FILTERS.map(filter => (
|
||||
<button
|
||||
key={filter.id}
|
||||
onClick={() => toggleFilter(filter.id)}
|
||||
className="w-full text-left px-4 py-2.5 flex items-center justify-between hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3 text-sm font-medium text-zinc-700 dark:text-zinc-300 group-hover:text-black dark:group-hover:text-white">
|
||||
<span className="text-zinc-400 dark:text-zinc-500 group-hover:text-zinc-600 dark:group-hover:text-zinc-300 transition-colors">
|
||||
{filter.icon}
|
||||
</span>
|
||||
{filter.label}
|
||||
</div>
|
||||
<div className={`
|
||||
w-4 h-4 rounded border flex items-center justify-center transition-all
|
||||
${activeFilters.has(filter.id)
|
||||
? 'bg-pink-600 border-pink-600'
|
||||
: 'border-zinc-300 dark:border-zinc-600 group-hover:border-zinc-400 dark:group-hover:border-zinc-500'
|
||||
}
|
||||
`}>
|
||||
{activeFilters.has(filter.id) && <Check size={10} className="text-white" strokeWidth={4} />}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="space-y-2"> {/* Reduced vertical spacing */}
|
||||
{filteredSongs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-zinc-500 space-y-4 border border-dashed border-zinc-200 dark:border-white/5 rounded-2xl bg-zinc-50 dark:bg-white/[0.02]">
|
||||
<div className="w-16 h-16 rounded-full bg-zinc-100 dark:bg-white/5 flex items-center justify-center">
|
||||
<Filter size={32} />
|
||||
</div>
|
||||
<p className="font-medium">No songs match your filters.</p>
|
||||
<button
|
||||
onClick={() => { setActiveFilters(new Set()); setSearchQuery(''); }}
|
||||
className="text-pink-600 dark:text-pink-500 text-sm font-bold hover:underline"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
filteredSongs.map((song) => (
|
||||
<SongItem
|
||||
key={song.id}
|
||||
song={song}
|
||||
isCurrent={currentSong?.id === song.id}
|
||||
isSelected={selectedSong?.id === song.id}
|
||||
isLiked={likedSongIds.has(song.id)}
|
||||
isPlaying={isPlaying}
|
||||
isOwner={user?.id === song.userId}
|
||||
onPlay={() => onPlay(song)}
|
||||
onSelect={() => onSelect(song)}
|
||||
onToggleLike={() => onToggleLike(song.id)}
|
||||
onAddToPlaylist={() => onAddToPlaylist(song)}
|
||||
onOpenVideo={() => onOpenVideo && onOpenVideo(song)}
|
||||
onShowDetails={() => onShowDetails && onShowDetails(song)}
|
||||
onNavigateToProfile={onNavigateToProfile}
|
||||
onReusePrompt={() => onReusePrompt?.(song)}
|
||||
onDelete={() => onDelete?.(song)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div> {/* End container */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SongItemProps {
|
||||
song: Song;
|
||||
isCurrent: boolean;
|
||||
isSelected: boolean;
|
||||
isLiked: boolean;
|
||||
isPlaying: boolean;
|
||||
isOwner: boolean;
|
||||
onPlay: () => void;
|
||||
onSelect: () => void;
|
||||
onToggleLike: () => void;
|
||||
onAddToPlaylist: () => void;
|
||||
onOpenVideo?: () => void;
|
||||
onShowDetails?: () => void;
|
||||
onNavigateToProfile?: (username: string) => void;
|
||||
onReusePrompt?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
const SongItem: React.FC<SongItemProps> = ({
|
||||
song,
|
||||
isCurrent,
|
||||
isSelected,
|
||||
isLiked,
|
||||
isPlaying,
|
||||
isOwner,
|
||||
onPlay,
|
||||
onSelect,
|
||||
onToggleLike,
|
||||
onAddToPlaylist,
|
||||
onOpenVideo,
|
||||
onShowDetails,
|
||||
onNavigateToProfile,
|
||||
onReusePrompt,
|
||||
onDelete
|
||||
}) => {
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onClick={onSelect}
|
||||
className={`group flex items-center gap-4 p-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-[#18181b] transition-all cursor-pointer border ${isSelected ? 'bg-zinc-100 dark:bg-[#18181b] border-zinc-200 dark:border-white/10' : 'border-transparent bg-transparent'}`}
|
||||
>
|
||||
|
||||
{/* Cover Art - Reduced size */}
|
||||
<div className="relative w-16 h-16 flex-shrink-0 rounded-md bg-zinc-200 dark:bg-zinc-800 overflow-hidden shadow-sm group/image">
|
||||
{/* Use gradient fallback if no coverUrl or image fails to load */}
|
||||
{(!song.coverUrl || imageError) ? (
|
||||
<AlbumCover seed={song.id || song.title} size="full" className={`w-full h-full ${song.isGenerating ? 'opacity-20 blur-sm' : 'opacity-100'}`} />
|
||||
) : (
|
||||
<img
|
||||
src={song.coverUrl}
|
||||
alt={song.title}
|
||||
className={`w-full h-full object-cover transition-opacity ${song.isGenerating ? 'opacity-20 blur-sm' : 'opacity-100'}`}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{song.isGenerating ? (
|
||||
<div className="absolute inset-0 bg-black/40 flex flex-col items-center justify-center gap-1">
|
||||
{song.queuePosition ? (
|
||||
/* Queue indicator */
|
||||
<>
|
||||
<div className="w-8 h-8 rounded-full bg-amber-500/20 flex items-center justify-center">
|
||||
<Clock size={16} className="text-amber-400" />
|
||||
</div>
|
||||
<span className="text-[10px] font-medium text-amber-400">Queue #{song.queuePosition}</span>
|
||||
</>
|
||||
) : (
|
||||
/* Generating - Music Waveform Animation */
|
||||
<div className="flex items-end gap-1 h-6">
|
||||
<div className="w-1 bg-pink-500 rounded-full music-bar-anim" style={{ animationDelay: '0.0s' }}></div>
|
||||
<div className="w-1 bg-pink-500 rounded-full music-bar-anim" style={{ animationDelay: '0.2s' }}></div>
|
||||
<div className="w-1 bg-pink-500 rounded-full music-bar-anim" style={{ animationDelay: '0.4s' }}></div>
|
||||
<div className="w-1 bg-pink-500 rounded-full music-bar-anim" style={{ animationDelay: '0.1s' }}></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/40 flex items-center justify-center backdrop-blur-[1px] cursor-pointer transition-opacity duration-200 ${isCurrent ? 'opacity-100' : 'opacity-0 group-hover/image:opacity-100'}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPlay();
|
||||
}}
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-white flex items-center justify-center shadow-lg transform transition-transform hover:scale-105">
|
||||
{isCurrent && isPlaying ? (
|
||||
<Pause fill="black" className="text-black w-5 h-5" />
|
||||
) : (
|
||||
<Play fill="black" className="text-black ml-1 w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 flex flex-col justify-between py-1">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className={`font-bold text-lg truncate ${isCurrent ? 'text-pink-600 dark:text-pink-500' : 'text-zinc-900 dark:text-white'}`}>
|
||||
{song.title || (song.isGenerating ? (song.queuePosition ? "Queued..." : "Creating...") : "Untitled")}
|
||||
</h3>
|
||||
<span className="inline-flex items-center justify-center text-[9px] font-bold text-white bg-gradient-to-r from-pink-500 to-purple-500 px-1.5 py-0.5 rounded-sm shadow-sm">
|
||||
v1.5
|
||||
</span>
|
||||
{song.isPublic === false && (
|
||||
<Lock size={12} className="text-zinc-400 dark:text-zinc-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex items-center gap-1.5 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (song.creator && onNavigateToProfile) {
|
||||
onNavigateToProfile(song.creator);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="w-4 h-4 rounded-full bg-purple-500 text-[8px] flex items-center justify-center font-bold text-white">
|
||||
{(song.creator?.[0] || 'U').toUpperCase()}
|
||||
</div>
|
||||
<span className="text-xs font-medium text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 transition-colors hover:underline">
|
||||
{song.creator || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-500 line-clamp-2 pt-1 font-medium max-w-2xl">
|
||||
{song.style}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions Row - Hidden while generating */}
|
||||
{!song.isGenerating && (
|
||||
<div className="flex items-center gap-1 pt-2">
|
||||
<button
|
||||
className={`flex items-center gap-1 px-3 py-1.5 rounded-full hover:bg-white/5 transition-colors ${isLiked ? 'text-pink-600 dark:text-pink-500 bg-pink-100 dark:bg-pink-500/10' : 'text-zinc-400 hover:text-black dark:hover:text-white'}`}
|
||||
onClick={(e) => { e.stopPropagation(); onToggleLike(); }}
|
||||
>
|
||||
<ThumbsUp size={16} fill={isLiked ? "currentColor" : "none"} />
|
||||
{(song.likeCount || 0) > 0 && (
|
||||
<span className="text-xs font-bold">{song.likeCount}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="p-2 rounded-full hover:bg-zinc-200 dark:hover:bg-white/5 text-zinc-400 hover:text-black dark:hover:text-white transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); }}
|
||||
>
|
||||
<ThumbsDown size={16} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="p-2 rounded-full hover:bg-zinc-200 dark:hover:bg-white/5 text-zinc-400 hover:text-black dark:hover:text-white transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); setShareModalOpen(true); }}
|
||||
title="Share"
|
||||
>
|
||||
<Share2 size={16} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="p-2 rounded-full hover:bg-zinc-200 dark:hover:bg-white/5 text-zinc-400 hover:text-black dark:hover:text-white transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); if (onOpenVideo) onOpenVideo(); }}
|
||||
title="Create Video"
|
||||
>
|
||||
<Video size={16} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="p-2 rounded-full hover:bg-zinc-200 dark:hover:bg-white/5 text-zinc-400 hover:text-black dark:hover:text-white transition-colors ml-auto"
|
||||
onClick={(e) => { e.stopPropagation(); onAddToPlaylist(); }}
|
||||
title="Add to Playlist"
|
||||
>
|
||||
<ListPlus size={16} />
|
||||
</button>
|
||||
|
||||
{/* Info Button - Visible only on small/medium screens where sidebar is hidden */}
|
||||
<button
|
||||
className="p-2 rounded-full hover:bg-zinc-200 dark:hover:bg-white/5 text-zinc-400 hover:text-black dark:hover:text-white transition-colors xl:hidden"
|
||||
onClick={(e) => { e.stopPropagation(); if (onShowDetails) onShowDetails(); }}
|
||||
title="Song Details"
|
||||
>
|
||||
<Info size={16} />
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
className="p-2 rounded-full hover:bg-zinc-200 dark:hover:bg-white/5 text-zinc-400 hover:text-black dark:hover:text-white transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowDropdown(!showDropdown);
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
<SongDropdownMenu
|
||||
song={song}
|
||||
isOpen={showDropdown}
|
||||
onClose={() => setShowDropdown(false)}
|
||||
isOwner={isOwner}
|
||||
onCreateVideo={() => onOpenVideo?.(song)}
|
||||
onReusePrompt={() => onReusePrompt?.(song)}
|
||||
onAddToPlaylist={() => onAddToPlaylist?.(song)}
|
||||
onDelete={() => onDelete?.(song)}
|
||||
onShare={() => setShareModalOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<div className="text-xs font-mono text-zinc-500 dark:text-zinc-600 self-start pt-1">
|
||||
{song.isGenerating ? (
|
||||
<span className={song.queuePosition ? 'text-amber-500' : 'text-pink-500'}>
|
||||
{song.queuePosition ? `#${song.queuePosition}` : 'Creating...'}
|
||||
</span>
|
||||
) : song.duration}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ShareModal
|
||||
isOpen={shareModalOpen}
|
||||
onClose={() => setShareModalOpen(false)}
|
||||
song={song}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,312 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Song } from '../types';
|
||||
import { songsApi, getAudioUrl } from '../services/api';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { ArrowLeft, Play, Pause, Heart, Share2, MoreHorizontal, ThumbsDown, Music as MusicIcon, Edit3, Eye } from 'lucide-react';
|
||||
import { ShareModal } from './ShareModal';
|
||||
|
||||
interface SongProfileProps {
|
||||
songId: string;
|
||||
onBack: () => void;
|
||||
onPlay: (song: Song, list?: Song[]) => void;
|
||||
onNavigateToProfile: (username: string) => void;
|
||||
currentSong?: Song | null;
|
||||
isPlaying?: boolean;
|
||||
likedSongIds?: Set<string>;
|
||||
onToggleLike?: (songId: string) => void;
|
||||
}
|
||||
|
||||
const updateMetaTags = (song: Song) => {
|
||||
const baseUrl = window.location.origin;
|
||||
const songUrl = `${baseUrl}/song/${song.id}`;
|
||||
const title = `${song.title} by ${song.creator || 'Unknown Artist'} | ACE-Step UI`;
|
||||
const description = `Listen to "${song.title}" - ${song.style}. ${song.viewCount || 0} plays, ${song.likeCount || 0} likes. Create your own AI music with ACE-Step UI.`;
|
||||
|
||||
document.title = title;
|
||||
|
||||
const updateOrCreateMeta = (selector: string, attribute: string, value: string) => {
|
||||
let element = document.querySelector(selector) as HTMLMetaElement;
|
||||
if (!element) {
|
||||
element = document.createElement('meta');
|
||||
const [attr, attrValue] = selector.replace(/[\[\]'"]/g, '').split('=');
|
||||
if (attr === 'property') element.setAttribute('property', attrValue);
|
||||
else if (attr === 'name') element.setAttribute('name', attrValue);
|
||||
document.head.appendChild(element);
|
||||
}
|
||||
element.setAttribute(attribute, value);
|
||||
};
|
||||
|
||||
updateOrCreateMeta('meta[name="description"]', 'content', description);
|
||||
updateOrCreateMeta('meta[name="title"]', 'content', title);
|
||||
|
||||
updateOrCreateMeta('meta[property="og:type"]', 'content', 'music.song');
|
||||
updateOrCreateMeta('meta[property="og:url"]', 'content', songUrl);
|
||||
updateOrCreateMeta('meta[property="og:title"]', 'content', title);
|
||||
updateOrCreateMeta('meta[property="og:description"]', 'content', description);
|
||||
updateOrCreateMeta('meta[property="og:image"]', 'content', song.coverUrl);
|
||||
updateOrCreateMeta('meta[property="og:image:width"]', 'content', '400');
|
||||
updateOrCreateMeta('meta[property="og:image:height"]', 'content', '400');
|
||||
updateOrCreateMeta('meta[property="og:audio"]', 'content', song.audioUrl || '');
|
||||
updateOrCreateMeta('meta[property="og:audio:type"]', 'content', 'audio/mpeg');
|
||||
|
||||
updateOrCreateMeta('meta[name="twitter:card"]', 'content', 'summary_large_image');
|
||||
updateOrCreateMeta('meta[name="twitter:url"]', 'content', songUrl);
|
||||
updateOrCreateMeta('meta[name="twitter:title"]', 'content', title);
|
||||
updateOrCreateMeta('meta[name="twitter:description"]', 'content', description);
|
||||
updateOrCreateMeta('meta[name="twitter:image"]', 'content', song.coverUrl);
|
||||
|
||||
updateOrCreateMeta('meta[property="music:duration"]', 'content', String(song.duration || 0));
|
||||
updateOrCreateMeta('meta[property="music:musician"]', 'content', song.creator || 'Unknown Artist');
|
||||
};
|
||||
|
||||
const resetMetaTags = () => {
|
||||
document.title = 'ACE-Step UI - Local AI Music Generator';
|
||||
const defaultDescription = 'Create original music with AI locally. Generate songs in any style with custom lyrics and professional quality using ACE-Step.';
|
||||
const defaultImage = '/og-image.png';
|
||||
|
||||
const updateMeta = (selector: string, content: string) => {
|
||||
const element = document.querySelector(selector) as HTMLMetaElement;
|
||||
if (element) element.setAttribute('content', content);
|
||||
};
|
||||
|
||||
updateMeta('meta[name="description"]', defaultDescription);
|
||||
updateMeta('meta[property="og:title"]', 'ACE-Step UI - Local AI Music Generator');
|
||||
updateMeta('meta[property="og:description"]', defaultDescription);
|
||||
updateMeta('meta[property="og:image"]', defaultImage);
|
||||
updateMeta('meta[property="og:type"]', 'website');
|
||||
updateMeta('meta[name="twitter:title"]', 'ACE-Step UI - Local AI Music Generator');
|
||||
updateMeta('meta[name="twitter:description"]', defaultDescription);
|
||||
updateMeta('meta[name="twitter:image"]', defaultImage);
|
||||
};
|
||||
|
||||
export const SongProfile: React.FC<SongProfileProps> = ({ songId, onBack, onPlay, onNavigateToProfile, currentSong, isPlaying, likedSongIds = new Set(), onToggleLike }) => {
|
||||
const { user, token } = useAuth();
|
||||
const [song, setSong] = useState<Song | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
|
||||
const isCurrentSong = song && currentSong?.id === song.id;
|
||||
const isCurrentlyPlaying = isCurrentSong && isPlaying;
|
||||
const isLiked = song ? likedSongIds.has(song.id) : false;
|
||||
|
||||
useEffect(() => {
|
||||
loadSongData();
|
||||
return () => resetMetaTags();
|
||||
}, [songId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (song) {
|
||||
updateMetaTags(song);
|
||||
}
|
||||
}, [song]);
|
||||
|
||||
const loadSongData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await songsApi.getFullSong(songId, token);
|
||||
|
||||
const transformedSong: Song = {
|
||||
id: response.song.id,
|
||||
title: response.song.title,
|
||||
lyrics: response.song.lyrics,
|
||||
style: response.song.style,
|
||||
coverUrl: `https://picsum.photos/seed/${response.song.id}/400/400`,
|
||||
duration: response.song.duration
|
||||
? `${Math.floor(response.song.duration / 60)}:${String(Math.floor(response.song.duration % 60)).padStart(2, '0')}`
|
||||
: '0:00',
|
||||
createdAt: new Date(response.song.created_at),
|
||||
tags: response.song.tags || [],
|
||||
audioUrl: getAudioUrl(response.song.audio_url, response.song.id),
|
||||
isPublic: response.song.is_public,
|
||||
likeCount: response.song.like_count || 0,
|
||||
viewCount: response.song.view_count || 0,
|
||||
userId: response.song.user_id,
|
||||
creator: response.song.creator,
|
||||
creator_avatar: response.song.creator_avatar,
|
||||
};
|
||||
|
||||
setSong(transformedSong);
|
||||
} catch (error) {
|
||||
console.error('Failed to load song:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full bg-zinc-50 dark:bg-black">
|
||||
<div className="text-zinc-500 dark:text-zinc-400 flex items-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-zinc-400 border-t-transparent rounded-full animate-spin" />
|
||||
Loading song...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!song) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 bg-zinc-50 dark:bg-black">
|
||||
<div className="text-zinc-500 dark:text-zinc-400">Song not found</div>
|
||||
<button onClick={onBack} className="px-4 py-2 bg-zinc-200 dark:bg-zinc-800 hover:bg-zinc-300 dark:hover:bg-zinc-700 rounded-lg text-zinc-900 dark:text-white transition-colors">
|
||||
Go Back
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col bg-zinc-50 dark:bg-black overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="border-b border-zinc-200 dark:border-zinc-800 px-4 md:px-6 py-4 flex-shrink-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white mb-4 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-zinc-900 dark:text-white mb-2">{song.title}</h1>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div
|
||||
onClick={() => song.creator && onNavigateToProfile(song.creator)}
|
||||
className="flex items-center gap-2 cursor-pointer hover:underline"
|
||||
>
|
||||
<div className="w-6 h-6 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-xs font-bold text-white overflow-hidden">
|
||||
{song.creator_avatar ? (
|
||||
<img src={song.creator_avatar} alt={song.creator || 'Creator'} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
song.creator ? song.creator[0].toUpperCase() : 'A'
|
||||
)}
|
||||
</div>
|
||||
<span className="text-zinc-900 dark:text-white font-semibold">{song.creator || 'Anonymous'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{song.style.split(',').slice(0, 4).map((tag, i) => (
|
||||
<span key={i} className="px-2 py-1 bg-zinc-200 dark:bg-zinc-800 rounded text-xs text-zinc-600 dark:text-zinc-300">
|
||||
{tag.trim()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-zinc-500">
|
||||
{new Date(song.createdAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })} at {new Date(song.createdAt).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
|
||||
{!song.isPublic && song.userId === user?.id && (
|
||||
<span className="ml-2 px-2 py-0.5 bg-zinc-200 dark:bg-zinc-800 rounded text-xs text-zinc-600 dark:text-zinc-400">Private</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Related Songs Tab - Hidden on mobile */}
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<button className="px-4 py-2 bg-zinc-900 dark:bg-white text-white dark:text-black rounded-full text-sm font-semibold">
|
||||
Similar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => song.creator && onNavigateToProfile(song.creator)}
|
||||
className="px-4 py-2 text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white text-sm font-semibold transition-colors"
|
||||
>
|
||||
By {song.creator || 'Artist'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto px-4 md:px-6 py-4 md:py-6">
|
||||
|
||||
{/* Left Column: Song Details */}
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
{/* Cover Art */}
|
||||
<div className="relative aspect-square max-w-xs md:max-w-sm mx-auto lg:mx-0 rounded-xl overflow-hidden shadow-2xl">
|
||||
<img src={song.coverUrl} alt={song.title} className={`w-full h-full object-cover transition-transform duration-500 ${isCurrentlyPlaying ? 'scale-105' : ''}`} />
|
||||
<button
|
||||
onClick={() => onPlay(song)}
|
||||
className={`absolute inset-0 transition-colors flex items-center justify-center group ${isCurrentSong ? 'bg-black/50' : 'bg-black/40 hover:bg-black/50'}`}
|
||||
>
|
||||
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full bg-white group-hover:scale-110 transition-transform flex items-center justify-center shadow-xl">
|
||||
{isCurrentlyPlaying ? (
|
||||
<Pause size={28} className="text-black fill-black md:w-8 md:h-8" />
|
||||
) : (
|
||||
<Play size={28} className="text-black fill-black ml-1 md:w-8 md:h-8" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{isCurrentlyPlaying && (
|
||||
<div className="absolute bottom-4 left-4 flex items-center gap-1">
|
||||
<span className="w-1.5 h-4 bg-pink-500 rounded-full animate-pulse" style={{ animationDelay: '0ms' }} />
|
||||
<span className="w-1.5 h-6 bg-pink-500 rounded-full animate-pulse" style={{ animationDelay: '150ms' }} />
|
||||
<span className="w-1.5 h-3 bg-pink-500 rounded-full animate-pulse" style={{ animationDelay: '300ms' }} />
|
||||
<span className="w-1.5 h-7 bg-pink-500 rounded-full animate-pulse" style={{ animationDelay: '450ms' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-center lg:justify-start gap-2 md:gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 bg-zinc-200 dark:bg-zinc-900 px-3 py-2 rounded-full text-sm">
|
||||
<Eye size={16} className="text-zinc-600 dark:text-white" />
|
||||
<span className="text-zinc-900 dark:text-white font-semibold">{song.viewCount || 0}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onToggleLike?.(song.id)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-full text-sm transition-colors ${isLiked ? 'bg-pink-500 text-white' : 'bg-zinc-200 dark:bg-zinc-900 hover:bg-zinc-300 dark:hover:bg-zinc-800 text-zinc-900 dark:text-white'}`}
|
||||
>
|
||||
<Heart size={16} className={isLiked ? 'fill-current' : ''} />
|
||||
<span className="font-semibold">{song.likeCount || 0}</span>
|
||||
</button>
|
||||
{user?.id === song.userId && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!song.audioUrl) return;
|
||||
const audioUrl = song.audioUrl.startsWith('http') ? song.audioUrl : `${window.location.origin}${song.audioUrl}`;
|
||||
window.open(`/editor?audioUrl=${encodeURIComponent(audioUrl)}`, '_blank');
|
||||
}}
|
||||
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-700 px-3 py-2 rounded-full text-sm font-semibold transition-colors text-white"
|
||||
>
|
||||
<Edit3 size={16} />
|
||||
<span className="hidden md:inline">Edit</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShareModalOpen(true)}
|
||||
className="p-2 bg-zinc-200 dark:bg-zinc-900 hover:bg-zinc-300 dark:hover:bg-zinc-800 rounded-full transition-colors"
|
||||
>
|
||||
<Share2 size={16} className="text-zinc-700 dark:text-white" />
|
||||
</button>
|
||||
<button className="p-2 bg-zinc-200 dark:bg-zinc-900 hover:bg-zinc-300 dark:hover:bg-zinc-800 rounded-full transition-colors">
|
||||
<MoreHorizontal size={16} className="text-zinc-700 dark:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Lyrics */}
|
||||
{song.lyrics && (
|
||||
<div className="bg-white dark:bg-zinc-900/50 border border-zinc-200 dark:border-zinc-800 rounded-xl p-4">
|
||||
<h3 className="text-sm font-semibold text-zinc-900 dark:text-white mb-3">Lyrics</h3>
|
||||
<div className="text-sm text-zinc-700 dark:text-zinc-300 whitespace-pre-line leading-relaxed max-h-72 md:max-h-96 overflow-y-auto">
|
||||
{song.lyrics}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{song && (
|
||||
<ShareModal
|
||||
isOpen={shareModalOpen}
|
||||
onClose={() => setShareModalOpen(false)}
|
||||
song={song}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { CheckCircle, AlertCircle, X } from 'lucide-react';
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'info';
|
||||
|
||||
interface ToastProps {
|
||||
message: string;
|
||||
type?: ToastType;
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export const Toast: React.FC<ToastProps> = ({
|
||||
message,
|
||||
type = 'success',
|
||||
isVisible,
|
||||
onClose,
|
||||
duration = 3000
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
if (isVisible && duration > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
onClose();
|
||||
}, duration);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isVisible, duration, onClose]);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
const bgColors = {
|
||||
success: 'bg-zinc-900 border-green-500/50 text-white',
|
||||
error: 'bg-zinc-900 border-red-500/50 text-white',
|
||||
info: 'bg-zinc-900 border-blue-500/50 text-white',
|
||||
};
|
||||
|
||||
const icons = {
|
||||
success: <CheckCircle className="text-green-500" size={20} />,
|
||||
error: <AlertCircle className="text-red-500" size={20} />,
|
||||
info: <AlertCircle className="text-blue-500" size={20} />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`fixed top-6 left-1/2 -translate-x-1/2 z-[100] flex items-center gap-3 px-6 py-4 rounded-full shadow-2xl border ${bgColors[type]} animate-in slide-in-from-top-4 fade-in duration-300`}>
|
||||
{icons[type]}
|
||||
<span className="font-medium text-sm">{message}</span>
|
||||
<button onClick={onClose} className="ml-2 hover:opacity-70">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,726 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Song, Playlist } from '../types';
|
||||
import { usersApi, getAudioUrl, UserProfile as UserProfileType, songsApi } from '../services/api';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { ArrowLeft, Play, Pause, Heart, Eye, Users, Music as MusicIcon, ChevronRight, Share2, MoreHorizontal, Edit3, X, Camera, Image as ImageIcon, Upload, Loader2 } from 'lucide-react';
|
||||
|
||||
interface UserProfileProps {
|
||||
username: string;
|
||||
onBack: () => void;
|
||||
onPlaySong: (song: Song, list?: Song[]) => void;
|
||||
onNavigateToProfile: (username: string) => void;
|
||||
onNavigateToPlaylist?: (playlistId: string) => void;
|
||||
currentSong?: Song | null;
|
||||
isPlaying?: boolean;
|
||||
likedSongIds?: Set<string>;
|
||||
onToggleLike?: (songId: string) => void;
|
||||
}
|
||||
|
||||
export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPlaySong, onNavigateToProfile, onNavigateToPlaylist, currentSong, isPlaying, likedSongIds = new Set(), onToggleLike }) => {
|
||||
const { user: currentUser, token } = useAuth();
|
||||
const [profileUser, setProfileUser] = useState<UserProfileType | null>(null);
|
||||
const [publicSongs, setPublicSongs] = useState<Song[]>([]);
|
||||
const [publicPlaylists, setPublicPlaylists] = useState<Playlist[]>([]);
|
||||
const [songsTab, setSongsTab] = useState<'recent' | 'top'>('recent');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Edit State
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [editBio, setEditBio] = useState('');
|
||||
const [editAvatarUrl, setEditAvatarUrl] = useState('');
|
||||
const [editBannerUrl, setEditBannerUrl] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [bannerFile, setBannerFile] = useState<File | null>(null);
|
||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||
const [bannerPreview, setBannerPreview] = useState<string | null>(null);
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
const [uploadingBanner, setUploadingBanner] = useState(false);
|
||||
const [avatarFailed, setAvatarFailed] = useState(false);
|
||||
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||||
const bannerInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadUserProfile();
|
||||
}, [username]);
|
||||
|
||||
const loadUserProfile = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [profileRes, songsRes, playlistsRes] = await Promise.all([
|
||||
usersApi.getProfile(username, token),
|
||||
usersApi.getPublicSongs(username),
|
||||
usersApi.getPublicPlaylists(username)
|
||||
]);
|
||||
|
||||
setProfileUser(profileRes.user);
|
||||
setEditBio(profileRes.user.bio || '');
|
||||
setEditAvatarUrl(profileRes.user.avatar_url || '');
|
||||
setEditBannerUrl(profileRes.user.banner_url || '');
|
||||
|
||||
const transformedSongs: Song[] = songsRes.songs.map(s => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
lyrics: s.lyrics,
|
||||
style: s.style,
|
||||
coverUrl: `https://picsum.photos/seed/${s.id}/400/400`,
|
||||
duration: s.duration ? `${Math.floor(s.duration / 60)}:${String(Math.floor(s.duration % 60)).padStart(2, '0')}` : '0:00',
|
||||
createdAt: new Date(s.created_at),
|
||||
tags: s.tags || [],
|
||||
audioUrl: getAudioUrl(s.audio_url, s.id),
|
||||
isPublic: true,
|
||||
likeCount: s.like_count || 0,
|
||||
viewCount: s.view_count || 0,
|
||||
creator: s.creator,
|
||||
}));
|
||||
setPublicSongs(transformedSongs);
|
||||
setPublicPlaylists(playlistsRes.playlists || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load user profile:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setAvatarFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => setAvatarPreview(ev.target?.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBannerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setBannerFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => setBannerPreview(ev.target?.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
if (!token) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// Upload avatar if changed
|
||||
if (avatarFile) {
|
||||
setUploadingAvatar(true);
|
||||
const avatarRes = await usersApi.uploadAvatar(avatarFile, token);
|
||||
setEditAvatarUrl(avatarRes.url);
|
||||
setUploadingAvatar(false);
|
||||
}
|
||||
|
||||
// Upload banner if changed
|
||||
if (bannerFile) {
|
||||
setUploadingBanner(true);
|
||||
const bannerRes = await usersApi.uploadBanner(bannerFile, token);
|
||||
setEditBannerUrl(bannerRes.url);
|
||||
setUploadingBanner(false);
|
||||
}
|
||||
|
||||
// Update bio (and any URL-based avatar/banner if not using file upload)
|
||||
const updates: Record<string, string> = { bio: editBio };
|
||||
if (!avatarFile && editAvatarUrl !== profileUser.avatar_url) {
|
||||
updates.avatarUrl = editAvatarUrl;
|
||||
}
|
||||
if (!bannerFile && editBannerUrl !== profileUser.banner_url) {
|
||||
updates.bannerUrl = editBannerUrl;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await usersApi.updateProfile(updates, token);
|
||||
}
|
||||
|
||||
setIsEditModalOpen(false);
|
||||
setAvatarFile(null);
|
||||
setBannerFile(null);
|
||||
setAvatarPreview(null);
|
||||
setBannerPreview(null);
|
||||
|
||||
// Reload to get fresh data
|
||||
loadUserProfile();
|
||||
} catch (error) {
|
||||
console.error('Failed to update profile:', error);
|
||||
alert('Failed to update profile');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setUploadingAvatar(false);
|
||||
setUploadingBanner(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full bg-zinc-50 dark:bg-black">
|
||||
<div className="text-zinc-500 dark:text-zinc-400 gap-2 flex items-center">
|
||||
<div className="w-4 h-4 border-2 border-zinc-400 border-t-transparent rounded-full animate-spin"></div>
|
||||
Loading profile...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!profileUser) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 bg-zinc-50 dark:bg-black">
|
||||
<div className="text-zinc-500 dark:text-zinc-400">User not found</div>
|
||||
<button onClick={onBack} className="px-4 py-2 bg-zinc-200 dark:bg-zinc-800 hover:bg-zinc-300 dark:hover:bg-zinc-700 rounded-lg text-zinc-900 dark:text-white">
|
||||
Go Back
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalLikes = publicSongs.reduce((sum, song) => sum + (song.likeCount || 0), 0);
|
||||
const totalPlays = publicSongs.reduce((sum, song) => sum + (song.viewCount || 0), 0);
|
||||
const isOwner = currentUser?.id === profileUser.id;
|
||||
|
||||
// Generate random gradient for banner fallback
|
||||
const gradients = [
|
||||
'from-purple-600 via-pink-600 to-red-600',
|
||||
'from-blue-600 via-purple-600 to-pink-600',
|
||||
'from-green-600 via-teal-600 to-blue-600',
|
||||
'from-orange-600 via-red-600 to-pink-600',
|
||||
'from-indigo-600 via-purple-600 to-pink-600',
|
||||
];
|
||||
const bannerGradient = gradients[username.length % gradients.length];
|
||||
const primaryBadge = profileUser.badges?.[0];
|
||||
const badgeRing = primaryBadge?.color === 'yellow'
|
||||
? 'ring-yellow-400/80 shadow-yellow-500/30'
|
||||
: primaryBadge?.color === 'purple'
|
||||
? 'ring-purple-400/80 shadow-purple-500/30'
|
||||
: primaryBadge?.color === 'blue'
|
||||
? 'ring-blue-400/80 shadow-blue-500/30'
|
||||
: primaryBadge?.color === 'teal'
|
||||
? 'ring-teal-400/80 shadow-teal-500/30'
|
||||
: primaryBadge?.color === 'green'
|
||||
? 'ring-green-400/80 shadow-green-500/30'
|
||||
: primaryBadge?.color === 'orange'
|
||||
? 'ring-orange-400/80 shadow-orange-500/30'
|
||||
: primaryBadge?.color === 'pink'
|
||||
? 'ring-pink-400/80 shadow-pink-500/30'
|
||||
: 'ring-zinc-500/50 shadow-zinc-500/20';
|
||||
const paidPulse = profileUser.accountTier && profileUser.accountTier !== 'free'
|
||||
? 'group-hover/avatar:animate-[wiggle_0.6s_ease-in-out] group-hover/avatar:rotate-1'
|
||||
: '';
|
||||
const paidNameStyle = primaryBadge?.color === 'yellow'
|
||||
? 'bg-gradient-to-r from-yellow-300 via-amber-300 to-orange-400 text-transparent bg-clip-text drop-shadow-[0_2px_12px_rgba(251,191,36,0.45)]'
|
||||
: primaryBadge?.color === 'purple'
|
||||
? 'bg-gradient-to-r from-fuchsia-400 via-purple-500 to-indigo-400 text-transparent bg-clip-text drop-shadow-[0_2px_12px_rgba(168,85,247,0.45)]'
|
||||
: primaryBadge?.color === 'blue'
|
||||
? 'bg-gradient-to-r from-sky-400 via-blue-500 to-indigo-400 text-transparent bg-clip-text drop-shadow-[0_2px_12px_rgba(59,130,246,0.45)]'
|
||||
: primaryBadge?.color === 'teal'
|
||||
? 'bg-gradient-to-r from-teal-300 via-emerald-400 to-cyan-400 text-transparent bg-clip-text drop-shadow-[0_2px_12px_rgba(45,212,191,0.45)]'
|
||||
: primaryBadge?.color === 'orange'
|
||||
? 'bg-gradient-to-r from-orange-300 via-amber-400 to-yellow-300 text-transparent bg-clip-text drop-shadow-[0_2px_12px_rgba(251,146,60,0.4)]'
|
||||
: primaryBadge?.color === 'pink'
|
||||
? 'bg-gradient-to-r from-pink-400 via-rose-500 to-fuchsia-500 text-transparent bg-clip-text drop-shadow-[0_2px_12px_rgba(244,114,182,0.45)]'
|
||||
: '';
|
||||
|
||||
// Banner Style
|
||||
const bannerStyle = profileUser.banner_url
|
||||
? { backgroundImage: `url(${profileUser.banner_url})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
||||
: {};
|
||||
const bannerClass = profileUser.banner_url
|
||||
? `h-48 md:h-64 relative overflow-hidden bg-zinc-200 dark:bg-zinc-900`
|
||||
: `h-48 md:h-64 bg-gradient-to-r ${bannerGradient} relative overflow-hidden`;
|
||||
|
||||
const featuredSongs = publicSongs.slice(0, 6);
|
||||
const displaySongs = songsTab === 'recent' ? publicSongs : [...publicSongs].sort((a, b) => (b.likeCount || 0) - (a.likeCount || 0));
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col bg-zinc-50 dark:bg-black overflow-y-auto relative">
|
||||
{/* Hero Banner */}
|
||||
<div className="relative group/banner">
|
||||
{/* Background Banner */}
|
||||
<div className={bannerClass} style={bannerStyle}>
|
||||
{!profileUser.banner_url && (
|
||||
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZGVmcz48cGF0dGVybiBpZD0iZ3JpZCIgd2lkdGg9IjQwIiBoZWlnaHQ9IjQwIiBwYXR0ZXJuVW5pdHM9InVzZXJTcGFjZU9uVXNlIj48cGF0aCBkPSJNIDQwIDAgTCAwIDAgMCA0MCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLW9wYWNpdHk9IjAuMSIgc3Ryb2tlLXdpZHRoPSIxIi8+PC9wYXR0ZXJuPjwvZGVmcz48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSJ1cmwoI2dyaWQpIi8+PC9zdmc+')] opacity-30"></div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-zinc-50 dark:from-black via-zinc-50/20 dark:via-black/20 to-transparent"></div>
|
||||
</div>
|
||||
|
||||
{/* Back Button */}
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="absolute top-4 left-4 flex items-center gap-2 text-white/80 hover:text-white bg-black/30 hover:bg-black/50 px-4 py-2 rounded-full backdrop-blur-sm transition-all z-20"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
|
||||
{/* Edit Banner Button (Owner Only) - Visual Cue */}
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={() => setIsEditModalOpen(true)}
|
||||
className="absolute top-4 right-4 bg-black/50 hover:bg-black/70 text-white p-2 rounded-full opacity-0 group-hover/banner:opacity-100 transition-opacity"
|
||||
title="Edit Banner" // Accessibility
|
||||
>
|
||||
<ImageIcon size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Profile Info */}
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 -mt-16 md:-mt-20 relative z-10 w-full">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-end gap-4 md:gap-6">
|
||||
{/* Avatar */}
|
||||
<div className="group/avatar relative">
|
||||
<div className={`w-24 h-24 md:w-40 md:h-40 rounded-full border-4 border-zinc-50 dark:border-black bg-zinc-200 dark:bg-zinc-800 flex items-center justify-center overflow-hidden shadow-2xl ring-4 ${badgeRing} transition-transform ${paidPulse}`}>
|
||||
{profileUser.avatar_url && !avatarFailed ? (
|
||||
<img
|
||||
src={profileUser.avatar_url}
|
||||
alt={profileUser.username}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => setAvatarFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className={`w-full h-full bg-gradient-to-br ${bannerGradient} flex items-center justify-center text-4xl md:text-6xl font-bold text-white`}>
|
||||
{profileUser.username[0].toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={() => setIsEditModalOpen(true)}
|
||||
className="absolute bottom-1 right-1 md:bottom-2 md:right-2 p-1.5 md:p-2 bg-zinc-200 dark:bg-zinc-800 rounded-full text-zinc-700 dark:text-white hover:bg-zinc-300 dark:hover:bg-zinc-700 border border-zinc-50 dark:border-black shadow-lg"
|
||||
>
|
||||
<Edit3 size={14} className="md:w-4 md:h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Info */}
|
||||
<div className="flex-1 pb-2 w-full">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className={`text-2xl md:text-5xl font-bold mb-1 ${paidNameStyle || 'text-zinc-900 dark:text-white'}`}>
|
||||
{profileUser.username}
|
||||
</h1>
|
||||
{profileUser.badges && profileUser.badges.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{profileUser.badges.map((badge) => {
|
||||
const style =
|
||||
badge.color === 'yellow'
|
||||
? 'from-yellow-300 via-amber-300 to-orange-400 text-amber-950 shadow-[0_0_20px_rgba(251,191,36,0.45)]'
|
||||
: badge.color === 'purple'
|
||||
? 'from-fuchsia-400 via-purple-500 to-indigo-500 text-white shadow-[0_0_20px_rgba(168,85,247,0.45)]'
|
||||
: badge.color === 'blue'
|
||||
? 'from-sky-400 via-blue-500 to-indigo-500 text-white shadow-[0_0_18px_rgba(59,130,246,0.45)]'
|
||||
: badge.color === 'teal'
|
||||
? 'from-teal-300 via-emerald-400 to-cyan-400 text-emerald-950 shadow-[0_0_18px_rgba(45,212,191,0.45)]'
|
||||
: badge.color === 'orange'
|
||||
? 'from-orange-300 via-amber-400 to-yellow-300 text-amber-950 shadow-[0_0_16px_rgba(251,146,60,0.4)]'
|
||||
: badge.color === 'pink'
|
||||
? 'from-pink-400 via-rose-500 to-fuchsia-500 text-white shadow-[0_0_18px_rgba(244,114,182,0.45)]'
|
||||
: badge.color === 'green'
|
||||
? 'from-emerald-300 via-green-400 to-lime-300 text-emerald-950 shadow-[0_0_16px_rgba(34,197,94,0.4)]'
|
||||
: 'from-zinc-200 via-zinc-300 to-zinc-200 text-zinc-700 dark:from-zinc-700 dark:via-zinc-600 dark:to-zinc-700 dark:text-zinc-100';
|
||||
|
||||
const icon =
|
||||
badge.id === 'supporter'
|
||||
? '🏅'
|
||||
: badge.id === 'patron'
|
||||
? '🏆'
|
||||
: badge.id === 'legendary'
|
||||
? '👑'
|
||||
: badge.id === 'diamond'
|
||||
? '💎'
|
||||
: badge.id === 'crown'
|
||||
? '👑'
|
||||
: badge.id === 'champion'
|
||||
? '🥇'
|
||||
: badge.id === 'music'
|
||||
? '🎵'
|
||||
: badge.id === 'coffee'
|
||||
? '☕'
|
||||
: '⭐';
|
||||
|
||||
return (
|
||||
<span
|
||||
key={badge.id}
|
||||
title={badge.description}
|
||||
className={`inline-flex items-center gap-2 px-3.5 py-1.5 rounded-full text-xs font-semibold border border-white/40 dark:border-white/10 bg-gradient-to-r ${style} transition-all duration-200 hover:-translate-y-0.5 hover:scale-[1.03] hover:brightness-110`}
|
||||
>
|
||||
<span className="text-sm drop-shadow">{icon}</span>
|
||||
{badge.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profileUser.supporter_since && profileUser.accountTier && profileUser.accountTier !== 'free' && (
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 mb-3">
|
||||
Supporting since {new Date(profileUser.supporter_since).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Bio */}
|
||||
{profileUser.bio && (
|
||||
<p className="text-zinc-700 dark:text-zinc-200 max-w-2xl mb-4 text-sm md:text-base leading-relaxed whitespace-pre-line">
|
||||
{profileUser.bio}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-zinc-500 text-xs md:text-sm mb-4">
|
||||
Joined {new Date(profileUser.created_at).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Edit Profile Button (Mobile/Desktop) */}
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={() => setIsEditModalOpen(true)}
|
||||
className="px-4 md:px-6 py-2 bg-zinc-900 dark:bg-white text-white dark:text-black hover:bg-zinc-800 dark:hover:bg-zinc-200 rounded-full font-bold transition-colors text-sm flex items-center gap-2"
|
||||
>
|
||||
<Edit3 size={16} />
|
||||
Edit Profile
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4 md:gap-6 text-sm pt-2 border-t border-zinc-200 dark:border-white/10 mt-2 flex-wrap">
|
||||
<div className="flex items-center gap-1.5 md:gap-2">
|
||||
<MusicIcon size={16} className="text-zinc-500 dark:text-zinc-400" />
|
||||
<span className="font-semibold text-zinc-900 dark:text-white">{publicSongs.length}</span>
|
||||
<span className="text-zinc-500 dark:text-zinc-400">Songs</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 md:gap-2">
|
||||
<Heart size={16} className="text-zinc-500 dark:text-zinc-400" />
|
||||
<span className="font-semibold text-zinc-900 dark:text-white">{totalLikes}</span>
|
||||
<span className="text-zinc-500 dark:text-zinc-400">Likes</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 md:gap-2">
|
||||
<Eye size={16} className="text-zinc-500 dark:text-zinc-400" />
|
||||
<span className="font-semibold text-zinc-900 dark:text-white">{totalPlays}</span>
|
||||
<span className="text-zinc-500 dark:text-zinc-400">Plays</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="max-w-7xl mx-auto w-full px-4 md:px-8 py-6 md:py-8 space-y-8 md:space-y-12">
|
||||
{/* Featured Songs */}
|
||||
{featuredSongs.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-xl md:text-2xl font-bold text-zinc-900 dark:text-white mb-4 md:mb-6">Featured Songs</h2>
|
||||
<div className="flex gap-3 md:gap-4 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-zinc-300 dark:scrollbar-thumb-zinc-700 scrollbar-track-transparent -mx-4 px-4 md:mx-0 md:px-0">
|
||||
{featuredSongs.map((song) => {
|
||||
const isCurrentSong = currentSong?.id === song.id;
|
||||
const isCurrentlyPlaying = isCurrentSong && isPlaying;
|
||||
const isLiked = likedSongIds.has(song.id);
|
||||
return (
|
||||
<div
|
||||
key={song.id}
|
||||
className="group relative flex-shrink-0 w-36 md:w-48"
|
||||
>
|
||||
<div
|
||||
onClick={() => onPlaySong(song, featuredSongs)}
|
||||
className="aspect-square rounded-lg overflow-hidden mb-2 md:mb-3 relative bg-zinc-200 dark:bg-zinc-800 cursor-pointer"
|
||||
>
|
||||
<img src={song.coverUrl} alt={song.title} className={`w-full h-full object-cover transition-transform duration-500 ${isCurrentlyPlaying ? 'scale-105' : 'group-hover:scale-105'}`} />
|
||||
<div className={`absolute inset-0 bg-black/40 transition-opacity flex items-center justify-center ${isCurrentSong ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}>
|
||||
<div className="w-12 h-12 md:w-14 md:h-14 rounded-full bg-white flex items-center justify-center shadow-lg">
|
||||
{isCurrentlyPlaying ? (
|
||||
<Pause size={20} className="text-black fill-black md:w-6 md:h-6" />
|
||||
) : (
|
||||
<Play size={20} className="text-black fill-black ml-1 md:w-6 md:h-6" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isCurrentlyPlaying && (
|
||||
<div className="absolute bottom-2 left-2 flex items-center gap-1">
|
||||
<span className="w-1 h-3 bg-pink-500 rounded-full animate-pulse" style={{ animationDelay: '0ms' }} />
|
||||
<span className="w-1 h-4 bg-pink-500 rounded-full animate-pulse" style={{ animationDelay: '150ms' }} />
|
||||
<span className="w-1 h-2 bg-pink-500 rounded-full animate-pulse" style={{ animationDelay: '300ms' }} />
|
||||
<span className="w-1 h-5 bg-pink-500 rounded-full animate-pulse" style={{ animationDelay: '450ms' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className={`font-semibold truncate mb-1 text-sm md:text-base ${isCurrentSong ? 'text-pink-500' : 'text-zinc-900 dark:text-white'}`}>{song.title}</h3>
|
||||
<p className="text-xs md:text-sm text-zinc-500 dark:text-zinc-400 truncate mb-2">{song.style}</p>
|
||||
</div>
|
||||
{onToggleLike && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onToggleLike(song.id); }}
|
||||
className={`p-1.5 rounded-full transition-colors ${isLiked ? 'text-pink-500' : 'text-zinc-400 hover:text-pink-500'}`}
|
||||
>
|
||||
<Heart size={16} className={isLiked ? 'fill-current' : ''} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-zinc-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Heart size={12} className={isLiked ? 'fill-pink-500 text-pink-500' : ''} /> {song.likeCount || 0}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Eye size={12} /> {song.viewCount || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Songs Section */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-4 md:mb-6">
|
||||
<h2 className="text-xl md:text-2xl font-bold text-zinc-900 dark:text-white">Songs</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex bg-zinc-200 dark:bg-zinc-900 rounded-full p-1">
|
||||
<button
|
||||
onClick={() => setSongsTab('recent')}
|
||||
className={`px-3 md:px-4 py-1.5 md:py-2 rounded-full text-xs md:text-sm font-medium transition-colors ${songsTab === 'recent' ? 'bg-white dark:bg-white text-zinc-900 dark:text-black shadow-sm' : 'text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Recent
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSongsTab('top')}
|
||||
className={`px-3 md:px-4 py-1.5 md:py-2 rounded-full text-xs md:text-sm font-medium transition-colors ${songsTab === 'top' ? 'bg-white dark:bg-white text-zinc-900 dark:text-black shadow-sm' : 'text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Top
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{displaySongs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-zinc-500">
|
||||
<MusicIcon size={64} className="mb-4 opacity-50" />
|
||||
<p>No public songs yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 md:gap-4">
|
||||
{displaySongs.map((song) => {
|
||||
const isCurrentSong = currentSong?.id === song.id;
|
||||
const isCurrentlyPlaying = isCurrentSong && isPlaying;
|
||||
const isLiked = likedSongIds.has(song.id);
|
||||
return (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`group flex items-center gap-3 md:gap-4 p-2 md:p-3 rounded-lg cursor-pointer transition-colors ${isCurrentSong ? 'bg-pink-50 dark:bg-pink-500/10' : 'hover:bg-zinc-100 dark:hover:bg-zinc-900'}`}
|
||||
>
|
||||
<div
|
||||
onClick={() => onPlaySong(song, displaySongs)}
|
||||
className="relative w-14 h-14 md:w-16 md:h-16 flex-shrink-0 rounded-md overflow-hidden bg-zinc-200 dark:bg-zinc-800"
|
||||
>
|
||||
<img src={song.coverUrl} alt={song.title} className="w-full h-full object-cover" />
|
||||
<div className={`absolute inset-0 bg-black/40 transition-opacity flex items-center justify-center ${isCurrentSong ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}>
|
||||
{isCurrentlyPlaying ? (
|
||||
<Pause size={18} className="text-white fill-white md:w-5 md:h-5" />
|
||||
) : (
|
||||
<Play size={18} className="text-white fill-white md:w-5 md:h-5" />
|
||||
)}
|
||||
</div>
|
||||
{isCurrentlyPlaying && (
|
||||
<div className="absolute bottom-1 left-1 flex items-center gap-0.5">
|
||||
<span className="w-0.5 h-2 bg-pink-500 rounded-full animate-pulse" style={{ animationDelay: '0ms' }} />
|
||||
<span className="w-0.5 h-3 bg-pink-500 rounded-full animate-pulse" style={{ animationDelay: '150ms' }} />
|
||||
<span className="w-0.5 h-1.5 bg-pink-500 rounded-full animate-pulse" style={{ animationDelay: '300ms' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0" onClick={() => onPlaySong(song, displaySongs)}>
|
||||
<h3 className={`font-semibold truncate text-sm md:text-base ${isCurrentSong ? 'text-pink-500' : 'text-zinc-900 dark:text-white'}`}>{song.title}</h3>
|
||||
<p className="text-xs md:text-sm text-zinc-500 dark:text-zinc-400 truncate">{song.style}</p>
|
||||
<div className="flex items-center gap-3 text-xs text-zinc-500 mt-1">
|
||||
<span className="flex items-center gap-1"><Heart size={10} className={isLiked ? 'fill-pink-500 text-pink-500' : ''} /> {song.likeCount || 0}</span>
|
||||
<span className="flex items-center gap-1"><Play size={10} /> {song.viewCount || 0}</span>
|
||||
<span>{song.duration}</span>
|
||||
</div>
|
||||
</div>
|
||||
{onToggleLike && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onToggleLike(song.id); }}
|
||||
className={`p-2 rounded-full transition-colors flex-shrink-0 ${isLiked ? 'text-pink-500' : 'text-zinc-400 hover:text-pink-500'}`}
|
||||
>
|
||||
<Heart size={18} className={isLiked ? 'fill-current' : ''} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Playlists Section */}
|
||||
{publicPlaylists.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-4 md:mb-6">
|
||||
<h2 className="text-xl md:text-2xl font-bold text-zinc-900 dark:text-white">Playlists</h2>
|
||||
<button className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors text-sm">
|
||||
See More <ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-3 md:gap-4 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-zinc-300 dark:scrollbar-thumb-zinc-700 scrollbar-track-transparent -mx-4 px-4 md:mx-0 md:px-0">
|
||||
{publicPlaylists.map((playlist: any) => (
|
||||
<div
|
||||
key={playlist.id}
|
||||
onClick={() => onNavigateToPlaylist?.(playlist.id)}
|
||||
className="group relative flex-shrink-0 w-36 md:w-48 cursor-pointer"
|
||||
>
|
||||
<div className="aspect-square rounded-lg bg-gradient-to-br from-indigo-600 to-purple-700 mb-2 md:mb-3 flex items-center justify-center relative overflow-hidden">
|
||||
<MusicIcon size={48} className="text-white/30 md:w-16 md:h-16" />
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<div className="w-12 h-12 md:w-14 md:h-14 rounded-full bg-white flex items-center justify-center">
|
||||
<Play size={20} className="text-black fill-black ml-1 md:w-6 md:h-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-semibold text-zinc-900 dark:text-white truncate mb-1 text-sm md:text-base">{playlist.name}</h3>
|
||||
<p className="text-xs md:text-sm text-zinc-500 dark:text-zinc-400">{playlist.song_count} songs</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit Profile Modal */}
|
||||
{isEditModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 dark:bg-black/80 backdrop-blur-sm p-4">
|
||||
<div className="w-full max-w-lg bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200 max-h-[90vh] overflow-y-auto">
|
||||
<div className="px-4 md:px-6 py-4 border-b border-zinc-200 dark:border-zinc-800 flex items-center justify-between sticky top-0 bg-white dark:bg-zinc-900 z-10">
|
||||
<h2 className="text-lg md:text-xl font-bold text-zinc-900 dark:text-white">Edit Profile</h2>
|
||||
<button onClick={() => setIsEditModalOpen(false)} className="text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
{/* Avatar Upload */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Avatar Image</label>
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="w-20 h-20 rounded-full bg-zinc-100 dark:bg-zinc-800 border-2 border-zinc-300 dark:border-zinc-700 border-dashed overflow-hidden flex-shrink-0 relative">
|
||||
{(avatarPreview || editAvatarUrl) ? (
|
||||
<img
|
||||
src={avatarPreview || editAvatarUrl}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => (e.currentTarget.style.display = 'none')}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-zinc-400 dark:text-zinc-500">
|
||||
<Camera size={24} />
|
||||
</div>
|
||||
)}
|
||||
{uploadingAvatar && (
|
||||
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
|
||||
<Loader2 size={20} className="animate-spin text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
onChange={handleAvatarChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 text-zinc-900 dark:text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
<Upload size={16} />
|
||||
Upload Avatar
|
||||
</button>
|
||||
<p className="text-xs text-zinc-500">JPG, PNG, WebP, GIF • Max 5MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Banner Upload */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Banner Image</label>
|
||||
<div
|
||||
onClick={() => bannerInputRef.current?.click()}
|
||||
className="relative w-full h-32 rounded-lg bg-zinc-100 dark:bg-zinc-800 border-2 border-zinc-300 dark:border-zinc-700 border-dashed overflow-hidden cursor-pointer hover:border-zinc-400 dark:hover:border-zinc-600 transition-colors"
|
||||
>
|
||||
{(bannerPreview || editBannerUrl) ? (
|
||||
<img
|
||||
src={bannerPreview || editBannerUrl}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => (e.currentTarget.style.display = 'none')}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center text-zinc-400 dark:text-zinc-500 gap-2">
|
||||
<ImageIcon size={32} />
|
||||
<span className="text-sm">Click to upload banner</span>
|
||||
</div>
|
||||
)}
|
||||
{uploadingBanner && (
|
||||
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
|
||||
<Loader2 size={24} className="animate-spin text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={bannerInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
onChange={handleBannerChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<p className="text-xs text-zinc-500">Recommended: 1500x500px • JPG, PNG, WebP, GIF • Max 5MB</p>
|
||||
</div>
|
||||
|
||||
{/* Bio Input */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Bio</label>
|
||||
<textarea
|
||||
value={editBio}
|
||||
onChange={(e) => setEditBio(e.target.value)}
|
||||
placeholder="Tell us about yourself..."
|
||||
rows={4}
|
||||
className="w-full bg-zinc-50 dark:bg-black border border-zinc-300 dark:border-zinc-800 rounded-lg px-3 py-2 text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:border-pink-500 dark:focus:border-indigo-500 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 md:px-6 py-4 bg-zinc-50 dark:bg-black/20 border-t border-zinc-200 dark:border-zinc-800 flex justify-end gap-3 sticky bottom-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsEditModalOpen(false);
|
||||
setAvatarFile(null);
|
||||
setBannerFile(null);
|
||||
setAvatarPreview(null);
|
||||
setBannerPreview(null);
|
||||
}}
|
||||
className="px-4 py-2 text-sm font-medium text-zinc-600 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-white transition-colors"
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveProfile}
|
||||
disabled={isSaving || uploadingAvatar || uploadingBanner}
|
||||
className="px-6 py-2 bg-zinc-900 dark:bg-white text-white dark:text-black hover:bg-zinc-800 dark:hover:bg-zinc-200 rounded-full text-sm font-bold transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isSaving && <Loader2 size={16} className="animate-spin" />}
|
||||
{uploadingAvatar ? 'Uploading Avatar...' : uploadingBanner ? 'Uploading Banner...' : isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, User, Sparkles } from 'lucide-react';
|
||||
|
||||
interface UsernameModalProps {
|
||||
isOpen: boolean;
|
||||
onSubmit: (username: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const UsernameModal: React.FC<UsernameModalProps> = ({ isOpen, onSubmit }) => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
const trimmed = username.trim();
|
||||
if (trimmed.length < 2) {
|
||||
setError('Username must be at least 2 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
|
||||
setError('Username can only contain letters, numbers, underscores, and dashes');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onSubmit(trimmed);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to set username');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm" />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-md bg-zinc-900 rounded-2xl shadow-2xl border border-white/10 overflow-hidden">
|
||||
{/* Header gradient */}
|
||||
<div className="h-2 bg-gradient-to-r from-pink-500 via-purple-500 to-blue-500" />
|
||||
|
||||
<div className="p-8">
|
||||
{/* Logo */}
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-pink-500 to-purple-600 flex items-center justify-center shadow-lg">
|
||||
<Sparkles className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h2 className="text-2xl font-bold text-center text-white mb-2">
|
||||
Welcome to ACE-Step UI
|
||||
</h2>
|
||||
<p className="text-zinc-400 text-center mb-8">
|
||||
Enter your name to get started creating AI music
|
||||
</p>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-zinc-300 mb-2">
|
||||
Your Name
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User className="w-5 h-5 text-zinc-500" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter your name"
|
||||
className="w-full pl-10 pr-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-transparent transition-all"
|
||||
autoFocus
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-red-400">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !username.trim()}
|
||||
className="w-full py-3 bg-gradient-to-r from-pink-500 to-purple-600 text-white font-semibold rounded-xl hover:from-pink-600 hover:to-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all transform hover:scale-[1.02] active:scale-[0.98]"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Getting Started...
|
||||
</span>
|
||||
) : (
|
||||
'Get Started'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="mt-6 text-xs text-zinc-500 text-center">
|
||||
Your music, your way. Create unlimited AI music for free.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user