refactor(playlist): G.61 — extract helpers + CSV import + modal components (cluster) (#628)

Four-cut cluster on PlaylistDetail.tsx. 2274 → 1761 LOC (−513); the page
keeps every behaviour and stateful path, but the leaf helpers, the
Spotify CSV import workflow, and the two stand-alone modals each live
in their own files now.

playlistDetailHelpers — pure helpers: sanitizeFilename, formatDuration,
formatSize, totalDurationLabel, codecLabel, plus SMART_PREFIX with
isSmartPlaylistName / displayPlaylistName. No deps beyond
formatHumanHoursMinutes + SubsonicSong. Kept the duplicates that
already live in ContextMenu / Sidebar / Playlists in place — dedup is
a separate cut, not part of this code-move.

spotifyCsvImport — full Spotify CSV pipeline: HEADER_MAPPINGS,
normalizeHeader, findColumnField, parseArtists, extractFeaturedArtists,
parseSpotifyCsv, plus the SpotifyCsvTrack type re-exported for callers.
papaparse moves with it; PlaylistDetail no longer imports Papa
directly. Header strings keep the \uXXXX escapes so the diff is
byte-identical.

PlaylistEditModal — full edit-meta dialog (name / description / public
toggle / cover swap / cover remove / save spinner). Props match the
old inline component verbatim. Uses React + i18next + CachedImage +
the same lucide icons (Camera, Loader2, X) and SubsonicPlaylist type.

CsvImportReportModal — full import-result dialog (4- or 5-cell stat
grid, duplicate / not-found / network-error lists, download-report
button via Blob + URL.createObjectURL). Still rendered through
createPortal to document.body so the z-index-99999 overlay clears
playlist UI. Imports the SpotifyCsvTrack type from the new CSV module.

PlaylistDetail loses createPortal and Papa from its import list, picks
up two component imports (PlaylistEditModal, CsvImportReportModal), and
the three util imports (playlistDetailHelpers, spotifyCsvImport, the
type-only SpotifyCsvTrack). Pure code move otherwise — no behaviour
change.
This commit is contained in:
Frank Stellmacher
2026-05-13 12:17:56 +02:00
committed by GitHub
parent 8adad2be6f
commit 84ceb5f423
5 changed files with 544 additions and 525 deletions
@@ -0,0 +1,185 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { Download, X } from 'lucide-react';
import { showToast } from '../../utils/toast';
import type { SpotifyCsvTrack } from '../../utils/spotifyCsvImport';
interface CsvReportModalProps {
report: {
added: number;
notFound: SpotifyCsvTrack[];
duplicates: number;
duplicateTracks: SpotifyCsvTrack[];
total: number;
searchErrors?: SpotifyCsvTrack[];
};
playlistName: string;
onClose: () => void;
}
export default function CsvImportReportModal({ report, playlistName, onClose }: CsvReportModalProps) {
const { t } = useTranslation();
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
};
const downloadReport = () => {
try {
const content = [
'CSV Import Report',
`Playlist: ${playlistName}`,
`Date: ${new Date().toLocaleString()}`,
`Total: ${report.total}, Added: ${report.added}, Duplicates: ${report.duplicates}, Not Found: ${report.notFound.length}${report.searchErrors ? `, Network Errors: ${report.searchErrors.length}` : ''}`,
'',
...(report.duplicateTracks.length > 0 ? ['Duplicate Tracks (skipped):', ...report.duplicateTracks.map(t => `- ${t.trackName} by ${t.artistName}${t.albumName ? ` (${t.albumName})` : ''}`), ''] : []),
...(report.notFound.length > 0 ? ['Not Found Tracks:', ...report.notFound.map(t => ` - ${t.trackName} | ${t.artistName} | ${t.albumName || 'N/A'} | Score: ${(t.score ?? 0).toFixed(2)} (threshold: ${(t.thresholdNeeded ?? 0).toFixed(2)})`), ''] : []),
...(report.searchErrors && report.searchErrors.length > 0 ? ['Network Error Tracks (may retry):', ...report.searchErrors.map(t => `- ${t.trackName} by ${t.artistName}`), ''] : []),
].join('\n');
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
// Detailed name: playlist + date-time-seconds
const timestamp = new Date().toISOString()
.replace(/[:.]/g, '-')
.slice(0, 19);
const safePlaylistName = playlistName.replace(/[/\\?%*:|"<>]/g, '-').substring(0, 50);
a.download = `import-report-${safePlaylistName}-${timestamp}.txt`;
a.href = url;
a.click();
URL.revokeObjectURL(url);
showToast(t('playlists.csvImportDownloadSuccess'), 3000, 'info');
} catch (err) {
console.error('Failed to download report:', err);
showToast(t('playlists.csvImportDownloadError'), 3000, 'error');
}
};
return createPortal(
<div
className="modal-overlay modal-overlay--csv"
onClick={handleOverlayClick}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 99999,
}}
>
<div
className="modal-content"
style={{
maxWidth: 500,
maxHeight: '80vh',
width: '90%',
display: 'flex',
flexDirection: 'column',
position: 'relative',
}}
onClick={e => e.stopPropagation()}
>
<button className="btn btn-ghost modal-close" onClick={onClose} style={{ position: 'absolute', top: 16, right: 16 }}>
<X size={18} />
</button>
<h2 className="modal-title" style={{ fontSize: 20, marginBottom: 16 }}>{t('playlists.csvImportReport')}</h2>
<div style={{ display: 'grid', gridTemplateColumns: report.searchErrors && report.searchErrors.length > 0 ? 'repeat(5, 1fr)' : 'repeat(4, 1fr)', gap: 12, marginBottom: 20, textAlign: 'center' }}>
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--text)' }}>{report.total}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportTotal')}</div>
</div>
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--accent)' }}>{report.added}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportAdded')}</div>
</div>
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--text-muted)' }}>{report.duplicates}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportDuplicates')}</div>
</div>
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: report.notFound.length > 0 ? '#ff6b6b' : 'var(--text-muted)' }}>{report.notFound.length}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportNotFound')}</div>
</div>
{report.searchErrors && report.searchErrors.length > 0 && (
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: '#ffa500' }}>{report.searchErrors.length}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportNetworkErrors')}</div>
</div>
)}
</div>
{report.duplicateTracks.length > 0 && (
<>
<h3 style={{ fontSize: 14, marginBottom: 8, color: 'var(--text-muted)' }}>{t('playlists.csvImportDuplicatesTitle')}</h3>
<div style={{ overflowY: 'auto', maxHeight: 150, marginBottom: 16, border: '1px solid var(--surface)', borderRadius: 8 }}>
{report.duplicateTracks.map((track, i) => (
<div key={i} style={{ padding: '8px 12px', borderBottom: '1px solid var(--surface)', fontSize: 13 }}>
<div style={{ fontWeight: 500 }}>{track.trackName}</div>
<div style={{ color: 'var(--text-muted)' }}>{track.artistName}</div>
</div>
))}
</div>
</>
)}
{report.notFound.length > 0 && (
<>
<h3 style={{ fontSize: 14, marginBottom: 8, color: 'var(--text-muted)' }}>{t('playlists.csvImportNotFoundTitle')}</h3>
<div style={{ overflowY: 'auto', maxHeight: 200, marginBottom: 16, border: '1px solid var(--surface)', borderRadius: 8 }}>
{report.notFound.map((track, i) => (
<div key={i} style={{ padding: '8px 12px', borderBottom: '1px solid var(--surface)', fontSize: 13 }}>
<div style={{ fontWeight: 500 }}>{track.trackName}</div>
<div style={{ color: 'var(--text-muted)' }}>{track.artistName}</div>
{track.albumName && <div style={{ color: 'var(--text-muted)', fontSize: 11 }}>{track.albumName}</div>}
{track.score !== undefined && (
<div style={{ fontSize: 11, marginTop: 2 }}>
<span style={{ color: 'var(--text-muted)' }}>Score: </span>
<span style={{ color: track.score >= (track.thresholdNeeded ?? 0.6) ? '#4ade80' : '#f87171' }}>
{track.score.toFixed(2)}
</span>
<span style={{ color: 'var(--text-muted)' }}> (threshold: {(track.thresholdNeeded ?? 0.6).toFixed(2)})</span>
</div>
)}
</div>
))}
</div>
</>
)}
{report.searchErrors && report.searchErrors.length > 0 && (
<>
<h3 style={{ fontSize: 14, marginBottom: 8, color: '#ffa500' }}>{t('playlists.csvImportNetworkErrorsTitle')}</h3>
<div style={{ overflowY: 'auto', maxHeight: 150, marginBottom: 16, border: '1px solid var(--surface)', borderRadius: 8 }}>
{report.searchErrors.map((track, i) => (
<div key={i} style={{ padding: '8px 12px', borderBottom: '1px solid var(--surface)', fontSize: 13 }}>
<div style={{ fontWeight: 500 }}>{track.trackName}</div>
<div style={{ color: 'var(--text-muted)' }}>{track.artistName}</div>
</div>
))}
</div>
</>
)}
<div className="modal-actions" style={{ marginTop: 'auto', display: 'flex', justifyContent: 'flex-end', gap: 12 }}>
<button className="btn btn-surface" onClick={downloadReport}>
<Download size={14} /> {t('playlists.csvImportDownloadReport')}
</button>
<button className="btn btn-primary" onClick={onClose}>
{t('playlists.csvImportClose')}
</button>
</div>
</div>
</div>,
document.body
);
}
@@ -0,0 +1,158 @@
import React, { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Camera, Loader2, X } from 'lucide-react';
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
import CachedImage from '../CachedImage';
interface EditModalProps {
playlist: SubsonicPlaylist;
customCoverId: string | null;
customCoverFetchUrl: string | null;
customCoverCacheKey: string | null;
coverQuadUrls: ({ src: string; cacheKey: string } | null)[];
onClose: () => void;
onSave: (opts: { name: string; comment: string; isPublic: boolean; coverFile: File | null; coverRemoved: boolean }) => Promise<void>;
}
export default function PlaylistEditModal({
playlist, customCoverId, customCoverFetchUrl, customCoverCacheKey,
coverQuadUrls, onClose, onSave,
}: EditModalProps) {
const { t } = useTranslation();
const [name, setName] = useState(playlist.name);
const [comment, setComment] = useState(playlist.comment ?? '');
const [isPublic, setIsPublic] = useState(playlist.public ?? false);
const [coverFile, setCoverFile] = useState<File | null>(null);
const [coverPreview, setCoverPreview] = useState<string | null>(null);
const [coverRemoved, setCoverRemoved] = useState(false);
const [saving, setSaving] = useState(false);
const coverInputRef = useRef<HTMLInputElement>(null);
const hasExistingCover = !coverRemoved && (coverPreview || customCoverId);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setCoverFile(file);
setCoverRemoved(false);
const reader = new FileReader();
reader.onload = ev => setCoverPreview(ev.target?.result as string);
reader.readAsDataURL(file);
};
const handleRemoveCover = (e: React.MouseEvent) => {
e.stopPropagation();
setCoverFile(null);
setCoverPreview(null);
setCoverRemoved(true);
};
const handleSave = async () => {
setSaving(true);
try {
await onSave({ name, comment, isPublic, coverFile, coverRemoved });
} finally {
setSaving(false);
}
};
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
};
return (
<div className="modal-overlay" onClick={handleOverlayClick}>
<div className="modal-content playlist-edit-modal" onClick={e => e.stopPropagation()}>
<button className="btn btn-ghost modal-close" onClick={onClose} style={{ top: 16, right: 16 }}>
<X size={18} />
</button>
<h2 className="modal-title" style={{ fontSize: 22 }}>{t('playlists.editMeta')}</h2>
<div className="playlist-edit-body">
{/* Left: cover */}
<div
className="playlist-edit-cover-wrap"
onClick={() => coverInputRef.current?.click()}
>
{coverPreview ? (
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
) : !coverRemoved && customCoverFetchUrl && customCoverCacheKey ? (
<CachedImage
src={customCoverFetchUrl}
cacheKey={customCoverCacheKey}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
) : (
<div className="playlist-cover-grid" style={{ width: '100%', height: '100%' }}>
{coverQuadUrls.map((entry, i) =>
entry
? <CachedImage key={i} className="playlist-cover-cell" src={entry.src} cacheKey={entry.cacheKey} alt="" />
: <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
)}
</div>
)}
<div className="playlist-edit-cover-overlay">
<div className="playlist-edit-cover-menu">
<button
className="playlist-edit-cover-menu-item"
onClick={e => { e.stopPropagation(); coverInputRef.current?.click(); }}
>
<Camera size={14} />
{t('playlists.changeCoverLabel')}
</button>
{hasExistingCover && (
<button
className="playlist-edit-cover-menu-item playlist-edit-cover-menu-item--danger"
onClick={handleRemoveCover}
>
{t('playlists.removeCover')}
</button>
)}
</div>
</div>
<input ref={coverInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleFileChange} />
</div>
{/* Right: fields */}
<div className="playlist-edit-fields">
<input
className="input playlist-edit-name-input"
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('playlists.editNamePlaceholder')}
autoFocus
/>
<textarea
className="input playlist-edit-desc-input"
value={comment}
onChange={e => setComment(e.target.value)}
placeholder={t('playlists.editCommentPlaceholder')}
/>
</div>
</div>
<div className="playlist-edit-footer">
<label className="toggle-label" style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none' }}>
<label className="toggle-switch" style={{ marginBottom: 0 }}>
<input type="checkbox" checked={isPublic} onChange={e => setIsPublic(e.target.checked)} />
<span className="toggle-track" />
</label>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('playlists.editPublic')}</span>
</label>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-surface" onClick={onClose}>
{t('playlists.editCancel')}
</button>
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !name.trim()}>
{saving ? <Loader2 size={14} className="spin-slow" /> : null}
{t('playlists.editSave')}
</button>
</div>
</div>
</div>
</div>
);
}
+12 -525
View File
@@ -6,7 +6,6 @@ import { getRandomSongs, filterSongsToActiveLibrary } from '../api/subsonicLibra
import type { SubsonicPlaylist, SubsonicSong } from '../api/subsonicTypes';
import { songToTrack } from '../utils/songToTrack';
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import { ChevronDown, ChevronLeft, ChevronRight, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check, Pencil, Globe, Lock, Camera, Download, FileUp, RotateCcw, Sparkles, Square, AudioLines } from 'lucide-react';
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
@@ -30,196 +29,19 @@ import { useDragDrop } from '../contexts/DragDropContext';
import CachedImage, { useCachedUrl } from '../components/CachedImage';
import { useTranslation } from 'react-i18next';
import { showToast } from '../utils/toast';
import { formatHumanHoursMinutes } from '../utils/formatHumanDuration';
import StarRating from '../components/StarRating';
import Papa from 'papaparse';
function sanitizeFilename(name: string): string {
return name
.replace(/[/\\?%*:|"<>]/g, '-')
.replace(/\.{2,}/g, '.')
.replace(/^[\s.]+|[\s.]+$/g, '')
.substring(0, 200) || 'download';
}
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
function formatSize(bytes?: number): string {
if (!bytes) return '';
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function totalDurationLabel(songs: SubsonicSong[]): string {
const total = songs.reduce((acc, s) => acc + (s.duration ?? 0), 0);
return formatHumanHoursMinutes(total);
}
const SMART_PREFIX = 'psy-smart-';
function isSmartPlaylistName(name: string): boolean {
return (name ?? '').toLowerCase().startsWith(SMART_PREFIX);
}
function displayPlaylistName(name: string): string {
const n = name ?? '';
if (isSmartPlaylistName(n)) return n.slice(SMART_PREFIX.length);
return n;
}
function codecLabel(song: SubsonicSong, showBitrate: boolean): string {
const parts: string[] = [];
if (song.suffix) parts.push(song.suffix.toUpperCase());
if (showBitrate && song.bitRate) parts.push(`${song.bitRate} kbps`);
return parts.join(' · ');
}
// ── CSV Import helpers ──────────────────────────────────────────────────────
interface SpotifyCsvTrack {
trackName: string;
artistName: string;
artistNames: string[]; // Array of all artists for better matching
albumName: string;
isrc?: string;
score?: number; // Match score when track not found
thresholdNeeded?: number; // Threshold required to pass
}
// Header mapping to canonical fields (supports English and Spanish)
const HEADER_MAPPINGS: Record<string, string> = {
// Track name
'track name': 'trackName',
'track name(s)': 'trackName',
'track': 'trackName',
'name': 'trackName',
'nombre de la cancion': 'trackName',
'nombre de cancion': 'trackName',
'nombre de la canci\u00f3n': 'trackName',
'nombre cancion': 'trackName',
't\u00edtulo': 'trackName',
'titulo': 'trackName',
// Artist name
'artist name': 'artistName',
'artist name(s)': 'artistName',
'artists name': 'artistName',
'artists name(s)': 'artistName',
'artist': 'artistName',
'artists': 'artistName',
'nombre del artista': 'artistName',
'nombres del artista': 'artistName',
'nombre artista': 'artistName',
'artista': 'artistName',
// Album name
'album name': 'albumName',
'album name(s)': 'albumName',
'album': 'albumName',
'nombre del album': 'albumName',
'nombre del \u00e1lbum': 'albumName',
'nombre album': 'albumName',
// ISRC
'isrc': 'isrc',
'isrc code': 'isrc',
'codigo isrc': 'isrc',
'c\u00f3digo isrc': 'isrc',
};
function normalizeHeader(header: string): string {
return header
.toLowerCase()
.replace(/\(s\)/g, '')
.replace(/[()]/g, '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim();
}
function findColumnField(header: string): string | undefined {
const normalized = normalizeHeader(header);
return HEADER_MAPPINGS[normalized];
}
function parseArtists(artistField: string): string[] {
// Spotify uses commas in extended format, semicolons in simple format
const separator = artistField.includes(';') ? ';' : ',';
return artistField
.split(separator)
.map(a => a.trim())
.filter(a => a.length > 0);
}
function extractFeaturedArtists(title: string): string[] {
const patterns = [
/\(feat\.?\s+([^)]+)\)/i,
/\(ft\.?\s+([^)]+)\)/i,
/\(featuring\s+([^)]+)\)/i,
/\(with\s+([^)]+)\)/i,
];
for (const regex of patterns) {
const match = title.match(regex);
if (match) {
return match[1].split(/,|\sand\s|\s&\s/).map(s => s.trim()).filter(Boolean);
}
}
return [];
}
function parseSpotifyCsv(csvContent: string): SpotifyCsvTrack[] {
// Strip BOM and parse with Papa Parse
const cleanContent = csvContent.replace(/^\uFEFF/, '');
const parseResult = Papa.parse(cleanContent, {
header: true,
skipEmptyLines: true,
transformHeader: (header: string) => {
const field = findColumnField(header);
return field || header;
},
});
if (parseResult.errors.length > 0) {
console.warn('CSV parse warnings:', parseResult.errors);
}
const data = parseResult.data as Record<string, string>[];
// Verify required columns
if (!data.length || !data[0].trackName || !data[0].artistName) {
console.error('CSV columns not found. Available headers:', Object.keys(data[0] || {}));
return [];
}
console.log('CSV parsed with Papa Parse:', {
rows: data.length,
sample: data[0],
});
const tracks: SpotifyCsvTrack[] = [];
for (const row of data) {
const trackName = row.trackName?.trim();
const artistField = row.artistName?.trim() || '';
if (!trackName || !artistField) continue;
// Parse multiple artists from field + extract collaborators from title
const artistNames = parseArtists(artistField);
const featuredArtists = extractFeaturedArtists(trackName);
const allArtists = [...new Set([...artistNames, ...featuredArtists])];
const primaryArtist = allArtists[0] || '';
tracks.push({
trackName,
artistName: primaryArtist,
artistNames: allArtists,
albumName: row.albumName?.trim() || '',
isrc: row.isrc?.trim() || undefined,
});
}
return tracks;
}
import {
sanitizeFilename,
formatDuration,
formatSize,
totalDurationLabel,
isSmartPlaylistName,
displayPlaylistName,
codecLabel,
} from '../utils/playlistDetailHelpers';
import { parseSpotifyCsv, type SpotifyCsvTrack } from '../utils/spotifyCsvImport';
import PlaylistEditModal from '../components/playlist/PlaylistEditModal';
import CsvImportReportModal from '../components/playlist/CsvImportReportModal';
// ── Column configuration ──────────────────────────────────────────────────────
const PL_COLUMNS: readonly ColDef[] = [
@@ -1937,338 +1759,3 @@ export default function PlaylistDetail() {
);
}
// ── Playlist Edit Modal ───────────────────────────────────────────────────────
interface EditModalProps {
playlist: SubsonicPlaylist;
customCoverId: string | null;
customCoverFetchUrl: string | null;
customCoverCacheKey: string | null;
coverQuadUrls: ({ src: string; cacheKey: string } | null)[];
onClose: () => void;
onSave: (opts: { name: string; comment: string; isPublic: boolean; coverFile: File | null; coverRemoved: boolean }) => Promise<void>;
}
function PlaylistEditModal({
playlist, customCoverId, customCoverFetchUrl, customCoverCacheKey,
coverQuadUrls, onClose, onSave,
}: EditModalProps) {
const { t } = useTranslation();
const [name, setName] = useState(playlist.name);
const [comment, setComment] = useState(playlist.comment ?? '');
const [isPublic, setIsPublic] = useState(playlist.public ?? false);
const [coverFile, setCoverFile] = useState<File | null>(null);
const [coverPreview, setCoverPreview] = useState<string | null>(null);
const [coverRemoved, setCoverRemoved] = useState(false);
const [saving, setSaving] = useState(false);
const coverInputRef = useRef<HTMLInputElement>(null);
const hasExistingCover = !coverRemoved && (coverPreview || customCoverId);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setCoverFile(file);
setCoverRemoved(false);
const reader = new FileReader();
reader.onload = ev => setCoverPreview(ev.target?.result as string);
reader.readAsDataURL(file);
};
const handleRemoveCover = (e: React.MouseEvent) => {
e.stopPropagation();
setCoverFile(null);
setCoverPreview(null);
setCoverRemoved(true);
};
const handleSave = async () => {
setSaving(true);
try {
await onSave({ name, comment, isPublic, coverFile, coverRemoved });
} finally {
setSaving(false);
}
};
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
};
return (
<div className="modal-overlay" onClick={handleOverlayClick}>
<div className="modal-content playlist-edit-modal" onClick={e => e.stopPropagation()}>
<button className="btn btn-ghost modal-close" onClick={onClose} style={{ top: 16, right: 16 }}>
<X size={18} />
</button>
<h2 className="modal-title" style={{ fontSize: 22 }}>{t('playlists.editMeta')}</h2>
<div className="playlist-edit-body">
{/* Left: cover */}
<div
className="playlist-edit-cover-wrap"
onClick={() => coverInputRef.current?.click()}
>
{coverPreview ? (
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
) : !coverRemoved && customCoverFetchUrl && customCoverCacheKey ? (
<CachedImage
src={customCoverFetchUrl}
cacheKey={customCoverCacheKey}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
) : (
<div className="playlist-cover-grid" style={{ width: '100%', height: '100%' }}>
{coverQuadUrls.map((entry, i) =>
entry
? <CachedImage key={i} className="playlist-cover-cell" src={entry.src} cacheKey={entry.cacheKey} alt="" />
: <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
)}
</div>
)}
<div className="playlist-edit-cover-overlay">
<div className="playlist-edit-cover-menu">
<button
className="playlist-edit-cover-menu-item"
onClick={e => { e.stopPropagation(); coverInputRef.current?.click(); }}
>
<Camera size={14} />
{t('playlists.changeCoverLabel')}
</button>
{hasExistingCover && (
<button
className="playlist-edit-cover-menu-item playlist-edit-cover-menu-item--danger"
onClick={handleRemoveCover}
>
{t('playlists.removeCover')}
</button>
)}
</div>
</div>
<input ref={coverInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleFileChange} />
</div>
{/* Right: fields */}
<div className="playlist-edit-fields">
<input
className="input playlist-edit-name-input"
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('playlists.editNamePlaceholder')}
autoFocus
/>
<textarea
className="input playlist-edit-desc-input"
value={comment}
onChange={e => setComment(e.target.value)}
placeholder={t('playlists.editCommentPlaceholder')}
/>
</div>
</div>
<div className="playlist-edit-footer">
<label className="toggle-label" style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none' }}>
<label className="toggle-switch" style={{ marginBottom: 0 }}>
<input type="checkbox" checked={isPublic} onChange={e => setIsPublic(e.target.checked)} />
<span className="toggle-track" />
</label>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('playlists.editPublic')}</span>
</label>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-surface" onClick={onClose}>
{t('playlists.editCancel')}
</button>
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !name.trim()}>
{saving ? <Loader2 size={14} className="spin-slow" /> : null}
{t('playlists.editSave')}
</button>
</div>
</div>
</div>
</div>
);
}
// ── CSV Import Report Modal ───────────────────────────────────────────────────
interface CsvReportModalProps {
report: {
added: number;
notFound: SpotifyCsvTrack[];
duplicates: number;
duplicateTracks: SpotifyCsvTrack[];
total: number;
searchErrors?: SpotifyCsvTrack[];
};
playlistName: string;
onClose: () => void;
}
function CsvImportReportModal({ report, playlistName, onClose }: CsvReportModalProps) {
const { t } = useTranslation();
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
};
const downloadReport = () => {
try {
const content = [
'CSV Import Report',
`Playlist: ${playlistName}`,
`Date: ${new Date().toLocaleString()}`,
`Total: ${report.total}, Added: ${report.added}, Duplicates: ${report.duplicates}, Not Found: ${report.notFound.length}${report.searchErrors ? `, Network Errors: ${report.searchErrors.length}` : ''}`,
'',
...(report.duplicateTracks.length > 0 ? ['Duplicate Tracks (skipped):', ...report.duplicateTracks.map(t => `- ${t.trackName} by ${t.artistName}${t.albumName ? ` (${t.albumName})` : ''}`), ''] : []),
...(report.notFound.length > 0 ? ['Not Found Tracks:', ...report.notFound.map(t => ` - ${t.trackName} | ${t.artistName} | ${t.albumName || 'N/A'} | Score: ${(t.score ?? 0).toFixed(2)} (threshold: ${(t.thresholdNeeded ?? 0).toFixed(2)})`), ''] : []),
...(report.searchErrors && report.searchErrors.length > 0 ? ['Network Error Tracks (may retry):', ...report.searchErrors.map(t => `- ${t.trackName} by ${t.artistName}`), ''] : []),
].join('\n');
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
// Detailed name: playlist + date-time-seconds
const timestamp = new Date().toISOString()
.replace(/[:.]/g, '-')
.slice(0, 19);
const safePlaylistName = playlistName.replace(/[/\\?%*:|"<>]/g, '-').substring(0, 50);
a.download = `import-report-${safePlaylistName}-${timestamp}.txt`;
a.href = url;
a.click();
URL.revokeObjectURL(url);
showToast(t('playlists.csvImportDownloadSuccess'), 3000, 'info');
} catch (err) {
console.error('Failed to download report:', err);
showToast(t('playlists.csvImportDownloadError'), 3000, 'error');
}
};
return createPortal(
<div
className="modal-overlay modal-overlay--csv"
onClick={handleOverlayClick}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 99999,
}}
>
<div
className="modal-content"
style={{
maxWidth: 500,
maxHeight: '80vh',
width: '90%',
display: 'flex',
flexDirection: 'column',
position: 'relative',
}}
onClick={e => e.stopPropagation()}
>
<button className="btn btn-ghost modal-close" onClick={onClose} style={{ position: 'absolute', top: 16, right: 16 }}>
<X size={18} />
</button>
<h2 className="modal-title" style={{ fontSize: 20, marginBottom: 16 }}>{t('playlists.csvImportReport')}</h2>
<div style={{ display: 'grid', gridTemplateColumns: report.searchErrors && report.searchErrors.length > 0 ? 'repeat(5, 1fr)' : 'repeat(4, 1fr)', gap: 12, marginBottom: 20, textAlign: 'center' }}>
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--text)' }}>{report.total}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportTotal')}</div>
</div>
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--accent)' }}>{report.added}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportAdded')}</div>
</div>
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--text-muted)' }}>{report.duplicates}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportDuplicates')}</div>
</div>
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: report.notFound.length > 0 ? '#ff6b6b' : 'var(--text-muted)' }}>{report.notFound.length}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportNotFound')}</div>
</div>
{report.searchErrors && report.searchErrors.length > 0 && (
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: '#ffa500' }}>{report.searchErrors.length}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportNetworkErrors')}</div>
</div>
)}
</div>
{report.duplicateTracks.length > 0 && (
<>
<h3 style={{ fontSize: 14, marginBottom: 8, color: 'var(--text-muted)' }}>{t('playlists.csvImportDuplicatesTitle')}</h3>
<div style={{ overflowY: 'auto', maxHeight: 150, marginBottom: 16, border: '1px solid var(--surface)', borderRadius: 8 }}>
{report.duplicateTracks.map((track, i) => (
<div key={i} style={{ padding: '8px 12px', borderBottom: '1px solid var(--surface)', fontSize: 13 }}>
<div style={{ fontWeight: 500 }}>{track.trackName}</div>
<div style={{ color: 'var(--text-muted)' }}>{track.artistName}</div>
</div>
))}
</div>
</>
)}
{report.notFound.length > 0 && (
<>
<h3 style={{ fontSize: 14, marginBottom: 8, color: 'var(--text-muted)' }}>{t('playlists.csvImportNotFoundTitle')}</h3>
<div style={{ overflowY: 'auto', maxHeight: 200, marginBottom: 16, border: '1px solid var(--surface)', borderRadius: 8 }}>
{report.notFound.map((track, i) => (
<div key={i} style={{ padding: '8px 12px', borderBottom: '1px solid var(--surface)', fontSize: 13 }}>
<div style={{ fontWeight: 500 }}>{track.trackName}</div>
<div style={{ color: 'var(--text-muted)' }}>{track.artistName}</div>
{track.albumName && <div style={{ color: 'var(--text-muted)', fontSize: 11 }}>{track.albumName}</div>}
{track.score !== undefined && (
<div style={{ fontSize: 11, marginTop: 2 }}>
<span style={{ color: 'var(--text-muted)' }}>Score: </span>
<span style={{ color: track.score >= (track.thresholdNeeded ?? 0.6) ? '#4ade80' : '#f87171' }}>
{track.score.toFixed(2)}
</span>
<span style={{ color: 'var(--text-muted)' }}> (threshold: {(track.thresholdNeeded ?? 0.6).toFixed(2)})</span>
</div>
)}
</div>
))}
</div>
</>
)}
{report.searchErrors && report.searchErrors.length > 0 && (
<>
<h3 style={{ fontSize: 14, marginBottom: 8, color: '#ffa500' }}>{t('playlists.csvImportNetworkErrorsTitle')}</h3>
<div style={{ overflowY: 'auto', maxHeight: 150, marginBottom: 16, border: '1px solid var(--surface)', borderRadius: 8 }}>
{report.searchErrors.map((track, i) => (
<div key={i} style={{ padding: '8px 12px', borderBottom: '1px solid var(--surface)', fontSize: 13 }}>
<div style={{ fontWeight: 500 }}>{track.trackName}</div>
<div style={{ color: 'var(--text-muted)' }}>{track.artistName}</div>
</div>
))}
</div>
</>
)}
<div className="modal-actions" style={{ marginTop: 'auto', display: 'flex', justifyContent: 'flex-end', gap: 12 }}>
<button className="btn btn-surface" onClick={downloadReport}>
<Download size={14} /> {t('playlists.csvImportDownloadReport')}
</button>
<button className="btn btn-primary" onClick={onClose}>
{t('playlists.csvImportClose')}
</button>
</div>
</div>
</div>,
document.body
);
}
+45
View File
@@ -0,0 +1,45 @@
import type { SubsonicSong } from '../api/subsonicTypes';
import { formatHumanHoursMinutes } from './formatHumanDuration';
export function sanitizeFilename(name: string): string {
return name
.replace(/[/\\?%*:|"<>]/g, '-')
.replace(/\.{2,}/g, '.')
.replace(/^[\s.]+|[\s.]+$/g, '')
.substring(0, 200) || 'download';
}
export function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
export function formatSize(bytes?: number): string {
if (!bytes) return '';
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
export function totalDurationLabel(songs: SubsonicSong[]): string {
const total = songs.reduce((acc, s) => acc + (s.duration ?? 0), 0);
return formatHumanHoursMinutes(total);
}
export const SMART_PREFIX = 'psy-smart-';
export function isSmartPlaylistName(name: string): boolean {
return (name ?? '').toLowerCase().startsWith(SMART_PREFIX);
}
export function displayPlaylistName(name: string): string {
const n = name ?? '';
if (isSmartPlaylistName(n)) return n.slice(SMART_PREFIX.length);
return n;
}
export function codecLabel(song: SubsonicSong, showBitrate: boolean): string {
const parts: string[] = [];
if (song.suffix) parts.push(song.suffix.toUpperCase());
if (showBitrate && song.bitRate) parts.push(`${song.bitRate} kbps`);
return parts.join(' · ');
}
+144
View File
@@ -0,0 +1,144 @@
import Papa from 'papaparse';
export interface SpotifyCsvTrack {
trackName: string;
artistName: string;
artistNames: string[]; // Array of all artists for better matching
albumName: string;
isrc?: string;
score?: number; // Match score when track not found
thresholdNeeded?: number; // Threshold required to pass
}
// Header mapping to canonical fields (supports English and Spanish)
const HEADER_MAPPINGS: Record<string, string> = {
// Track name
'track name': 'trackName',
'track name(s)': 'trackName',
'track': 'trackName',
'name': 'trackName',
'nombre de la cancion': 'trackName',
'nombre de cancion': 'trackName',
'nombre de la canci\u00f3n': 'trackName',
'nombre cancion': 'trackName',
't\u00edtulo': 'trackName',
'titulo': 'trackName',
// Artist name
'artist name': 'artistName',
'artist name(s)': 'artistName',
'artists name': 'artistName',
'artists name(s)': 'artistName',
'artist': 'artistName',
'artists': 'artistName',
'nombre del artista': 'artistName',
'nombres del artista': 'artistName',
'nombre artista': 'artistName',
'artista': 'artistName',
// Album name
'album name': 'albumName',
'album name(s)': 'albumName',
'album': 'albumName',
'nombre del album': 'albumName',
'nombre del \u00e1lbum': 'albumName',
'nombre album': 'albumName',
// ISRC
'isrc': 'isrc',
'isrc code': 'isrc',
'codigo isrc': 'isrc',
'c\u00f3digo isrc': 'isrc',
};
function normalizeHeader(header: string): string {
return header
.toLowerCase()
.replace(/\(s\)/g, '')
.replace(/[()]/g, '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim();
}
function findColumnField(header: string): string | undefined {
const normalized = normalizeHeader(header);
return HEADER_MAPPINGS[normalized];
}
function parseArtists(artistField: string): string[] {
// Spotify uses commas in extended format, semicolons in simple format
const separator = artistField.includes(';') ? ';' : ',';
return artistField
.split(separator)
.map(a => a.trim())
.filter(a => a.length > 0);
}
function extractFeaturedArtists(title: string): string[] {
const patterns = [
/\(feat\.?\s+([^)]+)\)/i,
/\(ft\.?\s+([^)]+)\)/i,
/\(featuring\s+([^)]+)\)/i,
/\(with\s+([^)]+)\)/i,
];
for (const regex of patterns) {
const match = title.match(regex);
if (match) {
return match[1].split(/,|\sand\s|\s&\s/).map(s => s.trim()).filter(Boolean);
}
}
return [];
}
export function parseSpotifyCsv(csvContent: string): SpotifyCsvTrack[] {
// Strip BOM and parse with Papa Parse
const cleanContent = csvContent.replace(/^\uFEFF/, '');
const parseResult = Papa.parse(cleanContent, {
header: true,
skipEmptyLines: true,
transformHeader: (header: string) => {
const field = findColumnField(header);
return field || header;
},
});
if (parseResult.errors.length > 0) {
console.warn('CSV parse warnings:', parseResult.errors);
}
const data = parseResult.data as Record<string, string>[];
// Verify required columns
if (!data.length || !data[0].trackName || !data[0].artistName) {
console.error('CSV columns not found. Available headers:', Object.keys(data[0] || {}));
return [];
}
console.log('CSV parsed with Papa Parse:', {
rows: data.length,
sample: data[0],
});
const tracks: SpotifyCsvTrack[] = [];
for (const row of data) {
const trackName = row.trackName?.trim();
const artistField = row.artistName?.trim() || '';
if (!trackName || !artistField) continue;
// Parse multiple artists from field + extract collaborators from title
const artistNames = parseArtists(artistField);
const featuredArtists = extractFeaturedArtists(trackName);
const allArtists = [...new Set([...artistNames, ...featuredArtists])];
const primaryArtist = allArtists[0] || '';
tracks.push({
trackName,
artistName: primaryArtist,
artistNames: allArtists,
albumName: row.albumName?.trim() || '',
isrc: row.isrc?.trim() || undefined,
});
}
return tracks;
}