Files
Christian Krakau-Louis 03b982e7c2 Initial clean commit
2025-05-13 08:59:02 +00:00

1479 lines
78 KiB
HTML

{% extends 'base.html' %}
{% block title %}Your Song Library - Quizzical Beats{% endblock %}
{% block head %}
<style>
.sort-header {
cursor: pointer;
position: relative;
}
.sort-header:hover {
background-color: #f3f4f6;
}
.sort-header::after {
content: '↕';
font-size: 0.8em;
margin-left: 5px;
opacity: 0.5;
}
.sort-asc::after {
content: '↑';
opacity: 1;
}
.sort-desc::after {
content: '↓';
opacity: 1;
}
.song-row {
transition: background-color 0.2s;
}
.song-row:hover {
background-color: #f0f9ff;
}
/* Filter inputs */
.filter-row input {
width: 100%;
padding: 6px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.filter-row input:focus {
border-color: #00ACC1;
outline: none;
box-shadow: 0 0 0 2px rgba(0, 172, 193, 0.2);
}
/* Tag styles */
.tag {
display: inline-flex;
align-items: center;
background-color: #e0f2f1;
color: #00796b;
border-radius: 16px;
padding: 4px 12px;
margin: 2px;
font-size: 0.75rem;
font-weight: 500;
transition: all 0.2s;
}
.tag:hover {
background-color: #b2dfdb;
}
.tag .tag-remove {
margin-left: 6px;
cursor: pointer;
}
/* Tab system for song details */
.tabs {
display: flex;
border-bottom: 1px solid #e2e8f0;
margin-bottom: 1rem;
}
.tab {
padding: 0.75rem 1.25rem;
cursor: pointer;
border-bottom: 2px solid transparent;
font-weight: 500;
}
.tab:hover {
background-color: #f7fafc;
}
.tab.active {
border-bottom-color: #00ACC1;
color: #00ACC1;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* Audio features styling */
.audio-feature {
display: flex;
align-items: center;
margin-bottom: 0.75rem;
}
.audio-feature-label {
width: 40%;
font-weight: 500;
color: #4a5568;
}
.audio-feature-value {
width: 15%;
font-weight: 600;
color: #2d3748;
}
.audio-feature-bar {
width: 45%;
height: 8px;
background-color: #e2e8f0;
border-radius: 4px;
overflow: hidden;
}
.audio-feature-bar-fill {
height: 100%;
background-color: #00ACC1;
}
.no-features-message {
padding: 2rem;
text-align: center;
background-color: #f7fafc;
border-radius: 0.5rem;
color: #718096;
}
/* Add indicator for songs without audio features */
.missing-features {
background-color: #fff5f5 !important;
}
.missing-features-indicator {
display: inline-flex;
align-items: center;
background-color: #fed7d7;
color: #c53030;
border-radius: 16px;
padding: 2px 8px;
margin-left: 8px;
font-size: 0.7rem;
font-weight: 500;
}
</style>
{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-navy-800 font-montserrat mb-3">Your Song Library</h1>
<p class="text-gray-600 mb-6">Browse, search and manage all your imported songs to use in music quizzes.</p>
</div>
{% if songs %}
<div class="mb-6 bg-white shadow-md rounded-lg p-5">
<div class="flex flex-col md:flex-row gap-4">
<div class="w-full md:w-1/3">
<label for="globalSearch" class="block text-sm font-medium text-gray-700 mb-1">Search Songs</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3">
<i class="fas fa-search text-gray-400"></i>
</span>
<input type="text" id="globalSearch" placeholder="Search titles, artists, genres..."
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500">
</div>
</div>
<div class="w-full md:w-1/3">
<label for="pageSizeSelect" class="block text-sm font-medium text-gray-700 mb-1">Records per page</label>
<select id="pageSizeSelect" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500">
<option value="25">25 songs</option>
<option value="50">50 songs</option>
<option value="100">100 songs</option>
<option value="all">All songs</option>
</select>
</div>
<div class="w-full md:w-1/3 flex items-end">
<div class="w-full text-right">
<p class="text-sm text-gray-600 mb-1">Total songs in your collection</p>
<p class="text-navy-800 font-semibold text-lg">
<span id="visibleCount">{{ songs|length }}</span> / <span id="totalCount">{{ songs|length }}</span>
</p>
</div>
</div>
</div>
</div>
<div class="overflow-x-auto bg-white shadow-md rounded-lg mb-8">
<table id="songs-table" class="w-full table-auto">
<thead>
<tr class="bg-navy-50 text-navy-800">
<th class="px-4 py-3 text-left sort-header" data-sort="id">ID</th>
<th class="px-4 py-3 text-left sort-header" data-sort="title">Title</th>
<th class="px-4 py-3 text-left sort-header" data-sort="artist">Artist(s)</th>
<th class="px-4 py-3 text-left sort-header" data-sort="genre">Genre</th>
<th class="px-4 py-3 text-left sort-header" data-sort="year">Year</th>
<th class="px-4 py-3 text-left sort-header" data-sort="used_count">Times Used</th>
<th class="px-4 py-3 text-left">Preview</th>
<th class="px-4 py-3 text-left">Actions</th>
</tr>
<tr class="filter-row bg-gray-50">
<td class="px-4 py-2"><input type="text" placeholder="Filter ID" data-col="id"></td>
<td class="px-4 py-2"><input type="text" placeholder="Filter title" data-col="title"></td>
<td class="px-4 py-2"><input type="text" placeholder="Filter artist" data-col="artist"></td>
<td class="px-4 py-2"><input type="text" placeholder="Filter genre" data-col="genre"></td>
<td class="px-4 py-2"><input type="text" placeholder="Filter year" data-col="year"></td>
<td class="px-4 py-2"><input type="text" placeholder="Filter used" data-col="used_count"></td>
<td class="px-4 py-2"></td>
<td class="px-4 py-2"></td>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{% for song in songs %}
<tr class="song-row hover:bg-gray-50" data-song-id="{{ song.id }}">
<td class="px-4 py-3">{{ song.id }}</td>
<td class="px-4 py-3 font-medium">{{ song.title }}</td>
<td class="px-4 py-3">{{ song.artist }}</td>
<td class="px-4 py-3">
{% if song.genre %}
<span class="bg-orange-100 text-orange-800 text-xs font-medium px-2 py-1 rounded">{{ song.genre }}</span>
{% else %}
<span class="text-gray-400">Unknown</span>
{% endif %}
</td>
<td class="px-4 py-3">{{ song.year }}</td>
<td class="px-4 py-3 text-center">
<span class="{% if song.used_count > 0 %}bg-navy-100 text-navy-800{% else %}bg-gray-100 text-gray-600{% endif %} text-xs font-medium px-2 py-1 rounded-full">
{{ song.used_count }}
</span>
</td>
<td class="px-4 py-3">
{% if song.preview_url %}
<audio controls class="preview-audio w-full max-w-[180px]" src="{{ song.preview_url }}" preload="none">Your browser does not support the audio element.</audio>
{% else %}
<span class="text-gray-400">No preview</span>
{% endif %}
</td>
<td class="px-4 py-3">
<div class="flex space-x-2">
<button type="button"
class="edit-song-btn text-teal-600 hover:text-teal-800 px-2 py-1 rounded-md border border-transparent hover:border-teal-200 hover:bg-teal-50 transition-all"
title="Edit song">
<i class="fas fa-edit"></i>
</button>
<button type="button"
class="delete-song-btn text-red-600 hover:text-red-800 px-2 py-1 rounded-md border border-transparent hover:border-red-200 hover:bg-red-50 transition-all"
data-song-id="{{ song.id }}"
data-song-title="{{ song.title }}"
title="Delete song">
<i class="fas fa-trash-alt"></i>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="bg-gray-50 px-4 py-3 flex items-center justify-between border-t border-gray-200">
<div class="flex items-center">
<span class="text-sm text-gray-700">
Page <span id="currentPage">1</span> of <span id="totalPages">1</span>
</span>
</div>
<div class="flex space-x-2">
<button id="prevPageBtn" class="flex items-center px-3 py-1 bg-navy-100 text-navy-800 rounded-md hover:bg-navy-200 disabled:opacity-50 disabled:cursor-not-allowed">
<i class="fas fa-angle-left mr-1"></i> Previous
</button>
<button id="nextPageBtn" class="flex items-center px-3 py-1 bg-navy-100 text-navy-800 rounded-md hover:bg-navy-200 disabled:opacity-50 disabled:cursor-not-allowed">
Next <i class="fas fa-angle-right ml-1"></i>
</button>
</div>
</div>
</div>
<div class="mt-8 bg-navy-50 rounded-lg p-6 border border-navy-100">
<h3 class="text-xl font-semibold text-navy-800 mb-3 font-montserrat">Library Stats</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-white rounded-md p-4 shadow-sm">
<h4 class="text-sm text-gray-600 mb-1">Total Songs</h4>
<p class="text-2xl font-semibold text-navy-800">{{ songs|length }}</p>
</div>
<div class="bg-white rounded-md p-4 shadow-sm">
<h4 class="text-sm text-gray-600 mb-1">Unique Artists</h4>
<p class="text-2xl font-semibold text-teal-500">{{ unique_artists|default('N/A') }}</p>
</div>
<div class="bg-white rounded-md p-4 shadow-sm">
<h4 class="text-sm text-gray-600 mb-1">Genres</h4>
<p class="text-2xl font-semibold text-orange-500">{{ unique_genres|default('N/A') }}</p>
</div>
<div class="bg-white rounded-md p-4 shadow-sm">
<h4 class="text-sm text-gray-600 mb-1">Most Used Songs</h4>
<p class="text-2xl font-semibold text-navy-800">{{ most_used|default('0') }}</p>
</div>
</div>
<!-- Spotify Audio Features Section -->
<div class="mt-6">
<div class="flex justify-between items-center">
<h4 class="text-lg font-medium text-navy-800">Spotify Audio Features</h4>
<button id="updateAudioFeaturesBtn" class="bg-[#1DB954] hover:bg-[#1AA346] text-white px-4 py-2 rounded-md transition-colors inline-flex items-center">
<i class="fas fa-music mr-2"></i> Update Audio Features
</button>
</div>
<p class="text-sm text-gray-600 mt-2 mb-4">
Retrieve audio features (danceability, energy, tempo, etc.) for Spotify tracks in your library.
</p>
<div id="audioFeaturesProgress" class="hidden">
<div class="w-full bg-gray-200 rounded-full h-4 mb-2">
<div id="audioFeaturesProgressBar" class="bg-[#1DB954] h-4 rounded-full text-xs text-white text-center leading-4" style="width: 0%">0%</div>
</div>
<p id="audioFeaturesStatus" class="text-sm text-gray-600">Processing songs...</p>
</div>
</div>
</div>
{% else %}
<div class="p-8 text-center bg-white shadow-md rounded-lg">
<div class="text-gray-400 text-5xl mb-4">
<i class="fas fa-music"></i>
</div>
<h3 class="text-xl font-semibold text-navy-800 mb-2 font-montserrat">No songs found</h3>
<p class="text-gray-600 mb-4">Start by importing songs from Spotify or Deezer</p>
<div class="flex flex-wrap justify-center gap-3">
<a href="{{ url_for('core.search') }}" class="bg-[#1DB954] hover:bg-[#1AA346] text-white px-4 py-2 rounded-md transition-colors inline-flex items-center">
<i class="fab fa-spotify mr-2"></i> Import from Spotify
</a>
<a href="{{ url_for('deezer.deezer_search') }}" class="bg-[#a238ff] hover:bg-[#8a30d8] text-white px-4 py-2 rounded-md transition-colors inline-flex items-center">
<i class="fab fa-deezer mr-2"></i> Import from Deezer
</a>
</div>
</div>
{% endif %}
</div>
<!-- Song Detail Modal -->
<div id="songDetailModal" class="hidden fixed inset-0 z-50 overflow-auto bg-black bg-opacity-50 flex items-center justify-center">
<div class="bg-white rounded-lg shadow-xl w-11/12 max-w-4xl mx-auto">
<div class="flex justify-between items-center bg-navy-800 text-white px-6 py-4 rounded-t-lg">
<h3 class="text-xl font-semibold font-montserrat" id="modalTitle">Song Details</h3>
<button type="button" class="text-white hover:text-gray-300 focus:outline-none" id="closeModal">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<div id="modalContent" class="p-6">
<!-- Content will be loaded dynamically -->
<div class="flex justify-center items-center py-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-teal-500"></div>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div id="deleteConfirmModal" class="hidden fixed inset-0 z-50 overflow-auto bg-black bg-opacity-50 flex items-center justify-center">
<div class="bg-white rounded-lg shadow-xl max-w-md mx-auto">
<div class="bg-red-500 text-white px-6 py-4 rounded-t-lg">
<h3 class="text-xl font-semibold font-montserrat">Confirm Delete</h3>
</div>
<div class="p-6">
<p>Are you sure you want to delete the song "<span id="deleteSongTitle" class="font-semibold"></span>"?</p>
<p class="text-sm text-gray-500 mt-2">This action cannot be undone.</p>
</div>
<div class="bg-gray-50 px-6 py-4 rounded-b-lg flex justify-end space-x-3">
<button id="cancelDeleteBtn" class="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 rounded-md transition-colors">
Cancel
</button>
<button id="confirmDeleteBtn" class="px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-md transition-colors">
Delete Song
</button>
</div>
</div>
</div>
<!-- Toast Notification -->
<div id="toast-notification" class="fixed top-5 right-5 z-50 p-4 rounded-lg shadow-lg max-w-xs hidden">
<div class="flex items-center">
<div class="flex-shrink-0">
<i id="toast-icon" class="mr-2"></i>
</div>
<div class="ml-3">
<p id="toast-message" class="text-sm font-medium text-white"></p>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script>
$(document).ready(function() {
// Store the CSRF token for use in AJAX requests
const csrfToken = "{{ csrf_token() }}";
// Set up CSRF token for all AJAX requests
$.ajaxSetup({
beforeSend: function(xhr, settings) {
if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {
xhr.setRequestHeader("X-CSRFToken", csrfToken);
}
}
});
// Basic table variables
let allRows = $('.song-row').toArray();
let currentPage = 1;
let pageSize = 25;
let sortColumn = 'title';
let sortDirection = 'asc';
let filteredRows = [...allRows];
// Initial sorting by title
sortTable('title', 'asc');
// Toast notification system
function showToast(message, type = 'success') {
const toast = $('#toast-notification');
const icon = $('#toast-icon');
const messageEl = $('#toast-message');
// Set icon and background color based on type
if (type === 'success') {
toast.removeClass('bg-red-600').addClass('bg-teal-600');
icon.removeClass('fa-exclamation-circle').addClass('fa-check-circle');
} else {
toast.removeClass('bg-teal-600').addClass('bg-red-600');
icon.removeClass('fa-check-circle').addClass('fa-exclamation-circle');
}
// Set message
messageEl.text(message);
// Show toast
toast.removeClass('hidden').addClass('block');
// Hide toast after 3 seconds
setTimeout(function() {
toast.removeClass('block').addClass('hidden');
}, 3000);
}
// Song row click handler
$('.song-row').on('click', function(e) {
// Ignore clicks on buttons, audio controls, etc.
if ($(e.target).is('button') || $(e.target).closest('button').length ||
$(e.target).is('audio') || $(e.target).closest('audio').length) {
return;
}
const songId = $(this).data('song-id');
openSongModal(songId);
});
// Edit song button click handler
$('.edit-song-btn').on('click', function(e) {
e.stopPropagation();
const songId = $(this).closest('.song-row').data('song-id');
openSongModal(songId);
});
// Delete song button click handler
$('.delete-song-btn').on('click', function(e) {
e.stopPropagation();
const songId = $(this).data('song-id');
const songTitle = $(this).data('song-title');
// Set the song title in the confirmation modal
$('#deleteSongTitle').text(songTitle);
// Store the song ID for the confirmation button
$('#confirmDeleteBtn').data('song-id', songId);
// Show the delete confirmation modal
$('#deleteConfirmModal').removeClass('hidden');
});
// Cancel delete button click handler
$('#cancelDeleteBtn').on('click', function() {
$('#deleteConfirmModal').addClass('hidden');
});
// Confirm delete button click handler
$('#confirmDeleteBtn').on('click', function() {
const songId = $(this).data('song-id');
// Call API to delete the song
$.ajax({
url: `/api/songs/${songId}`,
method: 'DELETE',
success: function(data) {
// Hide the confirmation modal
$('#deleteConfirmModal').addClass('hidden');
// Remove the row from the table
const row = $(`.song-row[data-song-id="${songId}"]`);
// Remove from our arrays
allRows = allRows.filter(r => $(r).data('song-id') != songId);
filteredRows = filteredRows.filter(r => $(r).data('song-id') != songId);
// Animate the row's removal
row.css('background-color', '#ffcccc')
.fadeOut(400, function() {
$(this).remove();
renderTable();
});
// Show success message
showToast(data.message || 'Song deleted successfully');
},
error: function(xhr) {
// Hide the confirmation modal
$('#deleteConfirmModal').addClass('hidden');
// Parse the error message
let errorMsg = xhr.responseJSON?.error || 'Failed to delete the song';
let roundsList = '';
// If the song is used in rounds, show which ones
if (xhr.responseJSON?.rounds && xhr.responseJSON.rounds.length > 0) {
roundsList = 'Used in rounds: ' + xhr.responseJSON.rounds.join(', ');
errorMsg += `. ${roundsList}`;
}
// Show error message
showToast(errorMsg, 'error');
}
});
});
// Close delete modal on outside click
$(document).on('click', function(e) {
if ($(e.target).is('#deleteConfirmModal')) {
$('#deleteConfirmModal').addClass('hidden');
}
});
// Sort table by column
function sortTable(column, direction) {
// Update sort indicators
$('.sort-header').removeClass('sort-asc sort-desc');
$(`.sort-header[data-sort="${column}"]`).addClass(direction === 'asc' ? 'sort-asc' : 'sort-desc');
// Store current sort settings
sortColumn = column;
sortDirection = direction;
// Sort the rows
filteredRows.sort(function(a, b) {
const aValue = $(a).find(`td:nth-child(${getColumnIndex(column)})`).text().trim();
const bValue = $(b).find(`td:nth-child(${getColumnIndex(column)})`).text().trim();
// Handle numeric columns
if (['id', 'year', 'used_count'].includes(column)) {
return direction === 'asc' ?
parseInt(aValue || 0) - parseInt(bValue || 0) :
parseInt(bValue || 0) - parseInt(aValue || 0);
}
// Handle text columns
return direction === 'asc' ?
aValue.localeCompare(bValue) :
bValue.localeCompare(aValue);
});
// Re-render table
renderTable();
}
// Helper to get column index by name
function getColumnIndex(columnName) {
const columnMap = {
'id': 1,
'title': 2,
'artist': 3,
'genre': 4,
'year': 5,
'used_count': 6
};
return columnMap[columnName] || 1;
}
// Sort header click handler
$('.sort-header').on('click', function() {
const column = $(this).data('sort');
let direction = 'asc';
// Toggle direction if already sorted by this column
if (sortColumn === column) {
direction = sortDirection === 'asc' ? 'desc' : 'asc';
}
// Sort table
sortTable(column, direction);
});
// Filter table by column
function filterTable() {
// Get all filter values
const filters = {};
$('.filter-row input').each(function() {
const column = $(this).data('col');
const value = $(this).val().toLowerCase().trim();
if (value) {
filters[column] = value;
}
});
// Get global search value
const globalSearch = $('#globalSearch').val().toLowerCase().trim();
// Reset filtered rows to all rows
filteredRows = allRows.filter(function(row) {
const $row = $(row);
let matches = true;
// Apply column filters
for (const column in filters) {
const cellText = $row.find(`td:nth-child(${getColumnIndex(column)})`).text().toLowerCase();
if (!cellText.includes(filters[column])) {
matches = false;
break;
}
}
// Apply global search if there's a value
if (matches && globalSearch) {
// Check all cells except the audio and actions columns
matches = false;
for (let i = 1; i <= 6; i++) {
const cellText = $row.find(`td:nth-child(${i})`).text().toLowerCase();
if (cellText.includes(globalSearch)) {
matches = true;
break;
}
}
}
return matches;
});
// Re-sort the filtered rows
sortTable(sortColumn, sortDirection);
// Reset to first page after filtering
currentPage = 1;
renderTable();
// Update count display
$('#visibleCount').text(filteredRows.length);
}
// Set up filter input handlers
$('.filter-row input').on('input', filterTable);
$('#globalSearch').on('input', filterTable);
// Render table with current page and filters
function renderTable() {
// Hide all rows first
$('.song-row').hide();
// Calculate page bounds
const start = pageSize === 'all' ? 0 : (currentPage - 1) * pageSize;
const end = pageSize === 'all' ? filteredRows.length : start + parseInt(pageSize);
// Show rows for current page
for (let i = start; i < end && i < filteredRows.length; i++) {
$(filteredRows[i]).show();
}
// Update pagination controls
const totalPages = pageSize === 'all' ? 1 : Math.ceil(filteredRows.length / pageSize);
$('#currentPage').text(currentPage);
$('#totalPages').text(totalPages);
$('#prevPageBtn').prop('disabled', currentPage <= 1);
$('#nextPageBtn').prop('disabled', currentPage >= totalPages);
// Update total count
$('#totalCount').text(allRows.length);
$('#visibleCount').text(filteredRows.length);
}
// Page size change handler
$('#pageSizeSelect').on('change', function() {
pageSize = $(this).val();
currentPage = 1;
renderTable();
});
// Pagination buttons
$('#prevPageBtn').on('click', function() {
if (currentPage > 1) {
currentPage--;
renderTable();
}
});
$('#nextPageBtn').on('click', function() {
const totalPages = pageSize === 'all' ? 1 : Math.ceil(filteredRows.length / pageSize);
if (currentPage < totalPages) {
currentPage++;
renderTable();
}
});
// Function to open song modal
function openSongModal(songId) {
const modal = $('#songDetailModal');
const modalContent = $('#modalContent');
// Show modal with loading state
modal.removeClass('hidden');
modalContent.html(`
<div class="flex justify-center items-center py-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-teal-500"></div>
</div>
`);
// Set modal title
$('#modalTitle').text('Loading Song Details...');
// Fetch song details
$.ajax({
url: `/api/songs/${songId}`,
method: 'GET',
success: function(data) {
// Set modal title
$('#modalTitle').text(data.title);
// Build modal content
const content = `
<div class="tabs">
<div class="tab active" data-tab="details">Details</div>
<div class="tab" data-tab="audio-features">Audio Features</div>
</div>
<div class="tab-content active" id="details">
<div class="flex flex-col md:flex-row gap-8">
<!-- Left column: Cover and audio preview -->
<div class="w-full md:w-1/3">
<div class="mb-6">
<img src="${data.cover_url || '/static/favicon.ico'}"
alt="Album Cover"
class="w-full h-auto max-w-[250px] mx-auto rounded-lg shadow-md">
</div>
<div class="mb-6">
<h4 class="font-medium mb-2 text-gray-700">Preview</h4>
<audio controls class="w-full" id="songAudio">
<source src="${data.preview_url || ''}" type="audio/mpeg">
Your browser does not support the audio element.
</audio>
</div>
<div class="mb-6">
<h4 class="font-medium mb-2 text-gray-700">Usage Statistics</h4>
<div class="bg-navy-50 rounded-lg p-4">
<p class="flex justify-between">
<span>Times used in quizzes:</span>
<span class="font-semibold text-navy-800">${data.used_count || 0}</span>
</p>
<p class="flex justify-between mt-1">
<span>Last used:</span>
<span class="font-semibold text-navy-800">${data.last_used || 'Never'}</span>
</p>
</div>
</div>
<div class="mb-6 flex flex-col space-y-2">
<button id="refreshSongBtn" data-song-id="${songId}"
class="w-full bg-teal-500 hover:bg-teal-600 text-white font-medium py-2 px-4 rounded-md flex items-center justify-center">
<i class="fas fa-sync-alt mr-2"></i> Refresh Metadata
</button>
<button id="deleteSongModalBtn" data-song-id="${songId}" data-song-title="${data.title}"
class="w-full bg-red-500 hover:bg-red-600 text-white font-medium py-2 px-4 rounded-md flex items-center justify-center">
<i class="fas fa-trash-alt mr-2"></i> Delete Song
</button>
</div>
</div>
<!-- Right column: Song details form -->
<div class="w-full md:w-2/3">
<form id="editSongForm">
<input type="hidden" name="csrf_token" value="${csrfToken}">
<input type="hidden" name="song_id" value="${songId}">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group">
<label class="block text-gray-700 text-sm font-bold mb-2" for="title">Title</label>
<input type="text" id="title" name="title" value="${data.title || ''}"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline focus:border-teal-500">
</div>
<div class="form-group">
<label class="block text-gray-700 text-sm font-bold mb-2" for="artist">Artist</label>
<input type="text" id="artist" name="artist" value="${data.artist || ''}"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline focus:border-teal-500">
</div>
<div class="form-group">
<label class="block text-gray-700 text-sm font-bold mb-2" for="genre">Genre</label>
<input type="text" id="genre" name="genre" value="${data.genre || ''}"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline focus:border-teal-500">
</div>
<div class="form-group">
<label class="block text-gray-700 text-sm font-bold mb-2" for="year">Year</label>
<input type="text" id="year" name="year" value="${data.year || ''}"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline focus:border-teal-500">
</div>
<div class="form-group">
<label class="block text-gray-700 text-sm font-bold mb-2" for="isrc">ISRC</label>
<input type="text" id="isrc" name="isrc" value="${data.isrc || ''}"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline focus:border-teal-500">
</div>
<div class="form-group">
<label class="block text-gray-700 text-sm font-bold mb-2" for="popularity">Popularity</label>
<input type="number" id="popularity" name="popularity" min="0" max="100" value="${data.popularity || ''}"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline focus:border-teal-500">
</div>
<div class="form-group">
<label class="block text-gray-700 text-sm font-bold mb-2" for="spotify_id">Spotify ID</label>
<input type="text" id="spotify_id" name="spotify_id" value="${data.spotify_id || ''}"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline focus:border-teal-500">
</div>
<div class="form-group">
<label class="block text-gray-700 text-sm font-bold mb-2" for="deezer_id">Deezer ID</label>
<input type="text" id="deezer_id" name="deezer_id" value="${data.deezer_id || ''}"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline focus:border-teal-500">
</div>
<div class="form-group col-span-2">
<label class="block text-gray-700 text-sm font-bold mb-2" for="preview_url">Preview URL</label>
<input type="text" id="preview_url" name="preview_url" value="${data.preview_url || ''}"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline focus:border-teal-500">
</div>
<div class="form-group col-span-2">
<label class="block text-gray-700 text-sm font-bold mb-2" for="cover_url">Cover URL</label>
<input type="text" id="cover_url" name="cover_url" value="${data.cover_url || ''}"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline focus:border-teal-500">
</div>
<div class="form-group col-span-2">
<label class="block text-gray-700 text-sm font-bold mb-2" for="metadata_sources">Metadata Sources</label>
<input type="text" id="metadata_sources" name="metadata_sources" value="${data.metadata_sources || ''}" readonly
class="bg-gray-100 shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight">
</div>
<!-- Tags section -->
<div class="form-group col-span-2">
<label class="block text-gray-700 text-sm font-bold mb-2">Tags</label>
<div id="tagsContainer" class="bg-gray-50 p-3 rounded-md min-h-[100px] border border-gray-200">
${renderTags(data.tags || [])}
</div>
<div class="flex mt-2">
<input type="text" id="newTagInput" placeholder="Add a new tag..."
class="flex-grow shadow appearance-none border rounded-l-md w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline focus:border-teal-500">
<button type="button" id="addTagBtn" class="bg-teal-500 hover:bg-teal-600 text-white px-4 py-2 rounded-r-md">
<i class="fas fa-plus"></i>
</button>
</div>
<input type="hidden" id="tags" name="tags" value="${JSON.stringify(data.tags || [])}">
</div>
</div>
<div class="mt-6">
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white font-medium py-2 px-4 rounded-md">
<i class="fas fa-save mr-2"></i> Save Changes
</button>
</div>
</form>
</div>
</div>
</div>
<div class="tab-content" id="audio-features">
${renderAudioFeatures(data)}
</div>
`;
// Set modal content
modalContent.html(content);
// Initialize audio player
if ($('#songAudio')[0]) {
$('#songAudio')[0].load();
}
// Tags functionality
setUpTagsSystem(data.tags || []);
// Modal refresh metadata button handler
$('#refreshSongBtn').on('click', function() {
const btnSongId = $(this).data('song-id');
const button = $(this);
// Update button state
const originalHtml = button.html();
button.html('<i class="fas fa-spinner fa-spin"></i> Refreshing...').prop('disabled', true);
// Call API to refresh metadata
$.ajax({
url: `/api/songs/${btnSongId}/refresh-metadata`,
method: 'POST',
success: function(data) {
// Update form fields
$('#title').val(data.title || '');
$('#artist').val(data.artist || '');
$('#genre').val(data.genre || '');
$('#year').val(data.year || '');
$('#isrc').val(data.isrc || '');
$('#popularity').val(data.popularity || '');
$('#spotify_id').val(data.spotify_id || '');
$('#deezer_id').val(data.deezer_id || '');
$('#preview_url').val(data.preview_url || '');
$('#cover_url').val(data.cover_url || '');
$('#metadata_sources').val(data.metadata_sources || '');
// Update tags
if (data.tags) {
setUpTagsSystem(data.tags);
}
// Update audio player
if (data.preview_url) {
$('#songAudio source').attr('src', data.preview_url);
$('#songAudio')[0].load();
}
// Update image
if (data.cover_url) {
$('img').attr('src', data.cover_url);
}
// Update title
$('#modalTitle').text(data.title);
// Update table row in the main table
updateRowData(btnSongId, data);
// Reset button
button.html(originalHtml).prop('disabled', false);
// Show success message
showToast('Metadata refreshed successfully!');
},
error: function(xhr) {
// Reset button
button.html(originalHtml).prop('disabled', false);
// Show error message
showToast(xhr.responseJSON?.error || 'Failed to refresh metadata', 'error');
}
});
});
// Delete song button in modal handler
$('#deleteSongModalBtn').on('click', function() {
const songId = $(this).data('song-id');
const songTitle = $(this).data('song-title');
// Close the detail modal first
closeModal();
// Set the song title in the confirmation modal
$('#deleteSongTitle').text(songTitle);
// Store the song ID for the confirmation button
$('#confirmDeleteBtn').data('song-id', songId);
// Show the delete confirmation modal
$('#deleteConfirmModal').removeClass('hidden');
});
// Form submission handler
$('#editSongForm').on('submit', function(e) {
e.preventDefault();
const form = $(this);
const submitBtn = form.find('button[type="submit"]');
const formData = {
title: $('#title').val(),
artist: $('#artist').val(),
genre: $('#genre').val(),
year: $('#year').val(),
isrc: $('#isrc').val(),
popularity: $('#popularity').val(),
spotify_id: $('#spotify_id').val(),
deezer_id: $('#deezer_id').val(),
preview_url: $('#preview_url').val(),
cover_url: $('#cover_url').val(),
tags: JSON.parse($('#tags').val())
};
// Update button state
const originalHtml = submitBtn.html();
submitBtn.html('<i class="fas fa-spinner fa-spin"></i> Saving...').prop('disabled', true);
// Call API to update song
$.ajax({
url: `/api/songs/${songId}`,
method: 'PUT',
contentType: 'application/json',
data: JSON.stringify(formData),
success: function(data) {
// Update modal title
$('#modalTitle').text(data.title);
// Update audio player if URL changed
if ($('#preview_url').val() !== data.preview_url && data.preview_url) {
$('#songAudio source').attr('src', data.preview_url);
$('#songAudio')[0].load();
}
// Update image if URL changed
if ($('#cover_url').val() !== data.cover_url && data.cover_url) {
$('img').attr('src', data.cover_url);
}
// Update table row
updateRowData(songId, data);
// Resort table if needed
if (['title', 'artist', 'genre', 'year'].includes(sortColumn)) {
sortTable(sortColumn, sortDirection);
}
// Reset button
submitBtn.html(originalHtml).prop('disabled', false);
// Show success message
showToast('Song updated successfully!');
},
error: function(xhr) {
// Reset button
submitBtn.html(originalHtml).prop('disabled', false);
// Show error message
showToast(xhr.responseJSON?.error || 'Failed to update song', 'error');
}
});
});
// Tab switching functionality
$('.tab').on('click', function() {
const tabName = $(this).data('tab');
// Update active tab
$('.tab').removeClass('active');
$(this).addClass('active');
// Update active content
$('.tab-content').removeClass('active');
$(`#${tabName}`).addClass('active');
});
// Handler for "Get Audio Features" button
$(document).on('click', '#getSongFeaturesBtn', function() {
const songId = $(this).data('song-id');
const button = $(this);
// Update button state
const originalHtml = button.html();
button.html('<i class="fas fa-spinner fa-spin"></i> Fetching...').prop('disabled', true);
// Call API to fetch and update audio features
$.ajax({
url: `/api/songs/update-audio-features`,
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({
song_ids: [songId],
process_specific: true
}),
success: function(data) {
// Refresh the song details to show new audio features
$.ajax({
url: `/api/songs/${songId}`,
method: 'GET',
success: function(songData) {
// Replace the tab content with fresh data
$('#audio-features').html(renderAudioFeatures(songData));
// Select the audio features tab to show the new data
$('.tab[data-tab="audio-features"]').click();
// Show success toast
showToast('Audio features updated successfully!');
},
error: function() {
// Reset button
button.html(originalHtml).prop('disabled', false);
showToast('Error fetching updated song data', 'error');
}
});
},
error: function(xhr) {
// Reset button
button.html(originalHtml).prop('disabled', false);
// Show error toast
showToast(xhr.responseJSON?.message || 'Failed to update audio features', 'error');
}
});
});
},
error: function(xhr) {
// Show error in modal
modalContent.html(`
<div class="text-center py-10">
<div class="bg-red-100 p-6 rounded-lg">
<i class="fas fa-exclamation-circle text-red-600 text-4xl mb-4"></i>
<p class="text-red-600 font-medium mb-4">Error loading song details</p>
<p class="text-gray-700 mb-4">${xhr.responseJSON?.error || xhr.statusText || 'Unknown error'}</p>
<button id="closeErrorBtn" class="bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded-md">
Close
</button>
</div>
</div>
`);
$('#closeErrorBtn').on('click', function() {
closeModal();
});
}
});
}
// Function to render tags
function renderTags(tags) {
if (!tags || tags.length === 0) {
return '<p class="text-gray-400 italic">No tags</p>';
}
let html = '';
tags.forEach(tag => {
html += `<span class="tag" data-id="${tag.id || ''}" data-name="${tag.name}">
${tag.name}
<span class="tag-remove" title="Remove tag"><i class="fas fa-times"></i></span>
</span>`;
});
return html;
}
// Set up tags management system
function setUpTagsSystem(tags) {
// Initialize tags
$('#tagsContainer').html(renderTags(tags));
$('#tags').val(JSON.stringify(tags));
// Add tag button handler
$('#addTagBtn').off('click').on('click', function() {
const tagInput = $('#newTagInput');
const tagName = tagInput.val().trim();
if (!tagName) return;
// Get current tags
let currentTags = JSON.parse($('#tags').val());
// Check if tag already exists
if (!currentTags.some(tag => tag.name.toLowerCase() === tagName.toLowerCase())) {
// Add new tag
currentTags.push({ id: null, name: tagName });
// Update hidden input and display
$('#tags').val(JSON.stringify(currentTags));
$('#tagsContainer').html(renderTags(currentTags));
// Set up remove handlers for the new tags
setUpTagRemoveHandlers();
}
// Clear input
tagInput.val('');
});
// Add tag on enter key
$('#newTagInput').off('keypress').on('keypress', function(e) {
if (e.which === 13) {
e.preventDefault();
$('#addTagBtn').click();
}
});
// Set up remove tag handlers
setUpTagRemoveHandlers();
}
// Set up handlers for removing tags
function setUpTagRemoveHandlers() {
$('.tag-remove').off('click').on('click', function(e) {
e.stopPropagation();
const tag = $(this).closest('.tag');
const tagName = tag.data('name');
const tagId = tag.data('id');
// Get current tags
let currentTags = JSON.parse($('#tags').val());
// Remove tag
currentTags = currentTags.filter(t => !(t.name === tagName && (t.id === tagId || (!t.id && !tagId))));
// Update hidden input
$('#tags').val(JSON.stringify(currentTags));
// Remove from display
tag.remove();
// If no tags left, show placeholder
if (currentTags.length === 0) {
$('#tagsContainer').html('<p class="text-gray-400 italic">No tags</p>');
}
});
}
// Function to update row data in the table
function updateRowData(songId, data) {
const row = $(`tr[data-song-id="${songId}"]`);
if (row.length) {
// Update text cells
row.find('td:nth-child(2)').text(data.title || '');
row.find('td:nth-child(3)').text(data.artist || '');
// Update genre with appropriate styling
const genreCell = row.find('td:nth-child(4)');
if (data.genre) {
genreCell.html(`<span class="bg-orange-100 text-orange-800 text-xs font-medium px-2 py-1 rounded">${data.genre}</span>`);
} else {
genreCell.html(`<span class="text-gray-400">Unknown</span>`);
}
row.find('td:nth-child(5)').text(data.year || '');
// Update audio preview
if (data.preview_url) {
const audioCell = row.find('td:nth-child(7)');
if (audioCell.find('audio').length) {
audioCell.find('audio').attr('src', data.preview_url);
audioCell.find('audio')[0].load();
} else {
audioCell.html(`<audio controls class="preview-audio w-full max-w-[180px]" src="${data.preview_url}" preload="none">Your browser does not support audio.</audio>`);
}
}
// Update delete button song title attribute
row.find('.delete-song-btn').data('song-title', data.title);
// Update the row in our arrays
for (let i = 0; i < allRows.length; i++) {
if ($(allRows[i]).data('song-id') == songId) {
allRows[i] = row[0];
break;
}
}
for (let i = 0; i < filteredRows.length; i++) {
if ($(filteredRows[i]).data('song-id') == songId) {
filteredRows[i] = row[0];
break;
}
}
}
}
// Close modal function
function closeModal() {
$('#songDetailModal').addClass('hidden');
}
// Close button handler
$('#closeModal').on('click', closeModal);
// Close on outside click
$(document).on('click', function(e) {
if ($(e.target).is('#songDetailModal')) {
closeModal();
}
});
// Close on escape key
$(document).on('keydown', function(e) {
if (e.key === 'Escape' && !$('#songDetailModal').hasClass('hidden')) {
closeModal();
}
});
// Function to render audio features in a visual way
function renderAudioFeatures(data) {
// Check if song has any audio features
if (!data.acousticness && !data.danceability && !data.energy && !data.tempo) {
return `
<div class="no-features-message">
<i class="fas fa-music text-4xl text-gray-300 mb-4"></i>
<h3 class="text-lg font-medium mb-2">No Audio Features Available</h3>
<p class="mb-4">This song doesn't have Spotify audio features data yet.</p>
<button id="getSongFeaturesBtn" data-song-id="${data.id}"
class="bg-[#1DB954] hover:bg-[#1AA346] text-white px-4 py-2 rounded-md transition-colors">
<i class="fas fa-sync-alt mr-2"></i> Get Audio Features
</button>
</div>
`;
}
// Format features with appropriate scale and labels
const features = [
{ name: 'Danceability', value: data.danceability, description: 'How suitable for dancing (0.0 to 1.0)', scale: 1.0 },
{ name: 'Energy', value: data.energy, description: 'Intensity and activity (0.0 to 1.0)', scale: 1.0 },
{ name: 'Acousticness', value: data.acousticness, description: 'Confidence track is acoustic (0.0 to 1.0)', scale: 1.0 },
{ name: 'Instrumentalness', value: data.instrumentalness, description: 'Predicts if track has no vocals (0.0 to 1.0)', scale: 1.0 },
{ name: 'Liveness', value: data.liveness, description: 'Presence of audience (0.0 to 1.0)', scale: 1.0 },
{ name: 'Speechiness', value: data.speechiness, description: 'Presence of spoken words (0.0 to 1.0)', scale: 1.0 },
{ name: 'Valence', value: data.valence, description: 'Musical positiveness (0.0 to 1.0)', scale: 1.0 }
];
// Format features with different scales
const otherFeatures = [
{ name: 'Loudness', value: data.loudness, description: 'Overall loudness in dB (-60 to 0 typically)', format: (val) => val ? `${val.toFixed(1)} dB` : 'N/A' },
{ name: 'Tempo', value: data.tempo, description: 'Estimated tempo in BPM', format: (val) => val ? `${val.toFixed(1)} BPM` : 'N/A' },
{ name: 'Key', value: data.key, description: 'Key of the track (standard Pitch Class notation)',
format: (val) => {
if (val === null || val === undefined) return 'N/A';
const keys = ['C', 'C♯/D♭', 'D', 'D♯/E♭', 'E', 'F', 'F♯/G♭', 'G', 'G♯/A♭', 'A', 'A♯/B♭', 'B'];
return val >= 0 && val < 12 ? keys[val] : 'Unknown';
}
},
{ name: 'Mode', value: data.mode, description: 'Modality - major (1) or minor (0)',
format: (val) => {
if (val === null || val === undefined) return 'N/A';
return val === 1 ? 'Major' : val === 0 ? 'Minor' : 'Unknown';
}
},
{ name: 'Time Signature', value: data.time_signature, description: 'Estimated time signature',
format: (val) => {
if (val === null || val === undefined) return 'N/A';
return `${val}/4`;
}
},
{ name: 'Duration', value: data.duration_ms, description: 'Duration of track',
format: (val) => {
if (!val) return 'N/A';
const minutes = Math.floor(val / 60000);
const seconds = ((val % 60000) / 1000).toFixed(0);
return `${minutes}:${seconds.padStart(2, '0')}`;
}
}
];
// Render the audio features visualization
let html = '<div class="p-4">';
// Add section for scaled features (0.0 to 1.0) with visualization bars
html += `<div class="mb-6">
<h3 class="text-lg font-medium mb-4">Audio Characteristics</h3>
<div class="bg-gray-50 rounded-lg p-4">`;
// Add feature bars
features.forEach(feature => {
if (feature.value !== null && feature.value !== undefined) {
const percentage = Math.round((feature.value / feature.scale) * 100);
html += `
<div class="audio-feature">
<div class="audio-feature-label" title="${feature.description}">${feature.name}</div>
<div class="audio-feature-value">${feature.value.toFixed(2)}</div>
<div class="audio-feature-bar">
<div class="audio-feature-bar-fill" style="width: ${percentage}%"></div>
</div>
</div>`;
}
});
html += `</div></div>`;
// Add section for other features (different scales)
html += `<div class="mb-6">
<h3 class="text-lg font-medium mb-4">Musical Properties</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">`;
// Add other feature cards
otherFeatures.forEach(feature => {
const value = feature.format ? feature.format(feature.value) : (feature.value || 'N/A');
html += `
<div class="bg-gray-50 rounded-lg p-4">
<div class="font-medium text-navy-800">${feature.name}</div>
<div class="text-2xl font-bold mt-2">${value}</div>
<div class="text-sm text-gray-500 mt-1">${feature.description}</div>
</div>`;
});
html += `</div></div>`;
html += '</div>';
return html;
}
// Auto-pause other audio when one starts playing
$(document).on('play', 'audio', function() {
$('audio').not(this).each(function() {
if (!this.paused) {
this.pause();
}
});
});
// Update Audio Features button handler
$('#updateAudioFeaturesBtn').on('click', function() {
const button = $(this);
const progressContainer = $('#audioFeaturesProgress');
const progressBar = $('#audioFeaturesProgressBar');
const statusText = $('#audioFeaturesStatus');
// Update button state
const originalHtml = button.html();
button.html('<i class="fas fa-spinner fa-spin mr-2"></i> Processing...').prop('disabled', true);
// Show progress container
progressContainer.removeClass('hidden');
progressBar.css('width', '0%').text('0%');
statusText.text('Starting audio features update...');
// Call API to update audio features
$.ajax({
url: '/api/songs/update-audio-features',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({
batch_size: 50,
process_all: false // Only process songs that don't have audio features yet
}),
success: function(data) {
// Calculate percentage
const processed = data.processed;
const total = data.total;
const percentage = total > 0 ? Math.round((processed / total) * 100) : 100;
// Update progress bar
progressBar.css('width', percentage + '%').text(percentage + '%');
// Update status text
if (data.processed === 0) {
statusText.text('No songs needed audio features.');
} else {
statusText.text(`Updated ${processed} of ${total} songs. ${data.errors} errors.`);
}
// Reset button
button.html(originalHtml).prop('disabled', false);
// Show success message
showToast(data.message || 'Audio features updated successfully!');
// Hide progress container after 5 seconds
setTimeout(function() {
progressContainer.addClass('hidden');
}, 5000);
},
error: function(xhr) {
// Reset button
button.html(originalHtml).prop('disabled', false);
// Update status text
statusText.text('Error updating audio features.');
// Show error message
showToast(xhr.responseJSON?.message || 'Failed to update audio features', 'error');
}
});
});
// Initial render
renderTable();
});
</script>
{% endblock %}