From e71ae7a5f7864edb7248437692bd902726004a26 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:03:13 -0700 Subject: [PATCH] Import file tab on sync page to create mirrored playlists from CSV/TXT files --- webui/index.html | 92 +++++++++ webui/static/script.js | 384 +++++++++++++++++++++++++++++++++++++- webui/static/style.css | 413 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 888 insertions(+), 1 deletion(-) diff --git a/webui/index.html b/webui/index.html index cb8a4cdb..62edb177 100644 --- a/webui/index.html +++ b/webui/index.html @@ -815,6 +815,9 @@ + @@ -1543,6 +1546,95 @@ + +
+
+

Import Playlist from File

+
+ + +
+
+
📄
+
Drop your file here
+
or click to browse
+
Supported: CSV, TSV, TXT
+ +
+
+
+ CSV / TSV + First row as headers (e.g. Title, Artist, Album). Columns are auto-detected or can be mapped manually. +
+
+ TXT + One track per line (e.g. Artist - Title). Format and separator can be adjusted after upload. +
+
+
+ + + +
+
diff --git a/webui/static/script.js b/webui/static/script.js index 885d396e..71f2393f 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -20339,6 +20339,9 @@ function initializeSyncPage() { mirroredRefreshBtn.addEventListener('click', loadMirroredPlaylists); } + // Initialize import file tab + _initImportFileTab(); + // Logic for the Beatport clear button const beatportClearBtn = document.getElementById('beatport-clear-btn'); if (beatportClearBtn) { @@ -45983,6 +45986,385 @@ function importPageClearFinishedJobs() { _importQueueRender(); } +// ── Import File Tab ────────────────────────────────────────────────── + +let _importFileState = { + rawText: '', + fileName: '', + fileType: '', // 'csv' or 'text' + headers: [], // CSV column headers + rows: [], // raw parsed rows (arrays for csv, strings for text) + columnMap: {}, // { columnIndex: 'track_name' | 'artist_name' | 'album_name' | 'duration' | 'skip' } + parsedTracks: [] // final [{track_name, artist_name, album_name, duration_ms}] +}; + +function _initImportFileTab() { + const dropzone = document.getElementById('import-file-dropzone'); + const fileInput = document.getElementById('import-file-input'); + if (!dropzone || !fileInput) return; + + dropzone.addEventListener('click', () => fileInput.click()); + dropzone.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('drag-over'); }); + dropzone.addEventListener('dragleave', () => dropzone.classList.remove('drag-over')); + dropzone.addEventListener('drop', (e) => { + e.preventDefault(); + dropzone.classList.remove('drag-over'); + const file = e.dataTransfer.files[0]; + if (file) _importFileRead(file); + }); + fileInput.addEventListener('change', () => { + if (fileInput.files[0]) _importFileRead(fileInput.files[0]); + fileInput.value = ''; + }); + + // Enable/disable import button based on playlist name + const nameInput = document.getElementById('import-file-playlist-name'); + if (nameInput) { + nameInput.addEventListener('input', () => { + const btn = document.getElementById('import-file-import-btn'); + if (btn) btn.disabled = !nameInput.value.trim(); + }); + } +} + +function _importFileRead(file) { + const ext = file.name.split('.').pop().toLowerCase(); + if (!['csv', 'tsv', 'txt'].includes(ext)) { + showToast('Unsupported file type. Use CSV, TSV, or TXT.', 'error'); + return; + } + + const reader = new FileReader(); + reader.onload = (e) => { + _importFileState.rawText = e.target.result; + _importFileState.fileName = file.name; + _importFileState.fileType = (ext === 'txt') ? 'text' : 'csv'; + _importFileParseAndPreview(); + }; + reader.readAsText(file); +} + +function _importFileDetectDelimiter(firstLine) { + const tab = (firstLine.match(/\t/g) || []).length; + const semi = (firstLine.match(/;/g) || []).length; + const comma = (firstLine.match(/,/g) || []).length; + if (tab >= comma && tab >= semi && tab > 0) return '\t'; + if (semi >= comma && semi > 0) return ';'; + return ','; +} + +function _importFileParseCsv(text, delimiter) { + const lines = text.split(/\r?\n/).filter(l => l.trim()); + if (lines.length < 2) return { headers: [], rows: [] }; + + // Parse CSV with basic quote handling + function parseLine(line) { + const result = []; + let current = ''; + let inQuotes = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch === '"') { + if (inQuotes && i + 1 < line.length && line[i + 1] === '"') { + current += '"'; + i++; + } else { + inQuotes = !inQuotes; + } + } else if (ch === delimiter && !inQuotes) { + result.push(current.trim()); + current = ''; + } else { + current += ch; + } + } + result.push(current.trim()); + return result; + } + + const headers = parseLine(lines[0]); + const rows = []; + for (let i = 1; i < lines.length; i++) { + const row = parseLine(lines[i]); + if (row.some(cell => cell)) rows.push(row); + } + return { headers, rows }; +} + +function _importFileAutoMapColumns(headers) { + const map = {}; + const lowerHeaders = headers.map(h => h.toLowerCase().trim()); + + const trackPatterns = ['track_name', 'track name', 'track', 'title', 'song', 'song_name', 'song name', 'name']; + const artistPatterns = ['artist_name', 'artist name', 'artist', 'artists', 'performer']; + const albumPatterns = ['album_name', 'album name', 'album']; + const durationPatterns = ['duration', 'duration_ms', 'length', 'time']; + + function findMatch(patterns) { + for (const p of patterns) { + const idx = lowerHeaders.indexOf(p); + if (idx !== -1 && !(idx in map)) return idx; + } + return -1; + } + + const trackIdx = findMatch(trackPatterns); + if (trackIdx !== -1) map[trackIdx] = 'track_name'; + + const artistIdx = findMatch(artistPatterns); + if (artistIdx !== -1) map[artistIdx] = 'artist_name'; + + const albumIdx = findMatch(albumPatterns); + if (albumIdx !== -1) map[albumIdx] = 'album_name'; + + const durIdx = findMatch(durationPatterns); + if (durIdx !== -1) map[durIdx] = 'duration'; + + return map; +} + +function _importFileParseAndPreview() { + const state = _importFileState; + const text = state.rawText; + + if (state.fileType === 'text') { + // Plain text: one track per line + const lines = text.split(/\r?\n/).filter(l => l.trim()); + state.rows = lines; + state.headers = []; + state.columnMap = {}; + } else { + // CSV/TSV + const firstLine = text.split(/\r?\n/)[0] || ''; + const delimiter = _importFileDetectDelimiter(firstLine); + const { headers, rows } = _importFileParseCsv(text, delimiter); + state.headers = headers; + state.rows = rows; + state.columnMap = _importFileAutoMapColumns(headers); + } + + _importFileBuildTracks(); + _importFileRenderPreview(); +} + +function _importFileBuildTracks() { + const state = _importFileState; + state.parsedTracks = []; + + if (state.fileType === 'text') { + const orderEl = document.getElementById('import-file-text-order'); + const sepEl = document.getElementById('import-file-text-separator'); + const order = orderEl ? orderEl.value : 'artist-title'; + const sep = sepEl ? sepEl.value : ' - '; + + for (const line of state.rows) { + const parts = line.split(sep); + if (parts.length >= 2) { + const a = parts[0].trim(); + const b = parts.slice(1).join(sep).trim(); + state.parsedTracks.push({ + track_name: order === 'artist-title' ? b : a, + artist_name: order === 'artist-title' ? a : b, + album_name: '', + duration_ms: 0 + }); + } else { + // Can't split — treat whole line as track name + state.parsedTracks.push({ + track_name: line.trim(), + artist_name: '', + album_name: '', + duration_ms: 0 + }); + } + } + } else { + // CSV mapped + const map = state.columnMap; + const trackCol = Object.keys(map).find(k => map[k] === 'track_name'); + const artistCol = Object.keys(map).find(k => map[k] === 'artist_name'); + const albumCol = Object.keys(map).find(k => map[k] === 'album_name'); + const durCol = Object.keys(map).find(k => map[k] === 'duration'); + + for (const row of state.rows) { + const track = trackCol !== undefined ? (row[trackCol] || '') : ''; + const artist = artistCol !== undefined ? (row[artistCol] || '') : ''; + const album = albumCol !== undefined ? (row[albumCol] || '') : ''; + let dur = durCol !== undefined ? (row[durCol] || '') : ''; + + // Parse duration: could be ms, seconds, or mm:ss + let durationMs = 0; + if (dur) { + dur = dur.trim(); + if (dur.includes(':')) { + const parts = dur.split(':'); + durationMs = (parseInt(parts[0]) * 60 + parseInt(parts[1] || 0)) * 1000; + } else { + const num = parseFloat(dur); + durationMs = num > 10000 ? num : num * 1000; // assume ms if > 10000, else seconds + } + if (isNaN(durationMs)) durationMs = 0; + } + + state.parsedTracks.push({ + track_name: track, + artist_name: artist, + album_name: album, + duration_ms: durationMs + }); + } + } +} + +function _importFileRenderPreview() { + const state = _importFileState; + const validTracks = state.parsedTracks.filter(t => t.track_name || t.artist_name); + + // Show/hide sections + document.getElementById('import-file-upload-zone').style.display = 'none'; + document.getElementById('import-file-preview-section').style.display = ''; + + // File info + document.getElementById('import-file-name-label').textContent = state.fileName; + document.getElementById('import-file-track-count').textContent = `${validTracks.length} track${validTracks.length !== 1 ? 's' : ''} parsed`; + + // Show format controls based on file type + document.getElementById('import-file-text-format').style.display = state.fileType === 'text' ? '' : 'none'; + document.getElementById('import-file-column-mapping').style.display = state.fileType === 'csv' ? '' : 'none'; + + // Render column mapping for CSV + if (state.fileType === 'csv') { + _importFileRenderColumnMapping(); + } + + // Pre-fill playlist name from filename (strip extension) + const nameInput = document.getElementById('import-file-playlist-name'); + if (nameInput && !nameInput.value) { + nameInput.value = state.fileName.replace(/\.[^.]+$/, ''); + } + // Update button state + const btn = document.getElementById('import-file-import-btn'); + if (btn) btn.disabled = !nameInput.value.trim(); + + // Render preview table + const tbody = document.getElementById('import-file-preview-tbody'); + tbody.innerHTML = ''; + + state.parsedTracks.forEach((t, i) => { + const valid = !!(t.track_name || t.artist_name); + const tr = document.createElement('tr'); + if (!valid) tr.classList.add('invalid-row'); + tr.innerHTML = ` + ${i + 1} + ${_esc(t.track_name)} + ${_esc(t.artist_name)} + ${_esc(t.album_name)} + `; + tbody.appendChild(tr); + }); +} + +function _importFileRenderColumnMapping() { + const state = _importFileState; + const container = document.getElementById('import-file-mapping-selects'); + container.innerHTML = ''; + + const options = ['skip', 'track_name', 'artist_name', 'album_name', 'duration']; + const optLabels = { skip: 'Skip', track_name: 'Track', artist_name: 'Artist', album_name: 'Album', duration: 'Duration' }; + + state.headers.forEach((header, idx) => { + const mapped = state.columnMap[idx] || 'skip'; + const wrap = document.createElement('div'); + wrap.className = 'import-file-col-map'; + if (mapped === 'track_name') wrap.classList.add('mapped-track'); + else if (mapped === 'artist_name') wrap.classList.add('mapped-artist'); + else if (mapped === 'album_name') wrap.classList.add('mapped-album'); + + const label = document.createElement('span'); + label.className = 'import-file-col-label'; + label.textContent = header; + label.title = header; + + const sel = document.createElement('select'); + sel.className = 'import-file-select'; + options.forEach(o => { + const opt = document.createElement('option'); + opt.value = o; + opt.textContent = optLabels[o]; + if (o === mapped) opt.selected = true; + sel.appendChild(opt); + }); + sel.addEventListener('change', () => { + if (sel.value === 'skip') { + delete state.columnMap[idx]; + } else { + // Remove this mapping from any other column + for (const k of Object.keys(state.columnMap)) { + if (state.columnMap[k] === sel.value) delete state.columnMap[k]; + } + state.columnMap[idx] = sel.value; + } + _importFileBuildTracks(); + _importFileRenderPreview(); + }); + + wrap.appendChild(label); + wrap.appendChild(sel); + container.appendChild(wrap); + }); +} + +function importFileReparse() { + _importFileBuildTracks(); + _importFileRenderPreview(); +} + +function importFileClear() { + _importFileState = { + rawText: '', fileName: '', fileType: '', + headers: [], rows: [], columnMap: {}, parsedTracks: [] + }; + document.getElementById('import-file-upload-zone').style.display = ''; + document.getElementById('import-file-preview-section').style.display = 'none'; + document.getElementById('import-file-playlist-name').value = ''; + document.getElementById('import-file-preview-tbody').innerHTML = ''; +} + +function importFileSubmit() { + const nameInput = document.getElementById('import-file-playlist-name'); + const name = nameInput ? nameInput.value.trim() : ''; + if (!name) { + showToast('Please enter a playlist name.', 'error'); + nameInput && nameInput.focus(); + return; + } + + const tracks = _importFileState.parsedTracks.filter(t => t.track_name || t.artist_name); + if (!tracks.length) { + showToast('No valid tracks to import.', 'error'); + return; + } + + // Use a unique ID based on timestamp so multiple imports don't collide + const sourceId = `file_${Date.now()}`; + + mirrorPlaylist('file', sourceId, name, tracks, { + description: `Imported from ${_importFileState.fileName}`, + owner: 'local' + }); + + showToast(`Imported "${name}" with ${tracks.length} tracks`, 'success'); + importFileClear(); + + // Switch to mirrored tab so user sees the result + const mirroredBtn = document.querySelector('.sync-tab-button[data-tab="mirrored"]'); + if (mirroredBtn) { + mirroredBtn.click(); + // Reload mirrored playlists to show the new one + setTimeout(() => loadMirroredPlaylists(), 500); + } +} + // ── Mirrored Playlists ──────────────────────────────────────────────── let mirroredPlaylistsLoaded = false; @@ -46070,7 +46452,7 @@ function renderMirroredCard(p, container) { phaseHtml = `Downloaded`; } - const sourceIcons = { spotify: '🎵', tidal: '🌊', youtube: '▶', beatport: '🎛' }; + const sourceIcons = { spotify: '🎵', tidal: '🌊', youtube: '▶', beatport: '🎛', file: '📄' }; const srcIcon = sourceIcons[p.source] || '📋'; // Discovery ratio diff --git a/webui/static/style.css b/webui/static/style.css index b045e141..e557952b 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -6195,6 +6195,413 @@ body { background-image: url('data:image/svg+xml;charset=utf-8,'); } +.import-file-icon { + background-image: url('data:image/svg+xml;charset=utf-8,'); +} + +.sync-tab-button[data-tab="import-file"].active { + background: #60a5fa; + color: #fff; + box-shadow: 0 4px 15px rgba(96, 165, 250, 0.3); +} + +.sync-tab-button.active .import-file-icon { + background-image: url('data:image/svg+xml;charset=utf-8,'); +} + +/* Import File Tab Styles */ +.import-file-zone { + display: flex; + flex-direction: column; + align-items: center; + padding: 20px; + gap: 20px; +} + +.import-file-zone-inner { + position: relative; + border: 2px dashed rgba(96, 165, 250, 0.2); + border-radius: 20px; + padding: 48px 40px; + text-align: center; + cursor: pointer; + transition: all 0.3s ease; + background: linear-gradient(135deg, rgba(96, 165, 250, 0.04) 0%, rgba(59, 130, 246, 0.02) 100%); + width: 100%; + max-width: 520px; + overflow: hidden; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.05); +} + +.import-file-zone-inner::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: + radial-gradient(circle at 30% 20%, rgba(96, 165, 250, 0.06) 0%, transparent 50%), + radial-gradient(circle at 70% 80%, rgba(59, 130, 246, 0.04) 0%, transparent 50%); + pointer-events: none; + opacity: 0; + transition: opacity 0.3s ease; +} + +.import-file-zone-inner:hover { + border-color: rgba(96, 165, 250, 0.35); + background: linear-gradient(135deg, rgba(96, 165, 250, 0.06) 0%, rgba(59, 130, 246, 0.04) 100%); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), + 0 4px 16px rgba(96, 165, 250, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.08); + transform: translateY(-2px); +} + +.import-file-zone-inner:hover::before { + opacity: 1; +} + +.import-file-zone-inner.drag-over { + border-color: rgba(96, 165, 250, 0.6); + border-style: solid; + background: linear-gradient(135deg, rgba(96, 165, 250, 0.1) 0%, rgba(59, 130, 246, 0.06) 100%); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), + 0 4px 24px rgba(96, 165, 250, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.1); + transform: translateY(-2px) scale(1.01); +} + +.import-file-zone-inner.drag-over::before { + opacity: 1; +} + +.import-file-format-hints { + display: flex; + gap: 12px; + width: 100%; + max-width: 520px; +} + +.import-file-hint { + flex: 1; + display: flex; + flex-direction: column; + gap: 6px; + padding: 14px 16px; + background: linear-gradient(135deg, rgba(26, 26, 26, 0.95) 0%, rgba(18, 18, 18, 0.98) 100%); + border: 1px solid rgba(255, 255, 255, 0.06); + border-top: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 14px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +.import-file-hint-label { + font-size: 11px; + font-weight: 700; + color: #60a5fa; + text-transform: uppercase; + letter-spacing: 0.8px; +} + +.import-file-hint-text { + font-size: 12px; + color: rgba(255, 255, 255, 0.4); + line-height: 1.5; +} + +.import-file-zone-icon { + font-size: 44px; + margin-bottom: 14px; + filter: drop-shadow(0 4px 8px rgba(96, 165, 250, 0.2)); +} + +.import-file-zone-title { + font-size: 18px; + font-weight: 700; + color: #fff; + margin-bottom: 6px; + letter-spacing: -0.2px; +} + +.import-file-zone-subtitle { + font-size: 13px; + color: rgba(255, 255, 255, 0.4); + margin-bottom: 16px; +} + +.import-file-zone-formats { + font-size: 11px; + color: rgba(255, 255, 255, 0.25); + letter-spacing: 0.5px; + text-transform: uppercase; + font-weight: 600; +} + +.import-file-info-bar { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 20px; + background: linear-gradient(135deg, rgba(96, 165, 250, 0.08) 0%, rgba(59, 130, 246, 0.04) 100%); + border: 1px solid rgba(96, 165, 250, 0.15); + border-top: 1px solid rgba(96, 165, 250, 0.2); + border-radius: 14px; + margin: 0 12px 14px 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +.import-file-info-filename { + font-weight: 700; + color: #60a5fa; + font-size: 14px; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.import-file-info-count { + font-size: 12px; + color: rgba(255, 255, 255, 0.5); + white-space: nowrap; + background: rgba(96, 165, 250, 0.1); + padding: 4px 10px; + border-radius: 8px; + font-weight: 600; +} + +.import-file-clear-btn { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.5); + width: 30px; + height: 30px; + border-radius: 50%; + cursor: pointer; + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.import-file-clear-btn:hover { + background: rgba(239, 68, 68, 0.15); + border-color: rgba(239, 68, 68, 0.3); + color: #ef4444; + transform: scale(1.1); +} + +.import-file-format-bar, +.import-file-mapping-bar { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 18px; + margin: 0 12px 14px 12px; + background: linear-gradient(135deg, rgba(26, 26, 26, 0.95) 0%, rgba(18, 18, 18, 0.98) 100%); + border: 1px solid rgba(255, 255, 255, 0.06); + border-top: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 14px; + flex-wrap: wrap; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +.import-file-format-label { + font-size: 12px; + color: rgba(255, 255, 255, 0.45); + white-space: nowrap; + font-weight: 600; +} + +.import-file-select { + background: #1a1a1a; + border: 1px solid rgba(255, 255, 255, 0.12); + color: #fff; + padding: 6px 10px; + border-radius: 8px; + font-size: 12px; + cursor: pointer; + transition: border-color 0.2s; +} + +.import-file-select option { + background: #1a1a1a; + color: #fff; +} + +.import-file-select:hover { + border-color: rgba(96, 165, 250, 0.3); +} + +.import-file-mapping-selects { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} + +.import-file-mapping-selects .import-file-col-map { + display: flex; + align-items: center; + gap: 6px; + background: rgba(255, 255, 255, 0.03); + padding: 6px 10px; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.08); + transition: border-color 0.2s; +} + +.import-file-mapping-selects .import-file-col-label { + font-size: 11px; + color: rgba(255, 255, 255, 0.4); + max-width: 90px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 600; +} + +.import-file-mapping-selects .import-file-col-map.mapped-track { + border-color: rgba(96, 165, 250, 0.25); + background: rgba(96, 165, 250, 0.04); +} +.import-file-mapping-selects .import-file-col-map.mapped-artist { + border-color: rgba(34, 197, 94, 0.25); + background: rgba(34, 197, 94, 0.04); +} +.import-file-mapping-selects .import-file-col-map.mapped-album { + border-color: rgba(251, 191, 36, 0.25); + background: rgba(251, 191, 36, 0.04); +} + +.import-file-mapping-selects .import-file-col-map.mapped-track select { + border-color: rgba(96, 165, 250, 0.3); +} +.import-file-mapping-selects .import-file-col-map.mapped-artist select { + border-color: rgba(34, 197, 94, 0.3); +} +.import-file-mapping-selects .import-file-col-map.mapped-album select { + border-color: rgba(251, 191, 36, 0.3); +} + +.import-file-preview-table-wrap { + margin: 0 12px; + max-height: 360px; + overflow-y: auto; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.06); + border-top: 1px solid rgba(255, 255, 255, 0.1); + background: linear-gradient(135deg, rgba(26, 26, 26, 0.95) 0%, rgba(18, 18, 18, 0.98) 100%); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); +} + +.import-file-preview-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.import-file-preview-table thead th { + position: sticky; + top: 0; + background: rgba(18, 18, 18, 0.98); + padding: 10px 14px; + text-align: left; + font-weight: 700; + color: rgba(255, 255, 255, 0.5); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + z-index: 1; +} + +.import-file-preview-table tbody tr { + border-bottom: 1px solid rgba(255, 255, 255, 0.03); + transition: background 0.15s; +} + +.import-file-preview-table tbody tr:hover { + background: rgba(96, 165, 250, 0.04); +} + +.import-file-preview-table tbody tr.invalid-row { + opacity: 0.35; +} + +.import-file-preview-table td { + padding: 9px 14px; + color: rgba(255, 255, 255, 0.8); +} + +.import-file-preview-table td:first-child { + color: rgba(255, 255, 255, 0.25); + width: 44px; + font-weight: 600; +} + +.import-file-action-bar { + display: flex; + align-items: center; + gap: 14px; + padding: 16px 12px 10px 12px; + margin: 0 12px; +} + +.import-file-name-input { + flex: 1; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.1); + border-top: 1px solid rgba(255, 255, 255, 0.15); + color: #fff; + padding: 12px 16px; + border-radius: 12px; + font-size: 14px; + font-weight: 500; + outline: none; + transition: all 0.2s; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); +} + +.import-file-name-input:focus { + border-color: rgba(96, 165, 250, 0.4); + background: rgba(96, 165, 250, 0.04); + box-shadow: 0 2px 12px rgba(96, 165, 250, 0.1); +} + +.import-file-name-input::placeholder { + color: rgba(255, 255, 255, 0.25); + font-weight: 400; +} + +.import-file-import-btn { + background: linear-gradient(135deg, #60a5fa, #3b82f6); + color: #fff; + border: none; + padding: 12px 24px; + border-radius: 12px; + font-size: 13px; + font-weight: 700; + cursor: pointer; + white-space: nowrap; + transition: all 0.25s; + letter-spacing: 0.2px; + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2); +} + +.import-file-import-btn:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(96, 165, 250, 0.35); +} + +.import-file-import-btn:disabled { + opacity: 0.35; + cursor: not-allowed; + box-shadow: none; +} + .mirrored-icon { background-image: url('data:image/svg+xml;charset=utf-8,'); } @@ -6274,6 +6681,11 @@ body { border-color: rgba(1, 255, 149, 0.25); color: #01ff95; } +.mirrored-playlist-card .source-icon.file { + background: linear-gradient(135deg, rgba(96, 165, 250, 0.2) 0%, rgba(96, 165, 250, 0.08) 100%); + border-color: rgba(96, 165, 250, 0.25); + color: #60a5fa; +} .mirrored-playlist-card .source-badge { font-size: 10px; @@ -6290,6 +6702,7 @@ body { .mirrored-playlist-card .source-badge.tidal { background: #ff6600; } .mirrored-playlist-card .source-badge.youtube { background: #ff0000; } .mirrored-playlist-card .source-badge.beatport { background: #01ff95; color: #000; } +.mirrored-playlist-card .source-badge.file { background: #60a5fa; } .mirrored-card-info { flex: 1;