Gradio API migration, training pipeline, news page, and UI improvements

- Migrate backend from REST API to Gradio @gradio/client for generation
- Fix Gradio parameter alignment (positions 36-49) for reference/cover audio
- Add LoRA training pipeline with dataset upload, preprocessing, and export
- Add News page with dismiss/restore and GitHub star button
- Add localization info icon in Settings language section
- Fix upload audio URL prefix, add missing MIME types
- Add training API routes and Python preprocess script
- Update i18n with news keys for all languages
This commit is contained in:
fspecii
2026-02-09 22:30:15 +02:00
parent f42fde9b40
commit 565faacb7b
23 changed files with 3145 additions and 236 deletions
+198 -137
View File
@@ -4,7 +4,7 @@ import { GenerationParams, Song } from '../types';
import { useAuth } from '../context/AuthContext';
import { useI18n } from '../context/I18nContext';
import { generateApi } from '../services/api';
import { MAIN_STYLES, SUB_STYLES } from '../data/genres';
import { MAIN_STYLES } from '../data/genres';
import { EditableSlider } from './EditableSlider';
interface ReferenceTrack {
@@ -48,7 +48,12 @@ const KEY_SIGNATURES = [
'B major', 'B minor'
];
const TIME_SIGNATURES = ['', '2/4', '3/4', '4/4', '6/8'];
const TIME_SIGNATURES = ['', '2', '3', '4', '6', 'N/A'];
const TRACK_NAMES = [
'woodwinds', 'brass', 'fx', 'synth', 'strings', 'percussion',
'keyboard', 'guitar', 'bass', 'drums', 'backing_vocals', 'vocals',
];
const VOCAL_LANGUAGE_KEYS = [
{ value: 'unknown', key: 'autoInstrumental' as const },
@@ -215,6 +220,7 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
const [showLoraPanel, setShowLoraPanel] = useState(false);
const [loraPath, setLoraPath] = useState('./lora_output/final/adapter');
const [loraLoaded, setLoraLoaded] = useState(false);
const [loraEnabled, setLoraEnabled] = useState(true);
const [loraScale, setLoraScale] = useState(1.0);
const [loraError, setLoraError] = useState<string | null>(null);
const [isLoraLoading, setIsLoraLoading] = useState(false);
@@ -263,19 +269,6 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
return modelId.includes('turbo');
};
// Genre selection state (cascading)
const [selectedMainGenre, setSelectedMainGenre] = useState<string>('');
const [selectedSubGenre, setSelectedSubGenre] = useState<string>('');
// Filter sub-genres based on selected main genre
const filteredSubGenres = useMemo(() => {
if (!selectedMainGenre) return [];
const mainLower = selectedMainGenre.toLowerCase().trim();
return SUB_STYLES.filter(style =>
style.toLowerCase().includes(mainLower)
);
}, [selectedMainGenre]);
const [isUploadingReference, setIsUploadingReference] = useState(false);
const [isUploadingSource, setIsUploadingSource] = useState(false);
const [isTranscribingReference, setIsTranscribingReference] = useState(false);
@@ -427,7 +420,7 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
const handleLoraScaleChange = async (newScale: number) => {
setLoraScale(newScale);
if (!token || !loraLoaded) return;
try {
@@ -437,6 +430,61 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
}
};
const handleLoraEnabledToggle = async () => {
if (!token || !loraLoaded) return;
const newEnabled = !loraEnabled;
setLoraEnabled(newEnabled);
try {
await generateApi.toggleLora({ enabled: newEnabled }, token);
} catch (err) {
console.error('Failed to toggle LoRA:', err);
setLoraEnabled(!newEnabled); // revert on error
}
};
// Load generation parameters from JSON file
const handleLoadParamsFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const data = JSON.parse(ev.target?.result as string);
if (data.lyrics !== undefined) setLyrics(data.lyrics);
if (data.style !== undefined) setStyle(data.style);
if (data.title !== undefined) setTitle(data.title);
if (data.caption !== undefined) setStyle(data.caption);
if (data.instrumental !== undefined) setInstrumental(data.instrumental);
if (data.vocal_language !== undefined) setVocalLanguage(data.vocal_language);
if (data.bpm !== undefined) setBpm(data.bpm);
if (data.key_scale !== undefined) setKeyScale(data.key_scale);
if (data.time_signature !== undefined) setTimeSignature(data.time_signature);
if (data.duration !== undefined) setDuration(data.duration);
if (data.inference_steps !== undefined) setInferenceSteps(data.inference_steps);
if (data.guidance_scale !== undefined) setGuidanceScale(data.guidance_scale);
if (data.audio_format !== undefined) setAudioFormat(data.audio_format);
if (data.infer_method !== undefined) setInferMethod(data.infer_method);
if (data.seed !== undefined) { setSeed(data.seed); setRandomSeed(false); }
if (data.shift !== undefined) setShift(data.shift);
if (data.lm_temperature !== undefined) setLmTemperature(data.lm_temperature);
if (data.lm_cfg_scale !== undefined) setLmCfgScale(data.lm_cfg_scale);
if (data.lm_top_k !== undefined) setLmTopK(data.lm_top_k);
if (data.lm_top_p !== undefined) setLmTopP(data.lm_top_p);
if (data.lm_negative_prompt !== undefined) setLmNegativePrompt(data.lm_negative_prompt);
if (data.task_type !== undefined) setTaskType(data.task_type);
if (data.audio_codes !== undefined) setAudioCodes(data.audio_codes);
if (data.repainting_start !== undefined) setRepaintingStart(data.repainting_start);
if (data.repainting_end !== undefined) setRepaintingEnd(data.repainting_end);
if (data.instruction !== undefined) setInstruction(data.instruction);
if (data.audio_cover_strength !== undefined) setAudioCoverStrength(data.audio_cover_strength);
} catch {
console.error('Failed to parse parameters JSON');
}
};
reader.readAsText(file);
e.target.value = ''; // reset so same file can be reloaded
};
// Reuse Effect - must be after all state declarations
useEffect(() => {
if (initialData) {
@@ -628,28 +676,6 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
setIsResizing(true);
};
const uploadAudio = async (file: File, target: 'reference' | 'source') => {
if (!token) {
setUploadError('Please sign in to upload audio.');
return;
}
setUploadError(null);
const setUploading = target === 'reference' ? setIsUploadingReference : setIsUploadingSource;
const setUrl = target === 'reference' ? setReferenceAudioUrl : setSourceAudioUrl;
setUploading(true);
try {
const result = await generateApi.uploadAudio(file, token);
setUrl(result.url);
setShowAudioModal(false);
setTempAudioUrl('');
} catch (err) {
const message = err instanceof Error ? err.message : 'Upload failed';
setUploadError(message);
} finally {
setUploading(false);
}
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>, target: 'reference' | 'source') => {
const file = e.target.files?.[0];
if (file) {
@@ -1182,8 +1208,28 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
<div className="space-y-5">
{/* Song Description */}
<div className="bg-white dark:bg-suno-card rounded-xl border border-zinc-200 dark:border-white/5 overflow-hidden">
<div className="px-3 py-2.5 text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400 border-b border-zinc-100 dark:border-white/5 bg-zinc-50 dark:bg-white/5">
{t('describeYourSong')}
<div className="px-3 py-2.5 flex items-center justify-between border-b border-zinc-100 dark:border-white/5 bg-zinc-50 dark:bg-white/5">
<span className="text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
{t('describeYourSong')}
</span>
<button
type="button"
onClick={async () => {
if (!token) return;
try {
const result = await generateApi.getRandomDescription(token);
setSongDescription(result.description);
setInstrumental(result.instrumental);
setVocalLanguage(result.vocalLanguage || 'unknown');
} catch (err) {
console.error('Failed to load random description:', err);
}
}}
title="Load random description"
className="p-1 rounded-md text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors"
>
<Dices size={14} />
</button>
</div>
<textarea
value={songDescription}
@@ -1194,32 +1240,37 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
</div>
{/* Vocal Language (Simple) */}
<div className="bg-white dark:bg-suno-card rounded-xl border border-zinc-200 dark:border-white/5 overflow-hidden">
<div className="px-3 py-2.5 text-xs font-bold uppercase tracking-wide text-zinc-500 dark:text-zinc-400 border-b border-zinc-100 dark:border-white/5 bg-zinc-50 dark:bg-white/5">
{t('vocalLanguage')}
</div>
<div className="flex flex-wrap items-center gap-2 p-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wide px-1">
{t('vocalLanguage')}
</label>
<select
value={vocalLanguage}
onChange={(e) => setVocalLanguage(e.target.value)}
className="flex-1 min-w-[180px] bg-transparent text-sm text-zinc-900 dark:text-white focus:outline-none"
className="w-full bg-white dark:bg-suno-card border border-zinc-200 dark:border-white/5 rounded-xl px-3 py-2 text-sm text-zinc-900 dark:text-white focus:outline-none focus:border-pink-500 dark:focus:border-pink-500 transition-colors cursor-pointer [&>option]:bg-white [&>option]:dark:bg-zinc-800 [&>option]:text-zinc-900 [&>option]:dark:text-white"
>
{VOCAL_LANGUAGE_KEYS.map(lang => (
<option key={lang.value} value={lang.value}>{lang.key}</option>
<option key={lang.value} value={lang.value}>{t(lang.key)}</option>
))}
</select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wide px-1">
{t('vocalGender')}
</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setVocalGender(vocalGender === 'male' ? '' : 'male')}
className={`px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors ${vocalGender === 'male' ? 'bg-pink-600 text-white border-pink-600' : 'border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300 hover:border-zinc-300 dark:hover:border-white/20'}`}
className={`flex-1 px-3 py-2 rounded-lg text-xs font-semibold border transition-colors ${vocalGender === 'male' ? 'bg-pink-600 text-white border-pink-600' : 'border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300 hover:border-zinc-300 dark:hover:border-white/20'}`}
>
{t('male')}
</button>
<button
type="button"
onClick={() => setVocalGender(vocalGender === 'female' ? '' : 'female')}
className={`px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors ${vocalGender === 'female' ? 'bg-pink-600 text-white border-pink-600' : 'border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300 hover:border-zinc-300 dark:hover:border-white/20'}`}
className={`flex-1 px-3 py-2 rounded-lg text-xs font-semibold border transition-colors ${vocalGender === 'female' ? 'bg-pink-600 text-white border-pink-600' : 'border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300 hover:border-zinc-300 dark:hover:border-white/20'}`}
>
{t('female')}
</button>
@@ -1239,7 +1290,7 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
label={t('duration')}
value={duration}
min={-1}
max={600}
max={activeMaxDuration}
step={5}
onChange={setDuration}
formatDisplay={(val) => val === -1 ? t('auto') : `${val}${t('seconds')}`}
@@ -1584,70 +1635,6 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
className="w-full h-20 bg-transparent p-3 text-sm text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none resize-none"
/>
<div className="px-3 pb-3 space-y-3">
{/* Cascading Genre Selector */}
<div className="space-y-2">
{/* First Level: Main Genre */}
<div className="flex gap-2">
<select
value={selectedMainGenre}
onChange={(e) => {
setSelectedMainGenre(e.target.value);
setSelectedSubGenre(''); // Reset sub genre when main changes
if (e.target.value) {
setStyle(prev => prev ? `${prev}, ${e.target.value}` : e.target.value);
}
}}
className="flex-1 bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-xl px-2 py-1.5 text-xs text-zinc-900 dark:text-white focus:outline-none focus:border-pink-500 dark:focus:border-pink-500 transition-colors cursor-pointer [&>option]:bg-white [&>option]:dark:bg-zinc-800 [&>option]:text-zinc-900 [&>option]:dark:text-white"
>
<option value="">{t('mainGenre')}</option>
{MAIN_STYLES.map(genre => (
<option key={genre} value={genre}>{genre}</option>
))}
</select>
{selectedMainGenre && (
<button
onClick={() => {
setSelectedMainGenre('');
setSelectedSubGenre('');
}}
className="px-2 py-1.5 text-xs text-zinc-500 hover:text-zinc-900 dark:hover:text-white transition-colors"
title={t('cancel')}
>
</button>
)}
</div>
{/* Second Level: Sub Genre (only show when main genre is selected) */}
{selectedMainGenre && filteredSubGenres.length > 0 && (
<div className="flex gap-2 pl-4 border-l-2 border-zinc-200 dark:border-white/10">
<select
value={selectedSubGenre}
onChange={(e) => {
setSelectedSubGenre(e.target.value);
if (e.target.value) {
setStyle(prev => prev ? `${prev}, ${e.target.value}` : e.target.value);
}
}}
className="flex-1 bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-xl px-2 py-1.5 text-xs text-zinc-900 dark:text-white focus:outline-none focus:border-pink-500 dark:focus:border-pink-500 transition-colors cursor-pointer [&>option]:bg-white [&>option]:dark:bg-zinc-800 [&>option]:text-zinc-900 [&>option]:dark:text-white"
>
<option value="">{t('subGenre')} ({filteredSubGenres.length})</option>
{filteredSubGenres.map(genre => (
<option key={genre} value={genre}>{genre}</option>
))}
</select>
{selectedSubGenre && (
<button
onClick={() => setSelectedSubGenre('')}
className="px-2 py-1.5 text-xs text-zinc-500 hover:text-zinc-900 dark:hover:text-white transition-colors"
title={t('cancel')}
>
</button>
)}
</div>
)}
</div>
{/* Quick Tags */}
<div className="flex flex-wrap gap-2">
{musicTags.map(tag => (
@@ -1799,13 +1786,27 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
)}
</div>
{/* Use LoRA Checkbox (enable/disable without unloading) */}
<div className={`flex items-center justify-between py-2 border-t border-zinc-100 dark:border-white/5 ${!loraLoaded ? 'opacity-40 pointer-events-none' : ''}`}>
<label className="flex items-center gap-2 text-xs font-medium text-zinc-600 dark:text-zinc-400 cursor-pointer">
<input
type="checkbox"
checked={loraEnabled}
onChange={handleLoraEnabledToggle}
disabled={!loraLoaded}
className="accent-pink-600"
/>
Use LoRA
</label>
</div>
{/* LoRA Scale Slider */}
<div className={!loraLoaded ? 'opacity-40 pointer-events-none' : ''}>
<div className={!loraLoaded || !loraEnabled ? 'opacity-40 pointer-events-none' : ''}>
<EditableSlider
label={t('loraScale')}
value={loraScale}
min={0}
max={2}
max={1}
step={0.05}
onChange={handleLoraScaleChange}
formatDisplay={(val) => val.toFixed(2)}
@@ -1881,6 +1882,17 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
{showAdvanced && (
<div className="bg-white dark:bg-suno-card rounded-xl border border-zinc-200 dark:border-white/5 p-4 space-y-4">
{/* Load Parameters from JSON */}
<label className="flex items-center gap-2 px-3 py-2 rounded-lg border border-dashed border-zinc-300 dark:border-white/15 text-xs font-medium text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-white/5 cursor-pointer transition-colors">
<Upload size={14} />
Load Parameters (JSON)
<input
type="file"
accept=".json"
onChange={handleLoadParamsFile}
className="hidden"
/>
</label>
{/* Duration */}
<EditableSlider
@@ -1937,8 +1949,8 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
<EditableSlider
label={t('inferenceSteps')}
value={inferenceSteps}
min={4}
max={32}
min={1}
max={isTurboModel(selectedModel) ? 20 : 200}
step={1}
onChange={setInferenceSteps}
helpText={t('moreStepsBetterQuality')}
@@ -1951,7 +1963,7 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
value={guidanceScale}
min={1}
max={15}
step={0.5}
step={0.1}
onChange={setGuidanceScale}
formatDisplay={(val) => val.toFixed(1)}
helpText={t('howCloselyFollowPrompt')}
@@ -2098,8 +2110,8 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
value={lmTemperature}
min={0}
max={2}
step={0.05}
onChange={(e) => setLmTemperature(Number(e.target.value))}
step={0.1}
onChange={setLmTemperature}
formatDisplay={(val) => val.toFixed(2)}
helpText={t('higherMoreRandom')}
title="Higher temperature = more random word choices."
@@ -2167,6 +2179,33 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
placeholder={t('optionalAudioCodes')}
className="w-full h-16 bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg p-2 text-xs text-zinc-900 dark:text-white focus:outline-none resize-none"
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => {
// Convert source audio to LM codes — requires Gradio lambda (not exposed as API)
// This is a placeholder: Gradio's convert_src_audio_to_codes_wrapper is not a named endpoint
console.log('Convert to Codes: requires source audio upload. Use Gradio UI for this feature.');
}}
disabled={!sourceAudioUrl}
title="Convert source audio to LM codes (requires source audio)"
className="px-2 py-1 rounded text-[10px] font-medium bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Convert to Codes
</button>
<button
type="button"
onClick={() => {
// Transcribe audio codes to metadata — requires Gradio lambda (not exposed as API)
console.log('Transcribe: requires audio codes. Use Gradio UI for this feature.');
}}
disabled={!audioCodes.trim()}
title="Transcribe audio codes to metadata (requires audio codes)"
className="px-2 py-1 rounded text-[10px] font-medium bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Transcribe
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
@@ -2187,7 +2226,7 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400" title="How strongly the source audio shapes the result.">{t('audioCoverStrength')}</label>
<input
type="number"
step="0.05"
step="0.01"
min="0"
max="1"
value={audioCoverStrength}
@@ -2238,7 +2277,7 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400" title="Fraction of the diffusion process to start applying guidance.">{t('cfgIntervalStart')}</label>
<input
type="number"
step="0.05"
step="0.01"
min="0"
max="1"
value={cfgIntervalStart}
@@ -2250,7 +2289,7 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400" title="Fraction of the diffusion process to stop applying guidance.">{t('cfgIntervalEnd')}</label>
<input
type="number"
step="0.05"
step="0.01"
min="0"
max="1"
value={cfgIntervalEnd}
@@ -2276,7 +2315,9 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400" title="Scales score-based guidance (advanced).">{t('scoreScale')}</label>
<input
type="number"
step="0.05"
step="0.01"
min="0.01"
max="1"
value={scoreScale}
onChange={(e) => setScoreScale(Number(e.target.value))}
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-3 py-2 text-xs text-zinc-900 dark:text-white focus:outline-none"
@@ -2287,6 +2328,8 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
<input
type="number"
min="1"
max="32"
step="1"
value={lmBatchChunkSize}
onChange={(e) => setLmBatchChunkSize(Number(e.target.value))}
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-3 py-2 text-xs text-zinc-900 dark:text-white focus:outline-none"
@@ -2296,24 +2339,42 @@ export const CreatePanel: React.FC<CreatePanelProps> = ({
<div className="space-y-1.5">
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">{t('trackName')}</label>
<input
type="text"
<select
value={trackName}
onChange={(e) => setTrackName(e.target.value)}
placeholder={t('optionalTrackName')}
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-3 py-2 text-xs text-zinc-900 dark:text-white focus:outline-none"
/>
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-2 py-1.5 text-xs text-zinc-900 dark:text-white focus:outline-none cursor-pointer [&>option]:bg-white [&>option]:dark:bg-zinc-800"
>
<option value="">None</option>
{TRACK_NAMES.map(name => (
<option key={name} value={name}>{name}</option>
))}
</select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-zinc-600 dark:text-zinc-400">{t('completeTrackClasses')}</label>
<input
type="text"
value={completeTrackClasses}
onChange={(e) => setCompleteTrackClasses(e.target.value)}
placeholder={t('trackClassesPlaceholder')}
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-3 py-2 text-xs text-zinc-900 dark:text-white focus:outline-none"
/>
<div className="flex flex-wrap gap-2">
{TRACK_NAMES.map(name => {
const selected = completeTrackClasses.split(',').map(s => s.trim()).filter(Boolean);
const isChecked = selected.includes(name);
return (
<label key={name} className="flex items-center gap-1 text-[10px] font-medium text-zinc-500 dark:text-zinc-400 cursor-pointer">
<input
type="checkbox"
checked={isChecked}
onChange={() => {
const next = isChecked
? selected.filter(s => s !== name)
: [...selected, name];
setCompleteTrackClasses(next.join(','));
}}
className="accent-pink-600"
/>
{name}
</label>
);
})}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
+180
View File
@@ -0,0 +1,180 @@
import React, { useState } from 'react';
import { Newspaper, X, Star, Github } from 'lucide-react';
import { useI18n } from '../context/I18nContext';
import newsData from '../data/news.json';
interface NewsItem {
id: string;
date: string;
title: string;
body: string;
tags: string[];
}
export const NewsPage: React.FC = () => {
const { t } = useI18n();
const [dismissedNews, setDismissedNews] = useState<Set<string>>(() => {
try {
const stored = localStorage.getItem('ace-dismissed-news');
return stored ? new Set(JSON.parse(stored)) : new Set();
} catch {
return new Set();
}
});
const allNews = newsData as NewsItem[];
const activeNews = allNews.filter(n => !dismissedNews.has(n.id));
const dismissed = allNews.filter(n => dismissedNews.has(n.id));
const dismissNewsItem = (id: string) => {
setDismissedNews(prev => {
const next = new Set(prev);
next.add(id);
localStorage.setItem('ace-dismissed-news', JSON.stringify([...next]));
return next;
});
};
const restoreNewsItem = (id: string) => {
setDismissedNews(prev => {
const next = new Set(prev);
next.delete(id);
localStorage.setItem('ace-dismissed-news', JSON.stringify([...next]));
return next;
});
};
const tagColor = (tag: string) => {
switch (tag) {
case 'experimental':
return 'bg-amber-500/15 text-amber-600 dark:text-amber-400';
case 'backend':
return 'bg-blue-500/15 text-blue-600 dark:text-blue-400';
case 'training':
return 'bg-purple-500/15 text-purple-600 dark:text-purple-400';
case 'feature':
return 'bg-green-500/15 text-green-600 dark:text-green-400';
case 'bugfix':
return 'bg-red-500/15 text-red-600 dark:text-red-400';
default:
return 'bg-zinc-200 dark:bg-white/10 text-zinc-500 dark:text-zinc-400';
}
};
const renderCard = (item: NewsItem, isDismissed: boolean) => (
<div
key={item.id}
className={`
group rounded-2xl border transition-all duration-200
${isDismissed
? 'bg-zinc-100 dark:bg-white/[0.02] border-zinc-200 dark:border-white/5 opacity-50'
: 'bg-white dark:bg-suno-card border-zinc-200 dark:border-white/5 hover:border-zinc-300 dark:hover:border-white/10'
}
`}
>
<div className="p-5 sm:p-6">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-semibold text-zinc-900 dark:text-zinc-100 leading-snug">
{item.title}
</h3>
<p className="text-xs text-zinc-400 dark:text-zinc-500 mt-1">{item.date}</p>
</div>
{!isDismissed ? (
<button
onClick={() => dismissNewsItem(item.id)}
className="opacity-0 group-hover:opacity-100 p-1.5 rounded-lg text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-white/10 transition-all flex-shrink-0"
title="Dismiss"
>
<X size={16} />
</button>
) : (
<button
onClick={() => restoreNewsItem(item.id)}
className="text-xs text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 hover:underline transition-colors flex-shrink-0"
>
Restore
</button>
)}
</div>
{/* Body */}
<p className="text-sm text-zinc-600 dark:text-zinc-400 mt-3 leading-relaxed">
{item.body}
</p>
{/* Tags */}
<div className="flex flex-wrap items-center gap-2 mt-4">
{item.tags.map(tag => (
<span
key={tag}
className={`text-[11px] font-medium px-2.5 py-1 rounded-full ${tagColor(tag)}`}
>
{tag}
</span>
))}
</div>
</div>
</div>
);
return (
<div className="flex-1 bg-white dark:bg-black overflow-y-auto p-6 lg:p-10 pb-32 transition-colors duration-300">
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="flex items-center gap-3 mb-8">
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center flex-shrink-0">
<Newspaper size={20} className="text-amber-600 dark:text-amber-400" />
</div>
<div>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white">{t('news')}</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400">Updates and announcements</p>
</div>
</div>
{/* Star Repo */}
<a
href="https://github.com/fspecii/ace-step-ui"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 mb-8 px-5 py-4 rounded-2xl border border-zinc-200 dark:border-white/5 bg-white dark:bg-suno-card hover:border-zinc-300 dark:hover:border-white/10 transition-all group"
>
<Github size={20} className="text-zinc-500 dark:text-zinc-400 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">fspecii/ace-step-ui</p>
<p className="text-xs text-zinc-500 dark:text-zinc-400">Star the repo to support the project</p>
</div>
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-zinc-100 dark:bg-white/10 text-zinc-700 dark:text-zinc-300 text-sm font-medium group-hover:bg-amber-500/15 group-hover:text-amber-600 dark:group-hover:text-amber-400 transition-colors flex-shrink-0">
<Star size={14} />
Star
</div>
</a>
{/* Active News */}
{activeNews.length > 0 ? (
<div className="space-y-4">
{activeNews.map(item => renderCard(item, false))}
</div>
) : (
<div className="text-center py-16">
<Newspaper size={48} className="mx-auto text-zinc-300 dark:text-zinc-600 mb-4" />
<p className="text-zinc-500 dark:text-zinc-400 text-sm">No new updates</p>
</div>
)}
{/* Dismissed News */}
{dismissed.length > 0 && (
<div className="mt-10">
<h2 className="text-xs font-semibold uppercase tracking-wider text-zinc-400 dark:text-zinc-500 mb-4">
Dismissed
</h2>
<div className="space-y-3">
{dismissed.map(item => renderCard(item, true))}
</div>
</div>
)}
</div>
</div>
);
};
+1 -1
View File
@@ -40,7 +40,7 @@ export const PlaylistDetail: React.FC<PlaylistDetailProps> = ({ playlistId, onBa
duration: s.duration,
bpm: s.bpm,
tags: s.tags || [],
isPublic: s.is_public || false,
is_public: s.is_public || false,
likeCount: s.like_count || 0,
viewCount: s.view_count || 0,
creator: s.creator,
+1 -1
View File
@@ -489,7 +489,7 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpe
</span>
))
) : (
song.style.split(',').map((tag, idx) => (
(song.style || '').split(',').filter(Boolean).map((tag, idx) => (
<span key={idx} className="px-2 py-0.5 bg-zinc-100 dark:bg-white/5 hover:bg-zinc-200 dark:hover:bg-white/10 border border-zinc-200 dark:border-white/10 rounded text-[11px] font-medium text-zinc-600 dark:text-zinc-300 transition-colors">
{tag.trim()}
</span>
-1
View File
@@ -103,7 +103,6 @@ export const SearchPage: React.FC<SearchPageProps> = ({
uniqueCreators.set(song.creator, {
id: song.user_id || song.userId || song.creator,
username: song.creator,
email: '',
created_at: song.created_at || song.createdAt,
avatar_url: song.creator_avatar || song.creatorAvatar || null,
});
+51 -28
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { X, User as UserIcon, Palette, Info, Edit3, ExternalLink, Globe, ChevronDown, Github } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import { useI18n } from '../context/I18nContext';
@@ -16,6 +16,19 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, t
const { user } = useAuth();
const { t, language, setLanguage } = useI18n();
const [isEditProfileOpen, setIsEditProfileOpen] = useState(false);
const [showLangInfo, setShowLangInfo] = useState(false);
const langInfoRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!showLangInfo) return;
const handleClick = (e: MouseEvent) => {
if (langInfoRef.current && !langInfoRef.current.contains(e.target as Node)) {
setShowLangInfo(false);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [showLangInfo]);
if (!isOpen || !user) {
if (isEditProfileOpen && user) {
@@ -108,6 +121,43 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, t
<div className="flex items-center gap-2 text-zinc-900 dark:text-white">
<Globe size={20} />
<h3 className="font-semibold">{t('language')}</h3>
<div className="relative" ref={langInfoRef}>
<button
onClick={() => setShowLangInfo(!showLangInfo)}
className="p-1 rounded-full text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors"
>
<Info size={14} />
</button>
{showLangInfo && (
<div className="absolute left-0 top-8 z-10 w-64 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-xl p-3">
<p className="text-xs text-zinc-500 dark:text-zinc-400 mb-2">{t('localizedBy')}</p>
<div className="flex flex-wrap gap-1.5">
<a
href="https://x.com/bdsqlsz"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 bg-black dark:bg-white text-white dark:text-black rounded-lg text-xs font-medium hover:bg-zinc-800 dark:hover:bg-zinc-200 transition-colors"
>
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
@bdsqlsz
</a>
<a
href="https://space.bilibili.com/219296"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 bg-[#00A1D6] text-white rounded-lg text-xs font-medium hover:bg-[#0090C0] transition-colors"
>
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor">
<path d="M17.813 4.653h.854c1.51.054 2.769.578 3.773 1.574 1.004.995 1.524 2.249 1.56 3.76v7.36c-.036 1.51-.556 2.769-1.56 3.773s-2.262 1.524-3.773 1.56H5.333c-1.51-.036-2.769-.556-3.773-1.56S.036 18.858 0 17.347v-7.36c.036-1.511.556-2.765 1.56-3.76 1.004-.996 2.262-1.52 3.773-1.574h.774l-1.174-1.12a1.234 1.234 0 0 1-.373-.906c0-.356.124-.658.373-.907l.027-.027c.267-.249.573-.373.92-.373.347 0 .653.124.92.373L9.653 4.44c.071.071.134.142.187.213h4.267a.836.836 0 0 1 .16-.213l2.853-2.747c.267-.249.573-.373.92-.373.347 0 .662.151.929.4.267.249.391.551.391.907 0 .355-.124.657-.373.906zM5.333 7.24c-.746.018-1.373.276-1.88.773-.506.498-.769 1.13-.786 1.894v7.52c.017.764.28 1.395.786 1.893.507.498 1.134.756 1.88.773h13.334c.746-.017 1.373-.275 1.88-.773.506-.498.769-1.129.786-1.893v-7.52c-.017-.765-.28-1.396-.786-1.894-.507-.497-1.134-.755-1.88-.773zM8 11.107c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c0-.373.129-.689.386-.947.258-.257.574-.386.947-.386zm8 0c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373Z"/>
</svg>
</a>
</div>
</div>
)}
</div>
</div>
<div className="pl-7 space-y-3">
<div className="relative">
@@ -200,33 +250,6 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, t
Report issues or request features on GitHub
</p>
</div>
<div>
<p className="text-zinc-900 dark:text-white font-medium mb-2">{t('localizedBy')}</p>
<div className="flex flex-wrap gap-2">
<a
href="https://x.com/bdsqlsz"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-black dark:bg-white text-white dark:text-black rounded-lg text-sm font-medium hover:bg-zinc-800 dark:hover:bg-zinc-200 transition-colors"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
{t('follow')} @bdsqlsz
</a>
<a
href="https://space.bilibili.com/219296"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-[#00A1D6] text-white rounded-lg text-sm font-medium hover:bg-[#0090C0] transition-colors"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M17.813 4.653h.854c1.51.054 2.769.578 3.773 1.574 1.004.995 1.524 2.249 1.56 3.76v7.36c-.036 1.51-.556 2.769-1.56 3.773s-2.262 1.524-3.773 1.56H5.333c-1.51-.036-2.769-.556-3.773-1.56S.036 18.858 0 17.347v-7.36c.036-1.511.556-2.765 1.56-3.76 1.004-.996 2.262-1.52 3.773-1.574h.774l-1.174-1.12a1.234 1.234 0 0 1-.373-.906c0-.356.124-.658.373-.907l.027-.027c.267-.249.573-.373.92-.373.347 0 .653.124.92.373L9.653 4.44c.071.071.134.142.187.213h4.267a.836.836 0 0 1 .16-.213l2.853-2.747c.267-.249.573-.373.92-.373.347 0 .662.151.929.4.267.249.391.551.391.907 0 .355-.124.657-.373.906zM5.333 7.24c-.746.018-1.373.276-1.88.773-.506.498-.769 1.13-.786 1.894v7.52c.017.764.28 1.395.786 1.893.507.498 1.134.756 1.88.773h13.334c.746-.017 1.373-.275 1.88-.773.506-.498.769-1.129.786-1.893v-7.52c-.017-.765-.28-1.396-.786-1.894-.507-.497-1.134-.755-1.88-.773zM8 11.107c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c0-.373.129-.689.386-.947.258-.257.574-.386.947-.386zm8 0c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373Z"/>
</svg>
{t('follow')}
</a>
</div>
</div>
</div>
</div>
</div>
+16 -1
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { Library, Disc, Search, User, LogIn, LogOut, Sun, Moon } from 'lucide-react';
import { Library, Disc, Search, LogIn, LogOut, Sun, Moon, GraduationCap, Newspaper } from 'lucide-react';
import { View } from '../types';
import { useI18n } from '../context/I18nContext';
@@ -104,6 +104,21 @@ export const Sidebar: React.FC<SidebarProps> = ({
onClick={() => onNavigate('search')}
isExpanded={isOpen}
/>
<NavItem
icon={<GraduationCap size={20} />}
label={t('training')}
active={currentView === 'training'}
onClick={() => onNavigate('training')}
isExpanded={isOpen}
/>
<NavItem
icon={<Newspaper size={20} />}
label={t('news')}
active={currentView === 'news'}
onClick={() => onNavigate('news')}
isExpanded={isOpen}
/>
<div className="mt-auto flex flex-col gap-2">
{/* Theme Toggle */}
<button
File diff suppressed because it is too large Load Diff