Import file tab on sync page to create mirrored playlists from CSV/TXT files

This commit is contained in:
Broque Thomas 2026-03-09 22:03:13 -07:00
parent aa93458ed3
commit e71ae7a5f7
3 changed files with 888 additions and 1 deletions

View file

@ -815,6 +815,9 @@
<button class="sync-tab-button" data-tab="beatport">
<span class="tab-icon beatport-icon"></span> Beatport
</button>
<button class="sync-tab-button" data-tab="import-file">
<span class="tab-icon import-file-icon"></span> Import
</button>
<button class="sync-tab-button" data-tab="mirrored">
<span class="tab-icon mirrored-icon"></span> Mirrored
</button>
@ -1543,6 +1546,95 @@
</div>
</div>
<!-- Import File Tab Content -->
<div class="sync-tab-content" id="import-file-tab-content">
<div class="playlist-header">
<h3>Import Playlist from File</h3>
</div>
<!-- Step 1: Upload Zone -->
<div id="import-file-upload-zone" class="import-file-zone">
<div class="import-file-zone-inner" id="import-file-dropzone">
<div class="import-file-zone-icon">📄</div>
<div class="import-file-zone-title">Drop your file here</div>
<div class="import-file-zone-subtitle">or click to browse</div>
<div class="import-file-zone-formats">Supported: CSV, TSV, TXT</div>
<input type="file" id="import-file-input" accept=".csv,.tsv,.txt" style="display:none">
</div>
<div class="import-file-format-hints">
<div class="import-file-hint">
<span class="import-file-hint-label">CSV / TSV</span>
<span class="import-file-hint-text">First row as headers (e.g. Title, Artist, Album). Columns are auto-detected or can be mapped manually.</span>
</div>
<div class="import-file-hint">
<span class="import-file-hint-label">TXT</span>
<span class="import-file-hint-text">One track per line (e.g. Artist - Title). Format and separator can be adjusted after upload.</span>
</div>
</div>
</div>
<!-- Step 2: Column Mapping & Preview (hidden until file is parsed) -->
<div id="import-file-preview-section" style="display:none">
<!-- File info bar -->
<div class="import-file-info-bar">
<span id="import-file-name-label" class="import-file-info-filename"></span>
<span id="import-file-track-count" class="import-file-info-count"></span>
<button class="import-file-clear-btn" onclick="importFileClear()" title="Clear and start over"></button>
</div>
<!-- Format selector (for plain text files) -->
<div id="import-file-text-format" class="import-file-format-bar" style="display:none">
<span class="import-file-format-label">Line format:</span>
<select id="import-file-text-order" class="import-file-select" onchange="importFileReparse()">
<option value="artist-title">Artist - Title</option>
<option value="title-artist">Title - Artist</option>
</select>
<span class="import-file-format-label" style="margin-left:12px">Separator:</span>
<select id="import-file-text-separator" class="import-file-select" onchange="importFileReparse()">
<option value=" - "> - </option>
<option value=" — "></option>
<option value="|">|</option>
<option value="/"> / </option>
</select>
</div>
<!-- Column mapping (for CSV/TSV files) -->
<div id="import-file-column-mapping" class="import-file-mapping-bar" style="display:none">
<span class="import-file-format-label">Column mapping:</span>
<div id="import-file-mapping-selects" class="import-file-mapping-selects">
<!-- Dynamically populated with dropdowns per CSV column -->
</div>
</div>
<!-- Preview table -->
<div class="import-file-preview-table-wrap">
<table class="import-file-preview-table" id="import-file-preview-table">
<thead>
<tr>
<th>#</th>
<th>Track</th>
<th>Artist</th>
<th>Album</th>
</tr>
</thead>
<tbody id="import-file-preview-tbody">
</tbody>
</table>
</div>
<!-- Playlist name + import button -->
<div class="import-file-action-bar">
<input type="text" id="import-file-playlist-name"
class="import-file-name-input"
placeholder="Enter playlist name..." maxlength="200">
<button class="import-file-import-btn" id="import-file-import-btn"
onclick="importFileSubmit()" disabled>
Import as Mirrored Playlist
</button>
</div>
</div>
</div>
<!-- Mirrored Playlists Tab Content -->
<div class="sync-tab-content" id="mirrored-tab-content">
<div class="playlist-header">

View file

@ -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 = `
<td>${i + 1}</td>
<td>${_esc(t.track_name)}</td>
<td>${_esc(t.artist_name)}</td>
<td>${_esc(t.album_name)}</td>
`;
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 = `<span style="color:#22c55e;">Downloaded</span>`;
}
const sourceIcons = { spotify: '🎵', tidal: '🌊', youtube: '▶', beatport: '🎛' };
const sourceIcons = { spotify: '🎵', tidal: '🌊', youtube: '▶', beatport: '🎛', file: '📄' };
const srcIcon = sourceIcons[p.source] || '📋';
// Discovery ratio

View file

@ -6195,6 +6195,413 @@ body {
background-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%23ffffff"><path d="M2 6h20v2H2zm0 5h20v2H2zm0 5h20v2H2z"/></svg>');
}
.import-file-icon {
background-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%2360a5fa"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6zm8-6v4h-4v-4H8l4-4 4 4h-2z"/></svg>');
}
.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,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%23ffffff"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6zm8-6v4h-4v-4H8l4-4 4 4h-2z"/></svg>');
}
/* 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,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%23a78bfa"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zM7 17h2V7H7v10zm4 0h2V7h-2v10zm4 0h2V7h-2v10z"/></svg>');
}
@ -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;