mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
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:
committed by
GitHub
parent
8adad2be6f
commit
84ceb5f423
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user