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
+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 { songsApi } from '../services/api';
import { useAuth } from '../context/AuthContext';
import { useI18n } from '../context/I18nContext';
import { SongDropdownMenu } from './SongDropdownMenu';
import { ShareModal } from './ShareModal';
import { AlbumCover } from './AlbumCover';
@@ -26,6 +27,7 @@ interface RightSidebarProps {
export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpenVideo, onReuse, onSongUpdate, onNavigateToProfile, onNavigateToSong, isLiked, onToggleLike, onDelete, onAddToPlaylist, onPlay, isPlaying, currentSong }) => {
const { token, user } = useAuth();
const { t } = useI18n();
const [showMenu, setShowMenu] = useState(false);
const [isOwner, setIsOwner] = useState(false);
const [tagsExpanded, setTagsExpanded] = useState(false);
@@ -118,7 +120,7 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
<div className="w-full h-full bg-zinc-50 dark:bg-suno-panel border-l border-zinc-200 dark:border-white/5 flex items-center justify-center text-zinc-400 dark:text-zinc-500 text-sm transition-colors duration-300">
<div className="flex flex-col items-center gap-2">
<Music size={40} className="text-zinc-300 dark:text-zinc-700" />
<p>Select a song to view details</p>
<p>{t('selectSongToView')}</p>
</div>
</div>
);
@@ -128,7 +130,7 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
{/* Header */}
<div className="h-14 flex items-center justify-between px-4 border-b border-zinc-200 dark:border-white/5 flex-shrink-0 bg-zinc-50/50 dark:bg-suno-panel/50 backdrop-blur-md z-10">
<span className="font-semibold text-sm text-zinc-900 dark:text-white">Song Details</span>
<span className="font-semibold text-sm text-zinc-900 dark:text-white">{t('songDetails')}</span>
<button
onClick={onClose}
className="p-1.5 hover:bg-zinc-200 dark:hover:bg-white/10 rounded-full text-zinc-500 dark:text-zinc-400 transition-colors"
@@ -138,7 +140,7 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar">
<div className="p-5 space-y-6">
<div className="p-5 pb-24 lg:pb-32 space-y-6">
{/* Cover Art */}
<div
@@ -217,14 +219,14 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
disabled={isSavingTitle}
className="px-3 py-1.5 rounded-md text-xs font-semibold bg-pink-600 text-white hover:bg-pink-700 disabled:opacity-60"
>
{isSavingTitle ? 'Saving...' : 'Save'}
{isSavingTitle ? t('saving') : t('save')}
</button>
<button
onClick={cancelTitleEdit}
disabled={isSavingTitle}
className="px-3 py-1.5 rounded-md text-xs font-semibold bg-zinc-200 text-zinc-700 hover:bg-zinc-300 dark:bg-white/10 dark:text-zinc-200 dark:hover:bg-white/20 disabled:opacity-60"
>
Cancel
{t('cancel')}
</button>
{titleError && (
<span className="text-xs text-red-500">{titleError}</span>
@@ -278,9 +280,9 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
onClick={() => song.creator && onNavigateToProfile?.(song.creator)}
className="text-sm font-semibold text-zinc-900 dark:text-white hover:underline cursor-pointer"
>
{song.creator || 'Anonymous'}
{song.creator || t('anonymous')}
</span>
<span className="text-xs text-zinc-500 dark:text-zinc-400">Created {new Date(song.createdAt).toLocaleDateString()}</span>
<p className="text-xs text-zinc-500">{t('created')} {new Date(song.createdAt).toLocaleDateString()}</p>
</div>
</div>
</div>
@@ -289,7 +291,7 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
<div className="flex items-center justify-between px-3 py-2.5 bg-zinc-200/80 dark:bg-black/40 backdrop-blur-sm rounded-2xl border border-zinc-300/50 dark:border-white/5">
<button
onClick={onOpenVideo}
title="Create Video"
title={t('createVideo')}
className="p-3 text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white hover:bg-zinc-300/50 dark:hover:bg-white/10 rounded-xl transition-all duration-200"
>
<Video size={18} strokeWidth={1.5} />
@@ -300,14 +302,14 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
const audioUrl = song.audioUrl.startsWith('http') ? song.audioUrl : `${window.location.origin}${song.audioUrl}`;
window.open(`/editor?audioUrl=${encodeURIComponent(audioUrl)}`, '_blank');
}}
title="Open in Editor"
title={t('openInEditor')}
className="p-3 text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white hover:bg-zinc-300/50 dark:hover:bg-white/10 rounded-xl transition-all duration-200"
>
<Edit3 size={18} strokeWidth={1.5} />
</button>
<button
onClick={() => onReuse && onReuse(song)}
title="Reuse Prompt"
title={t('reusePrompt')}
className="p-3 text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white hover:bg-zinc-300/50 dark:hover:bg-white/10 rounded-xl transition-all duration-200"
>
<Repeat size={18} strokeWidth={1.5} />
@@ -321,7 +323,7 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
const audioUrl = song.audioUrl.startsWith('http') ? song.audioUrl : `${baseUrl}${song.audioUrl}`;
window.open(`${baseUrl}/demucs-web/?audioUrl=${encodeURIComponent(audioUrl)}`, '_blank');
}}
title="Extract Stems"
title={t('extractStems')}
className="p-3 text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white hover:bg-zinc-300/50 dark:hover:bg-white/10 rounded-xl transition-all duration-200"
>
<Layers size={18} strokeWidth={1.5} />
@@ -342,7 +344,7 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
<div className="flex items-center gap-2">
<button
className="p-2 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors"
title="Download Audio"
title={t('downloadAudio')}
onClick={async () => {
if (!song.audioUrl) return;
try {
@@ -454,21 +456,26 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
{/* Tags / Style */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-xs font-bold text-zinc-500 dark:text-zinc-500 uppercase tracking-wider">Style & Tags</h3>
<h2 className="text-sm font-bold text-zinc-900 dark:text-white uppercase tracking-wide">{t('songDetails')}</h2>
<button
onClick={(e) => {
onClick={async (e) => {
e.stopPropagation();
const allTags = song.tags && song.tags.length > 0
? song.tags.join(', ')
: song.style;
navigator.clipboard.writeText(allTags);
setCopiedStyle(true);
setTimeout(() => setCopiedStyle(false), 2000);
try {
const allTags = Array.isArray(song.tags) && song.tags.length > 0
? song.tags.join(', ')
: (song.style ?? '');
if (!allTags) return;
await navigator.clipboard.writeText(allTags);
setCopiedStyle(true);
setTimeout(() => setCopiedStyle(false), 2000);
} catch (error) {
console.error('Failed to copy style tags:', error);
}
}}
className={`flex items-center gap-1 text-[10px] font-medium transition-colors ${copiedStyle ? 'text-green-500' : 'text-zinc-500 hover:text-black dark:hover:text-white'}`}
title="Copy all tags"
className={`relative z-10 flex items-center gap-1 text-[10px] font-medium transition-colors cursor-pointer ${copiedStyle ? 'text-green-500' : 'text-zinc-500 hover:text-black dark:hover:text-white'}`}
title={t('copyAllTags')}
>
<Copy size={12} /> {copiedStyle ? 'Copied!' : 'Copy'}
<Copy size={12} /> {copiedStyle ? t('copied') : t('copy')}
</button>
</div>
<div
@@ -489,8 +496,8 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
))
)}
{!tagsExpanded && (
<span className="absolute right-0 top-0 px-2 py-0.5 bg-zinc-200 dark:bg-zinc-700 rounded text-[11px] font-medium text-zinc-600 dark:text-zinc-300">
+more
<span className="absolute right-0 top-0 px-2 py-0.5 bg-zinc-200 dark:bg-zinc-700 rounded text-[11px] font-medium text-zinc-600 dark:text-zinc-300 pointer-events-none">
+{t('more')}
</span>
)}
</div>
@@ -499,18 +506,23 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
{/* Lyrics Section */}
<div className="bg-white dark:bg-black/20 rounded-xl border border-zinc-200 dark:border-white/5 overflow-hidden">
<div className="px-4 py-3 border-b border-zinc-100 dark:border-white/5 flex items-center justify-between bg-zinc-50 dark:bg-white/5">
<h3 className="text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Lyrics</h3>
<h3 className="text-[10px] font-bold text-zinc-500 uppercase tracking-wider mb-2 flex items-center justify-between">{t('lyricsSection')}</h3>
<button
onClick={() => {
if (song.lyrics) {
navigator.clipboard.writeText(song.lyrics);
setCopiedLyrics(true);
setTimeout(() => setCopiedLyrics(false), 2000);
onClick={async (e) => {
e.stopPropagation();
try {
if (song.lyrics) {
await navigator.clipboard.writeText(song.lyrics);
setCopiedLyrics(true);
setTimeout(() => setCopiedLyrics(false), 2000);
}
} catch (error) {
console.error('Failed to copy lyrics:', error);
}
}}
className={`flex items-center gap-1 text-[10px] font-medium transition-colors ${copiedLyrics ? 'text-green-500' : 'text-zinc-500 hover:text-black dark:hover:text-white'}`}
className={`flex items-center gap-1 text-[10px] font-medium transition-colors cursor-pointer ${copiedLyrics ? 'text-green-500' : 'text-zinc-500 hover:text-black dark:hover:text-white'}`}
>
<Copy size={12} /> {copiedLyrics ? 'Copied!' : 'Copy'}
<Copy size={12} /> {copiedLyrics ? t('copied') : t('copy')}
</button>
</div>
<div className="p-4 max-h-[300px] overflow-y-auto custom-scrollbar">