Add URL history pills for YouTube, Deezer, and Spotify Link sync tabs

Saves successfully loaded playlist URLs to localStorage and displays
them as clickable pills between the input bar and playlist container.
Clicking a pill re-loads that URL; X button removes it. Max 10 per
source, most recent first. Source-colored hover accents match each
tab's brand styling.

Also fixes duplicate playlist bug — YouTube and Spotify Public now
check for already-loaded playlists before making API calls, preventing
broken duplicate cards when the same URL is entered twice.
This commit is contained in:
Broque Thomas 2026-03-16 10:35:45 -07:00
parent e2ba879197
commit a02d14a23a
3 changed files with 255 additions and 0 deletions

View file

@ -1044,6 +1044,7 @@
placeholder="Paste Deezer Playlist URL...">
<button id="deezer-parse-btn">Load Playlist</button>
</div>
<div class="url-history-bar" id="deezer-url-history" style="display:none"></div>
<div class="playlist-scroll-container" id="deezer-playlist-container">
<div class="playlist-placeholder">Paste a Deezer playlist URL above to get started.</div>
</div>
@ -1056,6 +1057,7 @@
placeholder="Paste YouTube Music Playlist URL...">
<button id="youtube-parse-btn">Parse Playlist</button>
</div>
<div class="url-history-bar" id="youtube-url-history" style="display:none"></div>
<div class="playlist-scroll-container" id="youtube-playlist-container">
<div class="playlist-placeholder">Parsed YouTube playlists will appear here.</div>
</div>
@ -1068,6 +1070,7 @@
placeholder="Paste Spotify Playlist or Album URL...">
<button id="spotify-public-parse-btn">Load</button>
</div>
<div class="url-history-bar" id="spotify-public-url-history" style="display:none"></div>
<div class="playlist-scroll-container" id="spotify-public-playlist-container">
<div class="playlist-placeholder">Paste a Spotify playlist or album URL above to load tracks without needing Spotify API credentials.</div>
</div>

View file

@ -7611,6 +7611,9 @@ async function loadSyncData() {
// Load Beatport charts from backend (always refresh to get latest state)
await loadBeatportChartsFromBackend();
// Render saved URL histories for YouTube, Deezer, Spotify Link tabs
initUrlHistories();
}
async function checkForActiveProcesses() {
@ -22377,6 +22380,9 @@ async function loadDeezerPlaylist() {
})), { owner: playlist.owner, image_url: playlist.image_url, description: rawUrl });
}
// Save to URL history
saveUrlHistory('deezer', rawUrl, playlist.name);
renderDeezerPlaylists();
await loadDeezerPlaylistStatesFromBackend();
@ -26479,6 +26485,13 @@ async function parseSpotifyPublicUrl() {
return;
}
// Check if already loaded
if (_isUrlAlreadyLoaded('spotify-public', url)) {
showToast('This playlist is already loaded', 'info');
urlInput.value = '';
return;
}
const parseBtn = document.getElementById('spotify-public-parse-btn');
if (parseBtn) {
parseBtn.disabled = true;
@ -26523,6 +26536,9 @@ async function parseSpotifyPublicUrl() {
})), { owner: result.subtitle || '', image_url: '', description: result.url || '' });
}
// Save to URL history
saveUrlHistory('spotify-public', url, result.name);
renderSpotifyPublicPlaylists();
await loadSpotifyPublicPlaylistStatesFromBackend();
@ -27441,6 +27457,133 @@ async function startSpotifyPublicDownloadMissing(urlHash) {
}
}
// ===============================
// URL HISTORY (Saved playlist URLs)
// ===============================
const URL_HISTORY_MAX = 10;
const URL_HISTORY_SOURCES = {
youtube: { key: 'soulsync-url-history-youtube', icon: '▶', inputId: 'youtube-url-input', containerId: 'youtube-url-history', loadFn: () => parseYouTubePlaylist() },
deezer: { key: 'soulsync-url-history-deezer', icon: '🎵', inputId: 'deezer-url-input', containerId: 'deezer-url-history', loadFn: () => loadDeezerPlaylist() },
'spotify-public': { key: 'soulsync-url-history-spotify-public', icon: '🎧', inputId: 'spotify-public-url-input', containerId: 'spotify-public-url-history', loadFn: () => parseSpotifyPublicUrl() }
};
function getUrlHistory(source) {
try {
const cfg = URL_HISTORY_SOURCES[source];
if (!cfg) return [];
const raw = localStorage.getItem(cfg.key);
return raw ? JSON.parse(raw) : [];
} catch { return []; }
}
function saveUrlHistory(source, url, name) {
const cfg = URL_HISTORY_SOURCES[source];
if (!cfg || !url) return;
let history = getUrlHistory(source);
// Remove duplicate (same URL)
history = history.filter(h => h.url !== url);
// Add to front
history.unshift({ url, name: name || url, ts: Date.now() });
// Cap
if (history.length > URL_HISTORY_MAX) history = history.slice(0, URL_HISTORY_MAX);
localStorage.setItem(cfg.key, JSON.stringify(history));
renderUrlHistory(source);
}
function removeUrlHistoryEntry(source, url) {
const cfg = URL_HISTORY_SOURCES[source];
if (!cfg) return;
let history = getUrlHistory(source);
history = history.filter(h => h.url !== url);
localStorage.setItem(cfg.key, JSON.stringify(history));
renderUrlHistory(source);
}
function renderUrlHistory(source) {
const cfg = URL_HISTORY_SOURCES[source];
if (!cfg) return;
const container = document.getElementById(cfg.containerId);
if (!container) return;
const history = getUrlHistory(source);
if (history.length === 0) {
container.style.display = 'none';
container.innerHTML = '';
return;
}
container.style.display = 'flex';
container.innerHTML = `<span class="url-history-bar-label">Recent</span>` +
history.map(h => {
const rawName = h.name.length > 30 ? h.name.substring(0, 28) + '...' : h.name;
const safeName = escapeHtml(rawName);
const safeTitle = escapeHtml(h.name);
const safeUrl = h.url.replace(/"/g, '&quot;');
return `<div class="url-history-pill" data-url="${safeUrl}" title="${safeTitle}">
<span class="url-history-pill-icon">${cfg.icon}</span>
<span class="url-history-pill-name">${safeName}</span>
<button class="url-history-pill-remove" data-source="${source}" data-url="${safeUrl}">&times;</button>
</div>`;
}).join('');
// Pill click → fill input and load (skip if already loaded)
container.querySelectorAll('.url-history-pill').forEach(pill => {
pill.addEventListener('click', (e) => {
// Don't trigger if clicking the X button
if (e.target.classList.contains('url-history-pill-remove')) return;
const pillUrl = pill.dataset.url;
if (_isUrlAlreadyLoaded(source, pillUrl)) {
showToast('This playlist is already loaded', 'info');
return;
}
const input = document.getElementById(cfg.inputId);
if (input) input.value = pillUrl;
cfg.loadFn();
});
});
// X button click → remove entry
container.querySelectorAll('.url-history-pill-remove').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
removeUrlHistoryEntry(btn.dataset.source, btn.dataset.url);
});
});
}
function _isUrlAlreadyLoaded(source, url) {
if (source === 'youtube') {
// Check for existing YouTube card with this URL
const container = document.getElementById('youtube-playlist-container');
if (container) {
const cards = container.querySelectorAll('.youtube-playlist-card[data-url]');
for (const card of cards) {
if (card.dataset.url === url) return true;
}
}
return false;
} else if (source === 'deezer') {
// Extract playlist ID from URL and check deezerPlaylists array
const match = url.match(/deezer\.com\/(?:[a-z]{2}\/)?playlist\/(\d+)/i);
const id = match ? match[1] : (/^\d+$/.test(url) ? url : null);
if (id && deezerPlaylists.find(p => String(p.id) === String(id))) return true;
return false;
} else if (source === 'spotify-public') {
// Extract Spotify ID from URL and compare against loaded playlists
const spMatch = url.match(/open\.spotify\.com\/(playlist|album)\/([a-zA-Z0-9]+)/);
const spId = spMatch ? spMatch[2] : null;
if (spId && spotifyPublicPlaylists.some(p => p.id === spId)) return true;
// Fallback: direct URL comparison
return spotifyPublicPlaylists.some(p => p.url === url);
}
return false;
}
function initUrlHistories() {
for (const source of Object.keys(URL_HISTORY_SOURCES)) {
renderUrlHistory(source);
}
}
// ===============================
// YOUTUBE PLAYLIST FUNCTIONALITY
// ===============================
@ -27460,6 +27603,13 @@ async function parseYouTubePlaylist() {
return;
}
// Check if already loaded
if (_isUrlAlreadyLoaded('youtube', url)) {
showToast('This playlist is already loaded', 'info');
urlInput.value = '';
return;
}
try {
console.log('🎬 Parsing YouTube playlist:', url);
@ -27485,6 +27635,9 @@ async function parseYouTubePlaylist() {
console.log('✅ YouTube playlist parsed:', result.name, `(${result.tracks.length} tracks)`);
// Save to URL history
saveUrlHistory('youtube', url, result.name);
// Update card with parsed data and stay in 'fresh' phase
updateYouTubeCardData(result.url_hash, result);
updateYouTubeCardPhase(result.url_hash, 'fresh');

View file

@ -10422,6 +10422,105 @@ body {
padding: 40px;
}
/* URL History Bar (saved playlist URLs) */
.url-history-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 2px;
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
flex-shrink: 0;
}
.url-history-bar::-webkit-scrollbar {
display: none;
}
.url-history-bar-label {
font-size: 11px;
color: rgba(255, 255, 255, 0.35);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
flex-shrink: 0;
padding-left: 4px;
}
.url-history-pill {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px 4px 8px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
transition: all 0.2s ease;
max-width: 220px;
}
.url-history-pill:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.15);
}
.url-history-pill-icon {
font-size: 12px;
flex-shrink: 0;
}
.url-history-pill-name {
font-size: 12px;
font-weight: 500;
color: rgba(255, 255, 255, 0.75);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.url-history-pill:hover .url-history-pill-name {
color: rgba(255, 255, 255, 0.95);
}
.url-history-pill-remove {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border-radius: 50%;
font-size: 10px;
color: rgba(255, 255, 255, 0.3);
background: transparent;
border: none;
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s ease;
padding: 0;
line-height: 1;
}
.url-history-pill-remove:hover {
color: #ff4444;
background: rgba(255, 68, 68, 0.15);
}
/* Source-specific pill accent on hover */
#youtube-url-history .url-history-pill:hover {
border-color: rgba(255, 32, 32, 0.3);
}
#deezer-url-history .url-history-pill:hover {
border-color: rgba(162, 56, 255, 0.3);
}
#spotify-public-url-history .url-history-pill:hover {
border-color: rgba(29, 185, 84, 0.3);
}
/* Playlist URL input section (YouTube, Deezer) */
.youtube-input-section {
display: flex;