diff --git a/src/components/playlist/CsvImportReportModal.tsx b/src/components/playlist/CsvImportReportModal.tsx new file mode 100644 index 00000000..59a9c7d9 --- /dev/null +++ b/src/components/playlist/CsvImportReportModal.tsx @@ -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( +
+
e.stopPropagation()} + > + + +

{t('playlists.csvImportReport')}

+ +
0 ? 'repeat(5, 1fr)' : 'repeat(4, 1fr)', gap: 12, marginBottom: 20, textAlign: 'center' }}> +
+
{report.total}
+
{t('playlists.csvImportTotal')}
+
+
+
{report.added}
+
{t('playlists.csvImportAdded')}
+
+
+
{report.duplicates}
+
{t('playlists.csvImportDuplicates')}
+
+
+
0 ? '#ff6b6b' : 'var(--text-muted)' }}>{report.notFound.length}
+
{t('playlists.csvImportNotFound')}
+
+ {report.searchErrors && report.searchErrors.length > 0 && ( +
+
{report.searchErrors.length}
+
{t('playlists.csvImportNetworkErrors')}
+
+ )} +
+ + {report.duplicateTracks.length > 0 && ( + <> +

{t('playlists.csvImportDuplicatesTitle')}

+
+ {report.duplicateTracks.map((track, i) => ( +
+
{track.trackName}
+
{track.artistName}
+
+ ))} +
+ + )} + + {report.notFound.length > 0 && ( + <> +

{t('playlists.csvImportNotFoundTitle')}

+
+ {report.notFound.map((track, i) => ( +
+
{track.trackName}
+
{track.artistName}
+ {track.albumName &&
{track.albumName}
} + {track.score !== undefined && ( +
+ Score: + = (track.thresholdNeeded ?? 0.6) ? '#4ade80' : '#f87171' }}> + {track.score.toFixed(2)} + + (threshold: {(track.thresholdNeeded ?? 0.6).toFixed(2)}) +
+ )} +
+ ))} +
+ + )} + + {report.searchErrors && report.searchErrors.length > 0 && ( + <> +

{t('playlists.csvImportNetworkErrorsTitle')}

+
+ {report.searchErrors.map((track, i) => ( +
+
{track.trackName}
+
{track.artistName}
+
+ ))} +
+ + )} + +
+ + +
+
+
, + document.body + ); +} diff --git a/src/components/playlist/PlaylistEditModal.tsx b/src/components/playlist/PlaylistEditModal.tsx new file mode 100644 index 00000000..e3de07ec --- /dev/null +++ b/src/components/playlist/PlaylistEditModal.tsx @@ -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; +} + +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(null); + const [coverPreview, setCoverPreview] = useState(null); + const [coverRemoved, setCoverRemoved] = useState(false); + const [saving, setSaving] = useState(false); + const coverInputRef = useRef(null); + + const hasExistingCover = !coverRemoved && (coverPreview || customCoverId); + + const handleFileChange = (e: React.ChangeEvent) => { + 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 ( +
+
e.stopPropagation()}> + + +

{t('playlists.editMeta')}

+ +
+ {/* Left: cover */} +
coverInputRef.current?.click()} + > + {coverPreview ? ( + + ) : !coverRemoved && customCoverFetchUrl && customCoverCacheKey ? ( + + ) : ( +
+ {coverQuadUrls.map((entry, i) => + entry + ? + :
+ )} +
+ )} +
+
+ + {hasExistingCover && ( + + )} +
+
+ +
+ + {/* Right: fields */} +
+ setName(e.target.value)} + placeholder={t('playlists.editNamePlaceholder')} + autoFocus + /> +