From 325046eaf2ec95ed3d1136f80e5061e4f08f26f4 Mon Sep 17 00:00:00 2001 From: fspecii <4722521+fspecii@users.noreply.github.com> Date: Sun, 8 Feb 2026 18:35:09 +0200 Subject: [PATCH] Apply i18n to all components, add collapsible sidebar, playback speed control, inline title editing, and ConfirmDialog for deletions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- App.tsx | 243 +++++++++++++++++--------------- components/EditProfileModal.tsx | 68 ++++----- components/LibraryView.tsx | 18 +-- components/MobileDrawer.tsx | 2 +- components/Player.tsx | 169 +++++++++++++++++----- components/PlaylistDetail.tsx | 34 ++--- components/PlaylistModals.tsx | 29 ++-- components/RightSidebar.tsx | 78 +++++----- components/SearchPage.tsx | 76 +++++----- components/ShareModal.tsx | 31 ++-- components/Sidebar.tsx | 173 +++++++++++++++++------ components/SongDropdownMenu.tsx | 22 +-- components/SongList.tsx | 117 ++++++++++++--- components/SongProfile.tsx | 40 ++++-- components/UserProfile.tsx | 66 ++++----- components/UsernameModal.tsx | 24 ++-- types.ts | 1 + 17 files changed, 760 insertions(+), 431 deletions(-) diff --git a/App.tsx b/App.tsx index 137f936..789a970 100644 --- a/App.tsx +++ b/App.tsx @@ -20,6 +20,7 @@ import { List } from 'lucide-react'; import { PlaylistDetail } from './components/PlaylistDetail'; import { Toast, ToastType } from './components/Toast'; import { SearchPage } from './components/SearchPage'; +import { ConfirmDialog } from './components/ConfirmDialog'; function AppContent() { @@ -63,13 +64,18 @@ function AppContent() { const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = 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 [repeatMode, setRepeatMode] = useState<'none' | 'all' | 'one'>('all'); // UI State const [isGenerating, setIsGenerating] = useState(false); const [showRightSidebar, setShowRightSidebar] = useState(true); + const [showLeftSidebar, setShowLeftSidebar] = useState(true); const [pendingAudioSelection, setPendingAudioSelection] = useState<{ target: 'reference' | 'source'; url: string; title?: string } | null>(null); // Mobile UI Toggle @@ -100,6 +106,7 @@ function AppContent() { const [reuseData, setReuseData] = useState<{ song: Song, timestamp: number } | null>(null); const audioRef = useRef(null); + const currentSongIdRef = useRef(null); const pendingSeekRef = useRef(null); const playNextRef = useRef<() => void>(() => {}); @@ -113,6 +120,13 @@ function AppContent() { isVisible: false, }); + // Confirm Dialog State + const [confirmDialog, setConfirmDialog] = useState<{ + title: string; + message: string; + onConfirm: () => void; + } | null>(null); + interface ReferenceTrack { id: string; filename: string; @@ -176,6 +190,9 @@ function AppContent() { // Song Update Handler const handleSongUpdate = (updatedSong: Song) => { setSongs(prev => prev.map(s => s.id === updatedSong.id ? updatedSong : s)); + if (currentSong?.id === updatedSong.id) { + setCurrentSong(updatedSong); + } if (selectedSong?.id === updatedSong.id) { setSelectedSong(updatedSong); } @@ -298,6 +315,7 @@ function AppContent() { viewCount: s.view_count || 0, userId: s.user_id, creator: s.creator, + ditModel: s.ditModel, generationParams: (() => { try { 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.load(); if (isPlaying) playAudio(); @@ -528,8 +547,16 @@ function AppContent() { if (audioRef.current) { audioRef.current.volume = volume; } + localStorage.setItem('volume', String(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 const cleanupJob = useCallback((jobId: string, tempId: string) => { const jobData = activeJobsRef.current.get(jobId); @@ -570,6 +597,7 @@ function AppContent() { viewCount: s.view_count || 0, userId: s.user_id, creator: s.creator, + ditModel: s.ditModel, generationParams: (() => { try { if (!s.generation_params) return undefined; @@ -908,127 +936,99 @@ function AppContent() { } }; - const handleDeleteSong = async (song: Song) => { - if (!token) return; - - // 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 handleDeleteSong = (song: Song) => { + handleDeleteSongs([song]); }; - const handleDeleteSongs = async (songsToDelete: Song[]) => { + const handleDeleteSongs = (songsToDelete: Song[]) => { if (!token || songsToDelete.length === 0) return; - const confirmed = window.confirm( - `Delete ${songsToDelete.length} songs? This action cannot be undone.` - ); - if (!confirmed) return; + const isSingle = songsToDelete.length === 1; + const title = isSingle ? t('confirmDeleteTitle') : t('confirmDeleteManyTitle'); + const message = isSingle + ? t('deleteSongConfirm').replace('{title}', songsToDelete[0].title) + : t('deleteSongsConfirm').replace('{count}', String(songsToDelete.length)); - const idsToDelete = new Set(songsToDelete.map(song => song.id)); - const succeeded: string[] = []; - const failed: string[] = []; + setConfirmDialog({ + title, + message, + onConfirm: async () => { + setConfirmDialog(null); - for (const song of songsToDelete) { - try { - await songsApi.deleteSong(song.id, token); - succeeded.push(song.id); - } catch (error) { - console.error('Failed to delete song:', error); - failed.push(song.id); - } - } + const idsToDelete = new Set(songsToDelete.map(song => song.id)); + const succeeded: string[] = []; + const failed: string[] = []; - if (succeeded.length > 0) { - setSongs(prev => prev.filter(s => !idsToDelete.has(s.id) || failed.includes(s.id))); - - setLikedSongIds(prev => { - const next = new Set(prev); - succeeded.forEach(id => next.delete(id)); - 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 = ''; + for (const song of songsToDelete) { + try { + await songsApi.deleteSong(song.id, token!); + succeeded.push(song.id); + } catch (error) { + console.error('Failed to delete song:', error); + failed.push(song.id); + } } - } - 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) { - showToast(t('songsDeletedPartial').replace('{succeeded}', String(succeeded.length)).replace('{total}', String(songsToDelete.length)), 'error'); - } else { - showToast(t('songsDeletedSuccess')); - } + setLikedSongIds(prev => { + const next = new Set(prev); + succeeded.forEach(id => next.delete(id)); + 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; - const confirmed = window.confirm('Delete this upload? This action cannot be undone.'); - if (!confirmed) return; - try { - const response = await fetch(`/api/reference-tracks/${trackId}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${token}` } - }); - if (!response.ok) { - throw new Error('Failed to delete upload'); - } - setReferenceTracks(prev => prev.filter(track => track.id !== trackId)); - showToast(t('songDeleted')); - } catch (error) { - console.error('Failed to delete upload:', error); - showToast(t('failedToDeleteSong'), 'error'); - } + + setConfirmDialog({ + title: t('delete'), + message: t('deleteUploadConfirm'), + onConfirm: async () => { + setConfirmDialog(null); + try { + const response = await fetch(`/api/reference-tracks/${trackId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token!}` } + }); + if (!response.ok) { + throw new Error('Failed to delete upload'); + } + 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) => { @@ -1260,6 +1260,7 @@ function AppContent() { onCoverSong={handleCoverSong} onUseUploadAsReference={handleUseUploadAsReference} onCoverUpload={handleCoverUpload} + onSongUpdate={handleSongUpdate} /> @@ -1276,9 +1277,6 @@ function AppContent() { onNavigateToSong={handleNavigateToSong} isLiked={selectedSong ? likedSongIds.has(selectedSong.id) : false} onToggleLike={toggleLike} - onPlay={playSong} - isPlaying={isPlaying} - currentSong={currentSong} onDelete={handleDeleteSong} /> @@ -1314,6 +1312,7 @@ function AppContent() { } else if (v === 'search') { window.history.pushState({}, '', '/search'); } + if (isMobile) setShowLeftSidebar(false); }} theme={theme} onToggleTheme={toggleTheme} @@ -1321,6 +1320,8 @@ function AppContent() { onLogin={() => setShowUsernameModal(true)} onLogout={logout} onOpenSettings={() => setShowSettingsModal(true)} + isOpen={showLeftSidebar} + onToggle={() => setShowLeftSidebar(!showLeftSidebar)} />
@@ -1339,6 +1340,9 @@ function AppContent() { onPrevious={playPrevious} volume={volume} onVolumeChange={setVolume} + playbackRate={playbackRate} + onPlaybackRateChange={setPlaybackRate} + audioRef={audioRef} isShuffle={isShuffle} onToggleShuffle={() => setIsShuffle(!isShuffle)} repeatMode={repeatMode} @@ -1392,7 +1396,7 @@ function AppContent() { {/* Mobile Details Modal */} {showMobileDetails && selectedSong && ( -
+
setShowMobileDetails(false)} @@ -1408,14 +1412,19 @@ function AppContent() { onNavigateToSong={handleNavigateToSong} isLiked={selectedSong ? likedSongIds.has(selectedSong.id) : false} onToggleLike={toggleLike} - onPlay={playSong} - isPlaying={isPlaying} - currentSong={currentSong} onDelete={handleDeleteSong} />
)} + + confirmDialog?.onConfirm()} + onCancel={() => setConfirmDialog(null)} + />
); } diff --git a/components/EditProfileModal.tsx b/components/EditProfileModal.tsx index 3969aac..dc5bb47 100644 --- a/components/EditProfileModal.tsx +++ b/components/EditProfileModal.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { X, Camera, Image as ImageIcon, Upload, Loader2 } from 'lucide-react'; import { useAuth } from '../context/AuthContext'; import { usersApi, UserProfile } from '../services/api'; +import { useI18n } from '../context/I18nContext'; interface EditProfileModalProps { isOpen: boolean; @@ -10,6 +11,7 @@ interface EditProfileModalProps { } export const EditProfileModal: React.FC = ({ isOpen, onClose, onSaved }) => { + const { t } = useI18n(); const { user, token, refreshUser, updateUsername } = useAuth(); const [loading, setLoading] = useState(true); const [profile, setProfile] = useState(null); @@ -83,7 +85,7 @@ export const EditProfileModal: React.FC = ({ isOpen, onCl if (editUsername && editUsername !== profile.username) { const sanitized = editUsername.trim().replace(/[^a-zA-Z0-9_-]/g, ''); if (sanitized.length < 2) { - setUsernameError('Username must be at least 2 characters'); + setUsernameError(t('usernameMinLengthError')); setIsSaving(false); return; } @@ -92,9 +94,9 @@ export const EditProfileModal: React.FC = ({ isOpen, onCl } catch (err: unknown) { const error = err as Error & { message?: string }; if (error.message?.includes('taken')) { - setUsernameError('Username is already taken'); + setUsernameError(t('usernameTakenError')); } else { - setUsernameError('Failed to update username'); + setUsernameError(t('usernameUpdateFailedError')); } setIsSaving(false); return; @@ -152,27 +154,27 @@ export const EditProfileModal: React.FC = ({ isOpen, onCl if (!isOpen) return null; return ( -
-
-
-

Edit Profile

-
{loading ? (
- +
) : ( <>
{/* Username Input */}
- +
- @ + @ = ({ isOpen, onCl setEditUsername(e.target.value); setUsernameError(''); }} - placeholder="username" + placeholder={t('usernamePlaceholder')} maxLength={50} - className="flex-1 bg-black border border-zinc-800 rounded-lg px-3 py-2 text-white placeholder-zinc-600 focus:outline-none focus:border-indigo-500 transition-colors" + className="flex-1 bg-zinc-50 dark:bg-black border border-zinc-300 dark:border-zinc-800 rounded-lg px-3 py-2 text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:border-indigo-500 transition-colors" />
{usernameError && (

{usernameError}

)} -

Letters, numbers, underscores, and hyphens only

+

{t('usernameRequirements')}

{/* Avatar Upload */}
- +
-
+
{(avatarPreview || editAvatarUrl) ? ( = ({ isOpen, onCl onError={(e) => (e.currentTarget.style.display = 'none')} /> ) : ( -
+
)} @@ -224,22 +226,22 @@ export const EditProfileModal: React.FC = ({ isOpen, onCl -

JPG, PNG, WebP, GIF - Max 5MB

+

{t('avatarFormats')}

{/* Banner Upload */}
- +
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) ? ( = ({ isOpen, onCl onError={(e) => (e.currentTarget.style.display = 'none')} /> ) : ( -
+
- Click to upload banner + {t('clickToUploadBanner')}
)} {uploadingBanner && ( @@ -266,37 +268,37 @@ export const EditProfileModal: React.FC = ({ isOpen, onCl onChange={handleBannerChange} className="hidden" /> -

Recommended: 1500x500px - JPG, PNG, WebP, GIF - Max 5MB

+

{t('bannerFormats')}

{/* Bio Input */}
- +