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:
fspecii
2026-02-08 18:35:09 +02:00
parent 1b13f01b3d
commit 325046eaf2
17 changed files with 760 additions and 431 deletions
+126 -117
View File
@@ -20,6 +20,7 @@ import { List } from 'lucide-react';
import { PlaylistDetail } from './components/PlaylistDetail'; import { PlaylistDetail } from './components/PlaylistDetail';
import { Toast, ToastType } from './components/Toast'; import { Toast, ToastType } from './components/Toast';
import { SearchPage } from './components/SearchPage'; import { SearchPage } from './components/SearchPage';
import { ConfirmDialog } from './components/ConfirmDialog';
function AppContent() { function AppContent() {
@@ -63,13 +64,18 @@ function AppContent() {
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0); const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(0.8); const [volume, setVolume] = useState(() => {
const stored = localStorage.getItem('volume');
return stored ? parseFloat(stored) : 0.8;
});
const [playbackRate, setPlaybackRate] = useState(1.0);
const [isShuffle, setIsShuffle] = useState(false); const [isShuffle, setIsShuffle] = useState(false);
const [repeatMode, setRepeatMode] = useState<'none' | 'all' | 'one'>('all'); const [repeatMode, setRepeatMode] = useState<'none' | 'all' | 'one'>('all');
// UI State // UI State
const [isGenerating, setIsGenerating] = useState(false); const [isGenerating, setIsGenerating] = useState(false);
const [showRightSidebar, setShowRightSidebar] = useState(true); const [showRightSidebar, setShowRightSidebar] = useState(true);
const [showLeftSidebar, setShowLeftSidebar] = useState(true);
const [pendingAudioSelection, setPendingAudioSelection] = useState<{ target: 'reference' | 'source'; url: string; title?: string } | null>(null); const [pendingAudioSelection, setPendingAudioSelection] = useState<{ target: 'reference' | 'source'; url: string; title?: string } | null>(null);
// Mobile UI Toggle // Mobile UI Toggle
@@ -100,6 +106,7 @@ function AppContent() {
const [reuseData, setReuseData] = useState<{ song: Song, timestamp: number } | null>(null); const [reuseData, setReuseData] = useState<{ song: Song, timestamp: number } | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
const currentSongIdRef = useRef<string | null>(null);
const pendingSeekRef = useRef<number | null>(null); const pendingSeekRef = useRef<number | null>(null);
const playNextRef = useRef<() => void>(() => {}); const playNextRef = useRef<() => void>(() => {});
@@ -113,6 +120,13 @@ function AppContent() {
isVisible: false, isVisible: false,
}); });
// Confirm Dialog State
const [confirmDialog, setConfirmDialog] = useState<{
title: string;
message: string;
onConfirm: () => void;
} | null>(null);
interface ReferenceTrack { interface ReferenceTrack {
id: string; id: string;
filename: string; filename: string;
@@ -176,6 +190,9 @@ function AppContent() {
// Song Update Handler // Song Update Handler
const handleSongUpdate = (updatedSong: Song) => { const handleSongUpdate = (updatedSong: Song) => {
setSongs(prev => prev.map(s => s.id === updatedSong.id ? updatedSong : s)); setSongs(prev => prev.map(s => s.id === updatedSong.id ? updatedSong : s));
if (currentSong?.id === updatedSong.id) {
setCurrentSong(updatedSong);
}
if (selectedSong?.id === updatedSong.id) { if (selectedSong?.id === updatedSong.id) {
setSelectedSong(updatedSong); setSelectedSong(updatedSong);
} }
@@ -298,6 +315,7 @@ function AppContent() {
viewCount: s.view_count || 0, viewCount: s.view_count || 0,
userId: s.user_id, userId: s.user_id,
creator: s.creator, creator: s.creator,
ditModel: s.ditModel,
generationParams: (() => { generationParams: (() => {
try { try {
if (!s.generation_params) return undefined; if (!s.generation_params) return undefined;
@@ -513,7 +531,8 @@ function AppContent() {
} }
}; };
if (audio.src !== currentSong.audioUrl) { if (currentSongIdRef.current !== currentSong.id) {
currentSongIdRef.current = currentSong.id;
audio.src = currentSong.audioUrl; audio.src = currentSong.audioUrl;
audio.load(); audio.load();
if (isPlaying) playAudio(); if (isPlaying) playAudio();
@@ -528,8 +547,16 @@ function AppContent() {
if (audioRef.current) { if (audioRef.current) {
audioRef.current.volume = volume; audioRef.current.volume = volume;
} }
localStorage.setItem('volume', String(volume));
}, [volume]); }, [volume]);
// Handle Playback Rate
useEffect(() => {
if (audioRef.current) {
audioRef.current.playbackRate = playbackRate;
}
}, [playbackRate]);
// Helper to cleanup a job and check if all jobs are done // Helper to cleanup a job and check if all jobs are done
const cleanupJob = useCallback((jobId: string, tempId: string) => { const cleanupJob = useCallback((jobId: string, tempId: string) => {
const jobData = activeJobsRef.current.get(jobId); const jobData = activeJobsRef.current.get(jobId);
@@ -570,6 +597,7 @@ function AppContent() {
viewCount: s.view_count || 0, viewCount: s.view_count || 0,
userId: s.user_id, userId: s.user_id,
creator: s.creator, creator: s.creator,
ditModel: s.ditModel,
generationParams: (() => { generationParams: (() => {
try { try {
if (!s.generation_params) return undefined; if (!s.generation_params) return undefined;
@@ -908,127 +936,99 @@ function AppContent() {
} }
}; };
const handleDeleteSong = async (song: Song) => { const handleDeleteSong = (song: Song) => {
if (!token) return; handleDeleteSongs([song]);
// Show confirmation dialog
const confirmed = window.confirm(
`Are you sure you want to delete "${song.title}"? This action cannot be undone.`
);
if (!confirmed) return;
try {
// Call API to delete song
await songsApi.deleteSong(song.id, token);
// Remove from songs list
setSongs(prev => prev.filter(s => s.id !== song.id));
// Remove from liked songs if it was liked
setLikedSongIds(prev => {
const next = new Set(prev);
next.delete(song.id);
return next;
});
// Handle if deleted song is currently selected
if (selectedSong?.id === song.id) {
setSelectedSong(null);
}
// Handle if deleted song is currently playing
if (currentSong?.id === song.id) {
setCurrentSong(null);
setIsPlaying(false);
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.src = '';
}
}
// Remove from play queue if present
setPlayQueue(prev => prev.filter(s => s.id !== song.id));
showToast(t('songDeleted'));
} catch (error) {
console.error('Failed to delete song:', error);
showToast(t('failedToDeleteSong'), 'error');
}
}; };
const handleDeleteSongs = async (songsToDelete: Song[]) => { const handleDeleteSongs = (songsToDelete: Song[]) => {
if (!token || songsToDelete.length === 0) return; if (!token || songsToDelete.length === 0) return;
const confirmed = window.confirm( const isSingle = songsToDelete.length === 1;
`Delete ${songsToDelete.length} songs? This action cannot be undone.` const title = isSingle ? t('confirmDeleteTitle') : t('confirmDeleteManyTitle');
); const message = isSingle
if (!confirmed) return; ? t('deleteSongConfirm').replace('{title}', songsToDelete[0].title)
: t('deleteSongsConfirm').replace('{count}', String(songsToDelete.length));
const idsToDelete = new Set(songsToDelete.map(song => song.id)); setConfirmDialog({
const succeeded: string[] = []; title,
const failed: string[] = []; message,
onConfirm: async () => {
setConfirmDialog(null);
for (const song of songsToDelete) { const idsToDelete = new Set(songsToDelete.map(song => song.id));
try { const succeeded: string[] = [];
await songsApi.deleteSong(song.id, token); const failed: string[] = [];
succeeded.push(song.id);
} catch (error) {
console.error('Failed to delete song:', error);
failed.push(song.id);
}
}
if (succeeded.length > 0) { for (const song of songsToDelete) {
setSongs(prev => prev.filter(s => !idsToDelete.has(s.id) || failed.includes(s.id))); try {
await songsApi.deleteSong(song.id, token!);
setLikedSongIds(prev => { succeeded.push(song.id);
const next = new Set(prev); } catch (error) {
succeeded.forEach(id => next.delete(id)); console.error('Failed to delete song:', error);
return next; failed.push(song.id);
}); }
if (selectedSong?.id && succeeded.includes(selectedSong.id)) {
setSelectedSong(null);
}
if (currentSong?.id && succeeded.includes(currentSong.id)) {
setCurrentSong(null);
setIsPlaying(false);
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.src = '';
} }
}
setPlayQueue(prev => prev.filter(s => !idsToDelete.has(s.id) || failed.includes(s.id))); if (succeeded.length > 0) {
} setSongs(prev => prev.filter(s => !idsToDelete.has(s.id) || failed.includes(s.id)));
if (failed.length > 0) { setLikedSongIds(prev => {
showToast(t('songsDeletedPartial').replace('{succeeded}', String(succeeded.length)).replace('{total}', String(songsToDelete.length)), 'error'); const next = new Set(prev);
} else { succeeded.forEach(id => next.delete(id));
showToast(t('songsDeletedSuccess')); return next;
} });
if (selectedSong?.id && succeeded.includes(selectedSong.id)) {
setSelectedSong(null);
}
if (currentSong?.id && succeeded.includes(currentSong.id)) {
setCurrentSong(null);
setIsPlaying(false);
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.src = '';
}
}
setPlayQueue(prev => prev.filter(s => !idsToDelete.has(s.id) || failed.includes(s.id)));
}
if (failed.length > 0) {
showToast(t('songsDeletedPartial').replace('{succeeded}', String(succeeded.length)).replace('{total}', String(songsToDelete.length)), 'error');
} else if (isSingle) {
showToast(t('songDeleted'));
} else {
showToast(t('songsDeletedSuccess'));
}
},
});
}; };
const handleDeleteReferenceTrack = async (trackId: string) => { const handleDeleteReferenceTrack = (trackId: string) => {
if (!token) return; if (!token) return;
const confirmed = window.confirm('Delete this upload? This action cannot be undone.');
if (!confirmed) return; setConfirmDialog({
try { title: t('delete'),
const response = await fetch(`/api/reference-tracks/${trackId}`, { message: t('deleteUploadConfirm'),
method: 'DELETE', onConfirm: async () => {
headers: { Authorization: `Bearer ${token}` } setConfirmDialog(null);
}); try {
if (!response.ok) { const response = await fetch(`/api/reference-tracks/${trackId}`, {
throw new Error('Failed to delete upload'); method: 'DELETE',
} headers: { Authorization: `Bearer ${token!}` }
setReferenceTracks(prev => prev.filter(track => track.id !== trackId)); });
showToast(t('songDeleted')); if (!response.ok) {
} catch (error) { throw new Error('Failed to delete upload');
console.error('Failed to delete upload:', error); }
showToast(t('failedToDeleteSong'), 'error'); setReferenceTracks(prev => prev.filter(track => track.id !== trackId));
} showToast(t('songDeleted'));
} catch (error) {
console.error('Failed to delete upload:', error);
showToast(t('failedToDeleteSong'), 'error');
}
},
});
}; };
const createPlaylist = async (name: string, description: string) => { const createPlaylist = async (name: string, description: string) => {
@@ -1260,6 +1260,7 @@ function AppContent() {
onCoverSong={handleCoverSong} onCoverSong={handleCoverSong}
onUseUploadAsReference={handleUseUploadAsReference} onUseUploadAsReference={handleUseUploadAsReference}
onCoverUpload={handleCoverUpload} onCoverUpload={handleCoverUpload}
onSongUpdate={handleSongUpdate}
/> />
</div> </div>
@@ -1276,9 +1277,6 @@ function AppContent() {
onNavigateToSong={handleNavigateToSong} onNavigateToSong={handleNavigateToSong}
isLiked={selectedSong ? likedSongIds.has(selectedSong.id) : false} isLiked={selectedSong ? likedSongIds.has(selectedSong.id) : false}
onToggleLike={toggleLike} onToggleLike={toggleLike}
onPlay={playSong}
isPlaying={isPlaying}
currentSong={currentSong}
onDelete={handleDeleteSong} onDelete={handleDeleteSong}
/> />
</div> </div>
@@ -1314,6 +1312,7 @@ function AppContent() {
} else if (v === 'search') { } else if (v === 'search') {
window.history.pushState({}, '', '/search'); window.history.pushState({}, '', '/search');
} }
if (isMobile) setShowLeftSidebar(false);
}} }}
theme={theme} theme={theme}
onToggleTheme={toggleTheme} onToggleTheme={toggleTheme}
@@ -1321,6 +1320,8 @@ function AppContent() {
onLogin={() => setShowUsernameModal(true)} onLogin={() => setShowUsernameModal(true)}
onLogout={logout} onLogout={logout}
onOpenSettings={() => setShowSettingsModal(true)} onOpenSettings={() => setShowSettingsModal(true)}
isOpen={showLeftSidebar}
onToggle={() => setShowLeftSidebar(!showLeftSidebar)}
/> />
<main className="flex-1 flex overflow-hidden relative"> <main className="flex-1 flex overflow-hidden relative">
@@ -1339,6 +1340,9 @@ function AppContent() {
onPrevious={playPrevious} onPrevious={playPrevious}
volume={volume} volume={volume}
onVolumeChange={setVolume} onVolumeChange={setVolume}
playbackRate={playbackRate}
onPlaybackRateChange={setPlaybackRate}
audioRef={audioRef}
isShuffle={isShuffle} isShuffle={isShuffle}
onToggleShuffle={() => setIsShuffle(!isShuffle)} onToggleShuffle={() => setIsShuffle(!isShuffle)}
repeatMode={repeatMode} repeatMode={repeatMode}
@@ -1392,7 +1396,7 @@ function AppContent() {
{/* Mobile Details Modal */} {/* Mobile Details Modal */}
{showMobileDetails && selectedSong && ( {showMobileDetails && selectedSong && (
<div className="fixed inset-0 z-50 flex justify-end xl:hidden"> <div className="fixed inset-0 z-[60] flex justify-end xl:hidden">
<div <div
className="absolute inset-0 bg-black/60 backdrop-blur-sm animate-in fade-in" className="absolute inset-0 bg-black/60 backdrop-blur-sm animate-in fade-in"
onClick={() => setShowMobileDetails(false)} onClick={() => setShowMobileDetails(false)}
@@ -1408,14 +1412,19 @@ function AppContent() {
onNavigateToSong={handleNavigateToSong} onNavigateToSong={handleNavigateToSong}
isLiked={selectedSong ? likedSongIds.has(selectedSong.id) : false} isLiked={selectedSong ? likedSongIds.has(selectedSong.id) : false}
onToggleLike={toggleLike} onToggleLike={toggleLike}
onPlay={playSong}
isPlaying={isPlaying}
currentSong={currentSong}
onDelete={handleDeleteSong} onDelete={handleDeleteSong}
/> />
</div> </div>
</div> </div>
)} )}
<ConfirmDialog
isOpen={confirmDialog !== null}
title={confirmDialog?.title ?? ''}
message={confirmDialog?.message ?? ''}
onConfirm={() => confirmDialog?.onConfirm()}
onCancel={() => setConfirmDialog(null)}
/>
</div> </div>
); );
} }
+35 -33
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { X, Camera, Image as ImageIcon, Upload, Loader2 } from 'lucide-react'; import { X, Camera, Image as ImageIcon, Upload, Loader2 } from 'lucide-react';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { usersApi, UserProfile } from '../services/api'; import { usersApi, UserProfile } from '../services/api';
import { useI18n } from '../context/I18nContext';
interface EditProfileModalProps { interface EditProfileModalProps {
isOpen: boolean; isOpen: boolean;
@@ -10,6 +11,7 @@ interface EditProfileModalProps {
} }
export const EditProfileModal: React.FC<EditProfileModalProps> = ({ isOpen, onClose, onSaved }) => { export const EditProfileModal: React.FC<EditProfileModalProps> = ({ isOpen, onClose, onSaved }) => {
const { t } = useI18n();
const { user, token, refreshUser, updateUsername } = useAuth(); const { user, token, refreshUser, updateUsername } = useAuth();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [profile, setProfile] = useState<UserProfile | null>(null); const [profile, setProfile] = useState<UserProfile | null>(null);
@@ -83,7 +85,7 @@ export const EditProfileModal: React.FC<EditProfileModalProps> = ({ isOpen, onCl
if (editUsername && editUsername !== profile.username) { if (editUsername && editUsername !== profile.username) {
const sanitized = editUsername.trim().replace(/[^a-zA-Z0-9_-]/g, ''); const sanitized = editUsername.trim().replace(/[^a-zA-Z0-9_-]/g, '');
if (sanitized.length < 2) { if (sanitized.length < 2) {
setUsernameError('Username must be at least 2 characters'); setUsernameError(t('usernameMinLengthError'));
setIsSaving(false); setIsSaving(false);
return; return;
} }
@@ -92,9 +94,9 @@ export const EditProfileModal: React.FC<EditProfileModalProps> = ({ isOpen, onCl
} catch (err: unknown) { } catch (err: unknown) {
const error = err as Error & { message?: string }; const error = err as Error & { message?: string };
if (error.message?.includes('taken')) { if (error.message?.includes('taken')) {
setUsernameError('Username is already taken'); setUsernameError(t('usernameTakenError'));
} else { } else {
setUsernameError('Failed to update username'); setUsernameError(t('usernameUpdateFailedError'));
} }
setIsSaving(false); setIsSaving(false);
return; return;
@@ -152,27 +154,27 @@ export const EditProfileModal: React.FC<EditProfileModalProps> = ({ isOpen, onCl
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 z-[60] flex items-center justify-center 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-zinc-900 border border-zinc-800 rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200"> <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-800 flex items-center justify-between"> <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-white">Edit Profile</h2> <h2 className="text-xl font-bold text-zinc-900 dark:text-white">{t('editProfile')}</h2>
<button onClick={handleClose} className="text-zinc-400 hover:text-white transition-colors"> <button onClick={handleClose} className="text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors">
<X size={20} /> <X size={20} />
</button> </button>
</div> </div>
{loading ? ( {loading ? (
<div className="p-12 flex items-center justify-center"> <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>
) : ( ) : (
<> <>
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
{/* Username Input */} {/* Username Input */}
<div className="space-y-2"> <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"> <div className="flex items-center gap-2">
<span className="text-zinc-500">@</span> <span className="text-zinc-500 dark:text-zinc-500">@</span>
<input <input
type="text" type="text"
value={editUsername} value={editUsername}
@@ -180,22 +182,22 @@ export const EditProfileModal: React.FC<EditProfileModalProps> = ({ isOpen, onCl
setEditUsername(e.target.value); setEditUsername(e.target.value);
setUsernameError(''); setUsernameError('');
}} }}
placeholder="username" placeholder={t('usernamePlaceholder')}
maxLength={50} 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> </div>
{usernameError && ( {usernameError && (
<p className="text-sm text-red-500">{usernameError}</p> <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> </div>
{/* Avatar Upload */} {/* Avatar Upload */}
<div className="space-y-2"> <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="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) ? ( {(avatarPreview || editAvatarUrl) ? (
<img <img
src={avatarPreview || editAvatarUrl} src={avatarPreview || editAvatarUrl}
@@ -203,7 +205,7 @@ export const EditProfileModal: React.FC<EditProfileModalProps> = ({ isOpen, onCl
onError={(e) => (e.currentTarget.style.display = 'none')} 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} /> <Camera size={24} />
</div> </div>
)} )}
@@ -224,22 +226,22 @@ export const EditProfileModal: React.FC<EditProfileModalProps> = ({ isOpen, onCl
<button <button
type="button" type="button"
onClick={() => avatarInputRef.current?.click()} 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 size={16} />
Upload Avatar {t('uploadAvatar')}
</button> </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> </div>
</div> </div>
{/* Banner Upload */} {/* Banner Upload */}
<div className="space-y-2"> <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 <div
onClick={() => bannerInputRef.current?.click()} 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) ? ( {(bannerPreview || editBannerUrl) ? (
<img <img
@@ -248,9 +250,9 @@ export const EditProfileModal: React.FC<EditProfileModalProps> = ({ isOpen, onCl
onError={(e) => (e.currentTarget.style.display = 'none')} 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} /> <ImageIcon size={32} />
<span className="text-sm">Click to upload banner</span> <span className="text-sm">{t('clickToUploadBanner')}</span>
</div> </div>
)} )}
{uploadingBanner && ( {uploadingBanner && (
@@ -266,37 +268,37 @@ export const EditProfileModal: React.FC<EditProfileModalProps> = ({ isOpen, onCl
onChange={handleBannerChange} onChange={handleBannerChange}
className="hidden" 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> </div>
{/* Bio Input */} {/* Bio Input */}
<div className="space-y-2"> <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 <textarea
value={editBio} value={editBio}
onChange={(e) => setEditBio(e.target.value)} onChange={(e) => setEditBio(e.target.value)}
placeholder="Tell us about yourself..." placeholder={t('bioPlaceholder')}
rows={4} 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> </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 <button
onClick={handleClose} 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} disabled={isSaving}
> >
Cancel {t('cancel')}
</button> </button>
<button <button
onClick={handleSaveProfile} onClick={handleSaveProfile}
disabled={isSaving || uploadingAvatar || uploadingBanner} 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" />} {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> </button>
</div> </div>
</> </>
+10 -8
View File
@@ -5,6 +5,7 @@ import { useAuth } from '../context/AuthContext';
import { SongDropdownMenu } from './SongDropdownMenu'; import { SongDropdownMenu } from './SongDropdownMenu';
import { ShareModal } from './ShareModal'; import { ShareModal } from './ShareModal';
import { AlbumCover } from './AlbumCover'; import { AlbumCover } from './AlbumCover';
import { useI18n } from '../context/I18nContext';
interface LibraryViewProps { interface LibraryViewProps {
allSongs: Song[]; allSongs: Song[];
@@ -46,6 +47,7 @@ export const LibraryView: React.FC<LibraryViewProps> = ({
onDeleteSong, onDeleteSong,
onDeleteReferenceTrack, onDeleteReferenceTrack,
}) => { }) => {
const { t } = useI18n();
const { user } = useAuth(); const { user } = useAuth();
const [activeTab, setActiveTab] = useState<'all' | 'playlists' | 'liked' | 'uploads'>('all'); const [activeTab, setActiveTab] = useState<'all' | 'playlists' | 'liked' | 'uploads'>('all');
const [shareModalOpen, setShareModalOpen] = useState(false); 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-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"> <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 <button
onClick={onCreatePlaylist} 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" 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} /> <Plus size={18} />
<span>New Playlist</span> <span>{t('newPlaylist')}</span>
</button> </button>
</div> </div>
@@ -90,14 +92,14 @@ export const LibraryView: React.FC<LibraryViewProps> = ({
onClick={() => setActiveTab('liked')} 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'}`} 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>} {activeTab === 'liked' && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-green-500 rounded-full"></div>}
</button> </button>
<button <button
onClick={() => setActiveTab('playlists')} 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'}`} 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>} {activeTab === 'playlists' && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-green-500 rounded-full"></div>}
</button> </button>
<button <button
@@ -168,10 +170,10 @@ export const LibraryView: React.FC<LibraryViewProps> = ({
<Heart fill="white" size={64} className="text-white" /> <Heart fill="white" size={64} className="text-white" />
</div> </div>
<div className="mb-2"> <div className="mb-2">
<h2 className="text-sm font-bold uppercase text-zinc-500 dark:text-white mb-2">Playlist</h2> <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">Liked Songs</h1> <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"> <div className="text-sm text-zinc-500 dark:text-zinc-300 font-medium">
{likedSongs.length} songs {likedSongs.length} {t('songs')}
</div> </div>
</div> </div>
<div className="ml-auto mb-2 opacity-0 group-hover:opacity-100 transition-opacity"> <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> </div>
<h3 className="font-bold text-zinc-900 dark:text-white truncate">{playlist.name}</h3> <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>
))} ))}
</div> </div>
+1 -1
View File
@@ -100,7 +100,7 @@ export function MobileDrawer({ isOpen, onClose, position, children, title }: Mob
const content = ( const content = (
<div <div
className={`fixed inset-0 z-50 ${backdropAnimation}`} className={`fixed inset-0 z-[60] ${backdropAnimation}`}
onClick={handleBackdropClick} onClick={handleBackdropClick}
role="presentation" role="presentation"
> >
+131 -38
View File
@@ -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 { Play, Pause, SkipBack, SkipForward, Repeat, Shuffle, Download, Heart, MoreVertical, Volume2, VolumeX, Maximize2, Repeat1, ChevronDown, ChevronUp } from 'lucide-react';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useResponsive } from '../context/ResponsiveContext'; import { useResponsive } from '../context/ResponsiveContext';
import { useI18n } from '../context/I18nContext';
import { SongDropdownMenu } from './SongDropdownMenu'; import { SongDropdownMenu } from './SongDropdownMenu';
import { ShareModal } from './ShareModal'; import { ShareModal } from './ShareModal';
import { AlbumCover } from './AlbumCover'; import { AlbumCover } from './AlbumCover';
@@ -18,6 +19,9 @@ interface PlayerProps {
onPrevious: () => void; onPrevious: () => void;
volume: number; volume: number;
onVolumeChange: (val: number) => void; onVolumeChange: (val: number) => void;
playbackRate: number;
onPlaybackRateChange: (rate: number) => void;
audioRef: React.RefObject<HTMLAudioElement>;
isShuffle: boolean; isShuffle: boolean;
onToggleShuffle: () => void; onToggleShuffle: () => void;
repeatMode: 'none' | 'all' | 'one'; repeatMode: 'none' | 'all' | 'one';
@@ -42,6 +46,9 @@ export const Player: React.FC<PlayerProps> = ({
onPrevious, onPrevious,
volume, volume,
onVolumeChange, onVolumeChange,
playbackRate,
onPlaybackRateChange,
audioRef,
isShuffle, isShuffle,
onToggleShuffle, onToggleShuffle,
repeatMode, repeatMode,
@@ -56,12 +63,15 @@ export const Player: React.FC<PlayerProps> = ({
}) => { }) => {
const { user } = useAuth(); const { user } = useAuth();
const { isMobile } = useResponsive(); const { isMobile } = useResponsive();
const { t } = useI18n();
const progressBarRef = useRef<HTMLDivElement>(null); const progressBarRef = useRef<HTMLDivElement>(null);
const fullscreenProgressRef = useRef<HTMLDivElement>(null); const fullscreenProgressRef = useRef<HTMLDivElement>(null);
const [isHoveringVolume, setIsHoveringVolume] = useState(false); const [isHoveringVolume, setIsHoveringVolume] = useState(false);
const [showDropdown, setShowDropdown] = useState(false); const [showDropdown, setShowDropdown] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const [shareModalOpen, setShareModalOpen] = useState(false); const [shareModalOpen, setShareModalOpen] = useState(false);
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
const speedMenuRef = useRef<HTMLDivElement>(null);
// Close fullscreen on Escape key // Close fullscreen on Escape key
useEffect(() => { useEffect(() => {
@@ -75,6 +85,17 @@ export const Player: React.FC<PlayerProps> = ({
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [isFullscreen]); }, [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 // Show minimal player when no song is playing
if (!currentSong) { if (!currentSong) {
return ( return (
@@ -137,7 +158,7 @@ export const Player: React.FC<PlayerProps> = ({
> >
<ChevronDown size={28} /> <ChevronDown size={28} />
</button> </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 className="w-11" />
</div> </div>
@@ -236,15 +257,9 @@ export const Player: React.FC<PlayerProps> = ({
</button> </button>
</div> </div>
{/* Volume Control */} {/* Volume Control - Vertical */}
<div className="flex items-center gap-3 px-6 py-4"> <div className="flex flex-col items-center gap-3 px-6 py-4">
<button <div className="relative h-32 w-8 flex items-center justify-center">
onClick={() => onVolumeChange(volume === 0 ? 0.8 : 0)}
className="text-zinc-400 dark:text-white/50 tap-highlight-none"
>
{volume === 0 ? <VolumeX size={20} /> : <Volume2 size={20} />}
</button>
<div className="flex-1 h-1 bg-zinc-300 dark:bg-white/20 rounded-full relative">
<input <input
type="range" type="range"
min="0" min="0"
@@ -252,13 +267,19 @@ export const Player: React.FC<PlayerProps> = ({
step="0.01" step="0.01"
value={volume} value={volume}
onChange={(e) => onVolumeChange(parseFloat(e.target.value))} onChange={(e) => onVolumeChange(parseFloat(e.target.value))}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10" className="w-32 h-8 -rotate-90 origin-center appearance-none bg-transparent cursor-pointer"
/> style={{
<div WebkitAppearance: 'none',
className="h-full bg-zinc-700 dark:bg-white/70 rounded-full" 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%)`
style={{ width: `${volume * 100}%` }} }}
/> />
</div> </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> </div>
{/* Extra Actions */} {/* Extra Actions */}
@@ -271,7 +292,7 @@ export const Player: React.FC<PlayerProps> = ({
<button <button
onClick={handleDownload} onClick={handleDownload}
className="p-3 tap-highlight-none" className="p-3 tap-highlight-none"
title="Download Audio" title={t('downloadAudio')}
> >
<Download size={20} /> <Download size={20} />
</button> </button>
@@ -295,7 +316,7 @@ export const Player: React.FC<PlayerProps> = ({
onCreateVideo={onOpenVideo} onCreateVideo={onOpenVideo}
onReusePrompt={onReusePrompt} onReusePrompt={onReusePrompt}
onAddToPlaylist={onAddToPlaylist} onAddToPlaylist={onAddToPlaylist}
onDelete={onDelete} onDelete={onDelete}
onShare={() => setShareModalOpen(true)} onShare={() => setShareModalOpen(true)}
/> />
</div> </div>
@@ -391,7 +412,7 @@ export const Player: React.FC<PlayerProps> = ({
> >
<ChevronDown size={28} /> <ChevronDown size={28} />
</button> </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 className="w-11" />
</div> </div>
@@ -484,6 +505,34 @@ export const Player: React.FC<PlayerProps> = ({
</button> </button>
</div> </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 */} {/* Volume Control */}
<div className="flex items-center gap-4 w-full max-w-xs"> <div className="flex items-center gap-4 w-full max-w-xs">
<button <button
@@ -506,6 +555,12 @@ export const Player: React.FC<PlayerProps> = ({
className="h-full bg-zinc-700 dark:bg-white/70 rounded-full" className="h-full bg-zinc-700 dark:bg-white/70 rounded-full"
style={{ width: `${volume * 100}%` }} 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>
</div> </div>
@@ -528,7 +583,7 @@ export const Player: React.FC<PlayerProps> = ({
<button <button
onClick={handleDownload} onClick={handleDownload}
className="p-3 rounded-full hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors" 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} /> <Download size={20} />
</button> </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" 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}%` }} 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> </div>
{/* Hit area for easier clicking */} {/* Hit area for easier clicking */}
<div className="absolute top-1/2 -translate-y-1/2 w-full h-4 -z-10"></div> <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)} {formatTime(currentTime)} / {formatTime(duration || 0)}
</span> </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 <div
className="items-center gap-2 relative group hidden md:flex" className="relative group hidden md:block"
onMouseEnter={() => setIsHoveringVolume(true)} onMouseEnter={() => setIsHoveringVolume(true)}
onMouseLeave={() => setIsHoveringVolume(false)} onMouseLeave={() => setIsHoveringVolume(false)}
> >
@@ -670,28 +754,37 @@ export const Player: React.FC<PlayerProps> = ({
{volume === 0 ? <VolumeX size={18} /> : <Volume2 size={18} />} {volume === 0 ? <VolumeX size={18} /> : <Volume2 size={18} />}
</button> </button>
{/* Volume Slider */} {/* Vertical 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'}`}> {isHoveringVolume && (
<input <div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 pb-2">
type="range" <div className="bg-white dark:bg-zinc-800 rounded-lg shadow-xl border border-zinc-200 dark:border-white/10 p-2">
min="0" <div className="relative h-24 w-8 flex items-center justify-center">
max="1" <input
step="0.01" type="range"
value={volume} min="0"
onChange={(e) => onVolumeChange(parseFloat(e.target.value))} max="1"
className="w-full h-full opacity-0 cursor-pointer absolute z-10" step="0.01"
/> value={volume}
<div onChange={(e) => onVolumeChange(parseFloat(e.target.value))}
className="h-full bg-zinc-900 dark:bg-white rounded-full" className="w-24 h-8 -rotate-90 origin-center appearance-none bg-transparent cursor-pointer"
style={{ width: `${volume * 100}%` }} style={{
></div> WebkitAppearance: 'none',
</div> 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> </div>
<button <button
onClick={handleDownload} 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" 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} /> <Download size={18} />
</button> </button>
+18 -16
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Song, Playlist, playlistsApi, songsApi, getAudioUrl } from '../services/api'; import { Song, Playlist, playlistsApi, songsApi, getAudioUrl } from '../services/api';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useI18n } from '../context/I18nContext';
import { ArrowLeft, Play, MoreHorizontal, Clock, Calendar, Shuffle, Trash2, Mic2, Music } from 'lucide-react'; import { ArrowLeft, Play, MoreHorizontal, Clock, Calendar, Shuffle, Trash2, Mic2, Music } from 'lucide-react';
interface PlaylistDetailProps { interface PlaylistDetailProps {
@@ -13,6 +14,7 @@ interface PlaylistDetailProps {
export const PlaylistDetail: React.FC<PlaylistDetailProps> = ({ playlistId, onBack, onPlaySong, onSelect, onNavigateToProfile }) => { export const PlaylistDetail: React.FC<PlaylistDetailProps> = ({ playlistId, onBack, onPlaySong, onSelect, onNavigateToProfile }) => {
const { user: currentUser, token } = useAuth(); const { user: currentUser, token } = useAuth();
const { t } = useI18n();
const [playlist, setPlaylist] = useState<Playlist & { creator_avatar?: string } | null>(null); const [playlist, setPlaylist] = useState<Playlist & { creator_avatar?: string } | null>(null);
const [songs, setSongs] = useState<Song[]>([]); const [songs, setSongs] = useState<Song[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -67,7 +69,7 @@ export const PlaylistDetail: React.FC<PlaylistDetailProps> = ({ playlistId, onBa
const handleDeletePlaylist = async () => { const handleDeletePlaylist = async () => {
if (!token || !playlist) return; if (!token || !playlist) return;
if (!confirm('Are you sure you want to delete this playlist?')) return; if (!confirm(t('deletePlaylistConfirm'))) return;
try { try {
await playlistsApi.delete(playlist.id, token); await playlistsApi.delete(playlist.id, token);
onBack(); 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="flex items-center justify-center h-full bg-black">
<div className="text-zinc-400 gap-2 flex items-center"> <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> <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>
</div> </div>
); );
if (!playlist) return ( if (!playlist) return (
<div className="flex flex-col items-center justify-center h-full gap-4 bg-black"> <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"> <button onClick={onBack} className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 rounded-lg text-white">
Go Back {t('goBack')}
</button> </button>
</div> </div>
); );
@@ -124,7 +126,7 @@ export const PlaylistDetail: React.FC<PlaylistDetailProps> = ({ playlistId, onBa
{/* Info */} {/* Info */}
<div className="flex-1 space-y-2 md:space-y-4 text-center md:text-left"> <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"> <h1 className="text-2xl md:text-5xl lg:text-7xl font-bold text-white tracking-tight leading-none drop-shadow-lg">
{playlist.name} {playlist.name}
</h1> </h1>
@@ -147,11 +149,11 @@ export const PlaylistDetail: React.FC<PlaylistDetailProps> = ({ playlistId, onBa
</div> </div>
)} )}
<span className="w-1 h-1 rounded-full bg-white/50"></span> <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="w-1 h-1 rounded-full bg-white/50 hidden md:block"></span>
<span className="text-zinc-400 hidden md:block"> <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 {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> </span>
</div> </div>
@@ -171,7 +173,7 @@ export const PlaylistDetail: React.FC<PlaylistDetailProps> = ({ playlistId, onBa
<button <button
onClick={handleDeletePlaylist} onClick={handleDeletePlaylist}
className="text-zinc-400 hover:text-red-500 transition-colors p-2" className="text-zinc-400 hover:text-red-500 transition-colors p-2"
title="Delete Playlist" title={t('deletePlaylist')}
> >
<Trash2 size={20} /> <Trash2 size={20} />
</button> </button>
@@ -180,19 +182,19 @@ export const PlaylistDetail: React.FC<PlaylistDetailProps> = ({ playlistId, onBa
<div className="flex-1"></div> <div className="flex-1"></div>
<div className="text-zinc-400 text-xs md:text-sm"> <div className="text-zinc-400 text-xs md:text-sm">
{playlist.is_public ? 'Public' : 'Private'} {playlist.is_public ? t('public') : t('private')}
</div> </div>
</div> </div>
{/* Song List */} {/* Song List */}
<div className="flex-1 overflow-y-auto bg-black/40"> <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 */} {/* 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"> <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>#</span>
<span>Title</span> <span>{t('title')}</span>
<span>Artist</span> <span>{t('artist')}</span>
<span>Date Added</span> <span>{t('dateAdded')}</span>
<span className="text-right"><Clock size={16} className="inline" /></span> <span className="text-right"><Clock size={16} className="inline" /></span>
</div> </div>
@@ -226,7 +228,7 @@ export const PlaylistDetail: React.FC<PlaylistDetailProps> = ({ playlistId, onBa
<div className="flex flex-col truncate min-w-0"> <div className="flex flex-col truncate min-w-0">
<span className="font-medium text-white truncate">{song.title}</span> <span className="font-medium text-white truncate">{song.title}</span>
<span className="text-xs text-zinc-500 group-hover:text-zinc-400 truncate"> <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> </span>
</div> </div>
</div> </div>
@@ -236,12 +238,12 @@ export const PlaylistDetail: React.FC<PlaylistDetailProps> = ({ playlistId, onBa
e.stopPropagation(); e.stopPropagation();
song.creator && onNavigateToProfile(song.creator); song.creator && onNavigateToProfile(song.creator);
}}> }}>
{song.creator || 'Unknown'} {song.creator || t('unknown')}
</span> </span>
{/* Date Added - hidden on mobile */} {/* Date Added - hidden on mobile */}
<span className="hidden md:block"> <span className="hidden md:block">
{song.addedAt ? new Date(song.addedAt).toLocaleDateString() : 'Just now'} {song.addedAt ? new Date(song.addedAt).toLocaleDateString() : t('justNow')}
</span> </span>
{/* Duration + Actions */} {/* Duration + Actions */}
+16 -13
View File
@@ -1,6 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { X, Plus, Music } from 'lucide-react'; import { X, Plus, Music } from 'lucide-react';
import { Playlist } from '../types'; import { Playlist } from '../types';
import { useI18n } from '../context/I18nContext';
interface CreatePlaylistModalProps { interface CreatePlaylistModalProps {
isOpen: boolean; isOpen: boolean;
@@ -9,6 +10,7 @@ interface CreatePlaylistModalProps {
} }
export const CreatePlaylistModal: React.FC<CreatePlaylistModalProps> = ({ isOpen, onClose, onCreate }) => { export const CreatePlaylistModal: React.FC<CreatePlaylistModalProps> = ({ isOpen, onClose, onCreate }) => {
const { t } = useI18n();
const [name, setName] = useState(''); const [name, setName] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
@@ -25,33 +27,33 @@ export const CreatePlaylistModal: React.FC<CreatePlaylistModalProps> = ({ isOpen
}; };
return ( 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="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"> <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"> <button onClick={onClose} className="text-zinc-400 hover:text-zinc-900 dark:hover:text-white">
<X size={20} /> <X size={20} />
</button> </button>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <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 <input
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} 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" 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 autoFocus
/> />
</div> </div>
<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 <textarea
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} 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" 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>
<div className="flex justify-end gap-3 mt-6"> <div className="flex justify-end gap-3 mt-6">
@@ -60,14 +62,14 @@ export const CreatePlaylistModal: React.FC<CreatePlaylistModalProps> = ({ isOpen
onClick={onClose} 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" 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>
<button <button
type="submit" type="submit"
disabled={!name.trim()} 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" 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> </button>
</div> </div>
</form> </form>
@@ -85,13 +87,14 @@ interface AddToPlaylistModalProps {
} }
export const AddToPlaylistModal: React.FC<AddToPlaylistModalProps> = ({ isOpen, onClose, playlists, onSelect, onCreateNew }) => { export const AddToPlaylistModal: React.FC<AddToPlaylistModalProps> = ({ isOpen, onClose, playlists, onSelect, onCreateNew }) => {
const { t } = useI18n();
if (!isOpen) return null; if (!isOpen) return null;
return ( 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="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"> <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"> <button onClick={onClose} className="text-zinc-400 hover:text-zinc-900 dark:hover:text-white">
<X size={20} /> <X size={20} />
</button> </button>
@@ -106,7 +109,7 @@ export const AddToPlaylistModal: React.FC<AddToPlaylistModalProps> = ({ isOpen,
<Plus size={20} /> <Plus size={20} />
</div> </div>
<div className="text-left"> <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> </div>
</button> </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"> <div className="space-y-1 max-h-60 overflow-y-auto custom-scrollbar">
{playlists.length === 0 ? ( {playlists.length === 0 ? (
<div className="text-center py-6 text-zinc-500 text-sm italic"> <div className="text-center py-6 text-zinc-500 text-sm italic">
No existing playlists. {t('noExistingPlaylists')}
</div> </div>
) : ( ) : (
playlists.map(playlist => ( playlists.map(playlist => (
@@ -136,7 +139,7 @@ export const AddToPlaylistModal: React.FC<AddToPlaylistModalProps> = ({ isOpen,
</div> </div>
<div className="text-left overflow-hidden"> <div className="text-left overflow-hidden">
<div className="font-medium text-zinc-900 dark:text-white truncate">{playlist.name}</div> <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> </div>
</button> </button>
)) ))
+45 -33
View File
@@ -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 { 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 { songsApi } from '../services/api';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useI18n } from '../context/I18nContext';
import { SongDropdownMenu } from './SongDropdownMenu'; import { SongDropdownMenu } from './SongDropdownMenu';
import { ShareModal } from './ShareModal'; import { ShareModal } from './ShareModal';
import { AlbumCover } from './AlbumCover'; 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 }) => { 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 { token, user } = useAuth();
const { t } = useI18n();
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
const [isOwner, setIsOwner] = useState(false); const [isOwner, setIsOwner] = useState(false);
const [tagsExpanded, setTagsExpanded] = 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="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"> <div className="flex flex-col items-center gap-2">
<Music size={40} className="text-zinc-300 dark:text-zinc-700" /> <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>
</div> </div>
); );
@@ -128,7 +130,7 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
{/* Header */} {/* 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"> <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 <button
onClick={onClose} 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" 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>
<div className="flex-1 overflow-y-auto custom-scrollbar"> <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 */} {/* Cover Art */}
<div <div
@@ -217,14 +219,14 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
disabled={isSavingTitle} 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" 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>
<button <button
onClick={cancelTitleEdit} onClick={cancelTitleEdit}
disabled={isSavingTitle} 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" 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> </button>
{titleError && ( {titleError && (
<span className="text-xs text-red-500">{titleError}</span> <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)} onClick={() => song.creator && onNavigateToProfile?.(song.creator)}
className="text-sm font-semibold text-zinc-900 dark:text-white hover:underline cursor-pointer" className="text-sm font-semibold text-zinc-900 dark:text-white hover:underline cursor-pointer"
> >
{song.creator || 'Anonymous'} {song.creator || t('anonymous')}
</span> </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> </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"> <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 <button
onClick={onOpenVideo} 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" 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} /> <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}`; const audioUrl = song.audioUrl.startsWith('http') ? song.audioUrl : `${window.location.origin}${song.audioUrl}`;
window.open(`/editor?audioUrl=${encodeURIComponent(audioUrl)}`, '_blank'); 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" 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} /> <Edit3 size={18} strokeWidth={1.5} />
</button> </button>
<button <button
onClick={() => onReuse && onReuse(song)} 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" 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} /> <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}`; const audioUrl = song.audioUrl.startsWith('http') ? song.audioUrl : `${baseUrl}${song.audioUrl}`;
window.open(`${baseUrl}/demucs-web/?audioUrl=${encodeURIComponent(audioUrl)}`, '_blank'); 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" 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} /> <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"> <div className="flex items-center gap-2">
<button <button
className="p-2 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors" className="p-2 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors"
title="Download Audio" title={t('downloadAudio')}
onClick={async () => { onClick={async () => {
if (!song.audioUrl) return; if (!song.audioUrl) return;
try { try {
@@ -454,21 +456,26 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
{/* Tags / Style */} {/* Tags / Style */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <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 <button
onClick={(e) => { onClick={async (e) => {
e.stopPropagation(); e.stopPropagation();
const allTags = song.tags && song.tags.length > 0 try {
? song.tags.join(', ') const allTags = Array.isArray(song.tags) && song.tags.length > 0
: song.style; ? song.tags.join(', ')
navigator.clipboard.writeText(allTags); : (song.style ?? '');
setCopiedStyle(true); if (!allTags) return;
setTimeout(() => setCopiedStyle(false), 2000); 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'}`} 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="Copy all tags" title={t('copyAllTags')}
> >
<Copy size={12} /> {copiedStyle ? 'Copied!' : 'Copy'} <Copy size={12} /> {copiedStyle ? t('copied') : t('copy')}
</button> </button>
</div> </div>
<div <div
@@ -489,8 +496,8 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
)) ))
)} )}
{!tagsExpanded && ( {!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"> <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">
+more +{t('more')}
</span> </span>
)} )}
</div> </div>
@@ -499,18 +506,23 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
{/* Lyrics Section */} {/* Lyrics Section */}
<div className="bg-white dark:bg-black/20 rounded-xl border border-zinc-200 dark:border-white/5 overflow-hidden"> <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"> <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 <button
onClick={() => { onClick={async (e) => {
if (song.lyrics) { e.stopPropagation();
navigator.clipboard.writeText(song.lyrics); try {
setCopiedLyrics(true); if (song.lyrics) {
setTimeout(() => setCopiedLyrics(false), 2000); 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> </button>
</div> </div>
<div className="p-4 max-h-[300px] overflow-y-auto custom-scrollbar"> <div className="p-4 max-h-[300px] overflow-y-auto custom-scrollbar">
+38 -38
View File
@@ -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 { Search, Play, Pause, Heart, ChevronRight, ChevronLeft, Copy, Check, X, Loader2 } from 'lucide-react';
import { Song, Playlist } from '../types'; import { Song, Playlist } from '../types';
import { songsApi, usersApi, playlistsApi, searchApi, UserProfile, getAudioUrl } from '../services/api'; import { songsApi, usersApi, playlistsApi, searchApi, UserProfile, getAudioUrl } from '../services/api';
import { useI18n } from '../context/I18nContext';
import { GENRE_KEYS } from '../data/genres';
interface SearchPageProps { interface SearchPageProps {
onPlaySong?: (song: Song, list?: Song[]) => void; onPlaySong?: (song: Song, list?: Song[]) => void;
@@ -12,15 +14,6 @@ interface SearchPageProps {
onNavigateToPlaylist?: (playlistId: string) => void; onNavigateToPlaylist?: (playlistId: string) => void;
} }
const GENRES = [
'Pop', 'Rock', 'Electronic', 'Hip Hop', 'Country', 'Latin', 'Heavy Metal', 'Disco',
'K-Pop', 'EDM', 'R&B', 'Indie', 'Folk', 'Funk', 'Jazz', 'Alternative Pop',
'House', 'Afrobeats', 'Reggaeton', 'Rap', 'Blues', 'Gospel', 'Reggae',
'Synthwave', 'J-Pop', 'Punk', 'Soul', 'Techno', 'Classical', 'Bossa Nova',
'Ska', 'Bluegrass', 'Indie Surf', 'Lo-Fi Beats', 'Trap', 'Grunge', 'Chillhop',
'New Wave', 'Drum And Bass', 'Acoustic Cover', 'Cinematic Dubstep', 'Modern Bollywood',
'Opera', 'Ambient', 'Focus', 'A Capella', 'Meditation', 'Sleep'
];
const MAX_RESULTS = 20; const MAX_RESULTS = 20;
@@ -36,6 +29,7 @@ export const SearchPage: React.FC<SearchPageProps> = ({
onNavigateToSong, onNavigateToSong,
onNavigateToPlaylist, onNavigateToPlaylist,
}) => { }) => {
const { t } = useI18n();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [featuredSongs, setFeaturedSongs] = useState<ExtendedSong[]>([]); const [featuredSongs, setFeaturedSongs] = useState<ExtendedSong[]>([]);
const [featuredCreators, setFeaturedCreators] = useState<Array<UserProfile & { song_count?: number }>>([]); const [featuredCreators, setFeaturedCreators] = useState<Array<UserProfile & { song_count?: number }>>([]);
@@ -203,14 +197,14 @@ export const SearchPage: React.FC<SearchPageProps> = ({
return ( return (
<div className="flex-1 bg-zinc-50 dark:bg-[#0a0a0a] h-full overflow-y-auto custom-scrollbar"> <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 */} {/* Search Input */}
<div className="mb-8"> <div className="mb-8">
<div className="relative max-w-3xl"> <div className="relative max-w-3xl">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-400" size={20} /> <Search className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-400" size={20} />
<input <input
type="text" type="text"
placeholder="Search for songs, playlists, creators, or genres" placeholder={t('searchSongsPlaceholder')}
value={searchQuery} value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)} 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" 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"> <section className="mb-10">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-zinc-900 dark:text-white"> <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 && ( {isSearching && displaySongs.length > 0 && (
<span className="ml-2 text-sm font-normal text-zinc-500">({displaySongs.length})</span> <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> </div>
) : isSearching ? ( ) : isSearching ? (
<div className="text-center py-8 text-zinc-500 text-sm"> <div className="text-center py-8 text-zinc-500 text-sm">
No songs found matching "{searchQuery}" {t('noSongsFound')} "{searchQuery}"
</div> </div>
) : null} ) : null}
</section> </section>
@@ -287,7 +281,7 @@ export const SearchPage: React.FC<SearchPageProps> = ({
<section className="mb-10"> <section className="mb-10">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-zinc-900 dark:text-white"> <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 && ( {isSearching && displayCreators.length > 0 && (
<span className="ml-2 text-sm font-normal text-zinc-500">({displayCreators.length})</span> <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>
) : ( ) : (
<div className="text-center py-8 text-zinc-500 text-sm"> <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> </div>
)} )}
</section> </section>
@@ -346,7 +340,7 @@ export const SearchPage: React.FC<SearchPageProps> = ({
<section className="mb-10"> <section className="mb-10">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-zinc-900 dark:text-white"> <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 && ( {isSearching && displayPlaylists.length > 0 && (
<span className="ml-2 text-sm font-normal text-zinc-500">({displayPlaylists.length})</span> <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} playlist={playlist}
onNavigateToPlaylist={onNavigateToPlaylist} onNavigateToPlaylist={onNavigateToPlaylist}
onNavigateToProfile={onNavigateToProfile} onNavigateToProfile={onNavigateToProfile}
t={t}
/> />
))} ))}
</div> </div>
) : ( ) : (
<div className="text-center py-8 text-zinc-500 text-sm"> <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> </div>
)} )}
</section> </section>
{/* Genres */} {/* Genres */}
<section className="mb-10"> <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"> <div className="flex flex-wrap gap-2">
{GENRES.map((genre) => ( {GENRE_KEYS.map((genreKey) => {
<button const genreLabel = t(genreKey);
key={genre} return (
onClick={() => handleGenreClick(genre)} <button
className={`px-3 py-1.5 border rounded-full text-sm transition-all duration-200 group flex items-center gap-1.5 ${ key={genreKey}
searchQuery === genre onClick={() => handleGenreClick(genreLabel)}
? 'bg-pink-500 border-pink-500 text-white' className={`px-3 py-1.5 border rounded-full text-sm transition-all duration-200 group flex items-center gap-1.5 ${
: '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' 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'
{genre} }`}
<Copy >
size={12} {genreLabel}
className={`opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer ${searchQuery === genre ? 'text-white/70' : ''}`} <Copy
onClick={(e) => { e.stopPropagation(); handleCopyTag(genre); }} size={12}
/> className={`opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer ${searchQuery === genreLabel ? 'text-white/70' : ''}`}
{copiedTag === genre && <Check size={12} className="text-green-500" />} onClick={(e) => { e.stopPropagation(); handleCopyTag(genreLabel); }}
</button> />
))} {copiedTag === genreLabel && <Check size={12} className="text-green-500" />}
</button>
);
})}
</div> </div>
</section> </section>
</div> </div>
@@ -562,12 +560,14 @@ interface PlaylistCardProps {
playlist: Playlist & { creator?: string; creator_avatar?: string; song_count?: number }; playlist: Playlist & { creator?: string; creator_avatar?: string; song_count?: number };
onNavigateToPlaylist?: (playlistId: string) => void; onNavigateToPlaylist?: (playlistId: string) => void;
onNavigateToProfile?: (username: string) => void; onNavigateToProfile?: (username: string) => void;
t: (key: string) => string;
} }
const PlaylistCard: React.FC<PlaylistCardProps> = ({ const PlaylistCard: React.FC<PlaylistCardProps> = ({
playlist, playlist,
onNavigateToPlaylist, onNavigateToPlaylist,
onNavigateToProfile, onNavigateToProfile,
t,
}) => { }) => {
return ( return (
<div <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"> <div className="font-semibold text-zinc-900 dark:text-white text-sm truncate group-hover:text-pink-500 transition-colors">
{playlist.name} {playlist.name}
</div> </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 && ( {playlist.creator && (
<div <div
className="flex items-center gap-1.5 cursor-pointer" className="flex items-center gap-1.5 cursor-pointer"
+19 -12
View File
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { X, Link, Check } from 'lucide-react'; import { X, Link, Check } from 'lucide-react';
import { Song } from '../types'; import { Song } from '../types';
import { useI18n } from '../context/I18nContext';
interface ShareModalProps { interface ShareModalProps {
isOpen: boolean; isOpen: boolean;
@@ -53,6 +54,7 @@ const EmailIcon = () => (
); );
export const ShareModal: React.FC<ShareModalProps> = ({ isOpen, onClose, song }) => { export const ShareModal: React.FC<ShareModalProps> = ({ isOpen, onClose, song }) => {
const { t } = useI18n();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
if (!isOpen) return null; if (!isOpen) return null;
@@ -98,8 +100,13 @@ export const ShareModal: React.FC<ShareModalProps> = ({ isOpen, onClose, song })
}; };
const handleShareEmail = () => { const handleShareEmail = () => {
const subject = encodeURIComponent(`🎵 Check out this AI song: ${song.title}`); const subject = encodeURIComponent(`🎵 ${t('emailSubject')}: ${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 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}`; 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="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"> <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"> <button onClick={onClose} className="text-zinc-400 hover:text-zinc-900 dark:hover:text-white">
<X size={20} /> <X size={20} />
</button> </button>
@@ -149,7 +156,7 @@ export const ShareModal: React.FC<ShareModalProps> = ({ isOpen, onClose, song })
<button <button
onClick={handleShareX} 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" 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 /> <XIcon />
<span className="text-xs font-medium">X</span> <span className="text-xs font-medium">X</span>
@@ -158,7 +165,7 @@ export const ShareModal: React.FC<ShareModalProps> = ({ isOpen, onClose, song })
<button <button
onClick={handleShareFacebook} onClick={handleShareFacebook}
className="flex flex-col items-center gap-1.5 p-3 rounded-lg bg-[#1877F2] text-white hover:bg-[#166FE5] transition-colors" 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 /> <FacebookIcon />
<span className="text-xs font-medium">Facebook</span> <span className="text-xs font-medium">Facebook</span>
@@ -167,7 +174,7 @@ export const ShareModal: React.FC<ShareModalProps> = ({ isOpen, onClose, song })
<button <button
onClick={handleShareWhatsApp} onClick={handleShareWhatsApp}
className="flex flex-col items-center gap-1.5 p-3 rounded-lg bg-[#25D366] text-white hover:bg-[#22C55E] transition-colors" 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 /> <WhatsAppIcon />
<span className="text-xs font-medium">WhatsApp</span> <span className="text-xs font-medium">WhatsApp</span>
@@ -176,7 +183,7 @@ export const ShareModal: React.FC<ShareModalProps> = ({ isOpen, onClose, song })
<button <button
onClick={handleShareTelegram} onClick={handleShareTelegram}
className="flex flex-col items-center gap-1.5 p-3 rounded-lg bg-[#0088CC] text-white hover:bg-[#0077B5] transition-colors" 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 /> <TelegramIcon />
<span className="text-xs font-medium">Telegram</span> <span className="text-xs font-medium">Telegram</span>
@@ -185,7 +192,7 @@ export const ShareModal: React.FC<ShareModalProps> = ({ isOpen, onClose, song })
<button <button
onClick={handleShareReddit} onClick={handleShareReddit}
className="flex flex-col items-center gap-1.5 p-3 rounded-lg bg-[#FF4500] text-white hover:bg-[#FF5722] transition-colors" 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 /> <RedditIcon />
<span className="text-xs font-medium">Reddit</span> <span className="text-xs font-medium">Reddit</span>
@@ -194,7 +201,7 @@ export const ShareModal: React.FC<ShareModalProps> = ({ isOpen, onClose, song })
<button <button
onClick={handleShareLinkedIn} onClick={handleShareLinkedIn}
className="flex flex-col items-center gap-1.5 p-3 rounded-lg bg-[#0A66C2] text-white hover:bg-[#004182] transition-colors" 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 /> <LinkedInIcon />
<span className="text-xs font-medium">LinkedIn</span> <span className="text-xs font-medium">LinkedIn</span>
@@ -203,7 +210,7 @@ export const ShareModal: React.FC<ShareModalProps> = ({ isOpen, onClose, song })
<button <button
onClick={handleShareEmail} 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" 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 /> <EmailIcon />
<span className="text-xs font-medium">Email</span> <span className="text-xs font-medium">Email</span>
@@ -212,10 +219,10 @@ export const ShareModal: React.FC<ShareModalProps> = ({ isOpen, onClose, song })
<button <button
onClick={handleCopyLink} 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" 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} />} {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> </button>
</div> </div>
</div> </div>
+129 -44
View File
@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { Library, Disc, Search, User, LogIn, LogOut, Sun, Moon } from 'lucide-react'; import { Library, Disc, Search, User, LogIn, LogOut, Sun, Moon } from 'lucide-react';
import { View } from '../types'; import { View } from '../types';
import { useI18n } from '../context/I18nContext';
interface SidebarProps { interface SidebarProps {
currentView: View; currentView: View;
@@ -11,6 +12,8 @@ interface SidebarProps {
onLogin?: () => void; onLogin?: () => void;
onLogout?: () => void; onLogout?: () => void;
onOpenSettings?: () => void; onOpenSettings?: () => void;
isOpen?: boolean;
onToggle?: () => void;
} }
export const Sidebar: React.FC<SidebarProps> = ({ export const Sidebar: React.FC<SidebarProps> = ({
@@ -22,84 +25,161 @@ export const Sidebar: React.FC<SidebarProps> = ({
onLogin, onLogin,
onLogout, onLogout,
onOpenSettings, onOpenSettings,
isOpen = true,
onToggle,
}) => { }) => {
const { t } = useI18n();
return ( 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 */} {/* Backdrop for mobile - only when expanded */}
<div {isOpen && onToggle && (
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" <div
onClick={() => onNavigate('create')} className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 md:hidden"
title="ACE-Step UI" onClick={onToggle}
> />
<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" /> {/* Sidebar */}
<path d="M2 12L12 17L22 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> <div className={`
</svg> 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> </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 <NavItem
icon={<Disc size={24} />} icon={<Disc size={20} />}
label="Create" label={t('create')}
active={currentView === 'create'} active={currentView === 'create'}
onClick={() => onNavigate('create')} onClick={() => onNavigate('create')}
isExpanded={isOpen}
/> />
<NavItem <NavItem
icon={<Library size={24} />} icon={<Library size={20} />}
label="Library" label={t('library')}
active={currentView === 'library'} active={currentView === 'library'}
onClick={() => onNavigate('library')} onClick={() => onNavigate('library')}
isExpanded={isOpen}
/> />
<NavItem <NavItem
icon={<Search size={24} />} icon={<Search size={20} />}
label="Search" label={t('search')}
active={currentView === 'search'} active={currentView === 'search'}
onClick={() => onNavigate('search')} onClick={() => onNavigate('search')}
isExpanded={isOpen}
/> />
<div className="mt-auto flex flex-col gap-2">
<div className="mt-auto flex flex-col gap-4"> {/* Theme Toggle */}
<button <button
onClick={onToggleTheme} 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" className={`
title={theme === 'dark' ? 'Light Mode' : 'Dark Mode'} 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> </button>
{user ? ( {user ? (
<div className="flex flex-col items-center gap-2"> <>
<div {/* User Settings */}
<button
onClick={onOpenSettings} 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" className={`
title={`${user.username} - Settings`} 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 ? ( <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">
<img src={user.avatar_url} alt={user.username} className="w-full h-full object-cover" /> {user.avatar_url ? (
) : ( <img src={user.avatar_url} alt={user.username} className="w-full h-full object-cover" />
user.username.charAt(0).toUpperCase() ) : (
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 <button
onClick={onLogout} 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" className={`
title="Sign Out" 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> </button>
</div> </>
) : ( ) : (
<button <button
onClick={onLogin} 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" className={`
title="Sign In" 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> </button>
)} )}
</div> </div>
</nav> </nav>
</div> </div>
</>
); );
}; };
@@ -108,18 +188,23 @@ interface NavItemProps {
label: string; label: string;
active?: boolean; active?: boolean;
onClick: () => void; onClick: () => void;
isExpanded?: boolean;
} }
const NavItem: React.FC<NavItemProps> = ({ icon, label, active, onClick }) => ( const NavItem: React.FC<NavItemProps> = ({ icon, label, active, onClick, isExpanded }) => (
<button <button
onClick={onClick} onClick={onClick}
className={` 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'} ${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} 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>} {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> </button>
); );
+12 -10
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { Song } from '../types'; import { Song } from '../types';
import { useI18n } from '../context/I18nContext';
import { import {
Video, Video,
Edit3, Edit3,
@@ -76,6 +77,7 @@ export const SongDropdownMenu: React.FC<SongDropdownMenuProps> = ({
onUseAsReference, onUseAsReference,
onCoverSong onCoverSong
}) => { }) => {
const { t } = useI18n();
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
@@ -174,32 +176,32 @@ export const SongDropdownMenu: React.FC<SongDropdownMenuProps> = ({
{/* Creative Actions */} {/* Creative Actions */}
<MenuItem <MenuItem
icon={<Video size={14} />} icon={<Video size={14} />}
label="Create Video" label={t('createVideo')}
onClick={() => handleAction(onCreateVideo)} onClick={() => handleAction(onCreateVideo)}
/> />
{isOwner && ( {isOwner && (
<MenuItem <MenuItem
icon={<Edit3 size={14} />} icon={<Edit3 size={14} />}
label="Edit Audio" label={t('editAudio')}
onClick={onEditAudio ? () => handleAction(onEditAudio) : handleEditAudio} onClick={onEditAudio ? () => handleAction(onEditAudio) : handleEditAudio}
/> />
)} )}
<MenuItem <MenuItem
icon={<Layers size={14} />} icon={<Layers size={14} />}
label="Extract Stems" label={t('extractStems')}
onClick={onExtractStems ? () => handleAction(onExtractStems) : handleExtractStems} onClick={onExtractStems ? () => handleAction(onExtractStems) : handleExtractStems}
/> />
{onReusePrompt && ( {onReusePrompt && (
<MenuItem <MenuItem
icon={<Repeat size={14} />} icon={<Repeat size={14} />}
label="Reuse Prompt" label={t('reusePrompt')}
onClick={() => handleAction(onReusePrompt)} onClick={() => handleAction(onReusePrompt)}
/> />
)} )}
{onUseAsReference && ( {onUseAsReference && (
<MenuItem <MenuItem
icon={<Layers size={14} />} icon={<Layers size={14} />}
label="Use as Reference" label={t('useAsReference')}
onClick={() => handleAction(onUseAsReference)} onClick={() => handleAction(onUseAsReference)}
disabled={!song.audioUrl} disabled={!song.audioUrl}
/> />
@@ -207,7 +209,7 @@ export const SongDropdownMenu: React.FC<SongDropdownMenuProps> = ({
{onCoverSong && ( {onCoverSong && (
<MenuItem <MenuItem
icon={<Layers size={14} />} icon={<Layers size={14} />}
label="Cover Song" label={t('coverSong')}
onClick={() => handleAction(onCoverSong)} onClick={() => handleAction(onCoverSong)}
disabled={!song.audioUrl} disabled={!song.audioUrl}
/> />
@@ -218,17 +220,17 @@ export const SongDropdownMenu: React.FC<SongDropdownMenuProps> = ({
{/* Library Actions */} {/* Library Actions */}
<MenuItem <MenuItem
icon={<ListPlus size={14} />} icon={<ListPlus size={14} />}
label="Add to Playlist" label={t('addToPlaylist')}
onClick={() => handleAction(onAddToPlaylist)} onClick={() => handleAction(onAddToPlaylist)}
/> />
<MenuItem <MenuItem
icon={<Download size={14} />} icon={<Download size={14} />}
label="Download" label={t('download')}
onClick={onDownload ? () => handleAction(onDownload) : handleDownload} onClick={onDownload ? () => handleAction(onDownload) : handleDownload}
/> />
<MenuItem <MenuItem
icon={<Share2 size={14} />} icon={<Share2 size={14} />}
label="Share" label={t('share')}
onClick={() => handleAction(onShare)} onClick={() => handleAction(onShare)}
/> />
@@ -238,7 +240,7 @@ export const SongDropdownMenu: React.FC<SongDropdownMenuProps> = ({
<MenuDivider /> <MenuDivider />
<MenuItem <MenuItem
icon={<Trash2 size={14} />} icon={<Trash2 size={14} />}
label="Delete Song" label={t('deleteSong')}
onClick={() => handleAction(onDelete)} onClick={() => handleAction(onDelete)}
danger danger
/> />
+101 -16
View File
@@ -2,9 +2,11 @@ import React, { useState, useMemo, useRef, useEffect } from 'react';
import { Song } from '../types'; 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 { 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 { useAuth } from '../context/AuthContext';
import { useI18n } from '../context/I18nContext';
import { SongDropdownMenu } from './SongDropdownMenu'; import { SongDropdownMenu } from './SongDropdownMenu';
import { ShareModal } from './ShareModal'; import { ShareModal } from './ShareModal';
import { AlbumCover } from './AlbumCover'; import { AlbumCover } from './AlbumCover';
import { songsApi } from '../services/api';
interface SongListProps { interface SongListProps {
songs: Song[]; songs: Song[];
@@ -22,6 +24,7 @@ interface SongListProps {
onNavigateToProfile?: (username: string) => void; onNavigateToProfile?: (username: string) => void;
onReusePrompt?: (song: Song) => void; onReusePrompt?: (song: Song) => void;
onDelete?: (song: Song) => void; onDelete?: (song: Song) => void;
onSongUpdate?: (updatedSong: Song) => void;
onDeleteMany?: (songs: Song[]) => void; onDeleteMany?: (songs: Song[]) => void;
onUseAsReference?: (song: Song) => void; onUseAsReference?: (song: Song) => void;
onCoverSong?: (song: Song) => void; onCoverSong?: (song: Song) => void;
@@ -36,12 +39,20 @@ interface SongListProps {
// Define Filter Types // Define Filter Types
type FilterType = 'liked' | 'public' | 'private' | 'generating'; type FilterType = 'liked' | 'public' | 'private' | 'generating';
const FILTERS: { id: FilterType; label: string; icon: React.ReactNode }[] = [ // Map model ID to short display name
{ id: 'liked', label: 'Liked', icon: <ThumbsUp size={16} /> }, const getModelDisplayName = (modelId?: string): string => {
{ id: 'public', label: 'Public', icon: <Globe size={16} /> }, if (!modelId) return 'v1.5';
{ id: 'private', label: 'Private', icon: <Lock size={16} /> },
{ id: 'generating', label: 'Generating', icon: <Loader2 size={16} /> }, 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 createDragPreview = (element: HTMLElement) => {
const clone = element.cloneNode(true) as HTMLElement; const clone = element.cloneNode(true) as HTMLElement;
@@ -91,6 +102,7 @@ export const SongList: React.FC<SongListProps> = ({
onNavigateToProfile, onNavigateToProfile,
onReusePrompt, onReusePrompt,
onDelete, onDelete,
onSongUpdate,
onDeleteMany, onDeleteMany,
onUseAsReference, onUseAsReference,
onCoverSong, onCoverSong,
@@ -98,6 +110,7 @@ export const SongList: React.FC<SongListProps> = ({
onCoverUpload onCoverUpload
}) => { }) => {
const { user } = useAuth(); const { user } = useAuth();
const { t } = useI18n();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [activeFilters, setActiveFilters] = useState<Set<FilterType>>(new Set()); const [activeFilters, setActiveFilters] = useState<Set<FilterType>>(new Set());
const [isFilterOpen, setIsFilterOpen] = useState(false); const [isFilterOpen, setIsFilterOpen] = useState(false);
@@ -105,6 +118,13 @@ export const SongList: React.FC<SongListProps> = ({
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const filterRef = useRef<HTMLDivElement>(null); 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 // Close filter dropdown when clicking outside
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
@@ -213,7 +233,7 @@ export const SongList: React.FC<SongListProps> = ({
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} 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" 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" /> <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"} /> <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> </button>
{/* Filter Dropdown */} {/* Filter Dropdown */}
{isFilterOpen && ( {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="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"> <div className="px-3 py-2 text-[10px] font-bold text-zinc-500 uppercase tracking-wider">
Refine By {t('refineBy')}
</div> </div>
{FILTERS.map(filter => ( {FILTERS.map(filter => (
<button <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"> <div className="w-16 h-16 rounded-full bg-zinc-100 dark:bg-white/5 flex items-center justify-center">
<Filter size={32} /> <Filter size={32} />
</div> </div>
<p className="font-medium">No songs match your filters.</p> <p className="font-medium">{t('noSongsMatchFilters')}</p>
<button <button
onClick={() => { setActiveFilters(new Set()); setSearchQuery(''); }} onClick={() => { setActiveFilters(new Set()); setSearchQuery(''); }}
className="text-pink-600 dark:text-pink-500 text-sm font-bold hover:underline" className="text-pink-600 dark:text-pink-500 text-sm font-bold hover:underline"
> >
Clear filters {t('clearFilters')}
</button> </button>
</div> </div>
) : ( ) : (
@@ -365,6 +385,7 @@ export const SongList: React.FC<SongListProps> = ({
onNavigateToProfile={onNavigateToProfile} onNavigateToProfile={onNavigateToProfile}
onReusePrompt={() => onReusePrompt?.(item.song)} onReusePrompt={() => onReusePrompt?.(item.song)}
onDelete={() => onDelete?.(item.song)} onDelete={() => onDelete?.(item.song)}
onSongUpdate={onSongUpdate}
onUseAsReference={() => onUseAsReference?.(item.song)} onUseAsReference={() => onUseAsReference?.(item.song)}
onCoverSong={() => onCoverSong?.(item.song)} onCoverSong={() => onCoverSong?.(item.song)}
/> />
@@ -417,6 +438,7 @@ interface SongItemProps {
onNavigateToProfile?: (username: string) => void; onNavigateToProfile?: (username: string) => void;
onReusePrompt?: () => void; onReusePrompt?: () => void;
onDelete?: () => void; onDelete?: () => void;
onSongUpdate?: (updatedSong: Song) => void;
onUseAsReference?: () => void; onUseAsReference?: () => void;
onCoverSong?: () => void; onCoverSong?: () => void;
} }
@@ -440,12 +462,54 @@ const SongItem: React.FC<SongItemProps> = ({
onNavigateToProfile, onNavigateToProfile,
onReusePrompt, onReusePrompt,
onDelete, onDelete,
onSongUpdate,
onUseAsReference, onUseAsReference,
onCoverSong onCoverSong
}) => { }) => {
const { token } = useAuth();
const [showDropdown, setShowDropdown] = useState(false); const [showDropdown, setShowDropdown] = useState(false);
const [shareModalOpen, setShareModalOpen] = useState(false); const [shareModalOpen, setShareModalOpen] = useState(false);
const [imageError, setImageError] = 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 ( 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="flex-1 min-w-0 flex flex-col justify-between py-1">
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-2"> <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'}`}> {isEditingTitle && isOwner ? (
{song.title || (song.isGenerating ? (song.queuePosition ? "Queued..." : "Creating...") : "Untitled")} <input
</h3> ref={titleInputRef}
<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"> type="text"
v1.5 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> </span>
{song.isPublic === false && ( {song.isPublic === false && (
<Lock size={12} className="text-zinc-400 dark:text-zinc-500" /> <Lock size={12} className="text-zinc-400 dark:text-zinc-500" />
+31 -9
View File
@@ -2,8 +2,10 @@ import React, { useState, useEffect } from 'react';
import { Song } from '../types'; import { Song } from '../types';
import { songsApi, getAudioUrl } from '../services/api'; import { songsApi, getAudioUrl } from '../services/api';
import { useAuth } from '../context/AuthContext'; 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 { ArrowLeft, Play, Pause, Heart, Share2, MoreHorizontal, ThumbsDown, Music as MusicIcon, Edit3, Eye } from 'lucide-react';
import { ShareModal } from './ShareModal'; import { ShareModal } from './ShareModal';
import { SongDropdownMenu } from './SongDropdownMenu';
interface SongProfileProps { interface SongProfileProps {
songId: string; songId: string;
@@ -14,6 +16,7 @@ interface SongProfileProps {
isPlaying?: boolean; isPlaying?: boolean;
likedSongIds?: Set<string>; likedSongIds?: Set<string>;
onToggleLike?: (songId: string) => void; onToggleLike?: (songId: string) => void;
onDelete?: (song: Song) => void;
} }
const updateMetaTags = (song: Song) => { const updateMetaTags = (song: Song) => {
@@ -79,11 +82,13 @@ const resetMetaTags = () => {
updateMeta('meta[name="twitter:image"]', defaultImage); 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 { user, token } = useAuth();
const { t } = useI18n();
const [song, setSong] = useState<Song | null>(null); const [song, setSong] = useState<Song | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [shareModalOpen, setShareModalOpen] = useState(false); const [shareModalOpen, setShareModalOpen] = useState(false);
const [showDropdown, setShowDropdown] = useState(false);
const isCurrentSong = song && currentSong?.id === song.id; const isCurrentSong = song && currentSong?.id === song.id;
const isCurrentlyPlaying = isCurrentSong && isPlaying; 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="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="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" /> <div className="w-4 h-4 border-2 border-zinc-400 border-t-transparent rounded-full animate-spin" />
Loading song... {t('loadingSong')}
</div> </div>
</div> </div>
); );
@@ -147,9 +152,9 @@ export const SongProfile: React.FC<SongProfileProps> = ({ songId, onBack, onPlay
if (!song) { if (!song) {
return ( return (
<div className="flex flex-col items-center justify-center h-full gap-4 bg-zinc-50 dark:bg-black"> <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"> <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> </button>
</div> </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" 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} /> <ArrowLeft size={20} />
<span>Back</span> <span>{t('back')}</span>
</button> </button>
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4"> <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 */} {/* Content */}
<div className="flex-1 overflow-y-auto"> <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 */} {/* Left Column: Song Details */}
<div className="space-y-4 md:space-y-6"> <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" /> <Share2 size={16} className="text-zinc-700 dark:text-white" />
</button> </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"> <div className="relative">
<MoreHorizontal size={16} className="text-zinc-700 dark:text-white" /> <button
</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> </div>
{/* Lyrics */} {/* Lyrics */}
+34 -32
View File
@@ -3,6 +3,7 @@ import { Song, Playlist } from '../types';
import { usersApi, getAudioUrl, UserProfile as UserProfileType, songsApi } from '../services/api'; import { usersApi, getAudioUrl, UserProfile as UserProfileType, songsApi } from '../services/api';
import { useAuth } from '../context/AuthContext'; 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 { 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 { interface UserProfileProps {
username: string; 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 }) => { 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 { user: currentUser, token } = useAuth();
const [profileUser, setProfileUser] = useState<UserProfileType | null>(null); const [profileUser, setProfileUser] = useState<UserProfileType | null>(null);
const [publicSongs, setPublicSongs] = useState<Song[]>([]); const [publicSongs, setPublicSongs] = useState<Song[]>([]);
@@ -145,7 +147,7 @@ export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPl
loadUserProfile(); loadUserProfile();
} catch (error) { } catch (error) {
console.error('Failed to update profile:', error); console.error('Failed to update profile:', error);
alert('Failed to update profile'); alert(t('profileUpdateFailed'));
} finally { } finally {
setIsSaving(false); setIsSaving(false);
setUploadingAvatar(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="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="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> <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>
</div> </div>
); );
@@ -167,9 +169,9 @@ export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPl
if (!profileUser) { if (!profileUser) {
return ( return (
<div className="flex flex-col items-center justify-center h-full gap-4 bg-zinc-50 dark:bg-black"> <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"> <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> </button>
</div> </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)); const displaySongs = songsTab === 'recent' ? publicSongs : [...publicSongs].sort((a, b) => (b.likeCount || 0) - (a.likeCount || 0));
return ( 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 */} {/* Hero Banner */}
<div className="relative group/banner"> <div className="relative group/banner">
{/* Background 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" 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} /> <ArrowLeft size={20} />
<span>Back</span> <span>{t('back')}</span>
</button> </button>
{/* Edit Banner Button (Owner Only) - Visual Cue */} {/* 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' && ( {profileUser.supporter_since && profileUser.accountTier && profileUser.accountTier !== 'free' && (
<p className="text-xs text-zinc-500 dark:text-zinc-400 mb-3"> <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> </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"> <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> </p>
</div> </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" 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} /> <Edit3 size={16} />
Edit Profile {t('editProfile')}
</button> </button>
)} )}
</div> </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"> <div className="flex items-center gap-1.5 md:gap-2">
<MusicIcon size={16} className="text-zinc-500 dark:text-zinc-400" /> <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="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>
<div className="flex items-center gap-1.5 md:gap-2"> <div className="flex items-center gap-1.5 md:gap-2">
<Heart size={16} className="text-zinc-500 dark:text-zinc-400" /> <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="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>
<div className="flex items-center gap-1.5 md:gap-2"> <div className="flex items-center gap-1.5 md:gap-2">
<Eye size={16} className="text-zinc-500 dark:text-zinc-400" /> <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="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> </div>
</div> </div>
@@ -412,7 +414,7 @@ export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPl
{/* Featured Songs */} {/* Featured Songs */}
{featuredSongs.length > 0 && ( {featuredSongs.length > 0 && (
<section> <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"> <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) => { {featuredSongs.map((song) => {
const isCurrentSong = currentSong?.id === song.id; const isCurrentSong = currentSong?.id === song.id;
@@ -478,7 +480,7 @@ export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPl
{/* Songs Section */} {/* Songs Section */}
<section> <section>
<div className="flex items-center justify-between mb-4 md:mb-6"> <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 items-center gap-4">
<div className="flex bg-zinc-200 dark:bg-zinc-900 rounded-full p-1"> <div className="flex bg-zinc-200 dark:bg-zinc-900 rounded-full p-1">
<button <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' 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>
<button <button
onClick={() => setSongsTab('top')} 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' 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> </button>
</div> </div>
</div> </div>
@@ -502,7 +504,7 @@ export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPl
{displaySongs.length === 0 ? ( {displaySongs.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-zinc-500"> <div className="flex flex-col items-center justify-center py-16 text-zinc-500">
<MusicIcon size={64} className="mb-4 opacity-50" /> <MusicIcon size={64} className="mb-4 opacity-50" />
<p>No public songs yet</p> <p>{t('noPublicSongsYet')}</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 md:gap-4"> <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 && ( {publicPlaylists.length > 0 && (
<section> <section>
<div className="flex items-center justify-between mb-4 md:mb-6"> <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"> <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> </button>
</div> </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"> <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>
</div> </div>
<h3 className="font-semibold text-zinc-900 dark:text-white truncate mb-1 text-sm md:text-base">{playlist.name}</h3> <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>
))} ))}
</div> </div>
@@ -594,10 +596,10 @@ export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPl
{/* Edit Profile Modal */} {/* Edit Profile Modal */}
{isEditModalOpen && ( {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="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"> <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"> <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} /> <X size={20} />
</button> </button>
@@ -606,7 +608,7 @@ export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPl
<div className="p-4 md:p-6 space-y-6"> <div className="p-4 md:p-6 space-y-6">
{/* Avatar Upload */} {/* Avatar Upload */}
<div className="space-y-2"> <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="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"> <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) ? ( {(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" 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 size={16} />
Upload Avatar {t('uploadAvatar')}
</button> </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> </div>
</div> </div>
{/* Banner Upload */} {/* Banner Upload */}
<div className="space-y-2"> <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 <div
onClick={() => bannerInputRef.current?.click()} 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" 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"> <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} /> <ImageIcon size={32} />
<span className="text-sm">Click to upload banner</span> <span className="text-sm">{t('clickToUploadBanner')}</span>
</div> </div>
)} )}
{uploadingBanner && ( {uploadingBanner && (
@@ -679,16 +681,16 @@ export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPl
onChange={handleBannerChange} onChange={handleBannerChange}
className="hidden" 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> </div>
{/* Bio Input */} {/* Bio Input */}
<div className="space-y-2"> <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 <textarea
value={editBio} value={editBio}
onChange={(e) => setEditBio(e.target.value)} onChange={(e) => setEditBio(e.target.value)}
placeholder="Tell us about yourself..." placeholder={t('bioPlaceholder')}
rows={4} 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" 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" 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} disabled={isSaving}
> >
Cancel {t('cancel')}
</button> </button>
<button <button
onClick={handleSaveProfile} 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" 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" />} {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> </button>
</div> </div>
</div> </div>
+13 -11
View File
@@ -1,5 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { X, User, Sparkles } from 'lucide-react'; import { X, User, Sparkles } from 'lucide-react';
import { useI18n } from '../context/I18nContext';
interface UsernameModalProps { interface UsernameModalProps {
isOpen: boolean; isOpen: boolean;
@@ -7,6 +8,7 @@ interface UsernameModalProps {
} }
export const UsernameModal: React.FC<UsernameModalProps> = ({ isOpen, onSubmit }) => { export const UsernameModal: React.FC<UsernameModalProps> = ({ isOpen, onSubmit }) => {
const { t } = useI18n();
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -19,12 +21,12 @@ export const UsernameModal: React.FC<UsernameModalProps> = ({ isOpen, onSubmit }
const trimmed = username.trim(); const trimmed = username.trim();
if (trimmed.length < 2) { if (trimmed.length < 2) {
setError('Username must be at least 2 characters'); setError(t('usernameMinLength'));
return; return;
} }
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) { if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
setError('Username can only contain letters, numbers, underscores, and dashes'); setError(t('usernameInvalidChars'));
return; return;
} }
@@ -32,14 +34,14 @@ export const UsernameModal: React.FC<UsernameModalProps> = ({ isOpen, onSubmit }
try { try {
await onSubmit(trimmed); await onSubmit(trimmed);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to set username'); setError(err instanceof Error ? err.message : t('failedToSetUsername'));
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
return ( 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 */} {/* Backdrop */}
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm" /> <div className="absolute inset-0 bg-black/80 backdrop-blur-sm" />
@@ -58,17 +60,17 @@ export const UsernameModal: React.FC<UsernameModalProps> = ({ isOpen, onSubmit }
{/* Title */} {/* Title */}
<h2 className="text-2xl font-bold text-center text-white mb-2"> <h2 className="text-2xl font-bold text-center text-white mb-2">
Welcome to ACE-Step UI {t('welcomeTitle')}
</h2> </h2>
<p className="text-zinc-400 text-center mb-8"> <p className="text-zinc-400 text-center mb-8">
Enter your name to get started creating AI music {t('welcomeSubtitle')}
</p> </p>
{/* Form */} {/* Form */}
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label htmlFor="username" className="block text-sm font-medium text-zinc-300 mb-2"> <label htmlFor="username" className="block text-sm font-medium text-zinc-300 mb-2">
Your Name {t('yourName')}
</label> </label>
<div className="relative"> <div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <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" id="username"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} 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" 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 autoFocus
disabled={isLoading} 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" /> <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" /> <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> </svg>
Getting Started... {t('gettingStarted')}
</span> </span>
) : ( ) : (
'Get Started' t('getStarted')
)} )}
</button> </button>
</form> </form>
{/* Footer */} {/* Footer */}
<p className="mt-6 text-xs text-zinc-500 text-center"> <p className="mt-6 text-xs text-zinc-500 text-center">
Your music, your way. Create unlimited AI music for free. {t('yourMusicYourWay')}
</p> </p>
</div> </div>
</div> </div>
+1
View File
@@ -19,6 +19,7 @@ export interface Song {
userId?: string; userId?: string;
creator?: string; creator?: string;
creator_avatar?: string; creator_avatar?: string;
ditModel?: string;
} }
export interface Playlist { export interface Playlist {