Initial commit: ACE-Step UI - Open source music generation interface

This commit is contained in:
fspecii
2026-02-04 03:09:38 +02:00
commit 44f7563014
187 changed files with 99985 additions and 0 deletions
+296
View File
@@ -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
+307
View File
@@ -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>
);
};
+117
View File
@@ -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>
);
};
+140
View File
@@ -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);
}
+723
View File
@@ -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>
);
};
+292
View File
@@ -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>
);
};
+148
View File
@@ -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>
);
};
+312
View File
@@ -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>
);
+611
View File
@@ -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>
);
};
+182
View File
@@ -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>
);
};
+226
View File
@@ -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)}&quote=${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);
};
+125
View File
@@ -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>
);
+227
View File
@@ -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>
);
};
+456
View File
@@ -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}
/>
</>
);
};
+312
View File
@@ -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>
);
};
+53
View File
@@ -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>
);
};
+726
View File
@@ -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>
);
};
+120
View File
@@ -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