Apply i18n to all components, add collapsible sidebar, playback speed control, inline title editing, and ConfirmDialog for deletions
Phase 4: i18n all 15 components from PR #19. Sidebar gains collapse/expand toggle. Player gets playback speed selector (0.25x-2.0x) fixing bug B1 (hardcoded Chinese '正常'). SongList adds inline title editing and model version badge. Delete actions now use ConfirmDialog instead of window.confirm. Volume persisted to localStorage. Training nav item deferred to Phase 7.
This commit is contained in:
@@ -2,6 +2,7 @@ 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';
|
||||
import { useI18n } from '../context/I18nContext';
|
||||
|
||||
interface EditProfileModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -10,6 +11,7 @@ interface EditProfileModalProps {
|
||||
}
|
||||
|
||||
export const EditProfileModal: React.FC<EditProfileModalProps> = ({ isOpen, onClose, onSaved }) => {
|
||||
const { t } = useI18n();
|
||||
const { user, token, refreshUser, updateUsername } = useAuth();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
@@ -83,7 +85,7 @@ export const EditProfileModal: React.FC<EditProfileModalProps> = ({ isOpen, onCl
|
||||
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');
|
||||
setUsernameError(t('usernameMinLengthError'));
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
@@ -92,9 +94,9 @@ export const EditProfileModal: React.FC<EditProfileModalProps> = ({ isOpen, onCl
|
||||
} catch (err: unknown) {
|
||||
const error = err as Error & { message?: string };
|
||||
if (error.message?.includes('taken')) {
|
||||
setUsernameError('Username is already taken');
|
||||
setUsernameError(t('usernameTakenError'));
|
||||
} else {
|
||||
setUsernameError('Failed to update username');
|
||||
setUsernameError(t('usernameUpdateFailedError'));
|
||||
}
|
||||
setIsSaving(false);
|
||||
return;
|
||||
@@ -152,27 +154,27 @@ export const EditProfileModal: React.FC<EditProfileModalProps> = ({ isOpen, onCl
|
||||
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">
|
||||
<div className="fixed inset-0 z-[60] 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">
|
||||
<div className="px-6 py-4 border-b border-zinc-200 dark:border-zinc-800 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-zinc-900 dark:text-white">{t('editProfile')}</h2>
|
||||
<button onClick={handleClose} className="text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark: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" />
|
||||
<Loader2 size={32} className="animate-spin text-zinc-400 dark: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>
|
||||
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">{t('usernameLabel')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-zinc-500">@</span>
|
||||
<span className="text-zinc-500 dark:text-zinc-500">@</span>
|
||||
<input
|
||||
type="text"
|
||||
value={editUsername}
|
||||
@@ -180,22 +182,22 @@ export const EditProfileModal: React.FC<EditProfileModalProps> = ({ isOpen, onCl
|
||||
setEditUsername(e.target.value);
|
||||
setUsernameError('');
|
||||
}}
|
||||
placeholder="username"
|
||||
placeholder={t('usernamePlaceholder')}
|
||||
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"
|
||||
className="flex-1 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-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>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-500">{t('usernameRequirements')}</p>
|
||||
</div>
|
||||
|
||||
{/* Avatar Upload */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-300">Avatar Image</label>
|
||||
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">{t('avatarImage')}</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">
|
||||
<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}
|
||||
@@ -203,7 +205,7 @@ export const EditProfileModal: React.FC<EditProfileModalProps> = ({ isOpen, onCl
|
||||
onError={(e) => (e.currentTarget.style.display = 'none')}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-zinc-500">
|
||||
<div className="w-full h-full flex items-center justify-center text-zinc-400 dark:text-zinc-500">
|
||||
<Camera size={24} />
|
||||
</div>
|
||||
)}
|
||||
@@ -224,22 +226,22 @@ export const EditProfileModal: React.FC<EditProfileModalProps> = ({ isOpen, onCl
|
||||
<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"
|
||||
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
|
||||
{t('uploadAvatar')}
|
||||
</button>
|
||||
<p className="text-xs text-zinc-500">JPG, PNG, WebP, GIF - Max 5MB</p>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-500">{t('avatarFormats')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Banner Upload */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-300">Banner Image</label>
|
||||
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">{t('bannerImage')}</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"
|
||||
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
|
||||
@@ -248,9 +250,9 @@ export const EditProfileModal: React.FC<EditProfileModalProps> = ({ isOpen, onCl
|
||||
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">
|
||||
<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>
|
||||
<span className="text-sm">{t('clickToUploadBanner')}</span>
|
||||
</div>
|
||||
)}
|
||||
{uploadingBanner && (
|
||||
@@ -266,37 +268,37 @@ export const EditProfileModal: React.FC<EditProfileModalProps> = ({ isOpen, onCl
|
||||
onChange={handleBannerChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<p className="text-xs text-zinc-500">Recommended: 1500x500px - JPG, PNG, WebP, GIF - Max 5MB</p>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-500">{t('bannerFormats')}</p>
|
||||
</div>
|
||||
|
||||
{/* Bio Input */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-300">Bio</label>
|
||||
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">{t('bio')}</label>
|
||||
<textarea
|
||||
value={editBio}
|
||||
onChange={(e) => setEditBio(e.target.value)}
|
||||
placeholder="Tell us about yourself..."
|
||||
placeholder={t('bioPlaceholder')}
|
||||
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"
|
||||
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-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">
|
||||
<div className="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">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 text-sm font-medium text-zinc-300 hover:text-white transition-colors"
|
||||
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
|
||||
{t('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"
|
||||
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'}
|
||||
{uploadingAvatar ? t('uploadingAvatar') : uploadingBanner ? t('uploadingBanner') : isSaving ? t('saving') : t('saveChanges')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuth } from '../context/AuthContext';
|
||||
import { SongDropdownMenu } from './SongDropdownMenu';
|
||||
import { ShareModal } from './ShareModal';
|
||||
import { AlbumCover } from './AlbumCover';
|
||||
import { useI18n } from '../context/I18nContext';
|
||||
|
||||
interface LibraryViewProps {
|
||||
allSongs: Song[];
|
||||
@@ -46,6 +47,7 @@ export const LibraryView: React.FC<LibraryViewProps> = ({
|
||||
onDeleteSong,
|
||||
onDeleteReferenceTrack,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { user } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState<'all' | 'playlists' | 'liked' | 'uploads'>('all');
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
@@ -67,13 +69,13 @@ export const LibraryView: React.FC<LibraryViewProps> = ({
|
||||
<>
|
||||
<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>
|
||||
<h1 className="text-3xl font-bold text-zinc-900 dark:text-white">{t('yourLibrary')}</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>
|
||||
<span>{t('newPlaylist')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -90,14 +92,14 @@ export const LibraryView: React.FC<LibraryViewProps> = ({
|
||||
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
|
||||
{t('likedSongs')}
|
||||
{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
|
||||
{t('playlists')}
|
||||
{activeTab === 'playlists' && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-green-500 rounded-full"></div>}
|
||||
</button>
|
||||
<button
|
||||
@@ -168,10 +170,10 @@ export const LibraryView: React.FC<LibraryViewProps> = ({
|
||||
<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>
|
||||
<h2 className="text-sm font-bold uppercase text-zinc-500 dark:text-white mb-2">{t('playlist')}</h2>
|
||||
<h1 className="text-5xl font-extrabold text-zinc-900 dark:text-white mb-4">{t('likedSongs')}</h1>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-300 font-medium">
|
||||
{likedSongs.length} songs
|
||||
{likedSongs.length} {t('songs')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto mb-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
@@ -241,7 +243,7 @@ export const LibraryView: React.FC<LibraryViewProps> = ({
|
||||
)}
|
||||
</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>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 line-clamp-2">{playlist.description || t('byYou')}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -100,7 +100,7 @@ export function MobileDrawer({ isOpen, onClose, position, children, title }: Mob
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={`fixed inset-0 z-50 ${backdropAnimation}`}
|
||||
className={`fixed inset-0 z-[60] ${backdropAnimation}`}
|
||||
onClick={handleBackdropClick}
|
||||
role="presentation"
|
||||
>
|
||||
|
||||
+131
-38
@@ -3,6 +3,7 @@ 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 { useI18n } from '../context/I18nContext';
|
||||
import { SongDropdownMenu } from './SongDropdownMenu';
|
||||
import { ShareModal } from './ShareModal';
|
||||
import { AlbumCover } from './AlbumCover';
|
||||
@@ -18,6 +19,9 @@ interface PlayerProps {
|
||||
onPrevious: () => void;
|
||||
volume: number;
|
||||
onVolumeChange: (val: number) => void;
|
||||
playbackRate: number;
|
||||
onPlaybackRateChange: (rate: number) => void;
|
||||
audioRef: React.RefObject<HTMLAudioElement>;
|
||||
isShuffle: boolean;
|
||||
onToggleShuffle: () => void;
|
||||
repeatMode: 'none' | 'all' | 'one';
|
||||
@@ -42,6 +46,9 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
onPrevious,
|
||||
volume,
|
||||
onVolumeChange,
|
||||
playbackRate,
|
||||
onPlaybackRateChange,
|
||||
audioRef,
|
||||
isShuffle,
|
||||
onToggleShuffle,
|
||||
repeatMode,
|
||||
@@ -56,12 +63,15 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const { isMobile } = useResponsive();
|
||||
const { t } = useI18n();
|
||||
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);
|
||||
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
|
||||
const speedMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close fullscreen on Escape key
|
||||
useEffect(() => {
|
||||
@@ -75,6 +85,17 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isFullscreen]);
|
||||
|
||||
// Close speed menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (speedMenuRef.current && !speedMenuRef.current.contains(event.target as Node)) {
|
||||
setShowSpeedMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Show minimal player when no song is playing
|
||||
if (!currentSong) {
|
||||
return (
|
||||
@@ -137,7 +158,7 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
>
|
||||
<ChevronDown size={28} />
|
||||
</button>
|
||||
<span className="text-xs text-zinc-500 dark:text-white/50 uppercase tracking-wider">Now Playing</span>
|
||||
<span className="text-xs text-zinc-500 dark:text-white/50 uppercase tracking-wider">{t('nowPlaying')}</span>
|
||||
<div className="w-11" />
|
||||
</div>
|
||||
|
||||
@@ -236,15 +257,9 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
</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">
|
||||
{/* Volume Control - Vertical */}
|
||||
<div className="flex flex-col items-center gap-3 px-6 py-4">
|
||||
<div className="relative h-32 w-8 flex items-center justify-center">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
@@ -252,13 +267,19 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
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}%` }}
|
||||
className="w-32 h-8 -rotate-90 origin-center appearance-none bg-transparent cursor-pointer"
|
||||
style={{
|
||||
WebkitAppearance: 'none',
|
||||
background: `linear-gradient(to right, rgb(236 72 153) 0%, rgb(236 72 153) ${volume * 100}%, rgb(228 228 231) ${volume * 100}%, rgb(228 228 231) 100%)`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* Extra Actions */}
|
||||
@@ -271,7 +292,7 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-3 tap-highlight-none"
|
||||
title="Download Audio"
|
||||
title={t('downloadAudio')}
|
||||
>
|
||||
<Download size={20} />
|
||||
</button>
|
||||
@@ -295,7 +316,7 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
onCreateVideo={onOpenVideo}
|
||||
onReusePrompt={onReusePrompt}
|
||||
onAddToPlaylist={onAddToPlaylist}
|
||||
onDelete={onDelete}
|
||||
onDelete={onDelete}
|
||||
onShare={() => setShareModalOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
@@ -391,7 +412,7 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
>
|
||||
<ChevronDown size={28} />
|
||||
</button>
|
||||
<span className="text-sm text-zinc-500 dark:text-white/50 uppercase tracking-wider font-medium">Now Playing</span>
|
||||
<span className="text-sm text-zinc-500 dark:text-white/50 uppercase tracking-wider font-medium">{t('nowPlaying')}</span>
|
||||
<div className="w-11" />
|
||||
</div>
|
||||
|
||||
@@ -484,6 +505,34 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Playback Speed Dropdown */}
|
||||
<div className="relative group hidden lg:block" ref={speedMenuRef}>
|
||||
<button
|
||||
className="px-2 py-1 text-[11px] font-mono font-bold hover:bg-zinc-200 dark:hover:bg-white/10 rounded transition-colors min-w-[42px] text-center"
|
||||
onClick={() => setShowSpeedMenu(!showSpeedMenu)}
|
||||
>
|
||||
{playbackRate}x
|
||||
</button>
|
||||
{showSpeedMenu && (
|
||||
<div className="absolute bottom-full right-0 mb-2 bg-white dark:bg-zinc-800 rounded-lg shadow-xl border border-zinc-200 dark:border-white/10 py-1 min-w-[80px] z-50">
|
||||
{[0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0].map((rate) => (
|
||||
<button
|
||||
key={rate}
|
||||
onClick={() => {
|
||||
onPlaybackRateChange(rate);
|
||||
setShowSpeedMenu(false);
|
||||
}}
|
||||
className={`w-full px-3 py-1.5 text-left text-xs font-mono hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors ${
|
||||
playbackRate === rate ? 'text-pink-600 dark:text-pink-500 font-bold' : 'text-zinc-700 dark:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
{rate === 1.0 ? t('normalSpeed') : `${rate}x`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Volume Control */}
|
||||
<div className="flex items-center gap-4 w-full max-w-xs">
|
||||
<button
|
||||
@@ -506,6 +555,12 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
className="h-full bg-zinc-700 dark:bg-white/70 rounded-full"
|
||||
style={{ width: `${volume * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 w-3.5 h-3.5 bg-zinc-700 dark:bg-white/70 rounded-full shadow pointer-events-none"
|
||||
style={{
|
||||
left: `clamp(0px, calc(${volume * 100}% - 7px), calc(100% - 14px))`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -528,7 +583,7 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-3 rounded-full hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors"
|
||||
title="Download Audio"
|
||||
title={t('downloadAudio')}
|
||||
>
|
||||
<Download size={20} />
|
||||
</button>
|
||||
@@ -582,7 +637,7 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
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 className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 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>
|
||||
{/* Hit area for easier clicking */}
|
||||
<div className="absolute top-1/2 -translate-y-1/2 w-full h-4 -z-10"></div>
|
||||
@@ -658,8 +713,37 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
{formatTime(currentTime)} / {formatTime(duration || 0)}
|
||||
</span>
|
||||
|
||||
{/* Playback Speed */}
|
||||
<div className="relative group hidden lg:block" ref={speedMenuRef}>
|
||||
<button
|
||||
className="px-2 py-1 text-[11px] font-mono font-bold hover:bg-zinc-200 dark:hover:bg-white/10 rounded transition-colors min-w-[42px] text-center"
|
||||
onClick={() => setShowSpeedMenu(!showSpeedMenu)}
|
||||
>
|
||||
{playbackRate}x
|
||||
</button>
|
||||
{showSpeedMenu && (
|
||||
<div className="absolute bottom-full right-0 mb-2 bg-white dark:bg-zinc-800 rounded-lg shadow-xl border border-zinc-200 dark:border-white/10 py-1 min-w-[80px] z-50">
|
||||
{[0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0].map((rate) => (
|
||||
<button
|
||||
key={rate}
|
||||
onClick={() => {
|
||||
onPlaybackRateChange(rate);
|
||||
setShowSpeedMenu(false);
|
||||
}}
|
||||
className={`w-full px-3 py-1.5 text-left text-xs font-mono hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors ${
|
||||
playbackRate === rate ? 'text-pink-600 dark:text-pink-500 font-bold' : 'text-zinc-700 dark:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
{rate === 1.0 ? t('normalSpeed') : `${rate}x`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Volume Control with Vertical Slider */}
|
||||
<div
|
||||
className="items-center gap-2 relative group hidden md:flex"
|
||||
className="relative group hidden md:block"
|
||||
onMouseEnter={() => setIsHoveringVolume(true)}
|
||||
onMouseLeave={() => setIsHoveringVolume(false)}
|
||||
>
|
||||
@@ -670,28 +754,37 @@ export const Player: React.FC<PlayerProps> = ({
|
||||
{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>
|
||||
{/* Vertical Volume Slider */}
|
||||
{isHoveringVolume && (
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 pb-2">
|
||||
<div className="bg-white dark:bg-zinc-800 rounded-lg shadow-xl border border-zinc-200 dark:border-white/10 p-2">
|
||||
<div className="relative h-24 w-8 flex items-center justify-center">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={volume}
|
||||
onChange={(e) => onVolumeChange(parseFloat(e.target.value))}
|
||||
className="w-24 h-8 -rotate-90 origin-center appearance-none bg-transparent cursor-pointer"
|
||||
style={{
|
||||
WebkitAppearance: 'none',
|
||||
background: `linear-gradient(to right, rgb(236 72 153) 0%, rgb(236 72 153) ${volume * 100}%, rgb(228 228 231) ${volume * 100}%, rgb(228 228 231) 100%)`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[10px] text-center font-mono text-zinc-600 dark:text-zinc-400 mt-1">
|
||||
{Math.round(volume * 100)}%
|
||||
</div>
|
||||
</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"
|
||||
title={t('downloadAudio')}
|
||||
>
|
||||
<Download size={18} />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Song, Playlist, playlistsApi, songsApi, getAudioUrl } from '../services/api';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useI18n } from '../context/I18nContext';
|
||||
import { ArrowLeft, Play, MoreHorizontal, Clock, Calendar, Shuffle, Trash2, Mic2, Music } from 'lucide-react';
|
||||
|
||||
interface PlaylistDetailProps {
|
||||
@@ -13,6 +14,7 @@ interface PlaylistDetailProps {
|
||||
|
||||
export const PlaylistDetail: React.FC<PlaylistDetailProps> = ({ playlistId, onBack, onPlaySong, onSelect, onNavigateToProfile }) => {
|
||||
const { user: currentUser, token } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const [playlist, setPlaylist] = useState<Playlist & { creator_avatar?: string } | null>(null);
|
||||
const [songs, setSongs] = useState<Song[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -67,7 +69,7 @@ export const PlaylistDetail: React.FC<PlaylistDetailProps> = ({ playlistId, onBa
|
||||
|
||||
const handleDeletePlaylist = async () => {
|
||||
if (!token || !playlist) return;
|
||||
if (!confirm('Are you sure you want to delete this playlist?')) return;
|
||||
if (!confirm(t('deletePlaylistConfirm'))) return;
|
||||
try {
|
||||
await playlistsApi.delete(playlist.id, token);
|
||||
onBack();
|
||||
@@ -80,16 +82,16 @@ export const PlaylistDetail: React.FC<PlaylistDetailProps> = ({ playlistId, onBa
|
||||
<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...
|
||||
{t('loadingPlaylist')}
|
||||
</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>
|
||||
<div className="text-zinc-400">{t('playlistNotFound')}</div>
|
||||
<button onClick={onBack} className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 rounded-lg text-white">
|
||||
Go Back
|
||||
{t('goBack')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -124,7 +126,7 @@ export const PlaylistDetail: React.FC<PlaylistDetailProps> = ({ playlistId, onBa
|
||||
|
||||
{/* 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>
|
||||
<span className="text-xs font-bold tracking-wider uppercase text-white/80">{t('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>
|
||||
@@ -147,11 +149,11 @@ export const PlaylistDetail: React.FC<PlaylistDetailProps> = ({ playlistId, onBa
|
||||
</div>
|
||||
)}
|
||||
<span className="w-1 h-1 rounded-full bg-white/50"></span>
|
||||
<span>{songs.length} songs</span>
|
||||
<span>{songs.length} {t('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"
|
||||
? Math.floor(songs.reduce((acc, s) => acc + (s.duration as number || 0), 0) / 60) + " " + t('min')
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
@@ -171,7 +173,7 @@ export const PlaylistDetail: React.FC<PlaylistDetailProps> = ({ playlistId, onBa
|
||||
<button
|
||||
onClick={handleDeletePlaylist}
|
||||
className="text-zinc-400 hover:text-red-500 transition-colors p-2"
|
||||
title="Delete Playlist"
|
||||
title={t('deletePlaylist')}
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
@@ -180,19 +182,19 @@ export const PlaylistDetail: React.FC<PlaylistDetailProps> = ({ playlistId, onBa
|
||||
<div className="flex-1"></div>
|
||||
|
||||
<div className="text-zinc-400 text-xs md:text-sm">
|
||||
{playlist.is_public ? 'Public' : 'Private'}
|
||||
{playlist.is_public ? t('public') : t('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">
|
||||
<div className="px-2 md:px-8 py-2 md:py-4 pb-24 lg:pb-32">
|
||||
{/* 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>{t('title')}</span>
|
||||
<span>{t('artist')}</span>
|
||||
<span>{t('dateAdded')}</span>
|
||||
<span className="text-right"><Clock size={16} className="inline" /></span>
|
||||
</div>
|
||||
|
||||
@@ -226,7 +228,7 @@ export const PlaylistDetail: React.FC<PlaylistDetailProps> = ({ playlistId, onBa
|
||||
<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>
|
||||
{song.creator || t('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>
|
||||
@@ -236,12 +238,12 @@ export const PlaylistDetail: React.FC<PlaylistDetailProps> = ({ playlistId, onBa
|
||||
e.stopPropagation();
|
||||
song.creator && onNavigateToProfile(song.creator);
|
||||
}}>
|
||||
{song.creator || 'Unknown'}
|
||||
{song.creator || t('unknown')}
|
||||
</span>
|
||||
|
||||
{/* Date Added - hidden on mobile */}
|
||||
<span className="hidden md:block">
|
||||
{song.addedAt ? new Date(song.addedAt).toLocaleDateString() : 'Just now'}
|
||||
{song.addedAt ? new Date(song.addedAt).toLocaleDateString() : t('justNow')}
|
||||
</span>
|
||||
|
||||
{/* Duration + Actions */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Plus, Music } from 'lucide-react';
|
||||
import { Playlist } from '../types';
|
||||
import { useI18n } from '../context/I18nContext';
|
||||
|
||||
interface CreatePlaylistModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -9,6 +10,7 @@ interface CreatePlaylistModalProps {
|
||||
}
|
||||
|
||||
export const CreatePlaylistModal: React.FC<CreatePlaylistModalProps> = ({ isOpen, onClose, onCreate }) => {
|
||||
const { t } = useI18n();
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
@@ -25,33 +27,33 @@ export const CreatePlaylistModal: React.FC<CreatePlaylistModalProps> = ({ isOpen
|
||||
};
|
||||
|
||||
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="fixed inset-0 z-[60] 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>
|
||||
<h2 className="text-xl font-bold text-zinc-900 dark:text-white">{t('createPlaylist')}</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>
|
||||
<label className="block text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase mb-1">{t('playlistName')}</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"
|
||||
placeholder={t('playlistNamePlaceholder')}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase mb-1">Description</label>
|
||||
<label className="block text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase mb-1">{t('playlistDescription')}</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..."
|
||||
placeholder={t('descriptionPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
@@ -60,14 +62,14 @@ export const CreatePlaylistModal: React.FC<CreatePlaylistModalProps> = ({ isOpen
|
||||
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
|
||||
{t('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
|
||||
{t('createButton')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -85,13 +87,14 @@ interface AddToPlaylistModalProps {
|
||||
}
|
||||
|
||||
export const AddToPlaylistModal: React.FC<AddToPlaylistModalProps> = ({ isOpen, onClose, playlists, onSelect, onCreateNew }) => {
|
||||
const { t } = useI18n();
|
||||
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="fixed inset-0 z-[60] 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>
|
||||
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">{t('addToPlaylist')}</h2>
|
||||
<button onClick={onClose} className="text-zinc-400 hover:text-zinc-900 dark:hover:text-white">
|
||||
<X size={20} />
|
||||
</button>
|
||||
@@ -106,7 +109,7 @@ export const AddToPlaylistModal: React.FC<AddToPlaylistModalProps> = ({ isOpen,
|
||||
<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 className="font-semibold text-zinc-700 dark:text-white/90 group-hover:text-zinc-900 dark:group-hover:text-white">{t('createNewPlaylist')}</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -115,7 +118,7 @@ export const AddToPlaylistModal: React.FC<AddToPlaylistModalProps> = ({ isOpen,
|
||||
<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.
|
||||
{t('noExistingPlaylists')}
|
||||
</div>
|
||||
) : (
|
||||
playlists.map(playlist => (
|
||||
@@ -136,7 +139,7 @@ export const AddToPlaylistModal: React.FC<AddToPlaylistModalProps> = ({ isOpen,
|
||||
</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 className="text-xs text-zinc-500">{playlist.song_count || playlist.songIds?.length || 0} {t('songs')}</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
|
||||
+45
-33
@@ -3,6 +3,7 @@ import { Song } from '../types';
|
||||
import { Heart, Share2, Play, Pause, 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 { useI18n } from '../context/I18nContext';
|
||||
import { SongDropdownMenu } from './SongDropdownMenu';
|
||||
import { ShareModal } from './ShareModal';
|
||||
import { AlbumCover } from './AlbumCover';
|
||||
@@ -26,6 +27,7 @@ interface RightSidebarProps {
|
||||
|
||||
export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpenVideo, onReuse, onSongUpdate, onNavigateToProfile, onNavigateToSong, isLiked, onToggleLike, onDelete, onAddToPlaylist, onPlay, isPlaying, currentSong }) => {
|
||||
const { token, user } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const [isOwner, setIsOwner] = useState(false);
|
||||
const [tagsExpanded, setTagsExpanded] = useState(false);
|
||||
@@ -118,7 +120,7 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
|
||||
<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>
|
||||
<p>{t('selectSongToView')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -128,7 +130,7 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
|
||||
|
||||
{/* 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>
|
||||
<span className="font-semibold text-sm text-zinc-900 dark:text-white">{t('songDetails')}</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"
|
||||
@@ -138,7 +140,7 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||
<div className="p-5 space-y-6">
|
||||
<div className="p-5 pb-24 lg:pb-32 space-y-6">
|
||||
|
||||
{/* Cover Art */}
|
||||
<div
|
||||
@@ -217,14 +219,14 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
|
||||
disabled={isSavingTitle}
|
||||
className="px-3 py-1.5 rounded-md text-xs font-semibold bg-pink-600 text-white hover:bg-pink-700 disabled:opacity-60"
|
||||
>
|
||||
{isSavingTitle ? 'Saving...' : 'Save'}
|
||||
{isSavingTitle ? t('saving') : t('save')}
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelTitleEdit}
|
||||
disabled={isSavingTitle}
|
||||
className="px-3 py-1.5 rounded-md text-xs font-semibold bg-zinc-200 text-zinc-700 hover:bg-zinc-300 dark:bg-white/10 dark:text-zinc-200 dark:hover:bg-white/20 disabled:opacity-60"
|
||||
>
|
||||
Cancel
|
||||
{t('cancel')}
|
||||
</button>
|
||||
{titleError && (
|
||||
<span className="text-xs text-red-500">{titleError}</span>
|
||||
@@ -278,9 +280,9 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
|
||||
onClick={() => song.creator && onNavigateToProfile?.(song.creator)}
|
||||
className="text-sm font-semibold text-zinc-900 dark:text-white hover:underline cursor-pointer"
|
||||
>
|
||||
{song.creator || 'Anonymous'}
|
||||
{song.creator || t('anonymous')}
|
||||
</span>
|
||||
<span className="text-xs text-zinc-500 dark:text-zinc-400">Created {new Date(song.createdAt).toLocaleDateString()}</span>
|
||||
<p className="text-xs text-zinc-500">{t('created')} {new Date(song.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -289,7 +291,7 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
|
||||
<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"
|
||||
title={t('createVideo')}
|
||||
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} />
|
||||
@@ -300,14 +302,14 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
|
||||
const audioUrl = song.audioUrl.startsWith('http') ? song.audioUrl : `${window.location.origin}${song.audioUrl}`;
|
||||
window.open(`/editor?audioUrl=${encodeURIComponent(audioUrl)}`, '_blank');
|
||||
}}
|
||||
title="Open in Editor"
|
||||
title={t('openInEditor')}
|
||||
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"
|
||||
title={t('reusePrompt')}
|
||||
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} />
|
||||
@@ -321,7 +323,7 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
|
||||
const audioUrl = song.audioUrl.startsWith('http') ? song.audioUrl : `${baseUrl}${song.audioUrl}`;
|
||||
window.open(`${baseUrl}/demucs-web/?audioUrl=${encodeURIComponent(audioUrl)}`, '_blank');
|
||||
}}
|
||||
title="Extract Stems"
|
||||
title={t('extractStems')}
|
||||
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} />
|
||||
@@ -342,7 +344,7 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
|
||||
<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"
|
||||
title={t('downloadAudio')}
|
||||
onClick={async () => {
|
||||
if (!song.audioUrl) return;
|
||||
try {
|
||||
@@ -454,21 +456,26 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
|
||||
{/* 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>
|
||||
<h2 className="text-sm font-bold text-zinc-900 dark:text-white uppercase tracking-wide">{t('songDetails')}</h2>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
onClick={async (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);
|
||||
try {
|
||||
const allTags = Array.isArray(song.tags) && song.tags.length > 0
|
||||
? song.tags.join(', ')
|
||||
: (song.style ?? '');
|
||||
if (!allTags) return;
|
||||
await navigator.clipboard.writeText(allTags);
|
||||
setCopiedStyle(true);
|
||||
setTimeout(() => setCopiedStyle(false), 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy style tags:', error);
|
||||
}
|
||||
}}
|
||||
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"
|
||||
className={`relative z-10 flex items-center gap-1 text-[10px] font-medium transition-colors cursor-pointer ${copiedStyle ? 'text-green-500' : 'text-zinc-500 hover:text-black dark:hover:text-white'}`}
|
||||
title={t('copyAllTags')}
|
||||
>
|
||||
<Copy size={12} /> {copiedStyle ? 'Copied!' : 'Copy'}
|
||||
<Copy size={12} /> {copiedStyle ? t('copied') : t('copy')}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
@@ -489,8 +496,8 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
|
||||
))
|
||||
)}
|
||||
{!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 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 pointer-events-none">
|
||||
+{t('more')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -499,18 +506,23 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
|
||||
{/* 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>
|
||||
<h3 className="text-[10px] font-bold text-zinc-500 uppercase tracking-wider mb-2 flex items-center justify-between">{t('lyricsSection')}</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (song.lyrics) {
|
||||
navigator.clipboard.writeText(song.lyrics);
|
||||
setCopiedLyrics(true);
|
||||
setTimeout(() => setCopiedLyrics(false), 2000);
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
if (song.lyrics) {
|
||||
await navigator.clipboard.writeText(song.lyrics);
|
||||
setCopiedLyrics(true);
|
||||
setTimeout(() => setCopiedLyrics(false), 2000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to copy lyrics:', error);
|
||||
}
|
||||
}}
|
||||
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'}`}
|
||||
className={`flex items-center gap-1 text-[10px] font-medium transition-colors cursor-pointer ${copiedLyrics ? 'text-green-500' : 'text-zinc-500 hover:text-black dark:hover:text-white'}`}
|
||||
>
|
||||
<Copy size={12} /> {copiedLyrics ? 'Copied!' : 'Copy'}
|
||||
<Copy size={12} /> {copiedLyrics ? t('copied') : t('copy')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 max-h-[300px] overflow-y-auto custom-scrollbar">
|
||||
|
||||
+38
-38
@@ -2,6 +2,8 @@ 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';
|
||||
import { useI18n } from '../context/I18nContext';
|
||||
import { GENRE_KEYS } from '../data/genres';
|
||||
|
||||
interface SearchPageProps {
|
||||
onPlaySong?: (song: Song, list?: Song[]) => void;
|
||||
@@ -12,15 +14,6 @@ interface SearchPageProps {
|
||||
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;
|
||||
|
||||
@@ -36,6 +29,7 @@ export const SearchPage: React.FC<SearchPageProps> = ({
|
||||
onNavigateToSong,
|
||||
onNavigateToPlaylist,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [featuredSongs, setFeaturedSongs] = useState<ExtendedSong[]>([]);
|
||||
const [featuredCreators, setFeaturedCreators] = useState<Array<UserProfile & { song_count?: number }>>([]);
|
||||
@@ -203,14 +197,14 @@ export const SearchPage: React.FC<SearchPageProps> = ({
|
||||
|
||||
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">
|
||||
<div className="max-w-[1400px] mx-auto px-6 py-6 pb-24 lg:pb-32">
|
||||
{/* 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"
|
||||
placeholder={t('searchSongsPlaceholder')}
|
||||
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"
|
||||
@@ -232,7 +226,7 @@ export const SearchPage: React.FC<SearchPageProps> = ({
|
||||
<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 ? `${t('songsMatching')} "${searchQuery}"` : t('featuredSongs')}
|
||||
{isSearching && displaySongs.length > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-zinc-500">({displaySongs.length})</span>
|
||||
)}
|
||||
@@ -278,7 +272,7 @@ export const SearchPage: React.FC<SearchPageProps> = ({
|
||||
</div>
|
||||
) : isSearching ? (
|
||||
<div className="text-center py-8 text-zinc-500 text-sm">
|
||||
No songs found matching "{searchQuery}"
|
||||
{t('noSongsFound')} "{searchQuery}"
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
@@ -287,7 +281,7 @@ export const SearchPage: React.FC<SearchPageProps> = ({
|
||||
<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 ? `${t('creatorsMatching')} "${searchQuery}"` : t('featuredCreators')}
|
||||
{isSearching && displayCreators.length > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-zinc-500">({displayCreators.length})</span>
|
||||
)}
|
||||
@@ -337,7 +331,7 @@ export const SearchPage: React.FC<SearchPageProps> = ({
|
||||
</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!'}
|
||||
{isSearching ? `${t('noCreatorsFound')} "${searchQuery}"` : t('noCreatorsYet')}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -346,7 +340,7 @@ export const SearchPage: React.FC<SearchPageProps> = ({
|
||||
<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 ? `${t('playlistsMatching')} "${searchQuery}"` : t('featuredPlaylists')}
|
||||
{isSearching && displayPlaylists.length > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-zinc-500">({displayPlaylists.length})</span>
|
||||
)}
|
||||
@@ -393,39 +387,43 @@ export const SearchPage: React.FC<SearchPageProps> = ({
|
||||
playlist={playlist}
|
||||
onNavigateToPlaylist={onNavigateToPlaylist}
|
||||
onNavigateToProfile={onNavigateToProfile}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</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!'}
|
||||
{isSearching ? `${t('noPlaylistsFound')} "${searchQuery}"` : t('noPlaylistsYet')}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Genres */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">Genres</h2>
|
||||
<h2 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">{t('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>
|
||||
))}
|
||||
{GENRE_KEYS.map((genreKey) => {
|
||||
const genreLabel = t(genreKey);
|
||||
return (
|
||||
<button
|
||||
key={genreKey}
|
||||
onClick={() => handleGenreClick(genreLabel)}
|
||||
className={`px-3 py-1.5 border rounded-full text-sm transition-all duration-200 group flex items-center gap-1.5 ${
|
||||
searchQuery === genreLabel
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
{genreLabel}
|
||||
<Copy
|
||||
size={12}
|
||||
className={`opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer ${searchQuery === genreLabel ? 'text-white/70' : ''}`}
|
||||
onClick={(e) => { e.stopPropagation(); handleCopyTag(genreLabel); }}
|
||||
/>
|
||||
{copiedTag === genreLabel && <Check size={12} className="text-green-500" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -562,12 +560,14 @@ interface PlaylistCardProps {
|
||||
playlist: Playlist & { creator?: string; creator_avatar?: string; song_count?: number };
|
||||
onNavigateToPlaylist?: (playlistId: string) => void;
|
||||
onNavigateToProfile?: (username: string) => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const PlaylistCard: React.FC<PlaylistCardProps> = ({
|
||||
playlist,
|
||||
onNavigateToPlaylist,
|
||||
onNavigateToProfile,
|
||||
t,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
@@ -585,7 +585,7 @@ const PlaylistCard: React.FC<PlaylistCardProps> = ({
|
||||
<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>
|
||||
<div className="text-[11px] text-zinc-500 mb-1">{playlist.song_count || 0} {t('songs')}</div>
|
||||
{playlist.creator && (
|
||||
<div
|
||||
className="flex items-center gap-1.5 cursor-pointer"
|
||||
|
||||
+19
-12
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { X, Link, Check } from 'lucide-react';
|
||||
import { Song } from '../types';
|
||||
import { useI18n } from '../context/I18nContext';
|
||||
|
||||
interface ShareModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -53,6 +54,7 @@ const EmailIcon = () => (
|
||||
);
|
||||
|
||||
export const ShareModal: React.FC<ShareModalProps> = ({ isOpen, onClose, song }) => {
|
||||
const { t } = useI18n();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
@@ -98,8 +100,13 @@ export const ShareModal: React.FC<ShareModalProps> = ({ isOpen, onClose, song })
|
||||
};
|
||||
|
||||
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!`);
|
||||
const subject = encodeURIComponent(`🎵 ${t('emailSubject')}: ${song.title}`);
|
||||
const bodyText = t('emailBody')
|
||||
.replace('{title}', song.title)
|
||||
.replace('{creator}', song.creator || t('unknown'))
|
||||
.replace('{style}', song.style ? `${t('genres')}: ${song.style}` : '')
|
||||
.replace('{url}', shareUrl);
|
||||
const body = encodeURIComponent(bodyText);
|
||||
window.location.href = `mailto:?subject=${subject}&body=${body}`;
|
||||
};
|
||||
|
||||
@@ -127,7 +134,7 @@ export const ShareModal: React.FC<ShareModalProps> = ({ isOpen, onClose, song })
|
||||
>
|
||||
<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>
|
||||
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">{t('shareSong')}</h2>
|
||||
<button onClick={onClose} className="text-zinc-400 hover:text-zinc-900 dark:hover:text-white">
|
||||
<X size={20} />
|
||||
</button>
|
||||
@@ -149,7 +156,7 @@ export const ShareModal: React.FC<ShareModalProps> = ({ isOpen, onClose, song })
|
||||
<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"
|
||||
title={t('shareOnX')}
|
||||
>
|
||||
<XIcon />
|
||||
<span className="text-xs font-medium">X</span>
|
||||
@@ -158,7 +165,7 @@ export const ShareModal: React.FC<ShareModalProps> = ({ isOpen, onClose, song })
|
||||
<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"
|
||||
title={t('shareOnFacebook')}
|
||||
>
|
||||
<FacebookIcon />
|
||||
<span className="text-xs font-medium">Facebook</span>
|
||||
@@ -167,7 +174,7 @@ export const ShareModal: React.FC<ShareModalProps> = ({ isOpen, onClose, song })
|
||||
<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"
|
||||
title={t('shareOnWhatsApp')}
|
||||
>
|
||||
<WhatsAppIcon />
|
||||
<span className="text-xs font-medium">WhatsApp</span>
|
||||
@@ -176,7 +183,7 @@ export const ShareModal: React.FC<ShareModalProps> = ({ isOpen, onClose, song })
|
||||
<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"
|
||||
title={t('shareOnTelegram')}
|
||||
>
|
||||
<TelegramIcon />
|
||||
<span className="text-xs font-medium">Telegram</span>
|
||||
@@ -185,7 +192,7 @@ export const ShareModal: React.FC<ShareModalProps> = ({ isOpen, onClose, song })
|
||||
<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"
|
||||
title={t('shareOnReddit')}
|
||||
>
|
||||
<RedditIcon />
|
||||
<span className="text-xs font-medium">Reddit</span>
|
||||
@@ -194,7 +201,7 @@ export const ShareModal: React.FC<ShareModalProps> = ({ isOpen, onClose, song })
|
||||
<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"
|
||||
title={t('shareOnLinkedIn')}
|
||||
>
|
||||
<LinkedInIcon />
|
||||
<span className="text-xs font-medium">LinkedIn</span>
|
||||
@@ -203,7 +210,7 @@ export const ShareModal: React.FC<ShareModalProps> = ({ isOpen, onClose, song })
|
||||
<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"
|
||||
title={t('shareViaEmail')}
|
||||
>
|
||||
<EmailIcon />
|
||||
<span className="text-xs font-medium">Email</span>
|
||||
@@ -212,10 +219,10 @@ export const ShareModal: React.FC<ShareModalProps> = ({ isOpen, onClose, song })
|
||||
<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"
|
||||
title={t('copyLink')}
|
||||
>
|
||||
{copied ? <Check size={20} className="text-green-500" /> : <Link size={20} />}
|
||||
<span className="text-xs font-medium">{copied ? 'Copied!' : 'Copy'}</span>
|
||||
<span className="text-xs font-medium">{copied ? t('copied') : t('copy')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+129
-44
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Library, Disc, Search, User, LogIn, LogOut, Sun, Moon } from 'lucide-react';
|
||||
import { View } from '../types';
|
||||
import { useI18n } from '../context/I18nContext';
|
||||
|
||||
interface SidebarProps {
|
||||
currentView: View;
|
||||
@@ -11,6 +12,8 @@ interface SidebarProps {
|
||||
onLogin?: () => void;
|
||||
onLogout?: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
isOpen?: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({
|
||||
@@ -22,84 +25,161 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
onLogin,
|
||||
onLogout,
|
||||
onOpenSettings,
|
||||
isOpen = true,
|
||||
onToggle,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
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>
|
||||
<>
|
||||
{/* Backdrop for mobile - only when expanded */}
|
||||
{isOpen && onToggle && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 md:hidden"
|
||||
onClick={onToggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<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 py-4 overflow-y-auto scrollbar-hide transition-all duration-300
|
||||
fixed left-0 top-0 z-50 md:relative
|
||||
${isOpen ? 'w-[200px]' : 'w-[72px]'}
|
||||
`}>
|
||||
{/* Logo & Brand */}
|
||||
<div className="px-3 mb-8 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-full bg-gradient-to-br from-pink-500 to-purple-600 flex items-center justify-center cursor-pointer shadow-lg hover:scale-105 transition-transform flex-shrink-0"
|
||||
onClick={() => onNavigate('create')}
|
||||
title={t('aceStepUI')}
|
||||
>
|
||||
<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>
|
||||
{isOpen && (
|
||||
<span className="text-lg font-bold text-zinc-900 dark:text-white whitespace-nowrap">ACE Step</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Collapse/Expand Button */}
|
||||
{onToggle && (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-8 h-8 rounded-lg 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 flex-shrink-0"
|
||||
title={isOpen ? t('collapseSidebar') : t('expandSidebar')}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{isOpen ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 flex flex-col gap-4 w-full px-3">
|
||||
<nav className="flex-1 flex flex-col gap-2 w-full px-3">
|
||||
<NavItem
|
||||
icon={<Disc size={24} />}
|
||||
label="Create"
|
||||
icon={<Disc size={20} />}
|
||||
label={t('create')}
|
||||
active={currentView === 'create'}
|
||||
onClick={() => onNavigate('create')}
|
||||
isExpanded={isOpen}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<Library size={24} />}
|
||||
label="Library"
|
||||
icon={<Library size={20} />}
|
||||
label={t('library')}
|
||||
active={currentView === 'library'}
|
||||
onClick={() => onNavigate('library')}
|
||||
isExpanded={isOpen}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<Search size={24} />}
|
||||
label="Search"
|
||||
icon={<Search size={20} />}
|
||||
label={t('search')}
|
||||
active={currentView === 'search'}
|
||||
onClick={() => onNavigate('search')}
|
||||
isExpanded={isOpen}
|
||||
/>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-4">
|
||||
<div className="mt-auto flex flex-col gap-2">
|
||||
{/* Theme Toggle */}
|
||||
<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'}
|
||||
className={`
|
||||
w-full rounded-xl flex items-center gap-3 transition-all duration-200 text-zinc-500 dark:text-zinc-400 hover:text-black dark:hover:text-white hover:bg-zinc-100 dark:hover:bg-white/5
|
||||
${isOpen ? 'px-3 py-2.5 justify-start' : 'aspect-square justify-center'}
|
||||
`}
|
||||
title={theme === 'dark' ? t('lightMode') : t('darkMode')}
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={20} /> : <Moon size={20} />}
|
||||
<div className="flex-shrink-0">{theme === 'dark' ? <Sun size={20} /> : <Moon size={20} />}</div>
|
||||
{isOpen && (
|
||||
<span className="text-sm font-medium whitespace-nowrap">
|
||||
{theme === 'dark' ? t('lightMode') : t('darkMode')}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{user ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div
|
||||
<>
|
||||
{/* User Settings */}
|
||||
<button
|
||||
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`}
|
||||
className={`
|
||||
w-full rounded-xl flex items-center gap-3 transition-all duration-200 text-zinc-500 dark:text-zinc-400 hover:text-black dark:hover:text-white hover:bg-zinc-100 dark:hover:bg-white/5
|
||||
${isOpen ? 'px-3 py-2.5 justify-start' : 'aspect-square justify-center'}
|
||||
`}
|
||||
title={`${user.username} - ${t('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 className="w-6 h-6 rounded-full bg-gradient-to-br from-pink-500 to-purple-600 flex items-center justify-center text-white text-xs font-bold border border-white/20 overflow-hidden flex-shrink-0">
|
||||
{user.avatar_url ? (
|
||||
<img src={user.avatar_url} alt={user.username} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
user.username.charAt(0).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
{isOpen && (
|
||||
<span className="text-sm font-medium whitespace-nowrap truncate flex-1 text-left">
|
||||
{user.username}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{/* Logout */}
|
||||
<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"
|
||||
className={`
|
||||
w-full rounded-xl flex items-center gap-3 transition-all duration-200 text-zinc-500 hover:text-red-500 hover:bg-red-500/10
|
||||
${isOpen ? 'px-3 py-2.5 justify-start' : 'aspect-square justify-center'}
|
||||
`}
|
||||
title={t('signOut')}
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<div className="flex-shrink-0"><LogOut size={20} /></div>
|
||||
{isOpen && (
|
||||
<span className="text-sm font-medium whitespace-nowrap">{t('signOut')}</span>
|
||||
)}
|
||||
</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"
|
||||
className={`
|
||||
w-full rounded-xl flex items-center gap-3 transition-all duration-200 text-zinc-500 dark:text-zinc-400 hover:text-pink-500 hover:bg-zinc-100 dark:hover:bg-white/5
|
||||
${isOpen ? 'px-3 py-2.5 justify-start' : 'aspect-square justify-center'}
|
||||
`}
|
||||
title={t('signIn')}
|
||||
>
|
||||
<LogIn size={20} />
|
||||
<div className="flex-shrink-0"><LogIn size={20} /></div>
|
||||
{isOpen && (
|
||||
<span className="text-sm font-medium whitespace-nowrap">{t('signIn')}</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -108,18 +188,23 @@ interface NavItemProps {
|
||||
label: string;
|
||||
active?: boolean;
|
||||
onClick: () => void;
|
||||
isExpanded?: boolean;
|
||||
}
|
||||
|
||||
const NavItem: React.FC<NavItemProps> = ({ icon, label, active, onClick }) => (
|
||||
const NavItem: React.FC<NavItemProps> = ({ icon, label, active, onClick, isExpanded }) => (
|
||||
<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
|
||||
w-full rounded-xl flex items-center gap-3 transition-all duration-200 group relative overflow-hidden
|
||||
${isExpanded ? 'px-3 py-2.5 justify-start' : 'aspect-square justify-center'}
|
||||
${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}
|
||||
<div className="flex-shrink-0">{icon}</div>
|
||||
{isExpanded && (
|
||||
<span className="text-sm font-medium whitespace-nowrap">{label}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Song } from '../types';
|
||||
import { useI18n } from '../context/I18nContext';
|
||||
import {
|
||||
Video,
|
||||
Edit3,
|
||||
@@ -76,6 +77,7 @@ export const SongDropdownMenu: React.FC<SongDropdownMenuProps> = ({
|
||||
onUseAsReference,
|
||||
onCoverSong
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -174,32 +176,32 @@ export const SongDropdownMenu: React.FC<SongDropdownMenuProps> = ({
|
||||
{/* Creative Actions */}
|
||||
<MenuItem
|
||||
icon={<Video size={14} />}
|
||||
label="Create Video"
|
||||
label={t('createVideo')}
|
||||
onClick={() => handleAction(onCreateVideo)}
|
||||
/>
|
||||
{isOwner && (
|
||||
<MenuItem
|
||||
icon={<Edit3 size={14} />}
|
||||
label="Edit Audio"
|
||||
label={t('editAudio')}
|
||||
onClick={onEditAudio ? () => handleAction(onEditAudio) : handleEditAudio}
|
||||
/>
|
||||
)}
|
||||
<MenuItem
|
||||
icon={<Layers size={14} />}
|
||||
label="Extract Stems"
|
||||
label={t('extractStems')}
|
||||
onClick={onExtractStems ? () => handleAction(onExtractStems) : handleExtractStems}
|
||||
/>
|
||||
{onReusePrompt && (
|
||||
<MenuItem
|
||||
icon={<Repeat size={14} />}
|
||||
label="Reuse Prompt"
|
||||
label={t('reusePrompt')}
|
||||
onClick={() => handleAction(onReusePrompt)}
|
||||
/>
|
||||
)}
|
||||
{onUseAsReference && (
|
||||
<MenuItem
|
||||
icon={<Layers size={14} />}
|
||||
label="Use as Reference"
|
||||
label={t('useAsReference')}
|
||||
onClick={() => handleAction(onUseAsReference)}
|
||||
disabled={!song.audioUrl}
|
||||
/>
|
||||
@@ -207,7 +209,7 @@ export const SongDropdownMenu: React.FC<SongDropdownMenuProps> = ({
|
||||
{onCoverSong && (
|
||||
<MenuItem
|
||||
icon={<Layers size={14} />}
|
||||
label="Cover Song"
|
||||
label={t('coverSong')}
|
||||
onClick={() => handleAction(onCoverSong)}
|
||||
disabled={!song.audioUrl}
|
||||
/>
|
||||
@@ -218,17 +220,17 @@ export const SongDropdownMenu: React.FC<SongDropdownMenuProps> = ({
|
||||
{/* Library Actions */}
|
||||
<MenuItem
|
||||
icon={<ListPlus size={14} />}
|
||||
label="Add to Playlist"
|
||||
label={t('addToPlaylist')}
|
||||
onClick={() => handleAction(onAddToPlaylist)}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={<Download size={14} />}
|
||||
label="Download"
|
||||
label={t('download')}
|
||||
onClick={onDownload ? () => handleAction(onDownload) : handleDownload}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={<Share2 size={14} />}
|
||||
label="Share"
|
||||
label={t('share')}
|
||||
onClick={() => handleAction(onShare)}
|
||||
/>
|
||||
|
||||
@@ -238,7 +240,7 @@ export const SongDropdownMenu: React.FC<SongDropdownMenuProps> = ({
|
||||
<MenuDivider />
|
||||
<MenuItem
|
||||
icon={<Trash2 size={14} />}
|
||||
label="Delete Song"
|
||||
label={t('deleteSong')}
|
||||
onClick={() => handleAction(onDelete)}
|
||||
danger
|
||||
/>
|
||||
|
||||
+101
-16
@@ -2,9 +2,11 @@ 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 { useI18n } from '../context/I18nContext';
|
||||
import { SongDropdownMenu } from './SongDropdownMenu';
|
||||
import { ShareModal } from './ShareModal';
|
||||
import { AlbumCover } from './AlbumCover';
|
||||
import { songsApi } from '../services/api';
|
||||
|
||||
interface SongListProps {
|
||||
songs: Song[];
|
||||
@@ -22,6 +24,7 @@ interface SongListProps {
|
||||
onNavigateToProfile?: (username: string) => void;
|
||||
onReusePrompt?: (song: Song) => void;
|
||||
onDelete?: (song: Song) => void;
|
||||
onSongUpdate?: (updatedSong: Song) => void;
|
||||
onDeleteMany?: (songs: Song[]) => void;
|
||||
onUseAsReference?: (song: Song) => void;
|
||||
onCoverSong?: (song: Song) => void;
|
||||
@@ -36,12 +39,20 @@ interface SongListProps {
|
||||
// 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} /> },
|
||||
];
|
||||
// Map model ID to short display name
|
||||
const getModelDisplayName = (modelId?: string): string => {
|
||||
if (!modelId) return 'v1.5';
|
||||
|
||||
const mapping: Record<string, string> = {
|
||||
'acestep-v15-base': '1.5B',
|
||||
'acestep-v15-sft': '1.5S',
|
||||
'acestep-v15-turbo-shift1': '1.5TS1',
|
||||
'acestep-v15-turbo-shift3': '1.5TS3',
|
||||
'acestep-v15-turbo-continuous': '1.5TC',
|
||||
'acestep-v15-turbo': '1.5T',
|
||||
};
|
||||
return mapping[modelId] || 'v1.5';
|
||||
};
|
||||
|
||||
const createDragPreview = (element: HTMLElement) => {
|
||||
const clone = element.cloneNode(true) as HTMLElement;
|
||||
@@ -91,6 +102,7 @@ export const SongList: React.FC<SongListProps> = ({
|
||||
onNavigateToProfile,
|
||||
onReusePrompt,
|
||||
onDelete,
|
||||
onSongUpdate,
|
||||
onDeleteMany,
|
||||
onUseAsReference,
|
||||
onCoverSong,
|
||||
@@ -98,6 +110,7 @@ export const SongList: React.FC<SongListProps> = ({
|
||||
onCoverUpload
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeFilters, setActiveFilters] = useState<Set<FilterType>>(new Set());
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||
@@ -105,6 +118,13 @@ export const SongList: React.FC<SongListProps> = ({
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const filterRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const FILTERS: { id: FilterType; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: 'liked', label: t('liked'), icon: <ThumbsUp size={16} /> },
|
||||
{ id: 'public', label: t('public'), icon: <Globe size={16} /> },
|
||||
{ id: 'private', label: t('private'), icon: <Lock size={16} /> },
|
||||
{ id: 'generating', label: t('generatingStatus'), icon: <Loader2 size={16} /> }
|
||||
];
|
||||
|
||||
// Close filter dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
@@ -213,7 +233,7 @@ export const SongList: React.FC<SongListProps> = ({
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search your songs..."
|
||||
placeholder={t('searchYourSongs')}
|
||||
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" />
|
||||
@@ -231,14 +251,14 @@ export const SongList: React.FC<SongListProps> = ({
|
||||
`}
|
||||
>
|
||||
<Filter size={14} fill={activeFilters.size > 0 ? "currentColor" : "none"} />
|
||||
<span>Filters {activeFilters.size > 0 && `(${activeFilters.size})`}</span>
|
||||
<span>{t('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
|
||||
{t('refineBy')}
|
||||
</div>
|
||||
{FILTERS.map(filter => (
|
||||
<button
|
||||
@@ -326,12 +346,12 @@ export const SongList: React.FC<SongListProps> = ({
|
||||
<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>
|
||||
<p className="font-medium">{t('noSongsMatchFilters')}</p>
|
||||
<button
|
||||
onClick={() => { setActiveFilters(new Set()); setSearchQuery(''); }}
|
||||
className="text-pink-600 dark:text-pink-500 text-sm font-bold hover:underline"
|
||||
>
|
||||
Clear filters
|
||||
{t('clearFilters')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -365,6 +385,7 @@ export const SongList: React.FC<SongListProps> = ({
|
||||
onNavigateToProfile={onNavigateToProfile}
|
||||
onReusePrompt={() => onReusePrompt?.(item.song)}
|
||||
onDelete={() => onDelete?.(item.song)}
|
||||
onSongUpdate={onSongUpdate}
|
||||
onUseAsReference={() => onUseAsReference?.(item.song)}
|
||||
onCoverSong={() => onCoverSong?.(item.song)}
|
||||
/>
|
||||
@@ -417,6 +438,7 @@ interface SongItemProps {
|
||||
onNavigateToProfile?: (username: string) => void;
|
||||
onReusePrompt?: () => void;
|
||||
onDelete?: () => void;
|
||||
onSongUpdate?: (updatedSong: Song) => void;
|
||||
onUseAsReference?: () => void;
|
||||
onCoverSong?: () => void;
|
||||
}
|
||||
@@ -440,12 +462,54 @@ const SongItem: React.FC<SongItemProps> = ({
|
||||
onNavigateToProfile,
|
||||
onReusePrompt,
|
||||
onDelete,
|
||||
onSongUpdate,
|
||||
onUseAsReference,
|
||||
onCoverSong
|
||||
}) => {
|
||||
const { token } = useAuth();
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
const [editedTitle, setEditedTitle] = useState(song.title);
|
||||
const titleInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditingTitle && titleInputRef.current) {
|
||||
titleInputRef.current.focus();
|
||||
titleInputRef.current.select();
|
||||
}
|
||||
}, [isEditingTitle]);
|
||||
|
||||
const handleSaveTitle = async () => {
|
||||
if (!token || !isOwner || !editedTitle.trim() || editedTitle === song.title) {
|
||||
setIsEditingTitle(false);
|
||||
setEditedTitle(song.title);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await songsApi.updateSong(song.id, { title: editedTitle.trim() }, token);
|
||||
setIsEditingTitle(false);
|
||||
// Update the parent component's song list
|
||||
if (onSongUpdate && response.song) {
|
||||
onSongUpdate(response.song);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update title:', error);
|
||||
setEditedTitle(song.title);
|
||||
setIsEditingTitle(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTitleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSaveTitle();
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditedTitle(song.title);
|
||||
setIsEditingTitle(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -550,11 +614,32 @@ const SongItem: React.FC<SongItemProps> = ({
|
||||
<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
|
||||
{isEditingTitle && isOwner ? (
|
||||
<input
|
||||
ref={titleInputRef}
|
||||
type="text"
|
||||
value={editedTitle}
|
||||
onChange={(e) => setEditedTitle(e.target.value)}
|
||||
onBlur={handleSaveTitle}
|
||||
onKeyDown={handleTitleKeyDown}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="font-bold text-lg bg-zinc-100 dark:bg-zinc-800 px-2 py-0.5 rounded border border-pink-500 focus:outline-none text-zinc-900 dark:text-white min-w-0 flex-1"
|
||||
/>
|
||||
) : (
|
||||
<h3
|
||||
className={`font-bold text-lg truncate ${isCurrent ? 'text-pink-600 dark:text-pink-500' : 'text-zinc-900 dark:text-white'} ${isOwner && !song.isGenerating ? 'cursor-pointer hover:underline' : ''}`}
|
||||
onClick={(e) => {
|
||||
if (isOwner && !song.isGenerating) {
|
||||
e.stopPropagation();
|
||||
setIsEditingTitle(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{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" title={`DiT model: ${song.ditModel || 'undefined'}`}>
|
||||
{getModelDisplayName(song.ditModel)}
|
||||
</span>
|
||||
{song.isPublic === false && (
|
||||
<Lock size={12} className="text-zinc-400 dark:text-zinc-500" />
|
||||
|
||||
@@ -2,8 +2,10 @@ import React, { useState, useEffect } from 'react';
|
||||
import { Song } from '../types';
|
||||
import { songsApi, getAudioUrl } from '../services/api';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useI18n } from '../context/I18nContext';
|
||||
import { ArrowLeft, Play, Pause, Heart, Share2, MoreHorizontal, ThumbsDown, Music as MusicIcon, Edit3, Eye } from 'lucide-react';
|
||||
import { ShareModal } from './ShareModal';
|
||||
import { SongDropdownMenu } from './SongDropdownMenu';
|
||||
|
||||
interface SongProfileProps {
|
||||
songId: string;
|
||||
@@ -14,6 +16,7 @@ interface SongProfileProps {
|
||||
isPlaying?: boolean;
|
||||
likedSongIds?: Set<string>;
|
||||
onToggleLike?: (songId: string) => void;
|
||||
onDelete?: (song: Song) => void;
|
||||
}
|
||||
|
||||
const updateMetaTags = (song: Song) => {
|
||||
@@ -79,11 +82,13 @@ const resetMetaTags = () => {
|
||||
updateMeta('meta[name="twitter:image"]', defaultImage);
|
||||
};
|
||||
|
||||
export const SongProfile: React.FC<SongProfileProps> = ({ songId, onBack, onPlay, onNavigateToProfile, currentSong, isPlaying, likedSongIds = new Set(), onToggleLike }) => {
|
||||
export const SongProfile: React.FC<SongProfileProps> = ({ songId, onBack, onPlay, onNavigateToProfile, currentSong, isPlaying, likedSongIds = new Set(), onToggleLike, onDelete }) => {
|
||||
const { user, token } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const [song, setSong] = useState<Song | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
|
||||
const isCurrentSong = song && currentSong?.id === song.id;
|
||||
const isCurrentlyPlaying = isCurrentSong && isPlaying;
|
||||
@@ -138,7 +143,7 @@ export const SongProfile: React.FC<SongProfileProps> = ({ songId, onBack, onPlay
|
||||
<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...
|
||||
{t('loadingSong')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -147,9 +152,9 @@ export const SongProfile: React.FC<SongProfileProps> = ({ songId, onBack, onPlay
|
||||
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>
|
||||
<div className="text-zinc-500 dark:text-zinc-400">{t('songNotFound')}</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
|
||||
{t('goBack')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -164,7 +169,7 @@ export const SongProfile: React.FC<SongProfileProps> = ({ songId, onBack, onPlay
|
||||
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>
|
||||
<span>{t('back')}</span>
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
|
||||
@@ -220,7 +225,7 @@ export const SongProfile: React.FC<SongProfileProps> = ({ songId, onBack, onPlay
|
||||
|
||||
{/* 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">
|
||||
<div className="max-w-3xl mx-auto px-4 md:px-6 py-4 md:py-6 pb-24 lg:pb-32">
|
||||
|
||||
{/* Left Column: Song Details */}
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
@@ -281,9 +286,26 @@ export const SongProfile: React.FC<SongProfileProps> = ({ songId, onBack, onPlay
|
||||
>
|
||||
<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 className="relative">
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
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>
|
||||
{song && (
|
||||
<SongDropdownMenu
|
||||
song={song}
|
||||
isOpen={showDropdown}
|
||||
onClose={() => setShowDropdown(false)}
|
||||
isOwner={user?.id === song.userId}
|
||||
onReusePrompt={() => {}}
|
||||
onAddToPlaylist={() => {}}
|
||||
onDelete={() => onDelete?.(song)}
|
||||
onShare={() => setShareModalOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lyrics */}
|
||||
|
||||
+34
-32
@@ -3,6 +3,7 @@ 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';
|
||||
import { useI18n } from '../context/I18nContext';
|
||||
|
||||
interface UserProfileProps {
|
||||
username: string;
|
||||
@@ -17,6 +18,7 @@ interface UserProfileProps {
|
||||
}
|
||||
|
||||
export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPlaySong, onNavigateToProfile, onNavigateToPlaylist, currentSong, isPlaying, likedSongIds = new Set(), onToggleLike }) => {
|
||||
const { t, language } = useI18n();
|
||||
const { user: currentUser, token } = useAuth();
|
||||
const [profileUser, setProfileUser] = useState<UserProfileType | null>(null);
|
||||
const [publicSongs, setPublicSongs] = useState<Song[]>([]);
|
||||
@@ -145,7 +147,7 @@ export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPl
|
||||
loadUserProfile();
|
||||
} catch (error) {
|
||||
console.error('Failed to update profile:', error);
|
||||
alert('Failed to update profile');
|
||||
alert(t('profileUpdateFailed'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setUploadingAvatar(false);
|
||||
@@ -158,7 +160,7 @@ export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPl
|
||||
<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...
|
||||
{t('loadingProfile')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -167,9 +169,9 @@ export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPl
|
||||
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>
|
||||
<div className="text-zinc-500 dark:text-zinc-400">{t('userNotFound')}</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
|
||||
{t('goBack')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -233,7 +235,7 @@ export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPl
|
||||
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">
|
||||
<div className="w-full h-full flex flex-col bg-zinc-50 dark:bg-black overflow-y-auto pb-24 lg:pb-32 relative">
|
||||
{/* Hero Banner */}
|
||||
<div className="relative group/banner">
|
||||
{/* Background Banner */}
|
||||
@@ -250,7 +252,7 @@ export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPl
|
||||
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>
|
||||
<span>{t('back')}</span>
|
||||
</button>
|
||||
|
||||
{/* Edit Banner Button (Owner Only) - Visual Cue */}
|
||||
@@ -356,7 +358,7 @@ export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPl
|
||||
|
||||
{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' })}
|
||||
{t('supportingSince')} {new Date(profileUser.supporter_since).toLocaleDateString(language === 'zh' ? 'zh-CN' : 'en-US', { month: 'long', year: 'numeric' })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -368,7 +370,7 @@ export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPl
|
||||
)}
|
||||
|
||||
<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' })}
|
||||
{t('joined')} {new Date(profileUser.created_at).toLocaleDateString(language === 'zh' ? 'zh-CN' : 'en-US', { month: 'long', year: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -379,7 +381,7 @@ export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPl
|
||||
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
|
||||
{t('editProfile')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -389,17 +391,17 @@ export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPl
|
||||
<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>
|
||||
<span className="text-zinc-500 dark:text-zinc-400">{t('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>
|
||||
<span className="text-zinc-500 dark:text-zinc-400">{t('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>
|
||||
<span className="text-zinc-500 dark:text-zinc-400">{t('plays')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -412,7 +414,7 @@ export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPl
|
||||
{/* 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>
|
||||
<h2 className="text-xl md:text-2xl font-bold text-zinc-900 dark:text-white mb-4 md:mb-6">{t('featuredSongs')}</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;
|
||||
@@ -478,7 +480,7 @@ export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPl
|
||||
{/* 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>
|
||||
<h2 className="text-xl md:text-2xl font-bold text-zinc-900 dark:text-white">{t('songs')}</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex bg-zinc-200 dark:bg-zinc-900 rounded-full p-1">
|
||||
<button
|
||||
@@ -486,14 +488,14 @@ export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPl
|
||||
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
|
||||
{t('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
|
||||
{t('top')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -502,7 +504,7 @@ export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPl
|
||||
{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>
|
||||
<p>{t('noPublicSongsYet')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 md:gap-4">
|
||||
@@ -563,9 +565,9 @@ export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPl
|
||||
{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>
|
||||
<h2 className="text-xl md:text-2xl font-bold text-zinc-900 dark:text-white">{t('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} />
|
||||
{t('seeMore')} <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">
|
||||
@@ -584,7 +586,7 @@ export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPl
|
||||
</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>
|
||||
<p className="text-xs md:text-sm text-zinc-500 dark:text-zinc-400">{playlist.song_count} {t('songs')}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -594,10 +596,10 @@ export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPl
|
||||
|
||||
{/* 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="fixed inset-0 z-[60] 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>
|
||||
<h2 className="text-lg md:text-xl font-bold text-zinc-900 dark:text-white">{t('editProfile')}</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>
|
||||
@@ -606,7 +608,7 @@ export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPl
|
||||
<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>
|
||||
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">{t('avatarImage')}</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) ? (
|
||||
@@ -640,16 +642,16 @@ export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPl
|
||||
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
|
||||
{t('uploadAvatar')}
|
||||
</button>
|
||||
<p className="text-xs text-zinc-500">JPG, PNG, WebP, GIF • Max 5MB</p>
|
||||
<p className="text-xs text-zinc-500">{t('avatarFormats')}</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>
|
||||
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">{t('bannerImage')}</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"
|
||||
@@ -663,7 +665,7 @@ export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPl
|
||||
) : (
|
||||
<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>
|
||||
<span className="text-sm">{t('clickToUploadBanner')}</span>
|
||||
</div>
|
||||
)}
|
||||
{uploadingBanner && (
|
||||
@@ -679,16 +681,16 @@ export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPl
|
||||
onChange={handleBannerChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<p className="text-xs text-zinc-500">Recommended: 1500x500px • JPG, PNG, WebP, GIF • Max 5MB</p>
|
||||
<p className="text-xs text-zinc-500">{t('bannerFormats')}</p>
|
||||
</div>
|
||||
|
||||
{/* Bio Input */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Bio</label>
|
||||
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">{t('bio')}</label>
|
||||
<textarea
|
||||
value={editBio}
|
||||
onChange={(e) => setEditBio(e.target.value)}
|
||||
placeholder="Tell us about yourself..."
|
||||
placeholder={t('bioPlaceholder')}
|
||||
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"
|
||||
/>
|
||||
@@ -707,7 +709,7 @@ export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPl
|
||||
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
|
||||
{t('cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveProfile}
|
||||
@@ -715,7 +717,7 @@ export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPl
|
||||
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'}
|
||||
{uploadingAvatar ? t('uploadingAvatar') : uploadingBanner ? t('uploadingBanner') : isSaving ? t('saving') : t('saveChanges')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, User, Sparkles } from 'lucide-react';
|
||||
import { useI18n } from '../context/I18nContext';
|
||||
|
||||
interface UsernameModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -7,6 +8,7 @@ interface UsernameModalProps {
|
||||
}
|
||||
|
||||
export const UsernameModal: React.FC<UsernameModalProps> = ({ isOpen, onSubmit }) => {
|
||||
const { t } = useI18n();
|
||||
const [username, setUsername] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -19,12 +21,12 @@ export const UsernameModal: React.FC<UsernameModalProps> = ({ isOpen, onSubmit }
|
||||
|
||||
const trimmed = username.trim();
|
||||
if (trimmed.length < 2) {
|
||||
setError('Username must be at least 2 characters');
|
||||
setError(t('usernameMinLength'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
|
||||
setError('Username can only contain letters, numbers, underscores, and dashes');
|
||||
setError(t('usernameInvalidChars'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -32,14 +34,14 @@ export const UsernameModal: React.FC<UsernameModalProps> = ({ isOpen, onSubmit }
|
||||
try {
|
||||
await onSubmit(trimmed);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to set username');
|
||||
setError(err instanceof Error ? err.message : t('failedToSetUsername'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm" />
|
||||
|
||||
@@ -58,17 +60,17 @@ export const UsernameModal: React.FC<UsernameModalProps> = ({ isOpen, onSubmit }
|
||||
|
||||
{/* Title */}
|
||||
<h2 className="text-2xl font-bold text-center text-white mb-2">
|
||||
Welcome to ACE-Step UI
|
||||
{t('welcomeTitle')}
|
||||
</h2>
|
||||
<p className="text-zinc-400 text-center mb-8">
|
||||
Enter your name to get started creating AI music
|
||||
{t('welcomeSubtitle')}
|
||||
</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
|
||||
{t('yourName')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
@@ -79,7 +81,7 @@ export const UsernameModal: React.FC<UsernameModalProps> = ({ isOpen, onSubmit }
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter your name"
|
||||
placeholder={t('enterYourName')}
|
||||
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}
|
||||
@@ -101,17 +103,17 @@ export const UsernameModal: React.FC<UsernameModalProps> = ({ isOpen, onSubmit }
|
||||
<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...
|
||||
{t('gettingStarted')}
|
||||
</span>
|
||||
) : (
|
||||
'Get Started'
|
||||
t('getStarted')
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="mt-6 text-xs text-zinc-500 text-center">
|
||||
Your music, your way. Create unlimited AI music for free.
|
||||
{t('yourMusicYourWay')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user