mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
some fixes
This commit is contained in:
@@ -999,6 +999,9 @@ export const deTranslation = {
|
|||||||
csvImportNetworkErrorsTitle: 'Netzwerkfehler (Import kann wiederholt werden):',
|
csvImportNetworkErrorsTitle: 'Netzwerkfehler (Import kann wiederholt werden):',
|
||||||
csvImportDownloadReport: 'Bericht herunterladen',
|
csvImportDownloadReport: 'Bericht herunterladen',
|
||||||
csvImportClose: 'Schließen',
|
csvImportClose: 'Schließen',
|
||||||
|
csvImportNoValidTracks: 'Keine gültigen Titel in CSV-Datei gefunden',
|
||||||
|
csvImportFailed: 'CSV-Import fehlgeschlagen',
|
||||||
|
csvImportToast: '{{added}} hinzugefügt, {{notFound}} nicht gefunden, {{duplicates}} Duplikate',
|
||||||
},
|
},
|
||||||
mostPlayed: {
|
mostPlayed: {
|
||||||
title: 'Meistgehört',
|
title: 'Meistgehört',
|
||||||
|
|||||||
@@ -1001,6 +1001,9 @@ export const enTranslation = {
|
|||||||
csvImportNetworkErrorsTitle: 'Network Errors (may retry import):',
|
csvImportNetworkErrorsTitle: 'Network Errors (may retry import):',
|
||||||
csvImportDownloadReport: 'Download Report',
|
csvImportDownloadReport: 'Download Report',
|
||||||
csvImportClose: 'Close',
|
csvImportClose: 'Close',
|
||||||
|
csvImportNoValidTracks: 'No valid tracks found in CSV file',
|
||||||
|
csvImportFailed: 'Failed to import CSV file',
|
||||||
|
csvImportToast: '{{added}} added, {{notFound}} not found, {{duplicates}} duplicates',
|
||||||
},
|
},
|
||||||
mostPlayed: {
|
mostPlayed: {
|
||||||
title: 'Most Played',
|
title: 'Most Played',
|
||||||
|
|||||||
@@ -1002,6 +1002,9 @@ export const esTranslation = {
|
|||||||
csvImportNetworkErrorsTitle: 'Errores de Red (puede reintentar la importación):',
|
csvImportNetworkErrorsTitle: 'Errores de Red (puede reintentar la importación):',
|
||||||
csvImportDownloadReport: 'Descargar Reporte',
|
csvImportDownloadReport: 'Descargar Reporte',
|
||||||
csvImportClose: 'Cerrar',
|
csvImportClose: 'Cerrar',
|
||||||
|
csvImportNoValidTracks: 'No se encontraron canciones válidas en el archivo CSV',
|
||||||
|
csvImportFailed: 'Error al importar el archivo CSV',
|
||||||
|
csvImportToast: '{{added}} agregadas, {{notFound}} no encontradas, {{duplicates}} duplicadas',
|
||||||
},
|
},
|
||||||
mostPlayed: {
|
mostPlayed: {
|
||||||
title: 'Más Reproducidos',
|
title: 'Más Reproducidos',
|
||||||
|
|||||||
@@ -997,6 +997,9 @@ export const frTranslation = {
|
|||||||
csvImportNetworkErrorsTitle: 'Erreurs Réseau (peut réessayer l\'import):',
|
csvImportNetworkErrorsTitle: 'Erreurs Réseau (peut réessayer l\'import):',
|
||||||
csvImportDownloadReport: 'Télécharger le Rapport',
|
csvImportDownloadReport: 'Télécharger le Rapport',
|
||||||
csvImportClose: 'Fermer',
|
csvImportClose: 'Fermer',
|
||||||
|
csvImportNoValidTracks: 'Aucun titre valide trouvé dans le fichier CSV',
|
||||||
|
csvImportFailed: 'Échec de l\'import CSV',
|
||||||
|
csvImportToast: '{{added}} ajoutés, {{notFound}} non trouvés, {{duplicates}} doublons',
|
||||||
},
|
},
|
||||||
mostPlayed: {
|
mostPlayed: {
|
||||||
title: 'Les plus joués',
|
title: 'Les plus joués',
|
||||||
|
|||||||
@@ -996,6 +996,9 @@ export const nbTranslation = {
|
|||||||
csvImportNetworkErrorsTitle: 'Nettverksfeil (kan prøve import på nytt):',
|
csvImportNetworkErrorsTitle: 'Nettverksfeil (kan prøve import på nytt):',
|
||||||
csvImportDownloadReport: 'Last ned rapport',
|
csvImportDownloadReport: 'Last ned rapport',
|
||||||
csvImportClose: 'Lukk',
|
csvImportClose: 'Lukk',
|
||||||
|
csvImportNoValidTracks: 'Ingen gyldige spor funnet i CSV-filen',
|
||||||
|
csvImportFailed: 'Kunne ikke importere CSV-fil',
|
||||||
|
csvImportToast: '{{added}} lagt til, {{notFound}} ikke funnet, {{duplicates}} duplikater',
|
||||||
},
|
},
|
||||||
mostPlayed: {
|
mostPlayed: {
|
||||||
title: 'Mest spilt',
|
title: 'Mest spilt',
|
||||||
|
|||||||
@@ -996,6 +996,9 @@ export const nlTranslation = {
|
|||||||
csvImportNetworkErrorsTitle: 'Netwerkfouten (import kan opnieuw proberen):',
|
csvImportNetworkErrorsTitle: 'Netwerkfouten (import kan opnieuw proberen):',
|
||||||
csvImportDownloadReport: 'Rapport downloaden',
|
csvImportDownloadReport: 'Rapport downloaden',
|
||||||
csvImportClose: 'Sluiten',
|
csvImportClose: 'Sluiten',
|
||||||
|
csvImportNoValidTracks: 'Geen geldige nummers gevonden in CSV-bestand',
|
||||||
|
csvImportFailed: 'CSV-import mislukt',
|
||||||
|
csvImportToast: '{{added}} toegevoegd, {{notFound}} niet gevonden, {{duplicates}} duplicaten',
|
||||||
},
|
},
|
||||||
mostPlayed: {
|
mostPlayed: {
|
||||||
title: 'Meest gespeeld',
|
title: 'Meest gespeeld',
|
||||||
|
|||||||
@@ -1055,6 +1055,9 @@ export const ruTranslation = {
|
|||||||
csvImportNetworkErrorsTitle: 'Ошибки сети (можно повторить импорт):',
|
csvImportNetworkErrorsTitle: 'Ошибки сети (можно повторить импорт):',
|
||||||
csvImportDownloadReport: 'Скачать отчёт',
|
csvImportDownloadReport: 'Скачать отчёт',
|
||||||
csvImportClose: 'Закрыть',
|
csvImportClose: 'Закрыть',
|
||||||
|
csvImportNoValidTracks: 'В CSV-файле не найдено действительных треков',
|
||||||
|
csvImportFailed: 'Не удалось импортировать CSV-файл',
|
||||||
|
csvImportToast: '{{added}} добавлено, {{notFound}} не найдено, {{duplicates}} дубликатов',
|
||||||
},
|
},
|
||||||
mostPlayed: {
|
mostPlayed: {
|
||||||
title: 'Популярное',
|
title: 'Популярное',
|
||||||
|
|||||||
@@ -977,6 +977,22 @@ export const zhTranslation = {
|
|||||||
addToPlaylistSuccess: '{{count}} 首歌曲已添加到 {{playlist}}',
|
addToPlaylistSuccess: '{{count}} 首歌曲已添加到 {{playlist}}',
|
||||||
addToPlaylistNoNew: '{{playlist}} 没有新歌曲',
|
addToPlaylistNoNew: '{{playlist}} 没有新歌曲',
|
||||||
addToPlaylistError: '添加到播放列表时出错',
|
addToPlaylistError: '添加到播放列表时出错',
|
||||||
|
importCSV: '导入 CSV',
|
||||||
|
importCSVTooltip: '从 Spotify CSV 导入',
|
||||||
|
csvImportReport: 'CSV 导入报告',
|
||||||
|
csvImportTotal: '总计',
|
||||||
|
csvImportAdded: '已添加',
|
||||||
|
csvImportDuplicates: '重复项',
|
||||||
|
csvImportNotFound: '未找到',
|
||||||
|
csvImportNetworkErrors: '网络错误',
|
||||||
|
csvImportDuplicatesTitle: '重复曲目(已在播放列表中):',
|
||||||
|
csvImportNotFoundTitle: '未找到的曲目:',
|
||||||
|
csvImportNetworkErrorsTitle: '网络错误(可重试导入):',
|
||||||
|
csvImportDownloadReport: '下载报告',
|
||||||
|
csvImportClose: '关闭',
|
||||||
|
csvImportNoValidTracks: 'CSV 文件中未找到有效曲目',
|
||||||
|
csvImportFailed: 'CSV 文件导入失败',
|
||||||
|
csvImportToast: '{{added}} 已添加,{{notFound}} 未找到,{{duplicates}} 重复项',
|
||||||
},
|
},
|
||||||
mostPlayed: {
|
mostPlayed: {
|
||||||
title: '最常播放',
|
title: '最常播放',
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export default function Artists() {
|
|||||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||||
|
|
||||||
const showArtistImages = useAuthStore(s => s.showArtistImages);
|
const showArtistImages = useAuthStore(s => s.showArtistImages);
|
||||||
const PAGE_SIZE = showArtistImages ? 50 : 100; // Menor con imágenes para reducir I/O
|
const PAGE_SIZE = showArtistImages ? 50 : 100; // Smaller with images to reduce I/O
|
||||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const observerTarget = useRef<HTMLDivElement>(null);
|
const observerTarget = useRef<HTMLDivElement>(null);
|
||||||
|
|||||||
@@ -61,12 +61,12 @@ function codecLabel(song: SubsonicSong, showBitrate: boolean): string {
|
|||||||
interface SpotifyCsvTrack {
|
interface SpotifyCsvTrack {
|
||||||
trackName: string;
|
trackName: string;
|
||||||
artistName: string;
|
artistName: string;
|
||||||
artistNames: string[]; // Array de todos los artistas para mejor matching
|
artistNames: string[]; // Array of all artists for better matching
|
||||||
albumName: string;
|
albumName: string;
|
||||||
isrc?: string;
|
isrc?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mapeo de headers a campos canónicos (soporta inglés y español)
|
// Header mapping to canonical fields (supports English and Spanish)
|
||||||
const HEADER_MAPPINGS: Record<string, string> = {
|
const HEADER_MAPPINGS: Record<string, string> = {
|
||||||
// Track name
|
// Track name
|
||||||
'track name': 'trackName',
|
'track name': 'trackName',
|
||||||
@@ -145,7 +145,7 @@ function extractFeaturedArtists(title: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseSpotifyCsv(csvContent: string): SpotifyCsvTrack[] {
|
function parseSpotifyCsv(csvContent: string): SpotifyCsvTrack[] {
|
||||||
// Strip BOM y parsear con Papa Parse
|
// Strip BOM and parse with Papa Parse
|
||||||
const cleanContent = csvContent.replace(/^\uFEFF/, '');
|
const cleanContent = csvContent.replace(/^\uFEFF/, '');
|
||||||
|
|
||||||
const parseResult = Papa.parse(cleanContent, {
|
const parseResult = Papa.parse(cleanContent, {
|
||||||
@@ -163,7 +163,7 @@ function parseSpotifyCsv(csvContent: string): SpotifyCsvTrack[] {
|
|||||||
|
|
||||||
const data = parseResult.data as Record<string, string>[];
|
const data = parseResult.data as Record<string, string>[];
|
||||||
|
|
||||||
// Verificar columnas requeridas
|
// Verify required columns
|
||||||
if (!data.length || !data[0].trackName || !data[0].artistName) {
|
if (!data.length || !data[0].trackName || !data[0].artistName) {
|
||||||
console.error('CSV columns not found. Available headers:', Object.keys(data[0] || {}));
|
console.error('CSV columns not found. Available headers:', Object.keys(data[0] || {}));
|
||||||
return [];
|
return [];
|
||||||
@@ -181,7 +181,7 @@ function parseSpotifyCsv(csvContent: string): SpotifyCsvTrack[] {
|
|||||||
|
|
||||||
if (!trackName || !artistField) continue;
|
if (!trackName || !artistField) continue;
|
||||||
|
|
||||||
// Parsear múltiples artistas del campo + extraer colaboradores del título
|
// Parse multiple artists from field + extract collaborators from title
|
||||||
const artistNames = parseArtists(artistField);
|
const artistNames = parseArtists(artistField);
|
||||||
const featuredArtists = extractFeaturedArtists(trackName);
|
const featuredArtists = extractFeaturedArtists(trackName);
|
||||||
const allArtists = [...new Set([...artistNames, ...featuredArtists])];
|
const allArtists = [...new Set([...artistNames, ...featuredArtists])];
|
||||||
@@ -508,7 +508,7 @@ export default function PlaylistDetail() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── CSV Import ──────────────────────────────────────────────
|
// ── CSV Import ──────────────────────────────────────────────
|
||||||
// Normalizar strings para matching: quita tildes, caracteres especiales, lowercase, trim
|
// Normalize strings for matching: remove accents, special chars, lowercase, trim
|
||||||
const normalizeForMatching = (s: string): string =>
|
const normalizeForMatching = (s: string): string =>
|
||||||
s.toLowerCase()
|
s.toLowerCase()
|
||||||
.normalize('NFD')
|
.normalize('NFD')
|
||||||
@@ -517,7 +517,7 @@ export default function PlaylistDetail() {
|
|||||||
.replace(/[æ]/gi, 'ae')
|
.replace(/[æ]/gi, 'ae')
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
// Limpia sufijos comunes de títulos (remasterizados, en vivo, ediciones, etc.)
|
// Clean common title suffixes (remastered, live, editions, etc.)
|
||||||
const cleanTrackTitle = (title: string): string => {
|
const cleanTrackTitle = (title: string): string => {
|
||||||
const suffixes = [
|
const suffixes = [
|
||||||
// Remastered variants
|
// Remastered variants
|
||||||
@@ -612,19 +612,19 @@ export default function PlaylistDetail() {
|
|||||||
/\s*\(\d{4}\)$/i,
|
/\s*\(\d{4}\)$/i,
|
||||||
];
|
];
|
||||||
let cleaned = title.trim();
|
let cleaned = title.trim();
|
||||||
// Aplicar patrones múltiples veces para casos anidados
|
// Apply patterns multiple times for nested cases
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
const previous = cleaned;
|
const previous = cleaned;
|
||||||
for (const regex of suffixes) {
|
for (const regex of suffixes) {
|
||||||
cleaned = cleaned.replace(regex, '');
|
cleaned = cleaned.replace(regex, '');
|
||||||
}
|
}
|
||||||
cleaned = cleaned.trim();
|
cleaned = cleaned.trim();
|
||||||
if (previous === cleaned) break; // No más cambios
|
if (previous === cleaned) break; // No more changes
|
||||||
}
|
}
|
||||||
return cleaned;
|
return cleaned;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Distancia de Levenshtein para scoring de similitud
|
// Levenshtein distance for similarity scoring
|
||||||
const levenshtein = (a: string, b: string): number => {
|
const levenshtein = (a: string, b: string): number => {
|
||||||
const matrix: number[][] = [];
|
const matrix: number[][] = [];
|
||||||
for (let i = 0; i <= b.length; i++) matrix[i] = [i];
|
for (let i = 0; i <= b.length; i++) matrix[i] = [i];
|
||||||
@@ -642,12 +642,12 @@ export default function PlaylistDetail() {
|
|||||||
const similarityScore = (a: string, b: string): number => {
|
const similarityScore = (a: string, b: string): number => {
|
||||||
const maxLen = Math.max(a.length, b.length);
|
const maxLen = Math.max(a.length, b.length);
|
||||||
if (maxLen === 0) return 1;
|
if (maxLen === 0) return 1;
|
||||||
// Usar strings normalizados (sin tildes) para comparación
|
// Use normalized strings (without accents) for comparison
|
||||||
const dist = levenshtein(normalizeForMatching(a), normalizeForMatching(b));
|
const dist = levenshtein(normalizeForMatching(a), normalizeForMatching(b));
|
||||||
return 1 - dist / maxLen;
|
return 1 - dist / maxLen;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Procesa búsquedas en batches para no saturar el servidor
|
// Process searches in batches to avoid overloading the server
|
||||||
const processBatch = async <T, R>(
|
const processBatch = async <T, R>(
|
||||||
items: T[],
|
items: T[],
|
||||||
batchSize: number,
|
batchSize: number,
|
||||||
@@ -679,7 +679,7 @@ export default function PlaylistDetail() {
|
|||||||
const csvTracks = parseSpotifyCsv(content);
|
const csvTracks = parseSpotifyCsv(content);
|
||||||
|
|
||||||
if (csvTracks.length === 0) {
|
if (csvTracks.length === 0) {
|
||||||
showToast('No valid tracks found in CSV file', 3000, 'error');
|
showToast(t('playlists.csvImportNoValidTracks'), 3000, 'error');
|
||||||
setCsvImporting(false);
|
setCsvImporting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -691,10 +691,10 @@ export default function PlaylistDetail() {
|
|||||||
const duplicateTracks: SpotifyCsvTrack[] = [];
|
const duplicateTracks: SpotifyCsvTrack[] = [];
|
||||||
let duplicateCount = 0;
|
let duplicateCount = 0;
|
||||||
|
|
||||||
// Procesar en batches de 10 para balance velocidad/servidor
|
// Process in batches of 10 to balance speed/server load
|
||||||
await processBatch(csvTracks, 10, async (track) => {
|
await processBatch(csvTracks, 10, async (track) => {
|
||||||
try {
|
try {
|
||||||
// Retry: 2 intentos en caso de error de red
|
// Retry: 2 attempts in case of network error
|
||||||
let searchResult;
|
let searchResult;
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
const maxAttempts = 2;
|
const maxAttempts = 2;
|
||||||
@@ -706,7 +706,7 @@ export default function PlaylistDetail() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
attempts++;
|
attempts++;
|
||||||
if (attempts >= maxAttempts) throw err;
|
if (attempts >= maxAttempts) throw err;
|
||||||
// Esperar 500ms antes de reintentar
|
// Wait 500ms before retrying
|
||||||
await new Promise(r => setTimeout(r, 500));
|
await new Promise(r => setTimeout(r, 500));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -716,32 +716,32 @@ export default function PlaylistDetail() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scoring de confianza para cada resultado
|
// Confidence scoring for each result
|
||||||
// Limpiar título del CSV para comparación justa
|
// Clean CSV title for fair comparison
|
||||||
const cleanCsvTitle = cleanTrackTitle(track.trackName);
|
const cleanCsvTitle = cleanTrackTitle(track.trackName);
|
||||||
|
|
||||||
const scoredMatches = searchResult.songs.map(s => {
|
const scoredMatches = searchResult.songs.map(s => {
|
||||||
// Vía rápida ISRC: si ambos tienen ISRC y coinciden, score perfecto
|
// Fast ISRC path: if both have ISRC and they match, perfect score
|
||||||
if (track.isrc && s.isrc && track.isrc.toUpperCase() === s.isrc.toUpperCase()) {
|
if (track.isrc && s.isrc && track.isrc.toUpperCase() === s.isrc.toUpperCase()) {
|
||||||
return { song: s, score: 1.0, titleScore: 1.0, artistScore: 1.0, isrcMatch: true };
|
return { song: s, score: 1.0, titleScore: 1.0, artistScore: 1.0, isrcMatch: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limpiar título del resultado también
|
// Clean the result title as well
|
||||||
const cleanResultTitle = cleanTrackTitle(s.title);
|
const cleanResultTitle = cleanTrackTitle(s.title);
|
||||||
|
|
||||||
const titleScore = similarityScore(cleanResultTitle, cleanCsvTitle);
|
const titleScore = similarityScore(cleanResultTitle, cleanCsvTitle);
|
||||||
// Scoring de artista: máximo score contra cualquiera de los artistas del CSV
|
// Artist scoring: maximum score against any of the CSV artists
|
||||||
const artistScore = s.artist
|
const artistScore = s.artist
|
||||||
? Math.max(...track.artistNames.map(csvArtist =>
|
? Math.max(...track.artistNames.map(csvArtist =>
|
||||||
similarityScore(s.artist || '', csvArtist)
|
similarityScore(s.artist || '', csvArtist)
|
||||||
))
|
))
|
||||||
: 0;
|
: 0;
|
||||||
// Si no hay álbum en CSV o en local, usar 1.0 (neutral) para no penalizar
|
// If no album in CSV or local, use 1.0 (neutral) to avoid penalizing
|
||||||
const albumScore = (s.album && track.albumName)
|
const albumScore = (s.album && track.albumName)
|
||||||
? similarityScore(s.album, track.albumName)
|
? similarityScore(s.album, track.albumName)
|
||||||
: 1.0;
|
: 1.0;
|
||||||
|
|
||||||
// Peso dinámico: títulos específicos (>4 palabras) → más peso al título
|
// Dynamic weight: specific titles (>4 words) → more weight to title
|
||||||
const titleWords = cleanCsvTitle.split(/\s+/).length;
|
const titleWords = cleanCsvTitle.split(/\s+/).length;
|
||||||
const isSpecificTitle = titleWords > 4;
|
const isSpecificTitle = titleWords > 4;
|
||||||
const titleWeight = isSpecificTitle ? 0.55 : 0.4;
|
const titleWeight = isSpecificTitle ? 0.55 : 0.4;
|
||||||
@@ -809,10 +809,10 @@ export default function PlaylistDetail() {
|
|||||||
? ` (${searchErrors.length} network errors - may retry)`
|
? ` (${searchErrors.length} network errors - may retry)`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// Determinar tipo de toast según resultados:
|
// Determine toast type based on results:
|
||||||
// - success: todas las canciones fueron añadidas correctamente
|
// - success: all songs were added successfully
|
||||||
// - warning: al menos una añadida, pero algunas no se encontraron/duplicadas
|
// - warning: at least one added, but some not found/duplicates
|
||||||
// - error: ninguna añadida (todas duplicadas o no encontradas)
|
// - error: none added (all duplicates or not found)
|
||||||
const hasAdded = addedSongs.length > 0;
|
const hasAdded = addedSongs.length > 0;
|
||||||
const hasIssues = notFound.length > 0 || duplicateCount > 0 || searchErrors.length > 0;
|
const hasIssues = notFound.length > 0 || duplicateCount > 0 || searchErrors.length > 0;
|
||||||
|
|
||||||
@@ -826,13 +826,13 @@ export default function PlaylistDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
showToast(
|
showToast(
|
||||||
`${addedSongs.length} added, ${notFound.length} not found, ${duplicateCount} duplicates${errorMsg}`,
|
t('playlists.csvImportToast', { added: addedSongs.length, notFound: notFound.length, duplicates: duplicateCount }) + errorMsg,
|
||||||
5000,
|
5000,
|
||||||
toastVariant
|
toastVariant
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('CSV import failed:', err);
|
console.error('CSV import failed:', err);
|
||||||
showToast('Failed to import CSV file', 3000, 'error');
|
showToast(t('playlists.csvImportFailed'), 3000, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setCsvImporting(false);
|
setCsvImporting(false);
|
||||||
}
|
}
|
||||||
@@ -1999,7 +1999,7 @@ function CsvImportReportModal({ report, playlistName, onClose }: CsvReportModalP
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="modal-actions" style={{ marginTop: 'auto' }}>
|
<div className="modal-actions" style={{ marginTop: 'auto', display: 'flex', justifyContent: 'flex-end', gap: 12 }}>
|
||||||
<button className="btn btn-surface" onClick={downloadReport}>
|
<button className="btn btn-surface" onClick={downloadReport}>
|
||||||
<Download size={14} /> {t('playlists.csvImportDownloadReport')}
|
<Download size={14} /> {t('playlists.csvImportDownloadReport')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user