Initial clean commit
This commit is contained in:
@@ -0,0 +1,528 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Backup Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-6xl mx-auto my-8 p-6 bg-white rounded-lg shadow-md">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-navy-800">System Backup Manager</h2>
|
||||
<div class="text-sm text-navy-600">{{ version_info.version }} - {{ version_info.release_name }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Card -->
|
||||
<div class="mb-8 p-4 bg-gray-50 rounded-lg">
|
||||
<div class="flex flex-wrap md:flex-nowrap gap-4">
|
||||
<div class="flex-1 border rounded-lg p-4 bg-white">
|
||||
<h3 class="text-lg font-semibold mb-2 text-navy-700">Backup Status</h3>
|
||||
<div class="space-y-1">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Total backups:</span>
|
||||
<span class="font-medium">{{ backup_count }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Latest backup:</span>
|
||||
<span class="font-medium">{{ latest_backup|default('None yet') }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Scheduled backups:</span>
|
||||
<span class="font-medium">{{ 'Enabled' if schedule_enabled else 'Disabled' }}</span>
|
||||
</div>
|
||||
{% if schedule_enabled %}
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Next backup:</span>
|
||||
<span class="font-medium">{{ next_backup|default('Not scheduled') }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Storage location:</span>
|
||||
<span class="font-medium text-xs md:text-sm font-mono">{{ backup_location }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 border rounded-lg p-4 bg-white">
|
||||
<h3 class="text-lg font-semibold mb-2 text-navy-700">Quick Actions</h3>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<form method="POST" action="{{ url_for('users.create_backup') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="w-full bg-teal-500 hover:bg-teal-600 text-white py-2 px-4 rounded flex items-center justify-center">
|
||||
<i class="fas fa-save mr-2"></i> Create New Backup
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<a href="#backup-list" class="w-full bg-navy-600 hover:bg-navy-700 text-white py-2 px-4 rounded flex items-center justify-center">
|
||||
<i class="fas fa-list mr-2"></i> Manage Backups
|
||||
</a>
|
||||
|
||||
<button type="button" id="schedule-btn" onclick="toggleSchedulerForm()" class="w-full bg-gray-500 hover:bg-gray-600 text-white py-2 px-4 rounded flex items-center justify-center">
|
||||
<i class="fas fa-clock mr-2"></i> Schedule Backups
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup Schedule Configuration -->
|
||||
<div class="mb-8 p-4 border border-navy-200 rounded-lg {% if not show_schedule_form %}hidden{% endif %}" id="scheduler-form">
|
||||
<h3 class="text-xl font-semibold mb-4 text-navy-700">Backup Schedule Configuration</h3>
|
||||
|
||||
<form action="{{ url_for('users.schedule_backup') }}" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block font-medium mb-1" for="schedule-time">Time of day (24-hour format)</label>
|
||||
<input type="time" class="border rounded px-3 py-2 w-full" id="schedule-time" name="schedule_time" value="{{ schedule_time or '03:00' }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block font-medium mb-1" for="frequency">Frequency</label>
|
||||
<select class="border rounded px-3 py-2 w-full" id="frequency" name="frequency">
|
||||
<option value="hourly" {% if schedule_frequency == 'hourly' %}selected{% endif %}>Hourly</option>
|
||||
<option value="daily" {% if schedule_frequency == 'daily' or not schedule_frequency %}selected{% endif %}>Daily</option>
|
||||
<option value="weekly" {% if schedule_frequency == 'weekly' %}selected{% endif %}>Weekly</option>
|
||||
<option value="monthly" {% if schedule_frequency == 'monthly' %}selected{% endif %}>Monthly</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block font-medium mb-1" for="retention-days">Retention Policy (days)</label>
|
||||
<input type="number" class="border rounded px-3 py-2 w-full" id="retention-days" name="retention_days" value="{{ retention_days }}" min="0" max="365" onchange="syncRetentionDays(this.value)">
|
||||
<p class="text-sm text-gray-500 mt-1">Enter 0 to keep all backups indefinitely</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mt-4">
|
||||
<button type="submit" class="bg-teal-500 text-white px-4 py-2 rounded hover:bg-teal-600">
|
||||
<i class="fas fa-save mr-2"></i> Save Schedule
|
||||
</button>
|
||||
|
||||
<button type="button" onclick="toggleSchedulerForm()" class="bg-gray-300 text-gray-700 px-4 py-2 rounded hover:bg-gray-400">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Configuration Suggestion Button (replacing Docker Compose Labels button) -->
|
||||
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||
<button type="button" onclick="openConfigModal()" class="mt-2 bg-purple-500 text-white px-4 py-2 rounded hover:bg-purple-600">
|
||||
<i class="fas fa-file-code mr-2"></i> View Configuration Suggestion
|
||||
</button>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
View suggested configuration for scheduled backups
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Backup Form (Hidden by default) -->
|
||||
<div id="create-backup-form" class="mb-8 p-4 border border-navy-200 rounded-lg {% if not show_create_form %}hidden{% endif %}">
|
||||
<h3 class="text-lg font-semibold mb-4 text-navy-700">Create Custom Backup</h3>
|
||||
|
||||
<form method="POST" action="{{ url_for('users.create_backup') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block font-medium mb-1" for="backup_name">Backup Name (Optional)</label>
|
||||
<input type="text" id="backup_name" name="backup_name"
|
||||
class="border rounded px-3 py-2 w-full"
|
||||
placeholder="e.g., pre_upgrade_backup">
|
||||
<p class="text-sm text-gray-500 mt-1">Leave blank for automatic timestamp-based name</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mb-4">
|
||||
<input type="checkbox" id="include_mp3s" name="include_mp3s" value="true"
|
||||
checked
|
||||
class="w-4 h-4 text-teal-600 mr-2">
|
||||
<label for="include_mp3s" class="font-medium">Include MP3 Files</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mb-4">
|
||||
<input type="checkbox" id="include_config" name="include_config" value="true"
|
||||
checked
|
||||
class="w-4 h-4 text-teal-600 mr-2">
|
||||
<label for="include_config" class="font-medium">Include Configuration Files</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="submit" class="bg-teal-500 text-white px-4 py-2 rounded hover:bg-teal-600">
|
||||
<i class="fas fa-save mr-2"></i> Create Backup
|
||||
</button>
|
||||
<button type="button" onclick="toggleCreateForm()" class="bg-gray-300 text-gray-700 px-4 py-2 rounded hover:bg-gray-400">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Configuration Suggestion Modal (replacing Docker Compose Labels Modal) -->
|
||||
<div id="config-modal" class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center hidden overflow-y-auto p-4">
|
||||
<div class="bg-white rounded-lg w-full max-w-3xl max-h-[90vh] overflow-y-auto p-6">
|
||||
<div class="flex justify-between items-center mb-4 sticky top-0 bg-white pb-2">
|
||||
<h3 class="text-xl font-semibold text-navy-700">Backup Configuration Suggestion</h3>
|
||||
<button onclick="closeConfigModal()" class="text-gray-500 hover:text-gray-700">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<p class="text-gray-600 mb-2">
|
||||
Here's a suggested configuration for scheduled backups:
|
||||
</p>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
{% if config_suggestion %}
|
||||
<div class="mb-4">
|
||||
<h4 class="font-medium text-navy-700 mb-2">Docker Compose Labels</h4>
|
||||
<pre class="text-sm font-mono overflow-x-auto whitespace-pre-wrap">{{ config_suggestion.docker_compose_suggestion }}</pre>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="font-medium text-navy-700 mb-2">Ofelia Configuration (Alternative)</h4>
|
||||
<pre class="text-sm font-mono overflow-x-auto whitespace-pre-wrap">{{ config_suggestion.ofelia_ini_suggestion }}</pre>
|
||||
</div>
|
||||
|
||||
<div class="bg-blue-50 border-l-4 border-blue-400 p-4 mb-4">
|
||||
<h4 class="font-medium text-navy-700 mb-2">Instructions</h4>
|
||||
<pre class="text-sm font-mono overflow-x-auto whitespace-pre-wrap">{{ config_suggestion.instructions }}</pre>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-500">Configuration suggestion not available.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-blue-50 border-l-4 border-blue-400 p-4 mb-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-info-circle text-blue-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-blue-700">
|
||||
This is a configuration suggestion only. You'll need to implement it manually based on your system setup.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button onclick="copyConfigToClipboard()" class="bg-teal-500 text-white px-4 py-2 rounded hover:bg-teal-600 mr-2">
|
||||
<i class="fas fa-copy mr-2"></i> Copy to Clipboard
|
||||
</button>
|
||||
<button onclick="closeConfigModal()" class="bg-gray-300 text-gray-700 px-4 py-2 rounded hover:bg-gray-400">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification Modal -->
|
||||
<div id="notification-modal" class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center hidden">
|
||||
<div class="bg-white rounded-lg max-w-md w-full p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-xl font-semibold" id="notification-title">Notification</h3>
|
||||
<button onclick="closeNotificationModal()" class="text-gray-500 hover:text-gray-700">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<p id="notification-message" class="text-gray-600"></p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button onclick="closeNotificationModal()" class="bg-teal-500 text-white px-4 py-2 rounded hover:bg-teal-600">
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup List -->
|
||||
<div id="backup-list" class="mb-8">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-xl font-semibold text-navy-700">Existing Backups</h3>
|
||||
|
||||
<!-- Upload Backup Button -->
|
||||
<button type="button" onclick="toggleUploadForm()" class="bg-navy-600 text-white px-4 py-2 rounded hover:bg-navy-700">
|
||||
<i class="fas fa-upload mr-2"></i> Upload Backup
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Upload Backup Form (Hidden by default) -->
|
||||
<div id="upload-backup-form" class="mb-4 p-4 border border-navy-200 rounded-lg hidden">
|
||||
<h4 class="text-lg font-semibold mb-3 text-navy-700">Upload Backup File</h4>
|
||||
|
||||
<form method="POST" action="{{ url_for('users.upload_backup') }}" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block font-medium mb-1" for="backup_file">Select Backup ZIP File</label>
|
||||
<input type="file" id="backup_file" name="backup_file"
|
||||
accept=".zip"
|
||||
class="border rounded px-3 py-2 w-full">
|
||||
<p class="text-sm text-gray-500 mt-1">Only .zip backup files are supported</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="submit" class="bg-teal-500 text-white px-4 py-2 rounded hover:bg-teal-600">
|
||||
<i class="fas fa-upload mr-2"></i> Upload Backup
|
||||
</button>
|
||||
<button type="button" onclick="toggleUploadForm()" class="bg-gray-300 text-gray-700 px-4 py-2 rounded hover:bg-gray-400">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Retention Policy (add after Upload Backup Form) -->
|
||||
<div id="retention-policy-section" class="mb-8">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-xl font-semibold text-navy-700">Backup Retention Policy</h3>
|
||||
|
||||
<!-- Toggle Retention Policy Form Button -->
|
||||
<button type="button" onclick="toggleRetentionForm()" class="bg-navy-600 text-white px-4 py-2 rounded hover:bg-navy-700">
|
||||
<i class="fas fa-calendar-alt mr-2"></i> Configure Retention
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Current Retention Policy Status -->
|
||||
<div class="mb-4 p-4 bg-gray-50 rounded-lg">
|
||||
<div class="flex items-center mb-2">
|
||||
<div class="h-4 w-4 rounded-full {% if retention_days > 0 %}bg-green-500{% else %}bg-gray-400{% endif %} mr-2"></div>
|
||||
<h4 class="font-medium">Retention Policy Status</h4>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600">
|
||||
{% if retention_days > 0 %}
|
||||
Currently keeping backups for {{ retention_days }} days. Older backups will be automatically deleted.
|
||||
{% else %}
|
||||
No retention policy is currently applied. All backups will be kept indefinitely.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Retention Policy Form (Hidden by default) -->
|
||||
<div id="retention-policy-form" class="mb-4 p-4 border border-navy-200 rounded-lg hidden">
|
||||
<h4 class="text-lg font-semibold mb-3 text-navy-700">Configure Backup Retention</h4>
|
||||
|
||||
<form method="POST" action="{{ url_for('users.apply_retention_policy') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block font-medium mb-1" for="retention_days_policy">Keep Backups For</label>
|
||||
<div class="flex items-center">
|
||||
<input type="number" id="retention_days_policy" name="retention_days"
|
||||
min="0" max="365" value="{{ retention_days }}"
|
||||
class="border rounded px-3 py-2 w-24 mr-2" onchange="syncRetentionDays(this.value)">
|
||||
<span>days</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-1">Enter 0 to keep all backups indefinitely</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="submit" class="bg-teal-500 text-white px-4 py-2 rounded hover:bg-teal-600">
|
||||
<i class="fas fa-save mr-2"></i> Save Policy
|
||||
</button>
|
||||
<button type="submit" name="apply_now" value="true" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
|
||||
onclick="return confirm('Are you sure you want to apply the retention policy now? This will delete backups older than the specified period.')">
|
||||
<i class="fas fa-trash-alt mr-2"></i> Apply Now
|
||||
</button>
|
||||
<button type="button" onclick="toggleRetentionForm()" class="bg-gray-300 text-gray-700 px-4 py-2 rounded hover:bg-gray-400">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if backups %}
|
||||
<div class="overflow-x-auto border rounded-lg">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Backup Name</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Version</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Size</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{% for backup in backups %}
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="font-medium text-gray-900">{{ backup.backup_name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ backup.file_name }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
{% if backup.timestamp %}
|
||||
{{ backup.timestamp|timestamp_to_datetime|format_datetime('%Y-%m-%d %H:%M') }}
|
||||
{% else %}
|
||||
Unknown
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
v{{ backup.version }}
|
||||
{% if backup.release_name %}
|
||||
<div class="text-xs text-gray-500">{{ backup.release_name }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
{{ (backup.file_size / 1024 / 1024)|round(2) }} MB
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ url_for('users.download_backup', filename=backup.file_name) }}" class="text-teal-600 hover:text-teal-900" title="Download">
|
||||
<i class="fas fa-download"></i>
|
||||
</a>
|
||||
|
||||
<form method="POST" action="{{ url_for('users.verify_backup', filename=backup.file_name) }}" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="text-blue-600 hover:text-blue-900" title="Verify">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="{{ url_for('users.restore_backup', filename=backup.file_name) }}" class="inline"
|
||||
onsubmit="return confirm('Are you sure you want to restore this backup? This will overwrite current data.')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="text-orange-600 hover:text-orange-900" title="Restore">
|
||||
<i class="fas fa-undo"></i>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="{{ url_for('users.delete_backup', filename=backup.file_name) }}" class="inline"
|
||||
onsubmit="return confirm('Are you sure you want to delete this backup?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="text-red-600 hover:text-red-900" title="Delete">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-gray-50 p-4 rounded-lg text-center">
|
||||
<p class="text-gray-500">No backups found.</p>
|
||||
<button onclick="toggleCreateForm()" class="mt-2 text-teal-600 hover:text-teal-800">
|
||||
<i class="fas fa-plus-circle mr-1"></i> Create your first backup
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- System Health Status -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-xl font-semibold mb-4 text-navy-700">System Health</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="border rounded-lg p-4 bg-white">
|
||||
<div class="flex items-center mb-2">
|
||||
<div class="h-4 w-4 rounded-full bg-green-500 mr-2"></div>
|
||||
<h4 class="font-medium">Database</h4>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600">Database is operational and accessible.</p>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-lg p-4 bg-white">
|
||||
<div class="flex items-center mb-2">
|
||||
<div class="h-4 w-4 rounded-full bg-green-500 mr-2"></div>
|
||||
<h4 class="font-medium">File Storage</h4>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600">File storage is available and writable.</p>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-lg p-4 bg-white">
|
||||
<div class="flex items-center mb-2">
|
||||
<div class="h-4 w-4 rounded-full bg-green-500 mr-2"></div>
|
||||
<h4 class="font-medium">Configuration</h4>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600">System configuration is valid.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Back to Admin -->
|
||||
<div class="flex justify-between">
|
||||
<a href="{{ url_for('users.system_settings') }}" class="bg-navy-600 text-white px-6 py-2 rounded hover:bg-navy-700">
|
||||
<i class="fas fa-cog mr-2"></i> Back to System Settings
|
||||
</a>
|
||||
|
||||
<a href="{{ url_for('core.index') }}" class="bg-gray-300 text-gray-700 px-6 py-2 rounded hover:bg-gray-400">
|
||||
Back to Home
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function toggleCreateForm() {
|
||||
const form = document.getElementById('create-backup-form');
|
||||
form.classList.toggle('hidden');
|
||||
}
|
||||
|
||||
function toggleUploadForm() {
|
||||
const form = document.getElementById('upload-backup-form');
|
||||
form.classList.toggle('hidden');
|
||||
}
|
||||
|
||||
function toggleRetentionForm() {
|
||||
const form = document.getElementById('retention-policy-form');
|
||||
form.classList.toggle('hidden');
|
||||
}
|
||||
|
||||
function toggleSchedulerForm() {
|
||||
const form = document.getElementById('scheduler-form');
|
||||
form.classList.toggle('hidden');
|
||||
}
|
||||
|
||||
function openConfigModal() {
|
||||
const modal = document.getElementById('config-modal');
|
||||
modal.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeConfigModal() {
|
||||
const modal = document.getElementById('config-modal');
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
|
||||
function copyConfigToClipboard() {
|
||||
const content = document.querySelector('#config-modal pre').textContent;
|
||||
navigator.clipboard.writeText(content).then(() => {
|
||||
showNotificationModal('Success', 'Configuration suggestion copied to clipboard!');
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy: ', err);
|
||||
showNotificationModal('Error', 'Failed to copy to clipboard. Please select and copy manually.');
|
||||
});
|
||||
}
|
||||
|
||||
function closeNotificationModal() {
|
||||
const modal = document.getElementById('notification-modal');
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
|
||||
function showNotificationModal(title, message) {
|
||||
const modal = document.getElementById('notification-modal');
|
||||
document.getElementById('notification-title').textContent = title;
|
||||
document.getElementById('notification-message').textContent = message;
|
||||
modal.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Function to sync retention days between both forms
|
||||
function syncRetentionDays(value) {
|
||||
// Update both retention days inputs with the same value
|
||||
document.getElementById('retention-days').value = value;
|
||||
document.getElementById('retention_days_policy').value = value;
|
||||
}
|
||||
|
||||
// Show notification if passed from backend
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
{% if notification %}
|
||||
showNotificationModal('Notification', '{{ notification.message }}');
|
||||
{% endif %}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,95 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Spotify Token Wizard{% endblock %}
|
||||
{% block content %}
|
||||
<div class="max-w-3xl mx-auto my-8 p-6 bg-white rounded-lg shadow-md">
|
||||
<div class="flex items-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-navy-800 flex-grow">Spotify Refresh Token Wizard</h2>
|
||||
<img src="https://storage.googleapis.com/pr-newsroom-wp/1/2018/11/Spotify_Logo_RGB_Green.png" alt="Spotify Logo" class="h-8">
|
||||
</div>
|
||||
|
||||
<div class="mb-6 p-4 bg-gray-50 rounded-lg">
|
||||
<h3 class="text-lg font-medium mb-2">What is this for?</h3>
|
||||
<p class="mb-2">This wizard helps you generate a Spotify refresh token for the system account (fallback account). This token will be used when:</p>
|
||||
<ul class="list-disc pl-6 mb-2">
|
||||
<li>A user doesn't have their own Spotify account connected</li>
|
||||
<li>The system needs to perform Spotify API operations in the background</li>
|
||||
<li>For shared/global Spotify functionality</li>
|
||||
</ul>
|
||||
<p class="text-sm text-gray-600">The refresh token doesn't expire, making it ideal for long-term system use.</p>
|
||||
</div>
|
||||
|
||||
{% if has_token %}
|
||||
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<div class="flex items-center text-green-700 mb-2">
|
||||
<i class="fas fa-check-circle mr-2"></i>
|
||||
<span class="font-medium">Fallback token is configured</span>
|
||||
</div>
|
||||
<p>A Spotify refresh token is already configured for the system account. You can replace it if needed.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if has_credentials %}
|
||||
<div class="mb-8 border-b pb-4">
|
||||
<h3 class="text-xl font-semibold mb-4 text-navy-700">Option 1: Automated Setup (Recommended)</h3>
|
||||
<p class="mb-4">This will guide you through the Spotify OAuth flow to generate a refresh token automatically.</p>
|
||||
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="action" value="start_auth">
|
||||
|
||||
<button type="submit" class="bg-green-600 hover:bg-green-700 text-white py-2 px-6 rounded-md flex items-center">
|
||||
<i class="fab fa-spotify mr-2"></i> Start Spotify Authorization
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-3 text-sm text-gray-600">
|
||||
<p>You'll be redirected to Spotify to authorize access, then brought back to this page when complete.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-8">
|
||||
<h3 class="text-xl font-semibold mb-4 text-navy-700">Option 2: Manual Entry</h3>
|
||||
<p class="mb-4">If you already have a Spotify refresh token (obtained elsewhere), you can enter it directly:</p>
|
||||
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="action" value="manual_save">
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="refresh_token" class="block font-medium mb-1">Refresh Token</label>
|
||||
<input type="password" id="refresh_token" name="refresh_token"
|
||||
class="border rounded px-3 py-2 w-full" placeholder="Enter your Spotify refresh token">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="bg-navy-600 hover:bg-navy-700 text-white py-2 px-6 rounded-md">
|
||||
Save Token
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div class="flex items-center text-yellow-700 mb-2">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||
<span class="font-medium">Spotify API credentials not configured</span>
|
||||
</div>
|
||||
<p class="mb-3">Your Spotify API credentials are missing or incomplete. The following values need to be set in your environment configuration:</p>
|
||||
<ul class="list-disc pl-6">
|
||||
<li>SPOTIFY_CLIENT_ID</li>
|
||||
<li>SPOTIFY_CLIENT_SECRET</li>
|
||||
<li>SPOTIFY_REDIRECT_URI</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-8 flex justify-between">
|
||||
<a href="{{ url_for('users.system_settings') }}" class="bg-gray-200 hover:bg-gray-300 text-gray-800 py-2 px-6 rounded-md">
|
||||
Back to Settings
|
||||
</a>
|
||||
|
||||
<a href="https://developer.spotify.com/documentation/web-api/concepts/access-token" target="_blank" class="text-navy-600 hover:underline flex items-center">
|
||||
<span>Learn about Spotify authentication</span>
|
||||
<i class="fas fa-external-link-alt ml-1"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,219 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}System Health{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-6xl mx-auto my-8 p-6 bg-white rounded-lg shadow-md">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-navy-800">System Health Status</h2>
|
||||
<div class="text-sm text-navy-600">{{ version_info.version }} - {{ version_info.release_name }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Health Summary Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-{{ database_status.color }}-50 border border-{{ database_status.color }}-200 rounded-lg p-4">
|
||||
<div class="flex items-center mb-2">
|
||||
<div class="h-4 w-4 rounded-full bg-{{ database_status.color }}-500 mr-2"></div>
|
||||
<h3 class="font-semibold text-{{ database_status.color }}-700">Database</h3>
|
||||
</div>
|
||||
<p class="text-sm text-{{ database_status.color }}-600">{{ database_status.message }}</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-{{ storage_status.color }}-50 border border-{{ storage_status.color }}-200 rounded-lg p-4">
|
||||
<div class="flex items-center mb-2">
|
||||
<div class="h-4 w-4 rounded-full bg-{{ storage_status.color }}-500 mr-2"></div>
|
||||
<h3 class="font-semibold text-{{ storage_status.color }}-700">Storage</h3>
|
||||
</div>
|
||||
<p class="text-sm text-{{ storage_status.color }}-600">{{ storage_status.message }}</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-{{ api_status.color }}-50 border border-{{ api_status.color }}-200 rounded-lg p-4">
|
||||
<div class="flex items-center mb-2">
|
||||
<div class="h-4 w-4 rounded-full bg-{{ api_status.color }}-500 mr-2"></div>
|
||||
<h3 class="font-semibold text-{{ api_status.color }}-700">API Services</h3>
|
||||
</div>
|
||||
<p class="text-sm text-{{ api_status.color }}-600">{{ api_status.message }}</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-{{ memory_status.color }}-50 border border-{{ memory_status.color }}-200 rounded-lg p-4">
|
||||
<div class="flex items-center mb-2">
|
||||
<div class="h-4 w-4 rounded-full bg-{{ memory_status.color }}-500 mr-2"></div>
|
||||
<h3 class="font-semibold text-{{ memory_status.color }}-700">Memory</h3>
|
||||
</div>
|
||||
<p class="text-sm text-{{ memory_status.color }}-600">{{ memory_status.message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Database Details -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-xl font-semibold mb-4 text-navy-700">Database Information</h3>
|
||||
<div class="overflow-x-auto border rounded-lg">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Metric</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
Total Songs
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ database_stats.song_count|default('0') }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
Total Rounds
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ database_stats.round_count|default('0') }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
Total Users
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ database_stats.user_count|default('0') }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
Database File Size
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ database_stats.file_size|default('Unknown') }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
Last Backup
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ database_stats.last_backup|default('Never') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Storage Details -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-xl font-semibold mb-4 text-navy-700">Storage Information</h3>
|
||||
<div class="overflow-x-auto border rounded-lg">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Directory</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Files</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Size</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{% for dir in storage_stats %}
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{{ dir.name }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ dir.file_count }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ dir.size }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{% if dir.writable %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||
Writable
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
|
||||
Not Writable
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Service Status -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-xl font-semibold mb-4 text-navy-700">External Services</h3>
|
||||
<div class="overflow-x-auto border rounded-lg">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Service</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{% for service in service_stats %}
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{{ service.name }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{% if service.status == 'ok' %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||
Available
|
||||
</span>
|
||||
{% elif service.status == 'warning' %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800">
|
||||
Warning
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
|
||||
Unavailable
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
{{ service.message }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Version Information -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-xl font-semibold mb-4 text-navy-700">Version Information</h3>
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-sm"><strong>Application Version:</strong> {{ version_info.version }}</p>
|
||||
<p class="text-sm"><strong>Release Name:</strong> {{ version_info.release_name }}</p>
|
||||
<p class="text-sm"><strong>Release Date:</strong> {{ version_info.release_date }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm"><strong>Python Version:</strong> {{ system_info.python_version }}</p>
|
||||
<p class="text-sm"><strong>Platform:</strong> {{ system_info.platform }}</p>
|
||||
<p class="text-sm"><strong>Flask Version:</strong> {{ system_info.flask_version }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-between">
|
||||
<a href="{{ url_for('users.backup_manager') }}" class="bg-teal-500 text-white px-6 py-2 rounded hover:bg-teal-600">
|
||||
<i class="fas fa-database mr-2"></i> Go to Backup Manager
|
||||
</a>
|
||||
|
||||
<a href="{{ url_for('users.system_settings') }}" class="bg-navy-600 text-white px-6 py-2 rounded hover:bg-navy-700">
|
||||
<i class="fas fa-cog mr-2"></i> Back to System Settings
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,257 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}System Settings{% endblock %}
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto my-8 p-6 bg-white rounded-lg shadow-md">
|
||||
<h2 class="text-2xl font-bold mb-6 text-navy-800">System Settings</h2>
|
||||
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
|
||||
<!-- TTS Settings Section -->
|
||||
<div class="mb-8 border-b pb-6">
|
||||
<h3 class="text-xl font-semibold mb-4 text-navy-700">Text-to-Speech Settings</h3>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block font-medium mb-1" for="default_tts_service">Default TTS Service</label>
|
||||
<select name="default_tts_service" id="default_tts_service" class="border rounded px-3 py-2 w-full">
|
||||
<option value="">(None)</option>
|
||||
{% for service in tts_services %}
|
||||
<option value="{{ service.id }}" {% if settings.get('default_tts_service') == service.id %}selected{% endif %}>
|
||||
{{ service.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="text-sm text-gray-500 mt-1">The default text-to-speech service to use for audio generation</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block font-medium mb-1" for="default_tts_voice">Default TTS Voice</label>
|
||||
<select name="default_tts_voice" id="default_tts_voice" class="border rounded px-3 py-2 w-full">
|
||||
<option value="">(None)</option>
|
||||
|
||||
{% for service in tts_services %}
|
||||
<optgroup label="{{ service.name }}">
|
||||
{% for voice in service.voices %}
|
||||
<option value="{{ voice.id }}" {% if settings.get('default_tts_voice') == voice.id %}selected{% endif %}>
|
||||
{{ voice.name }} {% if voice.gender or voice.language %}({{ voice.gender }}{% if voice.language and voice.gender %}, {{ voice.language }}{% elif voice.language %}{{ voice.language }}{% endif %}){% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="text-sm text-gray-500 mt-1">Default voice ID for the selected TTS service</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block font-medium mb-1" for="default_tts_model">Default TTS Model</label>
|
||||
<select name="default_tts_model" id="default_tts_model" class="border rounded px-3 py-2 w-full">
|
||||
<option value="">(None)</option>
|
||||
|
||||
{% for service in tts_services %}
|
||||
{% if service.models %}
|
||||
<optgroup label="{{ service.name }} Models">
|
||||
{% for model in service.models %}
|
||||
<option value="{{ model.id }}" {% if settings.get('default_tts_model') == model.id %}selected{% endif %}>
|
||||
{{ model.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="text-sm text-gray-500 mt-1">Default model for OpenAI or ElevenLabs TTS</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spotify Integration Settings -->
|
||||
<div class="mb-8 border-b pb-6">
|
||||
<h3 class="text-xl font-semibold mb-4 text-navy-700">Spotify Integration</h3>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block font-medium mb-1" for="fallback_spotify_refresh_token">Fallback Spotify Refresh Token</label>
|
||||
<div class="flex">
|
||||
<input type="password" name="fallback_spotify_refresh_token" id="fallback_spotify_refresh_token"
|
||||
class="border rounded-l px-3 py-2 flex-grow"
|
||||
value="{{ settings.get('fallback_spotify_refresh_token', '') }}">
|
||||
<button type="button" onclick="toggleTokenVisibility()"
|
||||
class="bg-gray-200 px-3 py-2 rounded-r border-t border-r border-b">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-1">Service account refresh token used when users don't have Spotify access</p>
|
||||
<div class="mt-2">
|
||||
<a href="{{ url_for('users.spotify_link') }}" class="bg-navy-600 text-white px-4 py-2 rounded inline-flex items-center">
|
||||
<i class="fab fa-spotify mr-2"></i> Connect Spotify Account
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block font-medium mb-1" for="spotify_region">Default Spotify Region</label>
|
||||
<select name="spotify_region" id="spotify_region" class="border rounded px-3 py-2 w-full">
|
||||
<option value="">(None)</option>
|
||||
{% for region in spotify_regions %}
|
||||
<option value="{{ region.code }}" {% if settings.get('spotify_region') == region.code %}selected{% endif %}>
|
||||
{{ region.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="text-sm text-gray-500 mt-1">Default region for Spotify API searches and charts</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Authentication Settings Section -->
|
||||
<div class="mb-8 border-b pb-6">
|
||||
<h3 class="text-xl font-semibold mb-4 text-navy-700">Authentication Settings</h3>
|
||||
|
||||
<div class="flex items-center mb-4">
|
||||
<input type="checkbox" id="allow_signups" name="allow_signups" value="true"
|
||||
{% if settings.get('allow_signups', 'true') == 'true' %}checked{% endif %}
|
||||
class="w-4 h-4 text-blue-600 mr-2">
|
||||
<label for="allow_signups" class="font-medium">Allow New User Registrations</label>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
When disabled, new users cannot create accounts. Existing users can still log in, and OAuth login will still work for existing accounts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Backup & System Health Section -->
|
||||
<div class="mb-8 border-b pb-6">
|
||||
<h3 class="text-xl font-semibold mb-4 text-navy-700">Backup & System Health</h3>
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<a href="{{ url_for('users.backup_manager') }}" class="flex-1 bg-teal-500 hover:bg-teal-600 text-white py-3 px-4 rounded flex items-center justify-center">
|
||||
<i class="fas fa-database mr-2"></i> Backup Manager
|
||||
</a>
|
||||
|
||||
<a href="{{ url_for('users.system_health') }}" class="flex-1 bg-navy-600 hover:bg-navy-700 text-white py-3 px-4 rounded flex items-center justify-center">
|
||||
<i class="fas fa-heartbeat mr-2"></i> System Health
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-600 mt-2">
|
||||
Manage system backups, restore from previous backups, and monitor system health.
|
||||
</p>
|
||||
|
||||
<div class="mt-4 bg-blue-50 border border-blue-200 rounded-md p-3">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-info-circle text-blue-500"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-blue-700">
|
||||
Regular backups are recommended to prevent data loss. The backup system will save your database, MP3 files, and system configuration.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Settings -->
|
||||
<div class="mb-4">
|
||||
<h3 class="text-xl font-semibold mb-4 text-navy-700">Additional Settings</h3>
|
||||
|
||||
{% for key, label in editable_settings %}
|
||||
{% if key not in ['default_tts_service', 'default_tts_voice', 'default_tts_model', 'fallback_spotify_refresh_token', 'spotify_region', 'allow_signups'] %}
|
||||
<div class="mb-4">
|
||||
<label class="block font-medium mb-1" for="{{ key }}">{{ label }}</label>
|
||||
{% if key == 'enable_public_rounds' %}
|
||||
<select name="{{ key }}" id="{{ key }}" class="border rounded px-3 py-2 w-full">
|
||||
<option value="true" {% if settings.get(key) == 'true' %}selected{% endif %}>Enabled</option>
|
||||
<option value="false" {% if settings.get(key) != 'true' %}selected{% endif %}>Disabled</option>
|
||||
</select>
|
||||
{% else %}
|
||||
<input type="text" name="{{ key }}" id="{{ key }}" class="border rounded px-3 py-2 w-full"
|
||||
value="{{ settings.get(key, '') }}">
|
||||
{% endif %}
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
{% if key == 'max_songs_per_round' %}
|
||||
Maximum number of songs that can be included in a music round
|
||||
{% elif key == 'enable_public_rounds' %}
|
||||
Allow users to make their music rounds publicly accessible
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<button type="submit" class="bg-teal-500 text-white px-6 py-2 rounded hover:bg-teal-600">
|
||||
<i class="fas fa-save mr-2"></i> Save Settings
|
||||
</button>
|
||||
<a href="{{ url_for('core.index') }}" class="bg-gray-300 text-gray-700 px-6 py-2 rounded hover:bg-gray-400">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function toggleTokenVisibility() {
|
||||
const tokenField = document.getElementById('fallback_spotify_refresh_token');
|
||||
if (tokenField.type === 'password') {
|
||||
tokenField.type = 'text';
|
||||
} else {
|
||||
tokenField.type = 'password';
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic voice selection based on service
|
||||
document.getElementById('default_tts_service').addEventListener('change', function() {
|
||||
const selectedService = this.value;
|
||||
const voiceSelect = document.getElementById('default_tts_voice');
|
||||
const modelSelect = document.getElementById('default_tts_model');
|
||||
|
||||
// Hide all options first
|
||||
Array.from(voiceSelect.options).forEach(option => {
|
||||
option.style.display = 'none';
|
||||
});
|
||||
|
||||
Array.from(modelSelect.options).forEach(option => {
|
||||
option.style.display = 'none';
|
||||
});
|
||||
|
||||
// Show only options for the selected service
|
||||
Array.from(voiceSelect.options).forEach(option => {
|
||||
if (option.value === '' || option.parentNode.label.includes(selectedService)) {
|
||||
option.style.display = '';
|
||||
}
|
||||
});
|
||||
|
||||
Array.from(modelSelect.options).forEach(option => {
|
||||
if (option.value === '' || option.parentNode.label.includes(selectedService)) {
|
||||
option.style.display = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Reset to empty if current selection is not valid for the service
|
||||
let validVoice = false;
|
||||
Array.from(voiceSelect.options).forEach(option => {
|
||||
if (option.selected && option.style.display !== 'none') {
|
||||
validVoice = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!validVoice) {
|
||||
voiceSelect.value = '';
|
||||
}
|
||||
|
||||
let validModel = false;
|
||||
Array.from(modelSelect.options).forEach(option => {
|
||||
if (option.selected && option.style.display !== 'none') {
|
||||
validModel = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!validModel) {
|
||||
modelSelect.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger the change event on page load to set up initial state
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.getElementById('default_tts_service').dispatchEvent(new Event('change'));
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,229 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Quizzical Beats{% endblock %}</title>
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="{{ url_for('static', filename='img/light/logo.png') }}">
|
||||
<link rel="shortcut icon" type="image/png" href="{{ url_for('static', filename='img/light/logo.png') }}">
|
||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='img/light/logo.png') }}">
|
||||
<!-- Google Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700&family=Open+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- Tailwind CSS via CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<!-- Custom Tailwind Configuration -->
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'navy': {
|
||||
50: '#e7e8f4',
|
||||
100: '#c3c5e3',
|
||||
200: '#9b9fd1',
|
||||
300: '#7379bf',
|
||||
400: '#555cb2',
|
||||
500: '#3640a5',
|
||||
600: '#2e379e',
|
||||
700: '#242c8f',
|
||||
800: '#1A237E', // Deep Navy Blue (primary)
|
||||
900: '#0b0e59',
|
||||
},
|
||||
'teal': {
|
||||
500: '#00ACC1', // Vibrant Teal (accent)
|
||||
600: '#0097a7',
|
||||
700: '#00838f',
|
||||
},
|
||||
'orange': {
|
||||
500: '#FF7043', // Bright Orange (CTA)
|
||||
600: '#f4511e',
|
||||
700: '#e64a19',
|
||||
},
|
||||
'gray': {
|
||||
100: '#F5F5F5', // Light Gray (background)
|
||||
800: '#212121', // Dark Gray (text)
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
'montserrat': ['Montserrat', 'sans-serif'],
|
||||
'opensans': ['Open Sans', 'sans-serif'],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
|
||||
<!-- Custom styles -->
|
||||
<style type="text/tailwindcss">
|
||||
@layer base {
|
||||
html {
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.content-auto {
|
||||
content-visibility: auto;
|
||||
}
|
||||
|
||||
/* Form styling to replicate Tailwind Forms plugin behavior */
|
||||
[type='text'], [type='email'], [type='url'], [type='password'],
|
||||
[type='number'], [type='date'], [type='datetime-local'],
|
||||
[type='month'], [type='search'], [type='tel'],
|
||||
[type='time'], [type='week'], [multiple], textarea, select {
|
||||
@apply w-full rounded-md border-gray-300 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:ring-opacity-50;
|
||||
}
|
||||
|
||||
[type='checkbox'], [type='radio'] {
|
||||
@apply rounded border-gray-300 text-teal-600 focus:ring-teal-500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body class="flex flex-col min-h-screen bg-gray-100 font-opensans text-gray-800">
|
||||
<header>
|
||||
<nav class="bg-navy-800 text-white shadow-md">
|
||||
<div class="container mx-auto px-4 py-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<a class="flex items-center text-xl font-bold" href="{{ url_for('core.index') }}">
|
||||
<img src="{{ url_for('static', filename='img/dark/logo.png') }}" alt="Quizzical Beats" class="h-8 mr-2">
|
||||
<span class="hidden sm:inline">Quizzical Beats</span>
|
||||
</a>
|
||||
<button id="menu-toggle" class="md:hidden focus:outline-none">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
<div id="navbar-menu" class="hidden md:flex flex-grow items-center">
|
||||
<ul class="flex flex-col md:flex-row space-y-2 md:space-y-0 md:ml-8 md:space-x-6 mt-4 md:mt-0">
|
||||
{% if current_user.is_authenticated %}
|
||||
<li class="group relative">
|
||||
<button class="peer flex items-center text-white hover:text-teal-500">
|
||||
<i class="fab fa-spotify mr-2"></i>Import Spotify <i class="fas fa-chevron-down ml-1"></i>
|
||||
</button>
|
||||
<ul class="hidden peer-hover:flex hover:flex flex-col absolute bg-white text-gray-800 shadow-md py-2 rounded-md w-48 z-10">
|
||||
<li><a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('import_songs.import_song') }}">Song</a></li>
|
||||
<li><a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('import_songs.import_playlist') }}">Playlist</a></li>
|
||||
<li><a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('import_songs.import_album') }}">Album</a></li>
|
||||
<li><a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('core.search') }}">Search Spotify</a></li>
|
||||
<li class="border-t border-gray-200 mt-1 pt-1">
|
||||
<a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('import.import_official_playlists') }}">
|
||||
<span class="flex items-center text-teal-600">
|
||||
<i class="fab fa-spotify mr-2"></i> Official Playlists
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="group relative">
|
||||
<button class="peer flex items-center text-white hover:text-teal-500">
|
||||
<i class="fab fa-deezer mr-2"></i>Import Deezer <i class="fas fa-chevron-down ml-1"></i>
|
||||
</button>
|
||||
<ul class="hidden peer-hover:flex hover:flex flex-col absolute bg-white text-gray-800 shadow-md py-2 rounded-md w-48 z-10">
|
||||
<li><a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('deezer.import_deezer_track') }}">Song</a></li>
|
||||
<li><a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('deezer.import_deezer_playlist') }}">Playlist</a></li>
|
||||
<li><a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('deezer.import_deezer_album') }}">Album</a></li>
|
||||
<li><a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('deezer.deezer_search') }}">Search Deezer</a></li>
|
||||
<li class="border-t border-gray-200 mt-1 pt-1">
|
||||
<a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('deezer.browse_deezer_playlists') }}">
|
||||
<span class="flex items-center" style="color: #00ACC1;">
|
||||
<i class="fab fa-deezer mr-2"></i> Official Playlists
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<a class="text-white hover:text-teal-500" href="{{ url_for('core.view_songs') }}">View Songs</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="text-white hover:text-teal-500" href="{{ url_for('generate.build_music_round') }}">Build Round</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="text-white hover:text-teal-500" href="{{ url_for('rounds.rounds_list') }}">View Rounds</a>
|
||||
</li>
|
||||
{% if current_user.is_admin() %}
|
||||
<li class="group relative">
|
||||
<button class="peer flex items-center text-white hover:text-teal-500">
|
||||
<i class="fas fa-shield-alt mr-2"></i>Admin <i class="fas fa-chevron-down ml-1"></i>
|
||||
</button>
|
||||
<ul class="hidden peer-hover:flex hover:flex flex-col absolute bg-white text-gray-800 shadow-md py-2 rounded-md w-48 z-10">
|
||||
<li><a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('admin.index') }}">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-database mr-2"></i> Data Manager
|
||||
</span>
|
||||
</a></li>
|
||||
<li><a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('users.system_settings') }}">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-cogs mr-2"></i> System Settings
|
||||
</span>
|
||||
</a></li>
|
||||
<li><a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('users.backup_manager') }}">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-download mr-2"></i> Backup Manager
|
||||
</span>
|
||||
</a></li>
|
||||
<li><a class="block px-4 py-2 hover:bg-navy-50" href="{{ url_for('users.system_health') }}">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-heartbeat mr-2"></i> System Health
|
||||
</span>
|
||||
</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
<div class="ml-auto">
|
||||
{% if current_user.is_authenticated %}
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="{{ url_for('users.profile') }}" class="text-white hover:text-teal-500 flex items-center">
|
||||
<i class="fas fa-user-circle mr-1"></i> {{ current_user.username }}
|
||||
</a>
|
||||
<a href="{{ url_for('users.logout') }}" class="bg-orange-500 hover:bg-orange-600 text-white py-1 px-3 rounded">
|
||||
Logout
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="{{ url_for('users.login') }}" class="text-white hover:text-teal-500">
|
||||
Login
|
||||
</a>
|
||||
<a href="{{ url_for('users.register') }}" class="bg-orange-500 hover:bg-orange-600 text-white py-1 px-3 rounded">
|
||||
Register
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="flex-grow container mx-auto px-4 py-6">
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="bg-navy-800 text-white py-4 mt-auto">
|
||||
<div class="container mx-auto px-4 text-center">
|
||||
<p>© 2025 Quizzical Beats | <span class="text-teal-500">{{ get_version_str() }}</span></p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Mobile menu script -->
|
||||
<script>
|
||||
document.getElementById('menu-toggle').addEventListener('click', function() {
|
||||
const menu = document.getElementById('navbar-menu');
|
||||
menu.classList.toggle('hidden');
|
||||
});
|
||||
</script>
|
||||
|
||||
{% block scripts %}
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Official Deezer Playlists{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 py-8">
|
||||
<h2 class="text-3xl font-bold mb-2 text-navy-800 font-montserrat">Official Deezer Playlists</h2>
|
||||
<p class="text-gray-600 mb-6">Browse and import popular playlists from Deezer.</p>
|
||||
|
||||
<!-- Filtering -->
|
||||
<div class="bg-white shadow-md rounded-lg p-6 mb-8">
|
||||
<form method="GET" action="{{ url_for('deezer.browse_deezer_playlists') }}" class="space-y-4">
|
||||
<div>
|
||||
<label for="filter" class="block text-sm font-medium text-gray-700 mb-1">Filter by Keywords</label>
|
||||
<input type="text" name="filter" id="filter" value="{{ filter_keywords|join(',') if filter_keywords else '' }}"
|
||||
placeholder="top,hits,pop,rock,etc"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-teal-500 focus:border-teal-500">
|
||||
<p class="text-xs text-gray-500 mt-1">Comma-separated keywords to search in playlist names</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded-md shadow-sm transition-colors">
|
||||
Apply Filter
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Playlist grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{% for playlist in playlists %}
|
||||
<div class="bg-white shadow-md rounded-lg overflow-hidden border border-gray-200">
|
||||
{% if playlist.picture_xl %}
|
||||
<img src="{{ playlist.picture_xl }}" class="w-full h-48 object-cover playlist-detail-trigger cursor-pointer" data-id="{{ playlist.id }}" alt="{{ playlist.title }}">
|
||||
{% else %}
|
||||
<div class="w-full h-48 bg-gray-200 flex items-center justify-center">
|
||||
<span class="text-gray-500">No image</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-start">
|
||||
<h5 class="text-xl font-semibold mb-2 text-navy-800 font-montserrat playlist-detail-trigger cursor-pointer" data-id="{{ playlist.id }}">{{ playlist.title }}</h5>
|
||||
<span class="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded">Deezer</span>
|
||||
</div>
|
||||
{% if playlist.description %}
|
||||
<p class="text-gray-700 mb-3 text-sm">{{ playlist.description|truncate(100) }}</p>
|
||||
{% endif %}
|
||||
<p class="text-gray-600 mb-4">Tracks: {{ playlist.nb_tracks }}</p>
|
||||
<div class="flex justify-between items-center">
|
||||
<form method="POST" action="{{ url_for('deezer.import_deezer_playlist') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input type="hidden" name="playlist_id" value="{{ playlist.id }}">
|
||||
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded transition-colors">
|
||||
<i class="fas fa-plus-circle mr-1"></i> Import
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" class="text-teal-600 hover:text-teal-800 text-sm playlist-detail-trigger" data-id="{{ playlist.id }}">
|
||||
<i class="fas fa-info-circle mr-1"></i> Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-span-3 p-8 text-center bg-gray-50 rounded-lg border border-gray-200">
|
||||
<p class="text-gray-600">No playlists found matching your criteria. Try changing your filters.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<a href="{{ url_for('deezer.deezer_search') }}" class="inline-flex items-center bg-gray-200 hover:bg-gray-300 text-gray-700 py-2 px-4 rounded transition-colors">
|
||||
<i class="fas fa-arrow-left mr-2"></i> Back to Search
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Playlist Details Modal -->
|
||||
<div id="details-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-screen overflow-hidden">
|
||||
<div class="flex justify-between items-center border-b border-gray-200 px-6 py-4">
|
||||
<h3 class="text-xl font-semibold text-navy-800 font-montserrat" id="modal-title">Playlist Details</h3>
|
||||
<button id="close-modal" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times fa-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto p-6" style="max-height: calc(100vh - 200px);">
|
||||
<div id="modal-content" class="flex flex-col md:flex-row gap-6">
|
||||
<!-- Content will be loaded here -->
|
||||
<div class="w-full md:w-1/3 flex flex-col items-center">
|
||||
<div class="w-full max-w-xs aspect-square bg-gray-200 rounded-lg mb-4" id="modal-image-container">
|
||||
<img id="modal-image" src="" alt="" class="w-full h-full object-cover rounded-lg">
|
||||
</div>
|
||||
<div id="modal-metadata" class="w-full text-center mb-4">
|
||||
<!-- Metadata will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full md:w-2/3">
|
||||
<p class="text-gray-600 mb-4" id="modal-description"></p>
|
||||
<h4 class="font-semibold text-navy-800 mb-2 flex items-center">
|
||||
<i class="fas fa-music mr-2"></i> Tracks
|
||||
</h4>
|
||||
<div id="modal-tracks-container" class="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-100">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">#</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Title</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Artist</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="modal-tracks" class="bg-white divide-y divide-gray-200">
|
||||
<!-- Tracks will be loaded here -->
|
||||
<tr>
|
||||
<td colspan="4" class="px-4 py-4 text-center text-gray-500">
|
||||
<div class="flex justify-center items-center">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-500"></div>
|
||||
<span class="ml-2">Loading tracks...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-6 py-4 flex justify-end">
|
||||
<form action="{{ url_for('deezer.import_deezer_playlist') }}" method="POST" id="modal-import-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input type="hidden" name="playlist_id" id="modal-item-id" value="">
|
||||
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded transition-colors">
|
||||
<i class="fas fa-plus-circle mr-1"></i> Import
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const modal = document.getElementById('details-modal');
|
||||
const closeModal = document.getElementById('close-modal');
|
||||
const modalTitle = document.getElementById('modal-title');
|
||||
const modalDescription = document.getElementById('modal-description');
|
||||
const modalImage = document.getElementById('modal-image');
|
||||
const modalMetadata = document.getElementById('modal-metadata');
|
||||
const modalTracks = document.getElementById('modal-tracks');
|
||||
const modalImportForm = document.getElementById('modal-import-form');
|
||||
const modalItemId = document.getElementById('modal-item-id');
|
||||
|
||||
// Close modal
|
||||
closeModal.addEventListener('click', () => {
|
||||
modal.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Close modal when clicking outside
|
||||
window.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Escape key to close modal
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !modal.classList.contains('hidden')) {
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Format duration from milliseconds to mm:ss
|
||||
function formatDuration(ms) {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Playlist detail functionality
|
||||
document.querySelectorAll('.playlist-detail-trigger').forEach(trigger => {
|
||||
trigger.addEventListener('click', (e) => {
|
||||
const playlistId = e.target.getAttribute('data-id') || e.target.closest('.playlist-detail-trigger').getAttribute('data-id');
|
||||
|
||||
// Reset modal content
|
||||
modalTitle.textContent = "Loading playlist details...";
|
||||
modalDescription.textContent = "";
|
||||
modalImage.src = "";
|
||||
modalMetadata.innerHTML = "";
|
||||
modalTracks.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="4" class="px-4 py-4 text-center text-gray-500">
|
||||
<div class="flex justify-center items-center">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-500"></div>
|
||||
<span class="ml-2">Loading tracks...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
// Set up import form
|
||||
modalItemId.value = playlistId;
|
||||
|
||||
// Show modal
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
// Fetch playlist details
|
||||
fetch(`/api/deezer/playlist/${playlistId}`)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
// Update modal with playlist details
|
||||
modalTitle.textContent = data.name;
|
||||
|
||||
if (data.description) {
|
||||
modalDescription.textContent = data.description;
|
||||
}
|
||||
|
||||
if (data.image_url) {
|
||||
modalImage.src = data.image_url;
|
||||
modalImage.alt = data.name;
|
||||
}
|
||||
|
||||
// Add metadata
|
||||
modalMetadata.innerHTML = `
|
||||
<p class="font-semibold text-navy-800">By ${data.owner}</p>
|
||||
<p class="text-gray-600">${data.tracks.length} tracks</p>
|
||||
<p class="text-gray-600">Followers: ${data.followers || 'N/A'}</p>
|
||||
`;
|
||||
|
||||
// Add tracks
|
||||
if (data.tracks && data.tracks.length > 0) {
|
||||
modalTracks.innerHTML = '';
|
||||
data.tracks.forEach((track, index) => {
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'hover:bg-gray-50';
|
||||
|
||||
row.innerHTML = `
|
||||
<td class="px-4 py-2 text-sm whitespace-nowrap">${index + 1}</td>
|
||||
<td class="px-4 py-2">${track.name}</td>
|
||||
<td class="px-4 py-2 text-sm">${track.artist}</td>
|
||||
<td class="px-4 py-2 text-sm">${track.duration ? formatDuration(track.duration) : ''}</td>
|
||||
`;
|
||||
|
||||
modalTracks.appendChild(row);
|
||||
});
|
||||
} else {
|
||||
modalTracks.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="4" class="px-4 py-4 text-center text-gray-500">
|
||||
No tracks available
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching playlist details:', error);
|
||||
modalTitle.textContent = "Error Loading Details";
|
||||
modalDescription.textContent = "There was a problem loading the playlist details.";
|
||||
modalTracks.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="4" class="px-4 py-4 text-center text-red-500">
|
||||
Failed to load tracks. Please try again later.
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,112 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Build Music Quiz - Quizzical Beats{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-6xl mx-auto px-4 py-8">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-navy-800 font-montserrat mb-3">Create Your Music Quiz</h1>
|
||||
<p class="text-lg text-gray-600 max-w-2xl mx-auto">Choose your preferred method to generate the perfect music round for your trivia night.</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('generate.build_music_round') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
|
||||
<div class="mb-6 bg-white shadow-md rounded-lg p-6">
|
||||
<h3 class="text-xl font-semibold text-navy-800 font-montserrat mb-4">Round Details</h3>
|
||||
<div class="mb-4">
|
||||
<label for="round_name" class="block text-gray-700 text-sm font-bold mb-2">Round Name (Optional)</label>
|
||||
<input type="text" name="round_name" id="round_name" class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" placeholder="Enter a name for this round">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<!-- Random Selection Card -->
|
||||
<div class="bg-white shadow-md rounded-lg overflow-hidden border border-gray-200 transition-transform hover:transform hover:scale-[1.02]">
|
||||
<div class="h-24 bg-gradient-to-r from-navy-700 to-navy-900 flex items-center justify-center text-white">
|
||||
<i class="fas fa-random text-4xl"></i>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<h5 class="text-xl font-semibold text-navy-800 font-montserrat mb-3">Random Selection</h5>
|
||||
<p class="text-gray-600 mb-5 h-20">Create a music quiz with randomly selected songs from different artists and decades for a diverse challenge.</p>
|
||||
<button type="submit" name="round_type" value="Random" class="bg-orange-500 hover:bg-orange-600 text-white py-3 px-4 rounded-md transition-colors w-full font-semibold">
|
||||
<i class="fas fa-dice mr-2"></i> Generate Random Round
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Decade Card -->
|
||||
<div class="bg-white shadow-md rounded-lg overflow-hidden border border-gray-200 transition-transform hover:transform hover:scale-[1.02]">
|
||||
<div class="h-24 bg-gradient-to-r from-teal-500 to-teal-700 flex items-center justify-center text-white">
|
||||
<i class="fas fa-calendar-alt text-4xl"></i>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<h5 class="text-xl font-semibold text-navy-800 font-montserrat mb-3">By Decade</h5>
|
||||
<p class="text-gray-600 mb-5 h-20">Create a themed music quiz with songs from a specific decade that has been used the least in your quizzes.</p>
|
||||
<button type="submit" name="round_type" value="Decade" class="bg-orange-500 hover:bg-orange-600 text-white py-3 px-4 rounded-md transition-colors w-full font-semibold">
|
||||
<i class="fas fa-hourglass-half mr-2"></i> Generate Decade Round
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Genre Card -->
|
||||
<div class="bg-white shadow-md rounded-lg overflow-hidden border border-gray-200 transition-transform hover:transform hover:scale-[1.02]">
|
||||
<div class="h-24 bg-gradient-to-r from-orange-500 to-orange-700 flex items-center justify-center text-white">
|
||||
<i class="fas fa-guitar text-4xl"></i>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<h5 class="text-xl font-semibold text-navy-800 font-montserrat mb-3">By Genre</h5>
|
||||
<p class="text-gray-600 mb-5 h-20">Create a themed music quiz with songs from a specific genre that has been used the least in your quizzes.</p>
|
||||
<button type="submit" name="round_type" value="Genre" class="bg-orange-500 hover:bg-orange-600 text-white py-3 px-4 rounded-md transition-colors w-full font-semibold">
|
||||
<i class="fas fa-music mr-2"></i> Generate Genre Round
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tag Card -->
|
||||
<div class="bg-white shadow-md rounded-lg overflow-hidden border border-gray-200 transition-transform hover:transform hover:scale-[1.02]">
|
||||
<div class="h-24 bg-gradient-to-r from-purple-500 to-purple-700 flex items-center justify-center text-white">
|
||||
<i class="fas fa-tags text-4xl"></i>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<h5 class="text-xl font-semibold text-navy-800 font-montserrat mb-3">By Tag</h5>
|
||||
<p class="text-gray-600 mb-3 h-20">Create a music quiz with songs that share a specific tag from your collection.</p>
|
||||
<select name="tag_name" class="shadow border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline">
|
||||
<option value="">Select a Tag</option>
|
||||
{% for tag in tags %}
|
||||
<option value="{{ tag }}">{{ tag }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" name="round_type" value="Tag" class="bg-orange-500 hover:bg-orange-600 text-white py-3 px-4 rounded-md transition-colors w-full font-semibold">
|
||||
<i class="fas fa-tag mr-2"></i> Generate Tag Round
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-10 flex flex-col md:flex-row gap-6">
|
||||
<div class="flex-1 bg-navy-50 rounded-lg p-6 border border-navy-100 text-center">
|
||||
<h3 class="text-xl font-semibold text-navy-800 mb-2 font-montserrat">Need more songs?</h3>
|
||||
<p class="text-gray-600 mb-4">Import more songs from Spotify or Deezer to create even better music quizzes.</p>
|
||||
<div class="flex flex-wrap justify-center gap-4">
|
||||
<a href="{{ url_for('core.search') }}" class="bg-[#1DB954] hover:bg-[#1AA346] text-white py-2 px-4 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 py-2 px-4 rounded-md transition-colors inline-flex items-center">
|
||||
<i class="fab fa-deezer mr-2"></i> Import from Deezer
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 bg-orange-50 rounded-lg p-6 border border-orange-100 text-center">
|
||||
<h3 class="text-xl font-semibold text-navy-800 mb-2 font-montserrat">Import a Playlist</h3>
|
||||
<p class="text-gray-600 mb-4">Quickly create a music quiz from an existing Spotify or Deezer playlist.</p>
|
||||
<a href="{{ url_for('generate.import_playlist') }}" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded-md transition-colors inline-flex items-center">
|
||||
<i class="fas fa-cloud-download-alt mr-2"></i> Import from Playlist
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Error {{ code }} - Quizzical Beats{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex justify-center items-center min-h-[70vh] px-4">
|
||||
<div class="w-full max-w-lg text-center">
|
||||
<div class="mb-6">
|
||||
<i class="fas fa-exclamation-triangle text-orange-500 text-6xl"></i>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-navy-800 font-montserrat mb-4">Error {{ code }}</h1>
|
||||
|
||||
<!-- Error message container - will be updated by JS -->
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 px-6 py-4 rounded-lg mb-6 relative group">
|
||||
<!-- Loading indicator initially shown -->
|
||||
<div id="loading-message" class="flex items-center justify-center py-2">
|
||||
<div class="mr-3">
|
||||
<i class="fas fa-circle-notch fa-spin text-orange-500"></i>
|
||||
</div>
|
||||
<p>Interpreting this error for you...</p>
|
||||
</div>
|
||||
|
||||
<!-- Friendly error message initially hidden -->
|
||||
<div id="friendly-error-container" class="hidden">
|
||||
<div class="flex items-center justify-between">
|
||||
<p id="friendly-error-message" class="text-lg"></p>
|
||||
<button
|
||||
class="copy-btn text-gray-500 hover:text-navy-600 ml-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onclick="copyToClipboard(document.getElementById('friendly-error-message').innerText)"
|
||||
title="Copy error message">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Technical error message initially hidden (fallback) -->
|
||||
<div id="technical-error-container" class="hidden">
|
||||
<div class="flex items-center justify-between">
|
||||
<p id="technical-error-fallback" class="text-lg">{{ message }}</p>
|
||||
<button
|
||||
class="copy-btn text-gray-500 hover:text-navy-600 ml-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onclick="copyToClipboard('{{ message }}')"
|
||||
title="Copy error message">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Technical error message (always available in details) -->
|
||||
<div class="mt-4 bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6">
|
||||
<details class="technical-error-accordion">
|
||||
<summary class="cursor-pointer text-md font-bold text-navy-800 py-2 flex items-center justify-between">
|
||||
<span>Technical Details</span>
|
||||
<i class="fas fa-chevron-down text-sm transition-transform"></i>
|
||||
</summary>
|
||||
<div class="mt-2">
|
||||
<div class="bg-gray-100 p-3 rounded overflow-auto text-sm relative">
|
||||
<p id="technical-error">{{ message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{% if 'access_token' in session and (debug_info or traceback) %}
|
||||
<!-- Debug info section -->
|
||||
<div class="mt-6 bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6">
|
||||
<!-- Copy all button at the top -->
|
||||
<div class="flex justify-end mb-3">
|
||||
<button
|
||||
class="flex items-center bg-navy-600 hover:bg-navy-700 text-white text-sm py-1 px-3 rounded transition-colors"
|
||||
onclick="copyAllErrorInfo()"
|
||||
title="Copy all error information">
|
||||
<i class="fas fa-copy mr-2"></i> Copy All Error Info
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<details class="debug-accordion">
|
||||
<summary class="cursor-pointer text-lg font-bold text-navy-800 py-2 flex items-center justify-between">
|
||||
<span>Debug Information</span>
|
||||
<i class="fas fa-chevron-down text-sm transition-transform"></i>
|
||||
</summary>
|
||||
<div class="mt-4 debug-content">
|
||||
<div class="bg-gray-100 p-4 rounded overflow-auto text-sm font-mono relative">
|
||||
<pre id="debug-info-pre">{{ debug_info }}</pre>
|
||||
</div>
|
||||
|
||||
{% if traceback %}
|
||||
<div class="mt-4">
|
||||
<h3 class="text-md font-bold text-navy-800 mb-2">Traceback</h3>
|
||||
<div class="bg-gray-100 p-4 rounded overflow-auto text-sm font-mono relative">
|
||||
<pre id="traceback-pre">{{ traceback }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ url_for('core.index') }}" class="inline-flex items-center bg-navy-700 hover:bg-navy-800 text-white py-2 px-4 rounded transition-colors">
|
||||
<i class="fas fa-home mr-2"></i> Return to Homepage
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden element to store all error info for copying -->
|
||||
<div id="all-error-info" class="hidden">Error {{ code }}: <span id="copy-message"></span>
|
||||
|
||||
TECHNICAL DETAILS:
|
||||
{{ message }}
|
||||
{% if debug_info %}
|
||||
|
||||
DEBUG INFORMATION:
|
||||
{{ debug_info }}{% endif %}{% if traceback %}
|
||||
|
||||
TRACEBACK:
|
||||
{{ traceback }}{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Store error info for JS -->
|
||||
<div id="error-info-data" class="hidden" data-error-info='{{ error_info_for_js|safe }}'></div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Function to fetch friendly error message
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Get error info
|
||||
const errorInfoEl = document.getElementById('error-info-data');
|
||||
if (!errorInfoEl) return;
|
||||
|
||||
try {
|
||||
const errorInfo = JSON.parse(errorInfoEl.getAttribute('data-error-info'));
|
||||
|
||||
// Set a timeout in case the API call takes too long
|
||||
const timeoutId = setTimeout(() => {
|
||||
showTechnicalErrorFallback();
|
||||
}, 5000); // 5 seconds timeout
|
||||
|
||||
// Make API call to get friendly error message
|
||||
fetch('/api/friendly-error', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(errorInfo)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
clearTimeout(timeoutId);
|
||||
if (data.success && data.message) {
|
||||
showFriendlyErrorMessage(data.message);
|
||||
document.getElementById('copy-message').innerText = data.message;
|
||||
} else {
|
||||
showTechnicalErrorFallback();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
clearTimeout(timeoutId);
|
||||
console.error('Error fetching friendly message:', error);
|
||||
showTechnicalErrorFallback();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error parsing error info:', error);
|
||||
showTechnicalErrorFallback();
|
||||
}
|
||||
});
|
||||
|
||||
function showFriendlyErrorMessage(message) {
|
||||
document.getElementById('loading-message').classList.add('hidden');
|
||||
const friendlyContainer = document.getElementById('friendly-error-container');
|
||||
friendlyContainer.classList.remove('hidden');
|
||||
document.getElementById('friendly-error-message').innerText = message;
|
||||
}
|
||||
|
||||
function showTechnicalErrorFallback() {
|
||||
document.getElementById('loading-message').classList.add('hidden');
|
||||
document.getElementById('technical-error-container').classList.remove('hidden');
|
||||
document.getElementById('copy-message').innerText = document.getElementById('technical-error-fallback').innerText;
|
||||
}
|
||||
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
showCopyNotification();
|
||||
}, function(err) {
|
||||
console.error('Could not copy text: ', err);
|
||||
});
|
||||
}
|
||||
|
||||
function copyAllErrorInfo() {
|
||||
const allErrorInfo = document.getElementById('all-error-info').innerText;
|
||||
copyToClipboard(allErrorInfo);
|
||||
}
|
||||
|
||||
function showCopyNotification() {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = 'fixed bottom-4 right-4 bg-navy-600 text-white px-4 py-2 rounded shadow-lg transform translate-y-0 opacity-100 transition-all duration-300';
|
||||
notification.innerHTML = '<i class="fas fa-check mr-2"></i> Copied to clipboard';
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.classList.add('opacity-0', 'translate-y-2');
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(notification);
|
||||
}, 300);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// Add animation to details elements
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const details = document.querySelectorAll('.debug-accordion, .technical-error-accordion');
|
||||
details.forEach(detail => {
|
||||
detail.addEventListener('toggle', function() {
|
||||
const icon = this.querySelector('i.fa-chevron-down');
|
||||
if (this.open) {
|
||||
icon.classList.add('rotate-180');
|
||||
} else {
|
||||
icon.classList.remove('rotate-180');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Quizzical Beats - Where trivia meets the rhythm{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col justify-center items-center min-h-[80vh] py-12 px-4">
|
||||
<div class="text-center mb-10">
|
||||
<h1 class="text-4xl md:text-5xl font-bold text-navy-800 font-montserrat mb-4">Welcome to Quizzical Beats</h1>
|
||||
<p class="text-xl text-gray-600 max-w-2xl mx-auto">Where trivia meets the rhythm. Create unforgettable music rounds for your pub quiz or trivia night.</p>
|
||||
</div>
|
||||
|
||||
<div class="w-full max-w-md bg-white shadow-lg rounded-lg overflow-hidden mb-10">
|
||||
<div class="bg-teal-500 text-white text-center py-4">
|
||||
<h2 class="text-2xl font-montserrat font-semibold">Welcome, {{ user_info['display_name'] }}</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<ul class="space-y-3">
|
||||
<li class="flex border-b border-gray-100 pb-2">
|
||||
<span class="text-navy-800 font-semibold w-28">Username:</span>
|
||||
<span class="text-gray-600">{{ current_user.username }}</span>
|
||||
</li>
|
||||
<li class="flex border-b border-gray-100 pb-2">
|
||||
<span class="text-navy-800 font-semibold w-28">Email:</span>
|
||||
<span class="text-gray-600">{{ current_user.email }}</span>
|
||||
</li>
|
||||
{% if current_user.first_name or current_user.last_name %}
|
||||
<li class="flex border-b border-gray-100 pb-2">
|
||||
<span class="text-navy-800 font-semibold w-28">Name:</span>
|
||||
<span class="text-gray-600">{{ current_user.first_name }} {{ current_user.last_name }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
<div class="mt-6 flex flex-col space-y-3">
|
||||
<a href="{{ url_for('generate.build_music_round') }}"
|
||||
class="bg-orange-500 hover:bg-orange-600 text-white font-semibold py-2 px-4 rounded-md transition-colors text-center">
|
||||
<i class="fas fa-music mr-2"></i> Build a Music Round
|
||||
</a>
|
||||
<a href="{{ url_for('core.view_songs') }}"
|
||||
class="bg-teal-500 hover:bg-teal-600 text-white font-semibold py-2 px-4 rounded-md transition-colors text-center">
|
||||
<i class="fas fa-list mr-2"></i> View Your Songs
|
||||
</a>
|
||||
<a href="{{ url_for('users.profile') }}"
|
||||
class="bg-navy-600 hover:bg-navy-700 text-white font-semibold py-2 px-4 rounded-md transition-colors text-center">
|
||||
<i class="fas fa-user mr-2"></i> My Profile
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center text-gray-600 max-w-2xl">
|
||||
<p class="italic">"The soundtrack to your smartest guesses."</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Official Spotify Playlists{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 py-8">
|
||||
<h1 class="text-3xl font-bold mb-2 text-navy-800 font-montserrat">Official Spotify Playlists</h1>
|
||||
<p class="text-gray-600 mb-6">Browse and import playlists from official Spotify accounts worldwide.</p>
|
||||
|
||||
<!-- Direct Token Entry/Display -->
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-6 mb-6">
|
||||
<div class="flex flex-col md:flex-row justify-between items-start gap-4">
|
||||
<div class="w-full md:w-2/3">
|
||||
<h3 class="text-lg font-semibold text-navy-800 mb-2">Spotify Authentication</h3>
|
||||
<p class="text-gray-600 mb-3 text-sm">Enter a bearer token to access the Spotify API directly, bypassing OAuth limitations.</p>
|
||||
|
||||
{% if spotify_username %}
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-3 mb-4">
|
||||
<p class="text-sm text-green-700">
|
||||
<span class="font-medium">✓ Authenticated as:</span> {{ spotify_username }}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form action="{{ url_for('import.update_direct_token') }}" method="POST" class="flex flex-col md:flex-row gap-3 items-end">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input type="hidden" name="return_url" value="{{ request.path }}?{{ request.query_string.decode() }}"/>
|
||||
|
||||
<div class="w-full md:w-2/3">
|
||||
<label for="bearer_token" class="block text-sm font-medium text-gray-700 mb-1">Bearer Token</label>
|
||||
<textarea name="bearer_token" id="bearer_token" rows="1"
|
||||
placeholder="BQBcQ1foQG0x14axu2kQVz..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm text-sm focus:outline-none focus:ring-teal-500 focus:border-teal-500">{{ session_bearer_token }}</textarea>
|
||||
<p class="mt-1 text-xs text-gray-500">Get a token from <a href="https://developer.spotify.com/console/" target="_blank" class="text-teal-600 hover:underline">Spotify Developer Console</a></p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button type="submit" class="px-4 py-2 bg-teal-600 text-white text-sm font-medium rounded-md hover:bg-teal-700 transition-colors">
|
||||
Apply Token
|
||||
</button>
|
||||
{% if session_bearer_token %}
|
||||
<button type="submit" name="clear_token" value="1" class="px-4 py-2 bg-gray-500 text-white text-sm font-medium rounded-md hover:bg-gray-600 transition-colors">
|
||||
Clear Token
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="w-full md:w-1/3 md:text-right bg-blue-50 border border-blue-100 rounded-lg p-3">
|
||||
<h4 class="font-medium text-sm text-blue-800 mb-1">Authentication Mode</h4>
|
||||
{% if direct_mode %}
|
||||
<p class="text-sm text-blue-700 mb-2">Currently using: <span class="font-semibold">Direct Bearer Token</span></p>
|
||||
<a href="{{ url_for('import.import_official_playlists', **request.args) }}" class="text-sm text-blue-600 hover:underline">
|
||||
Switch to OAuth Authentication
|
||||
</a>
|
||||
{% else %}
|
||||
<p class="text-sm text-blue-700 mb-2">Currently using: <span class="font-semibold">OAuth Authentication</span></p>
|
||||
<a href="{{ url_for('import.direct_official_playlists', **request.args) }}" class="text-sm text-blue-600 hover:underline">
|
||||
Switch to Direct Bearer Token
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtering and Account Selection -->
|
||||
<div class="bg-white shadow-md rounded-lg p-6 mb-8">
|
||||
<form method="GET" action="{{ direct_mode and url_for('import.direct_official_playlists') or url_for('import.import_official_playlists') }}" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="filter" class="block text-sm font-medium text-gray-700 mb-1">Filter by Keywords</label>
|
||||
<input type="text" name="filter" id="filter" value="{{ filter_keywords|join(',') }}"
|
||||
placeholder="top,hits,pop,rock,etc"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-teal-500 focus:border-teal-500">
|
||||
<p class="text-xs text-gray-500 mt-1">Comma-separated keywords to search in playlist names</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="account" class="block text-sm font-medium text-gray-700 mb-1">Spotify Account</label>
|
||||
<select name="account" id="account"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-teal-500 focus:border-teal-500">
|
||||
<option value="all" {% if selected_account == 'all' %}selected{% endif %}>All Accounts</option>
|
||||
{% for account in spotify_accounts %}
|
||||
<option value="{{ account }}" {% if selected_account == account %}selected{% endif %}>{{ account }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<input type="checkbox" name="debug" id="debug" value="true" {% if debug_mode %}checked{% endif %}>
|
||||
<label for="debug" class="text-sm text-gray-700">Show Debug Info</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded-md shadow-sm transition-colors">
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Hidden field to persist the bearer token across requests -->
|
||||
{% if session_bearer_token %}
|
||||
<input type="hidden" name="bearer_token" value="{{ session_bearer_token }}">
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if debug_mode %}
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold mb-4">Debug Information</h2>
|
||||
|
||||
<!-- Summary stats -->
|
||||
<div class="overflow-x-auto mb-6">
|
||||
<table class="min-w-full divide-y divide-gray-300">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-3 py-2 bg-gray-100 text-left text-xs font-medium text-gray-600 uppercase tracking-wider">Metric</th>
|
||||
<th class="px-3 py-2 bg-gray-100 text-left text-xs font-medium text-gray-600 uppercase tracking-wider">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="px-3 py-2 whitespace-nowrap text-sm font-medium text-gray-900">Total Playlists Fetched</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-700">{{ debug_info.total_fetched }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-3 py-2 whitespace-nowrap text-sm font-medium text-gray-900">Keyword Filtered Out</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-700">{{ debug_info.filtered_out }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-3 py-2 whitespace-nowrap text-sm font-medium text-gray-900">Duplicates Removed</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-700">{{ debug_info.duplicates_removed }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-3 py-2 whitespace-nowrap text-sm font-medium text-gray-900">Final Count Displayed</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-700">{{ debug_info.total_filtered }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-3 py-2 whitespace-nowrap text-sm font-medium text-gray-900">Total Processing Time</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-700">{{ debug_info.query_time_ms }}ms</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Account stats -->
|
||||
<h3 class="text-lg font-semibold mb-2">Account Statistics</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
||||
{% for account, stats in debug_info.accounts.items() %}
|
||||
<div class="bg-white rounded-md border border-gray-200 p-4">
|
||||
<h4 class="font-medium mb-2">{{ account }}</h4>
|
||||
<ul class="list-disc pl-5 text-sm">
|
||||
<li>Total playlists: {{ stats.total }}</li>
|
||||
<li>Filtered: {{ stats.filtered }}/{{ stats.fetched }}</li>
|
||||
<li>Processing time: {{ stats.time_ms }}ms</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Keyword matches -->
|
||||
{% if debug_info.matched_keywords %}
|
||||
<h3 class="text-lg font-semibold mb-2">Keyword Matches</h3>
|
||||
<div class="bg-white rounded-md border border-gray-200 p-4 mb-6">
|
||||
<ul class="list-disc pl-5 text-sm">
|
||||
{% for keyword, count in debug_info.matched_keywords.items() %}
|
||||
<li><span class="font-medium">"{{ keyword }}"</span>: {{ count }} matches</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Playlist grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{% for playlist in playlists %}
|
||||
<div class="bg-white shadow-md rounded-lg overflow-hidden border border-gray-200">
|
||||
{% if playlist.images and playlist.images|length > 0 %}
|
||||
<img src="{{ playlist.images[0].url }}" class="w-full h-48 object-cover playlist-detail-trigger cursor-pointer" data-id="{{ playlist.id }}" alt="{{ playlist.name }}">
|
||||
{% else %}
|
||||
<div class="w-full h-48 bg-gray-200 flex items-center justify-center">
|
||||
<span class="text-gray-500">No image</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-start">
|
||||
<h5 class="text-xl font-semibold mb-2 text-navy-800 font-montserrat playlist-detail-trigger cursor-pointer" data-id="{{ playlist.id }}">{{ playlist.name }}</h5>
|
||||
<span class="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded">{{ playlist.owner.id }}</span>
|
||||
</div>
|
||||
{% if playlist.description %}
|
||||
<p class="text-gray-700 mb-3 text-sm">{{ playlist.description | replace('<a href="', '') | replace('">', ' - ') | replace('</a>', '') }}</p>
|
||||
{% endif %}
|
||||
<p class="text-gray-600 mb-4">Tracks: {{ playlist.tracks.total }}</p>
|
||||
<div class="flex justify-between items-center">
|
||||
<form action="{{ url_for('import.import_official_playlists')}}" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input type="hidden" name="playlist_id" value="{{ playlist.id }}">
|
||||
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded transition-colors">
|
||||
<i class="fas fa-plus-circle mr-1"></i> Import
|
||||
</button>
|
||||
</form>
|
||||
<div>
|
||||
<a href="https://open.spotify.com/playlist/{{ playlist.id }}" target="_blank" class="text-teal-600 hover:underline text-sm mr-3">
|
||||
<i class="fab fa-spotify mr-1"></i> View
|
||||
</a>
|
||||
<button type="button" class="text-teal-600 hover:text-teal-800 text-sm playlist-detail-trigger" data-id="{{ playlist.id }}">
|
||||
<i class="fas fa-info-circle mr-1"></i> Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-span-3 p-8 text-center bg-gray-50 rounded-lg border border-gray-200">
|
||||
<p class="text-gray-600">No playlists found matching your criteria. Try changing your filters.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Playlist Details Modal -->
|
||||
<div id="details-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-screen overflow-hidden">
|
||||
<div class="flex justify-between items-center border-b border-gray-200 px-6 py-4">
|
||||
<h3 class="text-xl font-semibold text-navy-800 font-montserrat" id="modal-title">Playlist Details</h3>
|
||||
<button id="close-modal" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times fa-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto p-6" style="max-height: calc(100vh - 200px);">
|
||||
<div id="modal-content" class="flex flex-col md:flex-row gap-6">
|
||||
<!-- Content will be loaded here -->
|
||||
<div class="w-full md:w-1/3 flex flex-col items-center">
|
||||
<div class="w-full max-w-xs aspect-square bg-gray-200 rounded-lg mb-4" id="modal-image-container">
|
||||
<img id="modal-image" src="" alt="" class="w-full h-full object-cover rounded-lg">
|
||||
</div>
|
||||
<div id="modal-metadata" class="w-full text-center mb-4">
|
||||
<!-- Metadata will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full md:w-2/3">
|
||||
<p class="text-gray-600 mb-4" id="modal-description"></p>
|
||||
<h4 class="font-semibold text-navy-800 mb-2 flex items-center">
|
||||
<i class="fas fa-music mr-2"></i> Tracks
|
||||
</h4>
|
||||
<div id="modal-tracks-container" class="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-100">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">#</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Title</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Artist</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="modal-tracks" class="bg-white divide-y divide-gray-200">
|
||||
<!-- Tracks will be loaded here -->
|
||||
<tr>
|
||||
<td colspan="4" class="px-4 py-4 text-center text-gray-500">
|
||||
<div class="flex justify-center items-center">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-500"></div>
|
||||
<span class="ml-2">Loading tracks...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-6 py-4 flex justify-end">
|
||||
<form action="{{ url_for('import.import_official_playlists') }}" method="POST" id="modal-import-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input type="hidden" name="playlist_id" id="modal-item-id" value="">
|
||||
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded transition-colors">
|
||||
<i class="fas fa-plus-circle mr-1"></i> Import
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const modal = document.getElementById('details-modal');
|
||||
const closeModal = document.getElementById('close-modal');
|
||||
const modalTitle = document.getElementById('modal-title');
|
||||
const modalDescription = document.getElementById('modal-description');
|
||||
const modalImage = document.getElementById('modal-image');
|
||||
const modalMetadata = document.getElementById('modal-metadata');
|
||||
const modalTracks = document.getElementById('modal-tracks');
|
||||
const modalImportForm = document.getElementById('modal-import-form');
|
||||
const modalItemId = document.getElementById('modal-item-id');
|
||||
|
||||
// Close modal
|
||||
closeModal.addEventListener('click', () => {
|
||||
modal.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Close modal when clicking outside
|
||||
window.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Escape key to close modal
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !modal.classList.contains('hidden')) {
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Format duration from milliseconds to mm:ss
|
||||
function formatDuration(ms) {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Playlist detail functionality
|
||||
document.querySelectorAll('.playlist-detail-trigger').forEach(trigger => {
|
||||
trigger.addEventListener('click', (e) => {
|
||||
const playlistId = e.target.getAttribute('data-id') || e.target.closest('.playlist-detail-trigger').getAttribute('data-id');
|
||||
if (!playlistId) return;
|
||||
|
||||
// Reset modal content
|
||||
modalTitle.textContent = "Loading playlist details...";
|
||||
modalDescription.textContent = "";
|
||||
modalImage.src = "";
|
||||
modalMetadata.innerHTML = "";
|
||||
modalTracks.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="4" class="px-4 py-4 text-center text-gray-500">
|
||||
<div class="flex justify-center items-center">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-500"></div>
|
||||
<span class="ml-2">Loading tracks...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
// Set up import form
|
||||
modalItemId.value = playlistId;
|
||||
|
||||
// Show modal
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
// Fetch playlist details
|
||||
fetch(`/api/spotify/playlist/${playlistId}`)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
// Update modal with playlist details
|
||||
modalTitle.textContent = data.name;
|
||||
|
||||
if (data.description) {
|
||||
// Remove HTML tags from description
|
||||
const cleanDescription = data.description
|
||||
.replace(/<a href="[^"]*">/g, '')
|
||||
.replace(/<\/a>/g, '')
|
||||
.replace(/&/g, '&');
|
||||
modalDescription.textContent = cleanDescription;
|
||||
}
|
||||
|
||||
if (data.image_url) {
|
||||
modalImage.src = data.image_url;
|
||||
modalImage.alt = data.name;
|
||||
}
|
||||
|
||||
// Add metadata
|
||||
modalMetadata.innerHTML = `
|
||||
<p class="font-semibold text-navy-800">By ${data.owner}</p>
|
||||
<p class="text-gray-600">${data.tracks.length} tracks</p>
|
||||
<p class="text-gray-600">Followers: ${data.followers || 'N/A'}</p>
|
||||
`;
|
||||
|
||||
// Add tracks
|
||||
if (data.tracks && data.tracks.length > 0) {
|
||||
modalTracks.innerHTML = '';
|
||||
data.tracks.forEach((track, index) => {
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'hover:bg-gray-50';
|
||||
|
||||
row.innerHTML = `
|
||||
<td class="px-4 py-2 text-sm whitespace-nowrap">${index + 1}</td>
|
||||
<td class="px-4 py-2">${track.name}</td>
|
||||
<td class="px-4 py-2 text-sm">${track.artist}</td>
|
||||
<td class="px-4 py-2 text-sm">${track.duration ? formatDuration(track.duration) : ''}</td>
|
||||
`;
|
||||
|
||||
modalTracks.appendChild(row);
|
||||
});
|
||||
} else {
|
||||
modalTracks.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="4" class="px-4 py-4 text-center text-gray-500">
|
||||
No tracks available
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching playlist details:', error);
|
||||
modalTitle.textContent = "Error Loading Details";
|
||||
modalDescription.textContent = "There was a problem loading the playlist details.";
|
||||
modalTracks.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="4" class="px-4 py-4 text-center text-red-500">
|
||||
Failed to load tracks. Please try again later.
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,132 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Import Playlist - Quizzical Beats{% 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-2">Import Playlist</h1>
|
||||
<p class="text-gray-600">Create a music quiz round from a Spotify or Deezer playlist.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow-md rounded-lg p-6">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="mb-4 p-4 border-l-4 {% if category == 'error' %}border-red-500 bg-red-50 text-red-700{% else %}border-green-500 bg-green-50 text-green-700{% endif %}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" action="{{ url_for('generate.import_playlist') }}" id="importPlaylistForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-gray-700 text-sm font-bold mb-2" for="round_name">
|
||||
Round Name (optional)
|
||||
</label>
|
||||
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="round_name" name="round_name" type="text" placeholder="Enter a name for this round">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-gray-700 text-sm font-bold mb-2" for="platform">
|
||||
Platform
|
||||
</label>
|
||||
<div class="flex space-x-4">
|
||||
<label class="inline-flex items-center">
|
||||
<input type="radio" class="form-radio" name="platform" value="spotify" checked>
|
||||
<span class="ml-2">Spotify</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center">
|
||||
<input type="radio" class="form-radio" name="platform" value="deezer">
|
||||
<span class="ml-2">Deezer</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="block text-gray-700 text-sm font-bold mb-2" for="playlist_url">
|
||||
Playlist URL or ID
|
||||
</label>
|
||||
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="playlist_url" name="playlist_url" type="text" placeholder="Enter Spotify or Deezer playlist URL">
|
||||
<p class="text-gray-600 text-xs italic mt-1">Example: https://open.spotify.com/playlist/37i9dQZF1DX0XUsuxWHRQd or https://www.deezer.com/en/playlist/1111111</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<button class="bg-orange-500 hover:bg-orange-600 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline transition-colors"
|
||||
type="submit" id="importButton">
|
||||
<i class="fas fa-cloud-download-alt mr-2"></i>Import Playlist
|
||||
</button>
|
||||
<a class="inline-block align-baseline font-bold text-sm text-navy-700 hover:text-navy-800"
|
||||
href="{{ url_for('generate.build_music_round') }}">
|
||||
<i class="fas fa-arrow-left mr-1"></i>Back to Quiz Builder
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</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">Import Tips</h3>
|
||||
<ul class="list-disc pl-5 space-y-2 text-gray-700">
|
||||
<li>Make sure your playlist is public or at least accessible via link</li>
|
||||
<li>Only the first 8 songs from the playlist will be imported for the quiz</li>
|
||||
<li>Preview URLs might not be available for all songs</li>
|
||||
<li>Songs will be saved in our database for future use</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Modal -->
|
||||
<div id="loadingModal" class="fixed inset-0 flex items-center justify-center z-50 bg-black bg-opacity-50 hidden">
|
||||
<div class="bg-white p-8 rounded-lg shadow-lg text-center max-w-md w-full">
|
||||
<div class="animate-spin rounded-full h-16 w-16 border-t-4 border-b-4 border-orange-500 mx-auto mb-4"></div>
|
||||
<h3 class="text-xl font-bold text-navy-800 mb-2">Importing Playlist...</h3>
|
||||
<p class="text-gray-600 mb-4">This might take a moment as we fetch and process each song.</p>
|
||||
<div class="text-sm text-gray-500">
|
||||
<p>We're working on:</p>
|
||||
<ul id="importSteps" class="mt-2 space-y-2 text-left px-4">
|
||||
<li><i class="fas fa-check-circle text-green-500 hidden step-complete"></i> <i class="fas fa-spinner fa-spin text-orange-500 step-in-progress"></i> <span class="ml-2">Fetching playlist data</span></li>
|
||||
<li><i class="fas fa-check-circle text-green-500 hidden step-complete"></i> <i class="fas fa-spinner fa-spin text-orange-500 hidden step-in-progress"></i> <span class="ml-2">Importing songs to database</span></li>
|
||||
<li><i class="fas fa-check-circle text-green-500 hidden step-complete"></i> <i class="fas fa-spinner fa-spin text-orange-500 hidden step-in-progress"></i> <span class="ml-2">Creating quiz round</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('importPlaylistForm');
|
||||
const loadingModal = document.getElementById('loadingModal');
|
||||
const importSteps = document.querySelectorAll('#importSteps li');
|
||||
|
||||
form.addEventListener('submit', function(e) {
|
||||
// Show loading modal when form is submitted
|
||||
loadingModal.classList.remove('hidden');
|
||||
|
||||
// Simulate progress updates (since we can't track actual backend progress)
|
||||
setTimeout(() => {
|
||||
// Mark first step as complete and start second step
|
||||
importSteps[0].querySelector('.step-in-progress').classList.add('hidden');
|
||||
importSteps[0].querySelector('.step-complete').classList.remove('hidden');
|
||||
importSteps[1].querySelector('.step-in-progress').classList.remove('hidden');
|
||||
}, 2000);
|
||||
|
||||
setTimeout(() => {
|
||||
// Mark second step as complete and start third step
|
||||
importSteps[1].querySelector('.step-in-progress').classList.add('hidden');
|
||||
importSteps[1].querySelector('.step-complete').classList.remove('hidden');
|
||||
importSteps[2].querySelector('.step-in-progress').classList.remove('hidden');
|
||||
}, 4000);
|
||||
|
||||
// Let the form submission continue
|
||||
return true;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,260 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Raw Spotify Playlists{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 py-8">
|
||||
<h1 class="text-3xl font-bold mb-2 text-navy-800 font-montserrat">Raw Spotify API Response</h1>
|
||||
<p class="text-gray-600 mb-6">Examining the raw Spotify API response for playlist queries.</p>
|
||||
|
||||
<!-- Parameters Form -->
|
||||
<div class="bg-white shadow-md rounded-lg p-6 mb-8">
|
||||
<form method="GET" action="{{ url_for('import.get_raw_playlists') }}" class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label for="account" class="block text-sm font-medium text-gray-700 mb-1">Spotify Account</label>
|
||||
<select name="account" id="account"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-teal-500 focus:border-teal-500">
|
||||
<option value="spotify" {% if account == 'spotify' %}selected{% endif %}>spotify</option>
|
||||
<option value="spotifycharts" {% if account == 'spotifycharts' %}selected{% endif %}>spotifycharts</option>
|
||||
<option value="spotifymaps" {% if account == 'spotifymaps' %}selected{% endif %}>spotifymaps</option>
|
||||
<option value="spotifyuk" {% if account == 'spotifyuk' %}selected{% endif %}>spotifyuk</option>
|
||||
<option value="spotifyusa" {% if account == 'spotifyusa' %}selected{% endif %}>spotifyusa</option>
|
||||
<option value="spotify_germany" {% if account == 'spotify_germany' %}selected{% endif %}>spotify_germany</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="limit" class="block text-sm font-medium text-gray-700 mb-1">Limit</label>
|
||||
<input type="number" name="limit" id="limit" min="1" max="50" value="{{ limit }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-teal-500 focus:border-teal-500">
|
||||
<p class="text-xs text-gray-500 mt-1">Max 50 items per request</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="offset" class="block text-sm font-medium text-gray-700 mb-1">Offset</label>
|
||||
<input type="number" name="offset" id="offset" min="0" value="{{ offset }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-teal-500 focus:border-teal-500">
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-3">
|
||||
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded-md shadow-sm transition-colors">
|
||||
Get Raw Response
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Spotipy Results -->
|
||||
<div>
|
||||
<h2 class="text-xl font-bold mb-4">Spotipy Response</h2>
|
||||
|
||||
{% if results.spotipy.error %}
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
|
||||
<h3 class="font-medium text-red-800 mb-2">Error:</h3>
|
||||
<pre class="text-red-700 text-sm whitespace-pre-wrap">{{ results.spotipy.error }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if results.spotipy.raw_response %}
|
||||
<div class="bg-white shadow-md rounded-lg p-6">
|
||||
<!-- Response metadata -->
|
||||
<div class="mb-4">
|
||||
<h3 class="font-medium text-gray-800 mb-2">Response Metadata:</h3>
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td class="py-2 text-sm font-medium text-gray-700">Total Playlists</td>
|
||||
<td class="py-2 text-sm text-gray-900">{{ results.spotipy.raw_response.total }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 text-sm font-medium text-gray-700">Items Returned</td>
|
||||
<td class="py-2 text-sm text-gray-900">{{ results.spotipy.raw_response.items|length }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 text-sm font-medium text-gray-700">Has Next Page</td>
|
||||
<td class="py-2 text-sm text-gray-900">{{ "Yes" if results.spotipy.raw_response.next else "No" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 text-sm font-medium text-gray-700">Has Previous Page</td>
|
||||
<td class="py-2 text-sm text-gray-900">{{ "Yes" if results.spotipy.raw_response.previous else "No" }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Playlists -->
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-800 mb-2">Playlists ({{ results.spotipy.raw_response.items|length }}):</h3>
|
||||
<div class="overflow-y-auto max-h-96 border border-gray-200 rounded-lg">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">#</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Tracks</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{% for playlist in results.spotipy.raw_response.items %}
|
||||
<tr>
|
||||
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-500">{{ loop.index }}</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-500">{{ playlist.id }}</td>
|
||||
<td class="px-3 py-2 text-sm text-gray-900">{{ playlist.name }}</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-500">{{ playlist.tracks.total }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination links -->
|
||||
{% if results.spotipy.raw_response.previous or results.spotipy.raw_response.next %}
|
||||
<div class="mt-4 flex justify-between">
|
||||
{% if results.spotipy.raw_response.previous %}
|
||||
<a href="{{ url_for('import.get_raw_playlists', account=account, limit=limit, offset=offset-limit) }}"
|
||||
class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded text-sm text-gray-700">
|
||||
« Previous
|
||||
</a>
|
||||
{% else %}
|
||||
<span></span>
|
||||
{% endif %}
|
||||
|
||||
{% if results.spotipy.raw_response.next %}
|
||||
<a href="{{ url_for('import.get_raw_playlists', account=account, limit=limit, offset=offset+limit) }}"
|
||||
class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded text-sm text-gray-700">
|
||||
Next »
|
||||
</a>
|
||||
{% else %}
|
||||
<span></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Raw JSON -->
|
||||
<div class="mt-4">
|
||||
<details>
|
||||
<summary class="cursor-pointer text-teal-600 hover:text-teal-800">View Raw JSON</summary>
|
||||
<div class="mt-2 p-4 bg-gray-50 rounded-lg overflow-x-auto">
|
||||
<pre class="text-xs text-gray-800">{{ results.spotipy.raw_response | tojson(indent=2) }}</pre>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 text-center">
|
||||
<p class="text-gray-500">No response data available</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Direct API Results -->
|
||||
<div>
|
||||
<h2 class="text-xl font-bold mb-4">Direct API Response</h2>
|
||||
|
||||
{% if results.direct.error %}
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
|
||||
<h3 class="font-medium text-red-800 mb-2">Error:</h3>
|
||||
<pre class="text-red-700 text-sm whitespace-pre-wrap">{{ results.direct.error }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if results.direct.raw_response %}
|
||||
<div class="bg-white shadow-md rounded-lg p-6">
|
||||
<!-- Response metadata -->
|
||||
<div class="mb-4">
|
||||
<h3 class="font-medium text-gray-800 mb-2">Response Metadata:</h3>
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td class="py-2 text-sm font-medium text-gray-700">Total Playlists</td>
|
||||
<td class="py-2 text-sm text-gray-900">{{ results.direct.raw_response.total }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 text-sm font-medium text-gray-700">Items Returned</td>
|
||||
<td class="py-2 text-sm text-gray-900">{{ results.direct.raw_response.items|length }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 text-sm font-medium text-gray-700">Has Next Page</td>
|
||||
<td class="py-2 text-sm text-gray-900">{{ "Yes" if results.direct.raw_response.next else "No" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 text-sm font-medium text-gray-700">Has Previous Page</td>
|
||||
<td class="py-2 text-sm text-gray-900">{{ "Yes" if results.direct.raw_response.previous else "No" }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Playlists -->
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-800 mb-2">Playlists ({{ results.direct.raw_response.items|length }}):</h3>
|
||||
<div class="overflow-y-auto max-h-96 border border-gray-200 rounded-lg">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">#</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Tracks</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{% for playlist in results.direct.raw_response.items %}
|
||||
<tr>
|
||||
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-500">{{ loop.index }}</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-500">{{ playlist.id }}</td>
|
||||
<td class="px-3 py-2 text-sm text-gray-900">{{ playlist.name }}</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-500">{{ playlist.tracks.total }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination links -->
|
||||
{% if results.direct.raw_response.previous or results.direct.raw_response.next %}
|
||||
<div class="mt-4 flex justify-between">
|
||||
{% if results.direct.raw_response.previous %}
|
||||
<a href="{{ url_for('import.get_raw_playlists', account=account, limit=limit, offset=offset-limit) }}"
|
||||
class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded text-sm text-gray-700">
|
||||
« Previous
|
||||
</a>
|
||||
{% else %}
|
||||
<span></span>
|
||||
{% endif %}
|
||||
|
||||
{% if results.direct.raw_response.next %}
|
||||
<a href="{{ url_for('import.get_raw_playlists', account=account, limit=limit, offset=offset+limit) }}"
|
||||
class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded text-sm text-gray-700">
|
||||
Next »
|
||||
</a>
|
||||
{% else %}
|
||||
<span></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Raw JSON -->
|
||||
<div class="mt-4">
|
||||
<details>
|
||||
<summary class="cursor-pointer text-teal-600 hover:text-teal-800">View Raw JSON</summary>
|
||||
<div class="mt-2 p-4 bg-gray-50 rounded-lg overflow-x-auto">
|
||||
<pre class="text-xs text-gray-800">{{ results.direct.raw_response | tojson(indent=2) }}</pre>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 text-center">
|
||||
<p class="text-gray-500">No response data available</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,86 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Review Music Quiz - Quizzical Beats{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 py-8">
|
||||
<h1 class="text-3xl font-bold mb-4 text-navy-800 font-montserrat">Review Your Music Quiz</h1>
|
||||
<div class="bg-white shadow-md rounded-lg p-6 mb-6">
|
||||
<p class="mb-4 font-medium text-navy-700">Quiz Criteria: {{ round_criteria }}
|
||||
{% if genre %}
|
||||
<span class="inline-block bg-orange-100 text-orange-800 text-sm font-medium px-2.5 py-0.5 rounded ml-2">Genre: {{ genre }}</span>
|
||||
{% endif %}
|
||||
{% if decade %}
|
||||
<span class="inline-block bg-teal-100 text-teal-800 text-sm font-medium px-2.5 py-0.5 rounded ml-2">Decade: {{ decade }}</span>
|
||||
{% endif %}
|
||||
{% if tag %}
|
||||
<span class="inline-block bg-purple-100 text-purple-800 text-sm font-medium px-2.5 py-0.5 rounded ml-2">Tag: {{ tag }}</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto bg-white shadow-md rounded-lg mb-8">
|
||||
<table class="w-full table-auto">
|
||||
<thead class="bg-navy-50 text-navy-800">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-semibold">#</th>
|
||||
<th class="px-4 py-3 text-left font-semibold">Cover</th>
|
||||
<th class="px-4 py-3 text-left font-semibold">Title</th>
|
||||
<th class="px-4 py-3 text-left font-semibold">Artist</th>
|
||||
<th class="px-4 py-3 text-left font-semibold">Year</th>
|
||||
<th class="px-4 py-3 text-left font-semibold">Genre</th>
|
||||
<th class="px-4 py-3 text-left font-semibold">Preview</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
{% for song in songs %}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 font-medium">{{ loop.index }}</td>
|
||||
<td class="px-4 py-3"><img src="{{ song.cover_url }}" alt="{{ song.title }}" class="w-16 h-16 object-cover rounded-md shadow-sm"></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">{{ song.year }}</td>
|
||||
<td class="px-4 py-3">{{ song.genre }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<audio controls class="w-full max-w-[200px]" src="{{ song.preview_url }}">Your browser does not support the audio element.</audio>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-4 mt-6">
|
||||
<form method="POST" action="{{ url_for('generate.save_round') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input type="hidden" name="round_criteria" value="{{ round_criteria }}">
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="round_name" class="block text-gray-700 text-sm font-bold mb-2">Round Name (Optional)</label>
|
||||
<input type="text" id="round_name" name="round_name" value="{{ round_name or '' }}"
|
||||
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
placeholder="Enter a name for this round">
|
||||
</div>
|
||||
|
||||
{% if genre %}
|
||||
<input type="hidden" name="genre" value="{{ genre }}">
|
||||
{% endif %}
|
||||
{% if decade %}
|
||||
<input type="hidden" name="decade" value="{{ decade }}">
|
||||
{% endif %}
|
||||
{% if tag %}
|
||||
<input type="hidden" name="tag" value="{{ tag }}">
|
||||
{% endif %}
|
||||
{% for song in songs %}
|
||||
<input type="hidden" name="song_id" value="{{ song.id }}">
|
||||
{% endfor %}
|
||||
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded transition-colors font-semibold">
|
||||
<i class="fas fa-save mr-2"></i> Save This Quiz
|
||||
</button>
|
||||
</form>
|
||||
<a href="{{ url_for('generate.build_music_round') }}" class="inline-block bg-navy-600 hover:bg-navy-700 text-white py-2 px-4 rounded transition-colors font-semibold">
|
||||
<i class="fas fa-refresh mr-2"></i> Generate Different Quiz
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Music Quizzes - Quizzical Beats{% 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-2">Your Music Quizzes</h1>
|
||||
<p class="text-gray-600">Browse and manage all your created music quizzes.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow-md rounded-lg overflow-hidden">
|
||||
<div class="p-4 bg-navy-50 border-b">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-xl font-semibold text-navy-800">Saved Quizzes</h2>
|
||||
<a href="{{ url_for('generate.build_music_round') }}" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded-md transition-colors text-sm font-semibold">
|
||||
<i class="fas fa-plus mr-1"></i> Create New Quiz
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full table-auto">
|
||||
<thead class="bg-gray-100 text-gray-700">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-semibold">ID</th>
|
||||
<th class="px-4 py-3 text-left font-semibold">Name</th>
|
||||
<th class="px-4 py-3 text-left font-semibold">Type</th>
|
||||
<th class="px-4 py-3 text-left font-semibold">Criteria</th>
|
||||
<th class="px-4 py-3 text-left font-semibold">Created</th>
|
||||
<th class="px-4 py-3 text-left font-semibold">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
{% for round in rounds %}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3">{{ round.id }}</td>
|
||||
<td class="px-4 py-3 font-medium">{{ round.name or 'Quiz #' + round.id|string }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full
|
||||
{% if round.round_type == 'Random' %}bg-navy-100 text-navy-800
|
||||
{% elif round.round_type == 'Decade' %}bg-teal-100 text-teal-800
|
||||
{% elif round.round_type == 'Genre' %}bg-orange-100 text-orange-800
|
||||
{% elif round.round_type == 'Tag' %}bg-purple-100 text-purple-800
|
||||
{% else %}bg-gray-100 text-gray-800{% endif %}">
|
||||
{{ round.round_type }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">{{ round.round_criteria_used }}</td>
|
||||
<td class="px-4 py-3 text-gray-600 text-sm">{{ round.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<a href="{{ url_for('rounds.round_detail', round_id=round.id) }}"
|
||||
class="bg-teal-500 hover:bg-teal-600 text-white py-1.5 px-3 rounded text-sm transition-colors">
|
||||
<i class="fas fa-eye mr-1"></i> View
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-8 text-center text-gray-500">
|
||||
<p class="mb-3">You haven't created any music quizzes yet.</p>
|
||||
<a href="{{ url_for('generate.build_music_round') }}" class="inline-block bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded transition-colors text-sm">
|
||||
<i class="fas fa-plus-circle mr-1"></i> Create Your First Quiz
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</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">Quiz Tips</h3>
|
||||
<ul class="list-disc pl-5 space-y-2 text-gray-700">
|
||||
<li>Export your quizzes to PDF for easy printing of answer sheets</li>
|
||||
<li>Generate MP3s to play your music round with consistent timing</li>
|
||||
<li>Mix different decades and genres for a balanced challenge</li>
|
||||
<li>Keep track of which rounds you've used to avoid repeating songs</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex gap-4 justify-center">
|
||||
<a href="{{ url_for('generate.import_playlist') }}" class="inline-block bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded-md transition-colors text-sm font-semibold">
|
||||
<i class="fas fa-cloud-download-alt mr-1"></i> Import Playlist
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,70 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Import from {{ service_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-2xl mx-auto px-4 py-10">
|
||||
<div class="bg-white rounded-lg shadow-md p-8">
|
||||
<h2 class="text-3xl font-bold mb-4 text-center text-navy-800 font-montserrat">
|
||||
Import {{ item_type }} from {{ service_name }}
|
||||
{% if service_name == 'Spotify' %}<i class="fab fa-spotify ml-2 text-sm"></i>{% elif service_name == 'Deezer' %}<i class="fab fa-deezer ml-2 text-sm"></i>{% endif %}
|
||||
</h2>
|
||||
|
||||
<div class="mb-8 bg-gray-50 rounded-lg p-5 border border-gray-200">
|
||||
<h3 class="font-semibold text-lg mb-2 flex items-center text-navy-800">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-teal-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
How to find the {{ service_name }} {{ item_type|lower }} ID
|
||||
</h3>
|
||||
<ol class="text-gray-700 space-y-2">
|
||||
<li>1. Go to the {{ service_name }} website and navigate to your desired {{ item_type|lower }}</li>
|
||||
<li>2. Look at the URL in your browser's address bar</li>
|
||||
<li>3. Find the ID in the URL format shown below:</li>
|
||||
</ol>
|
||||
<div class="mt-3 p-3 bg-gray-100 rounded border border-gray-300 font-mono text-sm break-all">
|
||||
{{ url_example_prefix }}<span class="font-bold text-teal-600">{{ url_example_id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ form_action }}" class="space-y-6">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div>
|
||||
<label for="{{ id_field }}" class="block text-sm font-medium text-gray-700 mb-2">{{ item_type }} ID:</label>
|
||||
<input type="text" id="{{ id_field }}" name="{{ id_field }}" placeholder="Enter {{ item_type|lower }} ID..." required
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center pt-2">
|
||||
<a href="{{ back_url }}" class="inline-flex items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-800 rounded-md transition-colors border border-gray-300">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Back to Search
|
||||
</a>
|
||||
<button type="submit" class="inline-flex items-center px-6 py-2 rounded-md font-medium text-white transition-colors bg-orange-500 hover:bg-orange-600">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
Import {{ item_type }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-center text-sm text-gray-500">
|
||||
<p>Importing from {{ service_name }} allows you to add songs directly to your music round collection.</p>
|
||||
<div class="mt-2">
|
||||
{% if service_name == 'Spotify' %}
|
||||
<span class="inline-flex items-center text-navy-800">
|
||||
<i class="fab fa-spotify mr-1"></i> Spotify Integration
|
||||
</span>
|
||||
{% elif service_name == 'Deezer' %}
|
||||
<span class="inline-flex items-center text-navy-800">
|
||||
<i class="fab fa-deezer mr-1"></i> Deezer Integration
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,169 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}{{ service_name }} Search - Quizzical Beats{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto px-4 py-8">
|
||||
<div class="bg-white shadow-md rounded-lg overflow-hidden">
|
||||
<div class="bg-navy-800 text-white px-6 py-4">
|
||||
<h2 class="text-xl font-semibold font-montserrat">
|
||||
{% if service_name == 'Spotify' %}
|
||||
<i class="fab fa-spotify mr-2"></i>
|
||||
{% elif service_name == 'Deezer' %}
|
||||
<i class="fab fa-deezer mr-2"></i>
|
||||
{% endif %}
|
||||
Search {{ service_name }}
|
||||
</h2>
|
||||
<p class="text-sm opacity-90">Import songs directly to your music quiz library</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form action="{{ search_results_url }}" method="POST" class="mb-6">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="mb-3">
|
||||
<input type="text" class="w-full px-4 py-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
|
||||
name="search_term"
|
||||
placeholder="Search for tracks, albums, or playlists..."
|
||||
aria-label="Search term" required>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 mb-4">
|
||||
Search for music on {{ service_name }} by artist, song title, album, or playlist name.
|
||||
</div>
|
||||
<button class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-6 rounded-md transition-colors flex items-center" type="submit">
|
||||
<i class="fas fa-search mr-2"></i> Search
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-8">
|
||||
<div class="border border-gray-200 rounded-lg p-5 hover:shadow-md transition-shadow">
|
||||
<div class="text-center">
|
||||
<i class="{% if service_name == 'Spotify' %}fab fa-spotify{% elif service_name == 'Deezer' %}fab fa-deezer{% endif %} fa-3x text-navy-800 mb-4"></i>
|
||||
<h5 class="text-lg font-semibold text-navy-800 font-montserrat">Browse Official Playlists</h5>
|
||||
<p class="text-gray-600 mb-4">Discover curated collections on {{ service_name }}</p>
|
||||
<a href="{{ browse_playlists_url }}" class="inline-block bg-teal-500 hover:bg-teal-600 text-white py-2 px-4 rounded-md transition-colors">
|
||||
Browse Playlists
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border border-gray-200 rounded-lg p-5 hover:shadow-md transition-shadow">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-link fa-3x text-navy-800 mb-4"></i>
|
||||
<h5 class="text-lg font-semibold text-navy-800 font-montserrat">Import by URL</h5>
|
||||
<p class="text-gray-600 mb-4">Enter a {{ service_name }} track, album or playlist URL</p>
|
||||
<button class="inline-block bg-teal-500 hover:bg-teal-600 text-white py-2 px-4 rounded-md transition-colors" type="button" data-bs-toggle="modal" data-bs-target="#{{ service_name|lower }}UrlModal">
|
||||
Import by URL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-8 text-sm text-gray-500">
|
||||
<p>Looking for something specific? Try our <a href="{{ url_for('core.view_songs') }}" class="text-teal-600 hover:underline">song library</a> to see what's already in your collection.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- URL Import Modal -->
|
||||
<div class="modal fade" id="{{ service_name|lower }}UrlModal" tabindex="-1" aria-labelledby="{{ service_name|lower }}UrlModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="{{ service_name|lower }}UrlModalLabel">Import from {{ service_name }} URL</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="{{ service_name|lower }}UrlForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="mb-3">
|
||||
<label for="{{ service_name|lower }}Url" class="block text-sm font-medium text-gray-700 mb-1">{{ service_name }} URL</label>
|
||||
<input type="url" class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-teal-500 focus:border-teal-500" id="{{ service_name|lower }}Url" placeholder="{{ url_placeholder }}" required>
|
||||
<div class="text-sm text-gray-500 mt-1">
|
||||
Paste a {{ service_name }} track, album, or playlist URL
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button type="button" class="bg-gray-300 hover:bg-gray-400 text-gray-800 py-2 px-4 rounded transition-colors mr-2" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded transition-colors">Import</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.getElementById('{{ service_name|lower }}UrlForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const url = document.getElementById('{{ service_name|lower }}Url').value;
|
||||
if (!url) return;
|
||||
|
||||
let match;
|
||||
let formAction;
|
||||
let idField;
|
||||
let idValue;
|
||||
|
||||
{% if service_name == 'Spotify' %}
|
||||
if (match = url.match(/spotify\.com\/track\/([a-zA-Z0-9]+)/)) {
|
||||
formAction = "{{ track_import_url }}";
|
||||
idField = "song_id";
|
||||
idValue = match[1];
|
||||
} else if (match = url.match(/spotify\.com\/album\/([a-zA-Z0-9]+)/)) {
|
||||
formAction = "{{ album_import_url }}";
|
||||
idField = "album_id";
|
||||
idValue = match[1];
|
||||
} else if (match = url.match(/spotify\.com\/playlist\/([a-zA-Z0-9]+)/)) {
|
||||
formAction = "{{ playlist_import_url }}";
|
||||
idField = "playlist_id";
|
||||
idValue = match[1];
|
||||
} else {
|
||||
alert("Invalid Spotify URL. Please enter a valid track, album, or playlist URL.");
|
||||
return;
|
||||
}
|
||||
{% elif service_name == 'Deezer' %}
|
||||
if (match = url.match(/deezer\.com\/(?:..\/)?track\/(\d+)/)) {
|
||||
formAction = "{{ track_import_url }}";
|
||||
idField = "track_id";
|
||||
idValue = match[1];
|
||||
} else if (match = url.match(/deezer\.com\/(?:..\/)?album\/(\d+)/)) {
|
||||
formAction = "{{ album_import_url }}";
|
||||
idField = "album_id";
|
||||
idValue = match[1];
|
||||
} else if (match = url.match(/deezer\.com\/(?:..\/)?playlist\/(\d+)/)) {
|
||||
formAction = "{{ playlist_import_url }}";
|
||||
idField = "playlist_id";
|
||||
idValue = match[1];
|
||||
} else {
|
||||
alert("Invalid Deezer URL. Please enter a valid track, album, or playlist URL.");
|
||||
return;
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
// Create and submit the form
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = formAction;
|
||||
form.style.display = 'none';
|
||||
|
||||
// Add CSRF token
|
||||
const csrfToken = document.querySelector('input[name="csrf_token"]').value;
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = csrfToken;
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
// Add ID field
|
||||
const idInput = document.createElement('input');
|
||||
idInput.type = 'hidden';
|
||||
idInput.name = idField;
|
||||
idInput.value = idValue;
|
||||
form.appendChild(idInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,516 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}{{ service_name }} Search Results: {{ search_term }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 py-8">
|
||||
<h2 class="text-3xl font-bold mb-4 text-navy-800 font-montserrat">Search Results for "{{ search_term }}"</h2>
|
||||
<p><a href="{{ search_url }}" class="inline-flex items-center bg-gray-200 hover:bg-gray-300 text-gray-700 py-2 px-4 rounded transition-colors mb-4">
|
||||
<i class="fas fa-arrow-left mr-2"></i> Back to Search
|
||||
</a></p>
|
||||
|
||||
<div class="mb-6">
|
||||
<form method="POST" action="{{ url_for(request.endpoint) }}" class="flex flex-col sm:flex-row gap-2 mb-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input type="text" class="flex-grow py-2 px-4 rounded-lg border border-gray-300 focus:ring-2 focus:ring-teal-500 focus:border-teal-500" placeholder="Search for tracks, albums, or playlists" name="search_term" value="{{ search_term }}">
|
||||
<button class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-6 rounded-lg transition-colors" type="submit">Search</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="mb-6">
|
||||
<div class="border-b border-gray-200">
|
||||
<nav class="flex flex-wrap -mb-px" aria-label="Tabs">
|
||||
<button class="tab-btn inline-block p-4 text-teal-500 border-teal-500 border-b-2 rounded-t-lg active" id="tracks-tab" data-tab="tracks" type="button" role="tab">
|
||||
{{ tracks_label }} ({{ tracks|length }})
|
||||
</button>
|
||||
<button class="tab-btn inline-block p-4 text-gray-600 hover:text-gray-800 hover:border-gray-300 border-b-2 border-transparent rounded-t-lg" id="albums-tab" data-tab="albums" type="button" role="tab">
|
||||
Albums ({{ albums|length }})
|
||||
</button>
|
||||
<button class="tab-btn inline-block p-4 text-gray-600 hover:text-gray-800 hover:border-gray-300 border-b-2 border-transparent rounded-t-lg" id="playlists-tab" data-tab="playlists" type="button" role="tab">
|
||||
Playlists ({{ playlists|length }})
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<!-- Tracks Section -->
|
||||
<div id="tracks" class="tab-pane block">
|
||||
<div class="bg-white shadow-md rounded-lg mb-5">
|
||||
<div class="p-6">
|
||||
{% if tracks %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full table-auto">
|
||||
<thead class="bg-gray-100 text-gray-700">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Title</th>
|
||||
<th class="px-4 py-3 text-left">Artist</th>
|
||||
{% if has_preview %}
|
||||
<th class="px-4 py-3 text-left">Preview</th>
|
||||
{% else %}
|
||||
<th class="px-4 py-3 text-left">Album</th>
|
||||
{% endif %}
|
||||
<th class="px-4 py-3 text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
{% for track in tracks %}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 flex items-center">
|
||||
{% if track.image_url %}
|
||||
<img src="{{ track.image_url }}" alt="Album cover" class="w-12 h-12 rounded mr-2">
|
||||
{% endif %}
|
||||
{{ track.name }}
|
||||
</td>
|
||||
<td class="px-4 py-3">{{ track.artist }}</td>
|
||||
{% if has_preview %}
|
||||
<td class="px-4 py-3">
|
||||
{% if track.preview_url %}
|
||||
<audio controls class="w-full max-w-[200px]">
|
||||
<source src="{{ track.preview_url }}" type="audio/mpeg">
|
||||
Your browser does not support the audio element.</audio>
|
||||
{% else %}
|
||||
<span class="text-gray-500">No preview available</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% else %}
|
||||
<td class="px-4 py-3">{{ track.album }}</td>
|
||||
{% endif %}
|
||||
<td class="px-4 py-3">
|
||||
<form action="{{ track_import_url }}" method="POST" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input type="hidden" name="{{ track_id_field }}" value="{{ track.id }}">
|
||||
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-1.5 px-3 rounded text-sm transition-colors">
|
||||
<i class="fas fa-plus-circle mr-1"></i> Import
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-blue-100 border border-blue-400 text-blue-700 px-4 py-3 rounded relative" role="alert">No tracks found matching your search.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Albums Section -->
|
||||
<div id="albums" class="tab-pane hidden">
|
||||
<div class="bg-white shadow-md rounded-lg mb-5">
|
||||
<div class="p-6">
|
||||
{% if albums %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{% for album in albums %}
|
||||
<div class="bg-white rounded-lg overflow-hidden shadow-md">
|
||||
{% if album.image_url %}
|
||||
<img src="{{ album.image_url }}" class="w-full h-48 object-cover album-detail-trigger cursor-pointer" data-id="{{ album.id }}" alt="{{ album.name }} cover">
|
||||
{% endif %}
|
||||
<div class="p-4">
|
||||
<h5 class="text-lg font-semibold text-navy-800 album-detail-trigger cursor-pointer" data-id="{{ album.id }}">{{ album.name }}</h5>
|
||||
<p class="text-gray-700">{{ album.artist }}</p>
|
||||
{% if album.track_count %}
|
||||
<p class="text-gray-600 text-sm">{{ album.track_count }} tracks</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="bg-gray-100 px-4 py-2 flex justify-between items-center">
|
||||
<button type="button" class="text-teal-600 hover:text-teal-800 text-sm album-detail-trigger" data-id="{{ album.id }}">
|
||||
<i class="fas fa-info-circle mr-1"></i> Details
|
||||
</button>
|
||||
<form action="{{ album_import_url }}" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input type="hidden" name="{{ album_id_field }}" value="{{ album.id }}">
|
||||
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-1.5 px-3 rounded text-sm transition-colors">
|
||||
<i class="fas fa-plus-circle mr-1"></i> Import
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-blue-100 border border-blue-400 text-blue-700 px-4 py-3 rounded relative" role="alert">No albums found matching your search.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Playlists Section -->
|
||||
<div id="playlists" class="tab-pane hidden">
|
||||
<div class="bg-white shadow-md rounded-lg mb-5">
|
||||
<div class="p-6">
|
||||
{% if playlists %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{% for playlist in playlists %}
|
||||
<div class="bg-white rounded-lg overflow-hidden shadow-md">
|
||||
{% if playlist.image_url %}
|
||||
<img src="{{ playlist.image_url }}" class="w-full h-48 object-cover playlist-detail-trigger cursor-pointer" data-id="{{ playlist.id }}" alt="{{ playlist.name }} cover">
|
||||
{% endif %}
|
||||
<div class="p-4">
|
||||
<h5 class="text-lg font-semibold text-navy-800 playlist-detail-trigger cursor-pointer" data-id="{{ playlist.id }}">{{ playlist.name }}</h5>
|
||||
<p class="text-gray-700">By {{ playlist.owner }}</p>
|
||||
{% if playlist.track_count %}
|
||||
<p class="text-gray-600 text-sm">{{ playlist.track_count }} tracks</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="bg-gray-100 px-4 py-2 flex justify-between items-center">
|
||||
<button type="button" class="text-teal-600 hover:text-teal-800 text-sm playlist-detail-trigger" data-id="{{ playlist.id }}">
|
||||
<i class="fas fa-info-circle mr-1"></i> Details
|
||||
</button>
|
||||
<form action="{{ playlist_import_url }}" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input type="hidden" name="{{ playlist_id_field }}" value="{{ playlist.id }}">
|
||||
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-1.5 px-3 rounded text-sm transition-colors">
|
||||
<i class="fas fa-plus-circle mr-1"></i> Import
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-blue-100 border border-blue-400 text-blue-700 px-4 py-3 rounded relative" role="alert">No playlists found matching your search.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Album/Playlist Details Modal -->
|
||||
<div id="details-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-screen overflow-hidden">
|
||||
<div class="flex justify-between items-center border-b border-gray-200 px-6 py-4">
|
||||
<h3 class="text-xl font-semibold text-navy-800 font-montserrat" id="modal-title">Details</h3>
|
||||
<button id="close-modal" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times fa-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto p-6" style="max-height: calc(100vh - 200px);">
|
||||
<div id="modal-content" class="flex flex-col md:flex-row gap-6">
|
||||
<!-- Content will be loaded here -->
|
||||
<div class="w-full md:w-1/3 flex flex-col items-center">
|
||||
<div class="w-full max-w-xs aspect-square bg-gray-200 rounded-lg mb-4" id="modal-image-container">
|
||||
<img id="modal-image" src="" alt="" class="w-full h-full object-cover rounded-lg">
|
||||
</div>
|
||||
<div id="modal-metadata" class="w-full text-center mb-4">
|
||||
<!-- Metadata will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full md:w-2/3">
|
||||
<p class="text-gray-600 mb-4" id="modal-description"></p>
|
||||
<h4 class="font-semibold text-navy-800 mb-2 flex items-center">
|
||||
<i class="fas fa-music mr-2"></i> Tracks
|
||||
</h4>
|
||||
<div id="modal-tracks-container" class="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-100">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">#</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Title</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Artist</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="modal-tracks" class="bg-white divide-y divide-gray-200">
|
||||
<!-- Tracks will be loaded here -->
|
||||
<tr>
|
||||
<td colspan="4" class="px-4 py-4 text-center text-gray-500">
|
||||
<div class="flex justify-center items-center">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-500"></div>
|
||||
<span class="ml-2">Loading tracks...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-6 py-4 flex justify-end">
|
||||
<form action="" method="POST" id="modal-import-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input type="hidden" name="item_id" id="modal-item-id" value="">
|
||||
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded transition-colors">
|
||||
<i class="fas fa-plus-circle mr-1"></i> Import
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const tabButtons = document.querySelectorAll('.tab-btn');
|
||||
const tabPanes = document.querySelectorAll('.tab-pane');
|
||||
const modal = document.getElementById('details-modal');
|
||||
const closeModal = document.getElementById('close-modal');
|
||||
const modalTitle = document.getElementById('modal-title');
|
||||
const modalDescription = document.getElementById('modal-description');
|
||||
const modalImage = document.getElementById('modal-image');
|
||||
const modalMetadata = document.getElementById('modal-metadata');
|
||||
const modalTracks = document.getElementById('modal-tracks');
|
||||
const modalImportForm = document.getElementById('modal-import-form');
|
||||
const modalItemId = document.getElementById('modal-item-id');
|
||||
|
||||
// Tab functionality
|
||||
tabButtons.forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const tabId = button.getAttribute('data-tab');
|
||||
|
||||
// Hide all tab panes
|
||||
tabPanes.forEach(pane => {
|
||||
pane.classList.add('hidden');
|
||||
pane.classList.remove('block');
|
||||
});
|
||||
|
||||
// Show the selected tab pane
|
||||
document.getElementById(tabId).classList.remove('hidden');
|
||||
document.getElementById(tabId).classList.add('block');
|
||||
|
||||
// Update active state for tab buttons
|
||||
tabButtons.forEach(btn => {
|
||||
// Remove all active classes
|
||||
btn.classList.remove('text-teal-500', 'border-teal-500');
|
||||
btn.classList.add('text-gray-600', 'border-transparent');
|
||||
});
|
||||
|
||||
// Add active classes to clicked button
|
||||
button.classList.remove('text-gray-600', 'border-transparent');
|
||||
button.classList.add('text-teal-500', 'border-teal-500');
|
||||
});
|
||||
});
|
||||
|
||||
// Close modal
|
||||
closeModal.addEventListener('click', () => {
|
||||
modal.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Close modal when clicking outside
|
||||
window.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Escape key to close modal
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !modal.classList.contains('hidden')) {
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Format duration from milliseconds to mm:ss
|
||||
function formatDuration(ms) {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Album detail functionality
|
||||
document.querySelectorAll('.album-detail-trigger').forEach(trigger => {
|
||||
trigger.addEventListener('click', (e) => {
|
||||
const albumId = e.target.getAttribute('data-id');
|
||||
|
||||
// Reset modal content
|
||||
modalTitle.textContent = "Loading album details...";
|
||||
modalDescription.textContent = "";
|
||||
modalImage.src = "";
|
||||
modalMetadata.innerHTML = "";
|
||||
modalTracks.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="4" class="px-4 py-4 text-center text-gray-500">
|
||||
<div class="flex justify-center items-center">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-500"></div>
|
||||
<span class="ml-2">Loading tracks...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
// Set up import form
|
||||
modalImportForm.action = "{{ album_import_url }}";
|
||||
modalItemId.name = "{{ album_id_field }}";
|
||||
modalItemId.value = albumId;
|
||||
|
||||
// Show modal
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
// Fetch album details
|
||||
fetch(`/api/{{ service_name|lower }}/album/${albumId}`)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
// Update modal with album details
|
||||
modalTitle.textContent = data.name;
|
||||
|
||||
if (data.description) {
|
||||
modalDescription.textContent = data.description;
|
||||
} else {
|
||||
modalDescription.textContent = `Album by ${data.artist}`;
|
||||
}
|
||||
|
||||
if (data.image_url) {
|
||||
modalImage.src = data.image_url;
|
||||
modalImage.alt = data.name;
|
||||
}
|
||||
|
||||
// Add metadata
|
||||
modalMetadata.innerHTML = `
|
||||
<p class="font-semibold text-navy-800">${data.artist}</p>
|
||||
<p class="text-gray-600">${data.release_date || 'Release date unknown'}</p>
|
||||
<p class="text-gray-600">${data.tracks.length} tracks</p>
|
||||
`;
|
||||
|
||||
// Add tracks
|
||||
if (data.tracks && data.tracks.length > 0) {
|
||||
modalTracks.innerHTML = '';
|
||||
data.tracks.forEach((track, index) => {
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'hover:bg-gray-50';
|
||||
|
||||
row.innerHTML = `
|
||||
<td class="px-4 py-2 text-sm whitespace-nowrap">${index + 1}</td>
|
||||
<td class="px-4 py-2">${track.name}</td>
|
||||
<td class="px-4 py-2 text-sm">${track.artist}</td>
|
||||
<td class="px-4 py-2 text-sm">${track.duration ? formatDuration(track.duration) : ''}</td>
|
||||
`;
|
||||
|
||||
modalTracks.appendChild(row);
|
||||
});
|
||||
} else {
|
||||
modalTracks.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="4" class="px-4 py-4 text-center text-gray-500">
|
||||
No tracks available
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching album details:', error);
|
||||
modalTitle.textContent = "Error Loading Details";
|
||||
modalDescription.textContent = "There was a problem loading the album details.";
|
||||
modalTracks.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="4" class="px-4 py-4 text-center text-red-500">
|
||||
Failed to load tracks. Please try again later.
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Playlist detail functionality
|
||||
document.querySelectorAll('.playlist-detail-trigger').forEach(trigger => {
|
||||
trigger.addEventListener('click', (e) => {
|
||||
const playlistId = e.target.getAttribute('data-id');
|
||||
|
||||
// Reset modal content
|
||||
modalTitle.textContent = "Loading playlist details...";
|
||||
modalDescription.textContent = "";
|
||||
modalImage.src = "";
|
||||
modalMetadata.innerHTML = "";
|
||||
modalTracks.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="4" class="px-4 py-4 text-center text-gray-500">
|
||||
<div class="flex justify-center items-center">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-500"></div>
|
||||
<span class="ml-2">Loading tracks...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
// Set up import form
|
||||
modalImportForm.action = "{{ playlist_import_url }}";
|
||||
modalItemId.name = "{{ playlist_id_field }}";
|
||||
modalItemId.value = playlistId;
|
||||
|
||||
// Show modal
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
// Fetch playlist details
|
||||
fetch(`/api/{{ service_name|lower }}/playlist/${playlistId}`)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
// Update modal with playlist details
|
||||
modalTitle.textContent = data.name;
|
||||
|
||||
if (data.description) {
|
||||
modalDescription.textContent = data.description;
|
||||
}
|
||||
|
||||
if (data.image_url) {
|
||||
modalImage.src = data.image_url;
|
||||
modalImage.alt = data.name;
|
||||
}
|
||||
|
||||
// Add metadata
|
||||
modalMetadata.innerHTML = `
|
||||
<p class="font-semibold text-navy-800">By ${data.owner}</p>
|
||||
<p class="text-gray-600">${data.tracks.length} tracks</p>
|
||||
<p class="text-gray-600">Followers: ${data.followers || 'N/A'}</p>
|
||||
`;
|
||||
|
||||
// Add tracks
|
||||
if (data.tracks && data.tracks.length > 0) {
|
||||
modalTracks.innerHTML = '';
|
||||
data.tracks.forEach((track, index) => {
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'hover:bg-gray-50';
|
||||
|
||||
row.innerHTML = `
|
||||
<td class="px-4 py-2 text-sm whitespace-nowrap">${index + 1}</td>
|
||||
<td class="px-4 py-2">${track.name}</td>
|
||||
<td class="px-4 py-2 text-sm">${track.artist}</td>
|
||||
<td class="px-4 py-2 text-sm">${track.duration ? formatDuration(track.duration) : ''}</td>
|
||||
`;
|
||||
|
||||
modalTracks.appendChild(row);
|
||||
});
|
||||
} else {
|
||||
modalTracks.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="4" class="px-4 py-4 text-center text-gray-500">
|
||||
No tracks available
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching playlist details:', error);
|
||||
modalTitle.textContent = "Error Loading Details";
|
||||
modalDescription.textContent = "There was a problem loading the playlist details.";
|
||||
modalTracks.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="4" class="px-4 py-4 text-center text-red-500">
|
||||
Failed to load tracks. Please try again later.
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,172 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Spotify Client Test{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 py-8">
|
||||
<h1 class="text-3xl font-bold mb-2 text-navy-800 font-montserrat">Spotify Client Comparison</h1>
|
||||
<p class="text-gray-600 mb-6">Comparing spotipy library vs direct API implementation.</p>
|
||||
|
||||
<!-- Account Selection -->
|
||||
<div class="bg-white shadow-md rounded-lg p-6 mb-8">
|
||||
<form method="GET" action="{{ url_for('import.test_spotify_client') }}" class="space-y-4">
|
||||
<div>
|
||||
<label for="account" class="block text-sm font-medium text-gray-700 mb-1">Spotify Account</label>
|
||||
<select name="account" id="account"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-teal-500 focus:border-teal-500">
|
||||
<option value="spotify" {% if account == 'spotify' %}selected{% endif %}>spotify</option>
|
||||
<option value="spotifycharts" {% if account == 'spotifycharts' %}selected{% endif %}>spotifycharts</option>
|
||||
<option value="spotifymaps" {% if account == 'spotifymaps' %}selected{% endif %}>spotifymaps</option>
|
||||
<option value="spotifyuk" {% if account == 'spotifyuk' %}selected{% endif %}>spotifyuk</option>
|
||||
<option value="spotifyusa" {% if account == 'spotifyusa' %}selected{% endif %}>spotifyusa</option>
|
||||
<option value="spotify_germany" {% if account == 'spotify_germany' %}selected{% endif %}>spotify_germany</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded-md shadow-sm transition-colors">
|
||||
Test Account
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Results Summary -->
|
||||
<div class="bg-white shadow-md rounded-lg p-6 mb-8">
|
||||
<h2 class="text-xl font-bold mb-4">Results Summary</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Spotipy Results -->
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<h3 class="text-lg font-semibold mb-2">Spotipy Implementation</h3>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between border-b border-gray-100 pb-1">
|
||||
<span class="text-gray-700">Playlists Retrieved:</span>
|
||||
<span class="font-medium">{{ results.spotipy.count }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-b border-gray-100 pb-1">
|
||||
<span class="text-gray-700">Expected Total:</span>
|
||||
<span class="font-medium">{{ results.spotipy.total }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-b border-gray-100 pb-1">
|
||||
<span class="text-gray-700">Execution Time:</span>
|
||||
<span class="font-medium">{{ results.spotipy.time_ms }} ms</span>
|
||||
</div>
|
||||
{% if results.spotipy.error %}
|
||||
<div class="mt-2 p-2 bg-red-50 text-red-700 rounded">
|
||||
<strong>Error:</strong> {{ results.spotipy.error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Direct API Results -->
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<h3 class="text-lg font-semibold mb-2">Direct API Implementation</h3>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between border-b border-gray-100 pb-1">
|
||||
<span class="text-gray-700">Playlists Retrieved:</span>
|
||||
<span class="font-medium">{{ results.direct.count }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-b border-gray-100 pb-1">
|
||||
<span class="text-gray-700">Expected Total:</span>
|
||||
<span class="font-medium">{{ results.direct.total }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-b border-gray-100 pb-1">
|
||||
<span class="text-gray-700">Execution Time:</span>
|
||||
<span class="font-medium">{{ results.direct.time_ms }} ms</span>
|
||||
</div>
|
||||
{% if results.direct.error %}
|
||||
<div class="mt-2 p-2 bg-red-50 text-red-700 rounded">
|
||||
<strong>Error:</strong> {{ results.direct.error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comparison -->
|
||||
<div class="bg-white shadow-md rounded-lg p-6 mb-8">
|
||||
<h2 class="text-xl font-bold mb-4">Implementation Comparison</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center">
|
||||
<div class="w-1/3 font-medium">Playlists in both implementations:</div>
|
||||
<div class="w-2/3">{{ comparison.in_both|length }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="w-1/3 font-medium">Only in spotipy:</div>
|
||||
<div class="w-2/3">{{ comparison.only_in_spotipy|length }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="w-1/3 font-medium">Only in direct API:</div>
|
||||
<div class="w-2/3">{{ comparison.only_in_direct|length }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Playlist Details -->
|
||||
<div class="flex flex-col md:flex-row gap-6">
|
||||
<!-- Spotipy Playlists -->
|
||||
<div class="w-full md:w-1/2">
|
||||
<div class="bg-white shadow-md rounded-lg p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Spotipy Playlists ({{ results.spotipy.count }})</h3>
|
||||
{% if results.spotipy.playlists %}
|
||||
<div class="overflow-y-auto max-h-96">
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-4 py-2 border-b text-left">#</th>
|
||||
<th class="px-4 py-2 border-b text-left">Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for playlist in results.spotipy.playlists %}
|
||||
<tr>
|
||||
<td class="px-4 py-2 border-b">{{ loop.index }}</td>
|
||||
<td class="px-4 py-2 border-b">{{ playlist.name }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-500">No playlists retrieved.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Direct API Playlists -->
|
||||
<div class="w-full md:w-1/2">
|
||||
<div class="bg-white shadow-md rounded-lg p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Direct API Playlists ({{ results.direct.count }})</h3>
|
||||
{% if results.direct.playlists %}
|
||||
<div class="overflow-y-auto max-h-96">
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-4 py-2 border-b text-left">#</th>
|
||||
<th class="px-4 py-2 border-b text-left">Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for playlist in results.direct.playlists %}
|
||||
<tr>
|
||||
<td class="px-4 py-2 border-b">{{ loop.index }}</td>
|
||||
<td class="px-4 py-2 border-b">{{ playlist.name }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-500">No playlists retrieved.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,141 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Direct Spotify Authentication{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto px-4 py-8">
|
||||
<h1 class="text-3xl font-bold mb-2 text-navy-800 font-montserrat">Direct Spotify Authentication</h1>
|
||||
<p class="text-gray-600 mb-6">Use a manual bearer token to access the Spotify API directly.</p>
|
||||
|
||||
<!-- Status Information -->
|
||||
{% if spotify_user %}
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-green-800">Currently authenticated</h3>
|
||||
<div class="mt-2 text-sm text-green-700">
|
||||
<p>You are currently authenticated as <strong>{{ spotify_username }}</strong> (ID: {{ spotify_user }}).</p>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<form action="{{ url_for('import.direct_spotify_auth') }}" method="POST">
|
||||
<input type="hidden" name="bearer_token" value="">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button type="submit" class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
|
||||
Sign Out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">Error</h3>
|
||||
<div class="mt-2 text-sm text-red-700">
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if success %}
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-green-800">Success</h3>
|
||||
<div class="mt-2 text-sm text-green-700">
|
||||
<p>{{ success }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Instructions -->
|
||||
<div class="bg-white shadow-md rounded-lg p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold mb-4 text-navy-800">How to Get a Bearer Token</h2>
|
||||
<ol class="list-decimal pl-5 space-y-4 text-gray-700">
|
||||
<li>
|
||||
<p class="mb-1">Go to the <a href="https://developer.spotify.com/console/" target="_blank" class="text-teal-600 hover:underline">Spotify Developer Console</a></p>
|
||||
<p class="text-sm text-gray-600">You'll need to log in with your Spotify account</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="mb-1">Select any API endpoint (e.g., "Get Current User's Profile")</p>
|
||||
<p class="text-sm text-gray-600">The specific endpoint doesn't matter, we just need to generate a token</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="mb-1">Click the "Get Token" button</p>
|
||||
<p class="text-sm text-gray-600">Make sure to select the following scopes:</p>
|
||||
<ul class="list-disc pl-5 mt-1 text-sm text-gray-600">
|
||||
<li>user-read-private</li>
|
||||
<li>user-read-email</li>
|
||||
<li>playlist-read-private</li>
|
||||
<li>playlist-read-collaborative</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<p class="mb-1">Copy the generated OAuth token (it starts with "BQ...")</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="mb-1">Paste the token in the form below and click "Authenticate"</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<!-- Token Entry Form -->
|
||||
<div class="bg-white shadow-md rounded-lg p-6">
|
||||
<h2 class="text-xl font-semibold mb-4 text-navy-800">Enter Your Bearer Token</h2>
|
||||
<form action="{{ url_for('import.direct_spotify_auth') }}" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="bearer_token" class="block text-sm font-medium text-gray-700 mb-1">Bearer Token</label>
|
||||
<textarea name="bearer_token" id="bearer_token" rows="3"
|
||||
placeholder="BQBcQ1foQG0x14axu2kQVz..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-teal-500 focus:border-teal-500"></textarea>
|
||||
<p class="mt-1 text-xs text-gray-500">The token will expire after about 1 hour. You'll need to generate a new one after that.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-orange-500 hover:bg-orange-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500">
|
||||
Authenticate
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Next Steps -->
|
||||
{% if spotify_user %}
|
||||
<div class="mt-8 text-center">
|
||||
<h3 class="text-lg font-semibold mb-2">Ready to go!</h3>
|
||||
<p class="text-gray-600 mb-4">You're authenticated and can now use the direct Spotify client.</p>
|
||||
<div class="flex justify-center space-x-4">
|
||||
<a href="{{ url_for('import.direct_official_playlists') }}"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-medium rounded-md text-white bg-teal-600 hover:bg-teal-700">
|
||||
Browse Official Playlists
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,461 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Audio Settings - Quizzical Beats{% 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-2">Custom Audio Settings</h1>
|
||||
<p class="text-gray-600">Upload or generate your own intro, outro, and replay announcements for quizzes.</p>
|
||||
</div>
|
||||
|
||||
{% for category, message in get_flashed_messages(with_categories=true) %}
|
||||
<div class="mb-6 p-4 rounded {% if category == 'danger' %}bg-red-50 text-red-700 border border-red-300{% elif category == 'success' %}bg-green-50 text-green-700 border border-green-300{% else %}bg-blue-50 text-blue-700 border border-blue-300{% endif %}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Intro Audio Section -->
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden mb-8">
|
||||
<div class="bg-navy-50 p-4 border-b">
|
||||
<h2 class="text-xl font-semibold text-navy-800">Intro Audio</h2>
|
||||
<p class="text-gray-600 text-sm mt-1">This audio plays at the beginning of your music quiz</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-medium text-gray-700 mb-2">Current Setting</h3>
|
||||
{% if current_user.intro_mp3 %}
|
||||
<div class="p-4 bg-navy-50 rounded-lg">
|
||||
<p class="font-medium text-navy-800 mb-2">Custom intro audio is active</p>
|
||||
<audio controls class="w-full">
|
||||
<source src="{{ url_for('static', filename='audio/intro.mp3') if not current_user.intro_mp3 else url_for('core.serve_user_audio', filepath=current_user.intro_mp3) }}" type="audio/mpeg">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
|
||||
<form method="POST" class="mt-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input type="hidden" name="action" value="reset">
|
||||
<input type="hidden" name="mp3_type" value="intro">
|
||||
<button type="submit" class="text-red-600 text-sm hover:underline">Reset to default</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-4 bg-gray-50 rounded-lg">
|
||||
<p class="font-medium text-gray-700 mb-2">Using default intro audio</p>
|
||||
<audio controls class="w-full">
|
||||
<source src="{{ url_for('static', filename='audio/intro.mp3') }}" type="audio/mpeg">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-6">
|
||||
<h3 class="text-lg font-medium text-gray-700 mb-4">Customize</h3>
|
||||
|
||||
<div class="mb-8">
|
||||
<h4 class="font-medium text-gray-700 mb-2">Option 1: Upload MP3</h4>
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input type="hidden" name="action" value="upload">
|
||||
<input type="hidden" name="mp3_type" value="intro">
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex-grow">
|
||||
<input type="file" name="audio_file" accept=".mp3" class="w-full px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500">
|
||||
</div>
|
||||
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded transition-colors">
|
||||
Upload
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-700 mb-2">Option 2: Generate with Text-to-Speech</h4>
|
||||
{% if has_tts_services %}
|
||||
<form method="GET" class="mb-4 flex items-end space-x-2">
|
||||
<input type="hidden" name="mp3_type" value="intro">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">TTS Service</label>
|
||||
<select name="tts_service" class="px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500 w-full">
|
||||
{% for svc in tts_services %}
|
||||
<option value="{{ svc.id }}" {% if selected_service and svc.id == selected_service.id %}selected{% endif %}>{{ svc.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="ml-2 bg-gray-200 hover:bg-gray-300 text-gray-700 px-3 py-2 rounded">Change</button>
|
||||
</form>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input type="hidden" name="action" value="generate">
|
||||
<input type="hidden" name="mp3_type" value="intro">
|
||||
<input type="hidden" name="tts_service" value="{{ selected_service.id }}">
|
||||
<p class="mt-1 text-sm text-gray-500">{{ selected_service.description if selected_service else '' }}</p>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Voice</label>
|
||||
<select name="tts_voice" class="px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500 w-full">
|
||||
{% for voice in selected_service.voices %}
|
||||
<option value="{{ voice.id }}">{{ voice.name }} ({{ voice.gender }}, {{ voice.language }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% if selected_service.id == 'openai' and selected_service.models %}
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Model Quality</label>
|
||||
<select name="openai_model" class="px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500 w-full">
|
||||
{% for model in selected_service.models %}
|
||||
<option value="{{ model.id }}">{{ model.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if selected_service.id == 'elevenlabs' and selected_service.models %}
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Model</label>
|
||||
<select name="elevenlabs_model" class="px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500 w-full">
|
||||
{% for model in selected_service.models %}
|
||||
<option value="{{ model.id }}">{{ model.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if selected_service.id == 'elevenlabs' and selected_service.settings %}
|
||||
<div class="mb-4 bg-gray-50 p-4 rounded-md">
|
||||
<h5 class="font-medium text-gray-700 mb-2">Voice Settings</h5>
|
||||
<div class="mb-3">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Stability <span class="text-xs text-gray-500">(0.0 - 1.0)</span></label>
|
||||
<input type="range" name="stability" min="0" max="1" step="0.05" value="0.5" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
|
||||
<div class="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>More variable</span>
|
||||
<span>More stable</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Similarity Boost <span class="text-xs text-gray-500">(0.0 - 1.0)</span></label>
|
||||
<input type="range" name="similarity_boost" min="0" max="1" step="0.05" value="0.75" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
|
||||
<div class="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>More unique</span>
|
||||
<span>More similar</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Text to Convert</label>
|
||||
<textarea name="tts_text" rows="3" class="w-full px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500"
|
||||
placeholder="Enter text for intro announcement...">{{ default_texts.intro }}</textarea>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="bg-teal-500 hover:bg-teal-600 text-white py-2 px-4 rounded transition-colors">
|
||||
Generate Audio
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="p-4 bg-yellow-50 text-yellow-700 border border-yellow-300 rounded-md">
|
||||
<p>Text-to-speech generation requires API credentials (AWS Polly, OpenAI, or ElevenLabs). Contact your administrator to enable this feature.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Outro Audio Section -->
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden mb-8">
|
||||
<div class="bg-navy-50 p-4 border-b">
|
||||
<h2 class="text-xl font-semibold text-navy-800">Outro Audio</h2>
|
||||
<p class="text-gray-600 text-sm mt-1">This audio plays at the end of your music quiz</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-medium text-gray-700 mb-2">Current Setting</h3>
|
||||
{% if current_user.outro_mp3 %}
|
||||
<div class="p-4 bg-navy-50 rounded-lg">
|
||||
<p class="font-medium text-navy-800 mb-2">Custom outro audio is active</p>
|
||||
<audio controls class="w-full">
|
||||
<source src="{{ url_for('static', filename='audio/outro.mp3') if not current_user.outro_mp3 else url_for('core.serve_user_audio', filepath=current_user.outro_mp3) }}" type="audio/mpeg">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
|
||||
<form method="POST" class="mt-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input type="hidden" name="action" value="reset">
|
||||
<input type="hidden" name="mp3_type" value="outro">
|
||||
<button type="submit" class="text-red-600 text-sm hover:underline">Reset to default</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-4 bg-gray-50 rounded-lg">
|
||||
<p class="font-medium text-gray-700 mb-2">Using default outro audio</p>
|
||||
<audio controls class="w-full">
|
||||
<source src="{{ url_for('static', filename='audio/outro.mp3') }}" type="audio/mpeg">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-6">
|
||||
<h3 class="text-lg font-medium text-gray-700 mb-4">Customize</h3>
|
||||
|
||||
<div class="mb-8">
|
||||
<h4 class="font-medium text-gray-700 mb-2">Option 1: Upload MP3</h4>
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input type="hidden" name="action" value="upload">
|
||||
<input type="hidden" name="mp3_type" value="outro">
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex-grow">
|
||||
<input type="file" name="audio_file" accept=".mp3" class="w-full px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500">
|
||||
</div>
|
||||
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded transition-colors">
|
||||
Upload
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-700 mb-2">Option 2: Generate with Text-to-Speech</h4>
|
||||
{% if has_tts_services %}
|
||||
<form method="GET" class="mb-4 flex items-end space-x-2">
|
||||
<input type="hidden" name="mp3_type" value="outro">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">TTS Service</label>
|
||||
<select name="tts_service" class="px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500 w-full">
|
||||
{% for svc in tts_services %}
|
||||
<option value="{{ svc.id }}" {% if selected_service and svc.id == selected_service.id %}selected{% endif %}>{{ svc.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="ml-2 bg-gray-200 hover:bg-gray-300 text-gray-700 px-3 py-2 rounded">Change</button>
|
||||
</form>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input type="hidden" name="action" value="generate">
|
||||
<input type="hidden" name="mp3_type" value="outro">
|
||||
<input type="hidden" name="tts_service" value="{{ selected_service.id }}">
|
||||
<p class="mt-1 text-sm text-gray-500">{{ selected_service.description if selected_service else '' }}</p>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Voice</label>
|
||||
<select name="tts_voice" class="px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500 w-full">
|
||||
{% for voice in selected_service.voices %}
|
||||
<option value="{{ voice.id }}">{{ voice.name }} ({{ voice.gender }}, {{ voice.language }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% if selected_service.id == 'openai' and selected_service.models %}
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Model Quality</label>
|
||||
<select name="openai_model" class="px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500 w-full">
|
||||
{% for model in selected_service.models %}
|
||||
<option value="{{ model.id }}">{{ model.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if selected_service.id == 'elevenlabs' and selected_service.models %}
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Model</label>
|
||||
<select name="elevenlabs_model" class="px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500 w-full">
|
||||
{% for model in selected_service.models %}
|
||||
<option value="{{ model.id }}">{{ model.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if selected_service.id == 'elevenlabs' and selected_service.settings %}
|
||||
<div class="mb-4 bg-gray-50 p-4 rounded-md">
|
||||
<h5 class="font-medium text-gray-700 mb-2">Voice Settings</h5>
|
||||
<div class="mb-3">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Stability <span class="text-xs text-gray-500">(0.0 - 1.0)</span></label>
|
||||
<input type="range" name="stability" min="0" max="1" step="0.05" value="0.5" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
|
||||
<div class="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>More variable</span>
|
||||
<span>More stable</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Similarity Boost <span class="text-xs text-gray-500">(0.0 - 1.0)</span></label>
|
||||
<input type="range" name="similarity_boost" min="0" max="1" step="0.05" value="0.75" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
|
||||
<div class="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>More unique</span>
|
||||
<span>More similar</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Text to Convert</label>
|
||||
<textarea name="tts_text" rows="3" class="w-full px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500"
|
||||
placeholder="Enter text for outro announcement...">{{ default_texts.outro }}</textarea>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="bg-teal-500 hover:bg-teal-600 text-white py-2 px-4 rounded transition-colors">
|
||||
Generate Audio
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="p-4 bg-yellow-50 text-yellow-700 border border-yellow-300 rounded-md">
|
||||
<p>Text-to-speech generation requires API credentials (AWS Polly, OpenAI, or ElevenLabs). Contact your administrator to enable this feature.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Replay Audio Section -->
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden mb-8">
|
||||
<div class="bg-navy-50 p-4 border-b">
|
||||
<h2 class="text-xl font-semibold text-navy-800">Replay Audio</h2>
|
||||
<p class="text-gray-600 text-sm mt-1">This audio plays before replaying all songs at the end of the quiz</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-medium text-gray-700 mb-2">Current Setting</h3>
|
||||
{% if current_user.replay_mp3 %}
|
||||
<div class="p-4 bg-navy-50 rounded-lg">
|
||||
<p class="font-medium text-navy-800 mb-2">Custom replay audio is active</p>
|
||||
<audio controls class="w-full">
|
||||
<source src="{{ url_for('static', filename='audio/replay.mp3') if not current_user.replay_mp3 else url_for('core.serve_user_audio', filepath=current_user.replay_mp3) }}" type="audio/mpeg">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
<form method="POST" class="mt-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input type="hidden" name="action" value="reset">
|
||||
<input type="hidden" name="mp3_type" value="replay">
|
||||
<button type="submit" class="text-red-600 text-sm hover:underline">Reset to default</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-4 bg-gray-50 rounded-lg">
|
||||
<p class="font-medium text-gray-700 mb-2">Using default replay audio</p>
|
||||
<audio controls class="w-full">
|
||||
<source src="{{ url_for('static', filename='audio/replay.mp3') }}" type="audio/mpeg">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="border-t pt-6">
|
||||
<h3 class="text-lg font-medium text-gray-700 mb-4">Customize</h3>
|
||||
<div class="mb-8">
|
||||
<h4 class="font-medium text-gray-700 mb-2">Option 1: Upload MP3</h4>
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input type="hidden" name="action" value="upload">
|
||||
<input type="hidden" name="mp3_type" value="replay">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex-grow">
|
||||
<input type="file" name="audio_file" accept=".mp3" class="w-full px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500">
|
||||
</div>
|
||||
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded transition-colors">
|
||||
Upload
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-700 mb-2">Option 2: Generate with Text-to-Speech</h4>
|
||||
{% if has_tts_services %}
|
||||
<form method="GET" class="mb-4 flex items-end space-x-2">
|
||||
<input type="hidden" name="mp3_type" value="replay">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">TTS Service</label>
|
||||
<select name="tts_service" class="px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500 w-full">
|
||||
{% for svc in tts_services %}
|
||||
<option value="{{ svc.id }}" {% if selected_service and svc.id == selected_service.id %}selected{% endif %}>{{ svc.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="ml-2 bg-gray-200 hover:bg-gray-300 text-gray-700 px-3 py-2 rounded">Change</button>
|
||||
</form>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input type="hidden" name="action" value="generate">
|
||||
<input type="hidden" name="mp3_type" value="replay">
|
||||
<input type="hidden" name="tts_service" value="{{ selected_service.id }}">
|
||||
<p class="mt-1 text-sm text-gray-500">{{ selected_service.description if selected_service else '' }}</p>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Voice</label>
|
||||
<select name="tts_voice" class="px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500 w-full">
|
||||
{% for voice in selected_service.voices %}
|
||||
<option value="{{ voice.id }}">{{ voice.name }} ({{ voice.gender }}, {{ voice.language }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% if selected_service.id == 'openai' and selected_service.models %}
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Model Quality</label>
|
||||
<select name="openai_model" class="px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500 w-full">
|
||||
{% for model in selected_service.models %}
|
||||
<option value="{{ model.id }}">{{ model.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if selected_service.id == 'elevenlabs' and selected_service.models %}
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Model</label>
|
||||
<select name="elevenlabs_model" class="px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500 w-full">
|
||||
{% for model in selected_service.models %}
|
||||
<option value="{{ model.id }}">{{ model.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if selected_service.id == 'elevenlabs' and selected_service.settings %}
|
||||
<div class="mb-4 bg-gray-50 p-4 rounded-md">
|
||||
<h5 class="font-medium text-gray-700 mb-2">Voice Settings</h5>
|
||||
<div class="mb-3">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Stability <span class="text-xs text-gray-500">(0.0 - 1.0)</span></label>
|
||||
<input type="range" name="stability" min="0" max="1" step="0.05" value="0.5" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
|
||||
<div class="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>More variable</span>
|
||||
<span>More stable</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Similarity Boost <span class="text-xs text-gray-500">(0.0 - 1.0)</span></label>
|
||||
<input type="range" name="similarity_boost" min="0" max="1" step="0.05" value="0.75" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
|
||||
<div class="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>More unique</span>
|
||||
<span>More similar</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Text to Convert</label>
|
||||
<textarea name="tts_text" rows="3" class="w-full px-4 py-2 border rounded-md focus:ring-orange-500 focus:border-orange-500"
|
||||
placeholder="Enter text for replay announcement...">{{ default_texts.replay }}</textarea>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="bg-teal-500 hover:bg-teal-600 text-white py-2 px-4 rounded transition-colors">
|
||||
Generate Audio
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="p-4 bg-yellow-50 text-yellow-700 border border-yellow-300 rounded-md">
|
||||
<p>Text-to-speech generation requires API credentials (AWS Polly, OpenAI, or ElevenLabs). Contact your administrator to enable this feature.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 text-center">
|
||||
<a href="{{ url_for('users.profile') }}" class="inline-block bg-gray-500 hover:bg-gray-600 text-white py-2 px-4 rounded transition-colors">
|
||||
Back to Profile
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<!-- No Alpine.js needed -->
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,56 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Change Password{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-md mx-auto my-8 p-6 bg-white rounded-lg shadow-md">
|
||||
<h2 class="text-2xl font-bold mb-6 text-navy-800">Change Password</h2>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="mb-4 p-3 {% if category == 'danger' %}bg-red-100 text-red-700{% elif category == 'success' %}bg-green-100 text-green-700{% else %}bg-blue-100 text-blue-700{% endif %} rounded">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" action="{{ url_for('users.change_password') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-gray-700 text-sm font-bold mb-2" for="current_password">
|
||||
Current Password
|
||||
</label>
|
||||
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="current_password" name="current_password" type="password" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-gray-700 text-sm font-bold mb-2" for="new_password">
|
||||
New Password
|
||||
</label>
|
||||
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="new_password" name="new_password" type="password" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="block text-gray-700 text-sm font-bold mb-2" for="confirm_password">
|
||||
Confirm New Password
|
||||
</label>
|
||||
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="confirm_password" name="confirm_password" type="password" required>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<button class="bg-teal-500 hover:bg-teal-600 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline" type="submit">
|
||||
Change Password
|
||||
</button>
|
||||
<a class="inline-block align-baseline font-bold text-sm text-teal-500 hover:text-teal-800" href="{{ url_for('users.profile') }}">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,566 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Edit Profile{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-md mx-auto my-8 p-6 bg-white rounded-lg shadow-md">
|
||||
<h2 class="text-2xl font-bold mb-6 text-navy-800">Edit Profile</h2>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="mb-4 p-3 {% if category == 'danger' %}bg-red-100 text-red-700{% elif category == 'success' %}bg-green-100 text-green-700{% else %}bg-blue-100 text-blue-700{% endif %} rounded">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" action="{{ url_for('users.edit_profile') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-gray-700 text-sm font-bold mb-2" for="username">
|
||||
Username
|
||||
</label>
|
||||
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="username" name="username" type="text" placeholder="Username" value="{{ current_user.username }}">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-gray-700 text-sm font-bold mb-2" for="email">
|
||||
Email
|
||||
</label>
|
||||
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="email" name="email" type="email" placeholder="Email address" value="{{ current_user.email }}">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-gray-700 text-sm font-bold mb-2" for="first_name">
|
||||
First Name
|
||||
</label>
|
||||
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="first_name" name="first_name" type="text" placeholder="First name" value="{{ current_user.first_name or '' }}">
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="block text-gray-700 text-sm font-bold mb-2" for="last_name">
|
||||
Last Name
|
||||
</label>
|
||||
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="last_name" name="last_name" type="text" placeholder="Last name" value="{{ current_user.last_name or '' }}">
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="block text-gray-700 text-sm font-bold mb-2" for="dropbox_export_path">
|
||||
Dropbox Export Folder
|
||||
</label>
|
||||
<div class="flex space-x-2">
|
||||
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="dropbox_export_path" name="dropbox_export_path" type="text" placeholder="/QuizzicalBeats"
|
||||
value="{{ current_user.dropbox_export_path or '/QuizzicalBeats' }}">
|
||||
<button type="button" id="browse-dropbox-btn"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded flex items-center"
|
||||
{% if not current_user.dropbox_token %}disabled{% endif %}>
|
||||
<i class="fab fa-dropbox mr-1"></i> Browse
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-1">Set the Dropbox folder where your exported rounds will be saved.</p>
|
||||
{% if not current_user.dropbox_token %}
|
||||
<p class="text-xs text-red-500 mt-1">
|
||||
<i class="fas fa-exclamation-circle mr-1"></i>
|
||||
<a href="{{ url_for('users.dropbox_auth') }}" class="underline">Connect your Dropbox account</a> to browse folders.
|
||||
</p>
|
||||
{% endif %}
|
||||
<div id="dropbox-error-container" class="hidden mt-3 p-3 bg-red-100 text-sm rounded">
|
||||
<div class="flex justify-between">
|
||||
<h4 class="font-semibold mb-1">Dropbox API Error</h4>
|
||||
<button id="close-error-btn" class="text-red-700">×</button>
|
||||
</div>
|
||||
<div id="dropbox-error-details"></div>
|
||||
<div class="mt-2">
|
||||
<button id="dropbox-debug-btn" type="button" class="text-xs bg-gray-200 hover:bg-gray-300 px-2 py-1 rounded">
|
||||
Show Technical Details
|
||||
</button>
|
||||
<div id="dropbox-debug-info" class="hidden mt-2 bg-gray-100 p-2 rounded font-mono text-xs overflow-x-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<button class="bg-teal-500 hover:bg-teal-600 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline" type="submit">
|
||||
Save Changes
|
||||
</button>
|
||||
<a class="inline-block align-baseline font-bold text-sm text-teal-500 hover:text-teal-800" href="{{ url_for('users.profile') }}">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Dropbox Folder Browser Modal -->
|
||||
<div id="dropbox-folder-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
||||
<div class="bg-white rounded-lg shadow-lg w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
|
||||
<h3 class="text-lg font-bold text-navy-800">Select Dropbox Folder</h3>
|
||||
<button id="close-dropbox-modal" class="text-gray-500 hover:text-gray-700">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-4 border-b border-gray-200 flex items-center bg-gray-50">
|
||||
<div class="flex-1 flex items-center">
|
||||
<button id="parent-folder-btn" class="bg-gray-200 hover:bg-gray-300 rounded p-1 mr-2">
|
||||
<i class="fas fa-arrow-up"></i>
|
||||
</button>
|
||||
<span id="current-path" class="text-gray-600 font-mono text-sm">/</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button id="create-folder-btn" class="bg-teal-500 hover:bg-teal-600 text-white rounded p-1 px-2 text-sm flex items-center">
|
||||
<i class="fas fa-folder-plus mr-1"></i> Create Folder
|
||||
</button>
|
||||
<button id="refresh-folders-btn" class="bg-gray-200 hover:bg-gray-300 rounded p-1">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto flex-1 p-2" style="max-height: 60vh;">
|
||||
<div id="folder-list" class="space-y-1">
|
||||
<div class="text-center p-8">
|
||||
<div class="animate-spin inline-block w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full mb-2"></div>
|
||||
<p>Loading folders...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 border-t border-gray-200 flex justify-end space-x-2">
|
||||
<button id="select-folder-btn" class="bg-teal-500 hover:bg-teal-600 text-white px-4 py-2 rounded">
|
||||
Select This Folder
|
||||
</button>
|
||||
<button id="cancel-selection-btn" class="bg-gray-300 hover:bg-gray-400 text-gray-800 px-4 py-2 rounded">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Folder Modal -->
|
||||
<div id="create-folder-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
||||
<div class="bg-white rounded-lg shadow-lg w-full max-w-md">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-bold text-navy-800">Create New Folder</h3>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="mb-4">
|
||||
<label class="block text-gray-700 text-sm font-bold mb-2" for="new-folder-name">
|
||||
Folder Name
|
||||
</label>
|
||||
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="new-folder-name" type="text" placeholder="My New Folder">
|
||||
<p id="folder-name-error" class="hidden mt-1 text-xs text-red-500"></p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-gray-700 text-sm font-bold mb-2">
|
||||
Parent Folder
|
||||
</label>
|
||||
<p class="py-2 px-3 bg-gray-100 rounded text-gray-700 font-mono text-sm" id="parent-folder-path"></p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-2 mt-6">
|
||||
<button id="create-folder-submit" class="bg-teal-500 hover:bg-teal-600 text-white px-4 py-2 rounded">
|
||||
Create
|
||||
</button>
|
||||
<button id="create-folder-cancel" class="bg-gray-300 hover:bg-gray-400 text-gray-800 px-4 py-2 rounded">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const browseDropboxBtn = document.getElementById('browse-dropbox-btn');
|
||||
const dropboxFolderModal = document.getElementById('dropbox-folder-modal');
|
||||
const closeDropboxModal = document.getElementById('close-dropbox-modal');
|
||||
const cancelSelectionBtn = document.getElementById('cancel-selection-btn');
|
||||
const selectFolderBtn = document.getElementById('select-folder-btn');
|
||||
const folderList = document.getElementById('folder-list');
|
||||
const currentPathDisplay = document.getElementById('current-path');
|
||||
const parentFolderBtn = document.getElementById('parent-folder-btn');
|
||||
const refreshFoldersBtn = document.getElementById('refresh-folders-btn');
|
||||
const dropboxExportPath = document.getElementById('dropbox_export_path');
|
||||
const createFolderBtn = document.getElementById('create-folder-btn');
|
||||
const createFolderModal = document.getElementById('create-folder-modal');
|
||||
const createFolderSubmit = document.getElementById('create-folder-submit');
|
||||
const createFolderCancel = document.getElementById('create-folder-cancel');
|
||||
const newFolderNameInput = document.getElementById('new-folder-name');
|
||||
const folderNameError = document.getElementById('folder-name-error');
|
||||
const parentFolderPathDisplay = document.getElementById('parent-folder-path');
|
||||
|
||||
// Error display elements
|
||||
const errorContainer = document.getElementById('dropbox-error-container');
|
||||
const errorDetails = document.getElementById('dropbox-error-details');
|
||||
const closeErrorBtn = document.getElementById('close-error-btn');
|
||||
const debugBtn = document.getElementById('dropbox-debug-btn');
|
||||
const debugInfo = document.getElementById('dropbox-debug-info');
|
||||
|
||||
let currentPath = '';
|
||||
let lastApiResponse = null;
|
||||
|
||||
// Open the Dropbox folder browser modal
|
||||
browseDropboxBtn.addEventListener('click', function() {
|
||||
dropboxFolderModal.classList.remove('hidden');
|
||||
// Always start at root when opening the browser
|
||||
currentPath = '/';
|
||||
loadFolders(currentPath);
|
||||
});
|
||||
|
||||
// Close the modal when clicking close button or cancel
|
||||
closeDropboxModal.addEventListener('click', closeModal);
|
||||
cancelSelectionBtn.addEventListener('click', closeModal);
|
||||
|
||||
// Close modal when clicking outside
|
||||
dropboxFolderModal.addEventListener('click', function(event) {
|
||||
if (event.target === dropboxFolderModal) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Close error display
|
||||
closeErrorBtn.addEventListener('click', function() {
|
||||
errorContainer.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Toggle debug info
|
||||
debugBtn.addEventListener('click', function() {
|
||||
if (debugInfo.classList.contains('hidden')) {
|
||||
debugInfo.classList.remove('hidden');
|
||||
debugBtn.textContent = 'Hide Technical Details';
|
||||
} else {
|
||||
debugInfo.classList.add('hidden');
|
||||
debugBtn.textContent = 'Show Technical Details';
|
||||
}
|
||||
});
|
||||
|
||||
// Select the current folder
|
||||
selectFolderBtn.addEventListener('click', function() {
|
||||
dropboxExportPath.value = currentPath;
|
||||
closeModal();
|
||||
});
|
||||
|
||||
// Navigate to parent folder
|
||||
parentFolderBtn.addEventListener('click', function() {
|
||||
if (currentPath === '/' || currentPath === '') {
|
||||
return; // Already at root
|
||||
}
|
||||
|
||||
// Get parent path
|
||||
const parts = currentPath.split('/').filter(p => p);
|
||||
parts.pop(); // Remove last folder
|
||||
|
||||
const parentPath = '/' + parts.join('/');
|
||||
loadFolders(parentPath);
|
||||
});
|
||||
|
||||
// Refresh current folder
|
||||
refreshFoldersBtn.addEventListener('click', function() {
|
||||
loadFolders(currentPath);
|
||||
});
|
||||
|
||||
// Open the Create Folder modal
|
||||
createFolderBtn.addEventListener('click', function() {
|
||||
createFolderModal.classList.remove('hidden');
|
||||
parentFolderPathDisplay.textContent = currentPath;
|
||||
newFolderNameInput.value = '';
|
||||
folderNameError.classList.add('hidden');
|
||||
|
||||
// Focus the input field for better UX
|
||||
setTimeout(() => {
|
||||
newFolderNameInput.focus();
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// Close the Create Folder modal when clicking outside
|
||||
createFolderModal.addEventListener('click', function(event) {
|
||||
if (event.target === createFolderModal) {
|
||||
createFolderModal.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Close the Create Folder modal
|
||||
createFolderCancel.addEventListener('click', function() {
|
||||
createFolderModal.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Handle Enter key in the folder name input
|
||||
newFolderNameInput.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
createFolderSubmit.click();
|
||||
}
|
||||
});
|
||||
|
||||
// Submit new folder creation
|
||||
createFolderSubmit.addEventListener('click', function() {
|
||||
const folderName = newFolderNameInput.value.trim();
|
||||
if (!folderName) {
|
||||
folderNameError.textContent = 'Folder name cannot be empty.';
|
||||
folderNameError.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for invalid characters
|
||||
if (/[\/\\:*?"<>|]/.test(folderName)) {
|
||||
folderNameError.textContent = 'Folder name contains invalid characters.';
|
||||
folderNameError.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
createFolderSubmit.disabled = true;
|
||||
createFolderSubmit.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i> Creating...';
|
||||
folderNameError.classList.add('hidden');
|
||||
|
||||
// Call API to create folder
|
||||
fetch('/api/dropbox/create-folder', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRFToken': document.querySelector('input[name="csrf_token"]').value
|
||||
},
|
||||
body: JSON.stringify({
|
||||
parent_path: currentPath,
|
||||
folder_name: folderName
|
||||
})
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.json().then(errorData => {
|
||||
throw {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data: errorData
|
||||
};
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
throw {
|
||||
status: 500,
|
||||
statusText: 'Server Error',
|
||||
data: { error: data.error }
|
||||
};
|
||||
}
|
||||
|
||||
// Close modal and refresh folder list
|
||||
createFolderModal.classList.add('hidden');
|
||||
|
||||
// If the folder was created successfully, navigate to it
|
||||
if (data.path) {
|
||||
loadFolders(data.path);
|
||||
} else {
|
||||
// Just refresh the current folder
|
||||
loadFolders(currentPath);
|
||||
}
|
||||
|
||||
// Show success message
|
||||
const successDiv = document.createElement('div');
|
||||
successDiv.className = 'p-2 mb-3 bg-green-100 text-green-800 rounded text-sm';
|
||||
successDiv.textContent = `Folder "${folderName}" created successfully.`;
|
||||
successDiv.style.opacity = '1';
|
||||
successDiv.style.transition = 'opacity 0.5s ease-in-out';
|
||||
folderList.insertBefore(successDiv, folderList.firstChild);
|
||||
|
||||
// Fade out success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
successDiv.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
if (successDiv.parentNode) {
|
||||
successDiv.parentNode.removeChild(successDiv);
|
||||
}
|
||||
}, 500);
|
||||
}, 3000);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error creating folder:', error);
|
||||
|
||||
// Show error message in the modal
|
||||
folderNameError.textContent = error.data?.error ||
|
||||
(error.status === 409 ? 'A folder with this name already exists.' :
|
||||
error.statusText || 'Error creating folder');
|
||||
folderNameError.classList.remove('hidden');
|
||||
|
||||
// Also show detailed error in the main error display if it's a server error
|
||||
if (error.status >= 500) {
|
||||
showError(error);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
// Reset button state
|
||||
createFolderSubmit.disabled = false;
|
||||
createFolderSubmit.innerHTML = 'Create';
|
||||
});
|
||||
});
|
||||
|
||||
// Function to load folders from Dropbox
|
||||
function loadFolders(path) {
|
||||
// Hide any previous errors
|
||||
errorContainer.classList.add('hidden');
|
||||
|
||||
// Update UI
|
||||
currentPath = path;
|
||||
currentPathDisplay.textContent = path || '/';
|
||||
|
||||
// Show loading indicator
|
||||
folderList.innerHTML = `
|
||||
<div class="text-center p-8">
|
||||
<div class="animate-spin inline-block w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full mb-2"></div>
|
||||
<p>Loading folders...</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Make API request to get folders
|
||||
fetch('/api/dropbox/folders?path=' + encodeURIComponent(path))
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.json().then(errorData => {
|
||||
throw {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data: errorData
|
||||
};
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
// Save the response for debugging
|
||||
lastApiResponse = data;
|
||||
|
||||
if (data.error) {
|
||||
throw {
|
||||
status: 500,
|
||||
statusText: 'Server Error',
|
||||
data: data
|
||||
};
|
||||
}
|
||||
|
||||
// Check if there's a warning message (returned when a path doesn't exist)
|
||||
if (data.warning) {
|
||||
// Display warning message
|
||||
const warningDiv = document.createElement('div');
|
||||
warningDiv.className = 'p-2 mb-3 bg-yellow-100 text-yellow-800 rounded text-sm';
|
||||
warningDiv.textContent = data.warning;
|
||||
folderList.innerHTML = '';
|
||||
folderList.appendChild(warningDiv);
|
||||
|
||||
// Update the current path
|
||||
currentPath = data.path;
|
||||
currentPathDisplay.textContent = data.path;
|
||||
} else {
|
||||
// Clear folder list
|
||||
folderList.innerHTML = '';
|
||||
}
|
||||
|
||||
if (data.folders.length === 0) {
|
||||
const emptyDiv = document.createElement('div');
|
||||
emptyDiv.className = 'text-center p-8 text-gray-500';
|
||||
emptyDiv.innerHTML = `
|
||||
<i class="fas fa-folder-open text-4xl mb-2"></i>
|
||||
<p>No folders found in this location</p>
|
||||
`;
|
||||
folderList.appendChild(emptyDiv);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort folders alphabetically
|
||||
data.folders.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Add each folder to the list
|
||||
data.folders.forEach(folder => {
|
||||
const folderItem = document.createElement('div');
|
||||
folderItem.className = 'p-2 hover:bg-gray-100 rounded cursor-pointer flex items-center';
|
||||
folderItem.innerHTML = `
|
||||
<i class="fas fa-folder text-blue-500 mr-2"></i>
|
||||
<span>${folder.name}</span>
|
||||
`;
|
||||
|
||||
folderItem.addEventListener('click', function() {
|
||||
loadFolders(folder.path_display);
|
||||
});
|
||||
|
||||
folderList.appendChild(folderItem);
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading folders:', error);
|
||||
|
||||
// Display error in the folder list
|
||||
folderList.innerHTML = `
|
||||
<div class="text-center p-8 text-red-500">
|
||||
<i class="fas fa-exclamation-circle text-4xl mb-2"></i>
|
||||
<p>${error.data?.error || error.statusText || 'Error loading folders'}</p>
|
||||
<p class="text-sm mt-2">Make sure your Dropbox account is connected</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Also show detailed error in the profile page
|
||||
showError(error);
|
||||
});
|
||||
}
|
||||
|
||||
function showError(error) {
|
||||
// Format and display the error information
|
||||
errorContainer.classList.remove('hidden');
|
||||
|
||||
let errorMessage = '';
|
||||
if (error.status) {
|
||||
errorMessage += `HTTP ${error.status}: `;
|
||||
}
|
||||
|
||||
if (error.data && error.data.error) {
|
||||
errorMessage += error.data.error;
|
||||
} else if (error.statusText) {
|
||||
errorMessage += error.statusText;
|
||||
} else {
|
||||
errorMessage += 'Unknown error occurred';
|
||||
}
|
||||
|
||||
errorDetails.textContent = errorMessage;
|
||||
|
||||
// Show technical details
|
||||
let debugText = '';
|
||||
if (error.data) {
|
||||
if (error.data.details) {
|
||||
debugText += "Error Details:\n" + JSON.stringify(error.data.details, null, 2) + "\n\n";
|
||||
}
|
||||
if (error.data.traceback) {
|
||||
debugText += "Traceback:\n" + error.data.traceback + "\n\n";
|
||||
}
|
||||
if (error.data.raw_response) {
|
||||
debugText += "Raw Response:\n" + error.data.raw_response + "\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
if (!debugText) {
|
||||
debugText = JSON.stringify(error, null, 2);
|
||||
}
|
||||
|
||||
debugInfo.textContent = debugText;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
dropboxFolderModal.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,56 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Forgot Password - Quizzical Beats{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex min-h-screen items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="w-full max-w-md space-y-8">
|
||||
<div class="text-center">
|
||||
<h2 class="mt-6 text-center text-3xl font-bold tracking-tight text-navy-800 font-montserrat">
|
||||
Forgot Password
|
||||
</h2>
|
||||
<p class="mt-2 text-center text-sm text-gray-600">
|
||||
Enter your email address and we'll send a reset link
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 bg-white py-8 px-6 shadow-md rounded-lg">
|
||||
{% for category, message in get_flashed_messages(with_categories=true) %}
|
||||
<div class="mb-4 p-4 rounded-md {% if category == 'danger' %}bg-red-50 text-red-700{% elif category == 'success' %}bg-green-50 text-green-700{% else %}bg-blue-50 text-blue-700{% endif %}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<form class="space-y-6" action="{{ url_for('users.forgot_password') }}" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700">Email address</label>
|
||||
<div class="mt-1">
|
||||
<input id="email" name="email" type="email" autocomplete="email" required
|
||||
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-orange-500 focus:border-orange-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit" class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-orange-500 hover:bg-orange-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500">
|
||||
Send Password Reset Link
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-6">
|
||||
<div class="text-center">
|
||||
<a href="{{ url_for('users.login') }}" class="text-sm text-orange-600 hover:text-orange-500">
|
||||
Remember your password? Log in
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-4 text-sm text-gray-500">
|
||||
<p>Need help? Contact support</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,92 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Login{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-md mx-auto my-8 p-6 bg-white rounded-lg shadow-md">
|
||||
<h2 class="text-2xl font-bold mb-6 text-navy-800">Login to Quizzical Beats</h2>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="mb-4 p-3 {% if category == 'danger' %}bg-red-100 text-red-700{% elif category == 'success' %}bg-green-100 text-green-700{% else %}bg-blue-100 text-blue-700{% endif %} rounded">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" action="{{ url_for('users.login') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-gray-700 text-sm font-bold mb-2" for="username">
|
||||
Username or Email
|
||||
</label>
|
||||
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="username" name="username" type="text" placeholder="Enter your username or email">
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="block text-gray-700 text-sm font-bold mb-2" for="password">
|
||||
Password
|
||||
</label>
|
||||
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="password" name="password" type="password" placeholder="Enter your password">
|
||||
</div>
|
||||
|
||||
<div class="mb-6 flex items-center">
|
||||
<input class="mr-2" type="checkbox" id="remember" name="remember">
|
||||
<label class="text-sm text-gray-700" for="remember">
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<button class="bg-teal-500 hover:bg-teal-600 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline" type="submit">
|
||||
Login
|
||||
</button>
|
||||
<a class="inline-block align-baseline font-bold text-sm text-teal-500 hover:text-teal-800" href="{{ url_for('users.forgot_password') }}">
|
||||
Forgot Password?
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Social Login Options -->
|
||||
{% if oauth_providers and (oauth_providers.google or oauth_providers.authentik) %}
|
||||
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||
<div class="flex flex-col space-y-3">
|
||||
{% if oauth_providers.google %}
|
||||
<a href="{{ url_for('users.google_login') }}"
|
||||
class="flex items-center justify-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
|
||||
<svg class="h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
||||
<path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"></path>
|
||||
<path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"></path>
|
||||
<path fill="#4CAF50" d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"></path>
|
||||
<path fill="#1976D2" d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"></path>
|
||||
</svg>
|
||||
Sign in with Google
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if oauth_providers.authentik %}
|
||||
<a href="{{ url_for('users.authentik_login') }}"
|
||||
class="flex items-center justify-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
|
||||
<img src="https://cdn.jsdelivr.net/gh/selfhst/icons/svg/authentik.svg" class="h-5 w-5 mr-2" alt="Authentik logo" />
|
||||
Sign in with Authentik
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-6 pt-6 border-t border-gray-200 text-center">
|
||||
<p class="text-gray-700">
|
||||
Don't have an account?
|
||||
<a href="{{ url_for('users.register') }}" class="font-bold text-teal-500 hover:text-teal-800">
|
||||
Register
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,832 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}My Profile{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto my-8 p-6 bg-white rounded-lg shadow-md">
|
||||
<h2 class="text-2xl font-bold mb-6 text-navy-800">My Profile</h2>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="mb-4 p-3 {% if category == 'danger' %}bg-red-100 text-red-700{% elif category == 'success' %}bg-green-100 text-green-700{% else %}bg-blue-100 text-blue-700{% endif %} rounded">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<!-- Profile Info -->
|
||||
<div class="md:col-span-2">
|
||||
<div class="bg-gray-50 p-4 rounded-lg mb-6">
|
||||
<h3 class="text-lg font-semibold mb-3 text-navy-700">Account Information</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Username</p>
|
||||
<p class="font-medium">{{ current_user.username }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Email</p>
|
||||
<p class="font-medium">{{ current_user.email }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Name</p>
|
||||
<p class="font-medium">
|
||||
{% if current_user.first_name or current_user.last_name %}
|
||||
{{ current_user.first_name }} {{ current_user.last_name }}
|
||||
{% else %}
|
||||
<span class="text-gray-400">Not provided</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Member Since</p>
|
||||
<p class="font-medium">{{ current_user.created_at.strftime('%B %d, %Y') }}</p>
|
||||
</div>
|
||||
<!-- Add Admin Status -->
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Admin Status</p>
|
||||
<p class="font-medium">
|
||||
{% if current_user.is_admin() %}
|
||||
<span class="text-green-600">Administrator</span>
|
||||
<a href="{{ url_for('admin.index') }}" class="ml-2 text-xs text-blue-600 hover:underline">
|
||||
Admin Dashboard
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-gray-400">Regular User</span>
|
||||
{% if not admin_exists %}
|
||||
<a href="{{ url_for('users.setup') }}" class="ml-2 text-xs text-blue-600 hover:underline">
|
||||
Setup Admin
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Dropbox Export Folder</p>
|
||||
<p class="font-medium">
|
||||
{{ current_user.dropbox_export_path or '/QuizzicalBeats' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-semibold mb-3 text-navy-700">Account Actions</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<a href="{{ url_for('users.edit_profile') }}" class="bg-teal-500 hover:bg-teal-600 text-white py-2 px-4 rounded-md text-center transition-colors">
|
||||
<i class="fas fa-user-edit mr-2"></i> Edit Profile
|
||||
</a>
|
||||
<a href="{{ url_for('users.change_password') }}" class="bg-navy-600 hover:bg-navy-700 text-white py-2 px-4 rounded-md text-center transition-colors">
|
||||
<i class="fas fa-key mr-2"></i> Change Password
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Spotify Connection Debug - Only for Admins -->
|
||||
{% if current_user.is_admin() %}
|
||||
<div class="mt-6 p-4 border border-gray-300 rounded-lg bg-gray-50">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-lg font-semibold text-navy-700">Spotify Connection Debug</h3>
|
||||
<button id="showSpotifyDebugBtn" class="bg-indigo-500 hover:bg-indigo-600 text-white py-1 px-3 rounded-md text-sm transition-colors">
|
||||
<i class="fas fa-bug mr-1"></i> Show Details
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center text-sm">
|
||||
<div class="w-3 h-3 rounded-full mr-2
|
||||
{% if spotify_status == 'user' %}
|
||||
bg-green-500
|
||||
{% elif spotify_status == 'system' %}
|
||||
bg-yellow-500
|
||||
{% elif spotify_status == 'bearer' %}
|
||||
bg-blue-500
|
||||
{% else %}
|
||||
bg-red-500
|
||||
{% endif %}"></div>
|
||||
<span>
|
||||
{% if spotify_status == 'user' %}
|
||||
Using your Spotify account
|
||||
{% elif spotify_status == 'system' %}
|
||||
Using system Spotify account
|
||||
{% elif spotify_status == 'bearer' %}
|
||||
Using manual bearer token
|
||||
{% else %}
|
||||
No Spotify connection active
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dropbox Connection Debug - Only for Admins -->
|
||||
<div class="mt-4 p-4 border border-gray-300 rounded-lg bg-gray-50">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-lg font-semibold text-navy-700">Dropbox Connection Debug</h3>
|
||||
<button id="showDropboxDebugBtn" class="bg-blue-500 hover:bg-blue-600 text-white py-1 px-3 rounded-md text-sm transition-colors">
|
||||
<i class="fas fa-bug mr-1"></i> Show Details
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center text-sm">
|
||||
<div class="w-3 h-3 rounded-full mr-2
|
||||
{% if current_user.dropbox_token and current_user.dropbox_token_expiry and current_user.dropbox_token_expiry > now %}
|
||||
bg-green-500
|
||||
{% elif current_user.dropbox_token %}
|
||||
bg-yellow-500
|
||||
{% else %}
|
||||
bg-red-500
|
||||
{% endif %}"></div>
|
||||
<span>
|
||||
{% if current_user.dropbox_token and current_user.dropbox_token_expiry and current_user.dropbox_token_expiry > now %}
|
||||
Active Dropbox connection
|
||||
{% elif current_user.dropbox_token %}
|
||||
Dropbox token expired or expiring soon
|
||||
{% else %}
|
||||
No Dropbox connection active
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Connected Services -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-4 text-navy-700">Connected Services</h3>
|
||||
|
||||
<!-- Spotify Connection - Only shown for admins -->
|
||||
{% if current_user.is_admin() %}
|
||||
<div class="bg-gray-50 p-4 rounded-lg mb-4 shadow-sm">
|
||||
<div class="flex items-center mb-3">
|
||||
<i class="fab fa-spotify text-[#1DB954] text-2xl mr-3"></i>
|
||||
<div>
|
||||
<h4 class="font-medium">Spotify</h4>
|
||||
<p class="text-sm text-gray-600">
|
||||
{% if current_user.spotify_token %}
|
||||
Connected
|
||||
{% if current_user.oauth_id %}
|
||||
as {{ current_user.oauth_id }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
Not connected
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('users.spotify_link') }}" class="inline-block w-full bg-{% if current_user.spotify_token %}gray-200 hover:bg-gray-300{% else %}[#1DB954] hover:bg-[#1ed760]{% endif %} text-{% if current_user.spotify_token %}gray-700{% else %}white{% endif %} text-center py-1 px-3 rounded-md text-sm transition-colors">
|
||||
{% if current_user.spotify_token %}
|
||||
Manage Connection
|
||||
{% else %}
|
||||
Connect Spotify
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Dropbox Connection -->
|
||||
<div class="bg-gray-50 p-4 rounded-lg mb-4 shadow-sm">
|
||||
<div class="flex items-center mb-3">
|
||||
<i class="fab fa-dropbox text-[#0061FF] text-2xl mr-3"></i>
|
||||
<div>
|
||||
<h4 class="font-medium">Dropbox</h4>
|
||||
<p class="text-sm text-gray-600">
|
||||
{% if current_user.dropbox_id %}
|
||||
Connected
|
||||
{% if current_user.dropbox_id %}
|
||||
as {{ current_user.dropbox_id[:10] }}...
|
||||
{% endif %}
|
||||
{% else %}
|
||||
Not connected
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if current_user.dropbox_id %}
|
||||
<div class="grid grid-cols-1 gap-2">
|
||||
<p class="text-xs text-gray-600">
|
||||
{% if current_user.dropbox_token_expiry %}
|
||||
Token expires: {{ current_user.dropbox_token_expiry.strftime('%Y-%m-%d %H:%M') }}
|
||||
{% endif %}
|
||||
</p>
|
||||
<form action="{{ url_for('users.dropbox_disconnect') }}" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button type="submit" class="inline-block w-full bg-gray-200 hover:bg-gray-300 text-gray-700 text-center py-1 px-3 rounded-md text-sm transition-colors">
|
||||
Disconnect Dropbox
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<a href="{{ url_for('users.dropbox_auth') }}" class="inline-block w-full bg-[#0061FF] hover:bg-[#0055DF] text-white text-center py-1 px-3 rounded-md text-sm transition-colors">
|
||||
Connect Dropbox
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Manual Bearer Token Entry - Only for Admins -->
|
||||
<div class="bg-gray-50 p-4 rounded-lg mb-4 shadow-sm">
|
||||
<h4 class="font-medium mb-2">Direct Spotify Access</h4>
|
||||
<p class="text-xs text-gray-600 mb-3">
|
||||
Enter a Spotify bearer token for direct API access.
|
||||
This bypasses OAuth and allows immediate access to Spotify features.
|
||||
</p>
|
||||
|
||||
<form action="{{ url_for('users.update_bearer_token') }}" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="mb-2">
|
||||
<textarea name="bearer_token" rows="2"
|
||||
placeholder="BQBcQ1foQG0x14axu2kQVz..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm text-sm focus:outline-none focus:ring-teal-500 focus:border-teal-500">{{ session.get('access_token', '') }}</textarea>
|
||||
<p class="mt-1 text-xs text-gray-500">Get a token from <a href="https://developer.spotify.com/console/" target="_blank" class="text-teal-600 hover:underline">Spotify Developer Console</a></p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button type="submit" class="px-4 py-2 bg-teal-600 text-white text-sm font-medium rounded-md hover:bg-teal-700 transition-colors">
|
||||
Apply Token
|
||||
</button>
|
||||
{% if session.get('access_token') %}
|
||||
<button type="submit" name="clear_token" value="1" class="px-4 py-2 bg-gray-500 text-white text-sm font-medium rounded-md hover:bg-gray-600 transition-colors">
|
||||
Clear Token
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Audio Settings -->
|
||||
<div class="bg-gray-50 p-4 rounded-lg mb-4 shadow-sm">
|
||||
<h4 class="font-medium mb-2">Audio Settings</h4>
|
||||
<p class="text-xs text-gray-600 mb-3">
|
||||
Customize your audio preferences for quizzes.
|
||||
</p>
|
||||
<a href="{{ url_for('users.audio_settings') }}" class="inline-block w-full bg-orange-500 hover:bg-orange-600 text-white text-center py-2 px-4 rounded-md text-sm transition-colors">
|
||||
Manage Audio Settings
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Last Login Info -->
|
||||
<div class="mt-6">
|
||||
<h4 class="text-md font-medium mb-2">Last Login</h4>
|
||||
<p class="text-sm text-gray-600">
|
||||
{% if current_user.last_login %}
|
||||
{{ current_user.last_login.strftime('%d %b %Y, %H:%M') }}
|
||||
{% else %}
|
||||
<span class="text-gray-400">No login history</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spotify Debug Modal - Only for Admins -->
|
||||
{% if current_user.is_admin() %}
|
||||
<div id="spotifyDebugModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
||||
<div class="bg-white rounded-lg shadow-lg w-full max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-xl font-bold text-navy-800">Spotify Connection Details</h3>
|
||||
<button id="closeSpotifyDebugBtn" class="text-gray-500 hover:text-gray-700">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Connection Status -->
|
||||
<div class="mb-4 p-3 rounded-lg
|
||||
{% if spotify_status == 'user' %}
|
||||
bg-green-100 border border-green-300
|
||||
{% elif spotify_status == 'system' %}
|
||||
bg-yellow-100 border border-yellow-300
|
||||
{% elif spotify_status == 'bearer' %}
|
||||
bg-blue-100 border border-blue-300
|
||||
{% else %}
|
||||
bg-red-100 border border-red-300
|
||||
{% endif %}">
|
||||
<div class="flex items-center font-semibold mb-2">
|
||||
<div class="w-4 h-4 rounded-full mr-2
|
||||
{% if spotify_status == 'user' %}
|
||||
bg-green-500
|
||||
{% elif spotify_status == 'system' %}
|
||||
bg-yellow-500
|
||||
{% elif spotify_status == 'bearer' %}
|
||||
bg-blue-500
|
||||
{% else %}
|
||||
bg-red-500
|
||||
{% endif %}"></div>
|
||||
<span>
|
||||
{% if spotify_status == 'user' %}
|
||||
Using your Spotify account
|
||||
{% elif spotify_status == 'system' %}
|
||||
Using system Spotify account
|
||||
{% elif spotify_status == 'bearer' %}
|
||||
Using manual bearer token
|
||||
{% else %}
|
||||
No Spotify credentials available
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
{% if token_source %}
|
||||
<span class="ml-2 text-xs py-1 px-2 rounded-full bg-gray-200">
|
||||
Source: {{ token_source }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="text-sm">
|
||||
{% if spotify_status == 'user' %}
|
||||
Your personal Spotify account is connected and authenticated
|
||||
{% elif spotify_status == 'system' %}
|
||||
Using the fallback system Spotify account (refresh token from settings)
|
||||
{% elif spotify_status == 'bearer' %}
|
||||
Using a manual bearer token from session
|
||||
{% else %}
|
||||
No Spotify credentials available
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{% if token_source %}
|
||||
<p class="mt-2 text-xs text-gray-600">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
{% if token_source == 'user' %}
|
||||
This token was automatically generated from your user account refresh token
|
||||
{% elif token_source == 'system' %}
|
||||
This token was automatically generated from the system fallback refresh token
|
||||
{% else %}
|
||||
Token source: {{ token_source }}
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Debug Information -->
|
||||
<div class="space-y-4">
|
||||
<!-- Personal Account Details -->
|
||||
<div class="p-3 bg-gray-50 rounded-lg">
|
||||
<h4 class="font-semibold mb-2">Your Spotify Account</h4>
|
||||
<table class="w-full text-sm">
|
||||
<tr class="border-b border-gray-200">
|
||||
<td class="py-2 font-medium">Status:</td>
|
||||
<td class="py-2">
|
||||
{% if current_user.spotify_token %}
|
||||
<span class="text-green-600">Connected</span>
|
||||
{% else %}
|
||||
<span class="text-red-600">Not connected</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if current_user.spotify_token %}
|
||||
<tr class="border-b border-gray-200">
|
||||
<td class="py-2 font-medium">Spotify ID:</td>
|
||||
<td class="py-2">{{ current_user.oauth_id or 'Unknown' }}</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-200">
|
||||
<td class="py-2 font-medium">Token Expiry:</td>
|
||||
<td class="py-2">
|
||||
{% if current_user.spotify_token_expiry %}
|
||||
{% if current_user.spotify_token_expiry > now %}
|
||||
<span class="text-green-600">{{ current_user.spotify_token_expiry.strftime('%Y-%m-%d %H:%M:%S') }}</span>
|
||||
{% else %}
|
||||
<span class="text-red-600">Expired ({{ current_user.spotify_token_expiry.strftime('%Y-%m-%d %H:%M:%S') }})</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
Unknown
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-200">
|
||||
<td class="py-2 font-medium">Has Refresh Token:</td>
|
||||
<td class="py-2">
|
||||
{% if current_user.spotify_refresh_token %}
|
||||
<span class="text-green-600">Yes</span>
|
||||
{% else %}
|
||||
<span class="text-red-600">No</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- System Account Details -->
|
||||
<div class="p-3 bg-gray-50 rounded-lg">
|
||||
<h4 class="font-semibold mb-2">System Spotify Account</h4>
|
||||
<table class="w-full text-sm">
|
||||
<tr class="border-b border-gray-200">
|
||||
<td class="py-2 font-medium">Has Refresh Token:</td>
|
||||
<td class="py-2">
|
||||
{% if system_refresh_token %}
|
||||
<span class="text-green-600">Yes</span>
|
||||
{% else %}
|
||||
<span class="text-red-600">No</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if system_refresh_token %}
|
||||
<tr>
|
||||
<td class="py-2 font-medium">Token Preview:</td>
|
||||
<td class="py-2 font-mono">{{ system_refresh_token[:10] }}...{{ system_refresh_token[-5:] }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Manual Bearer Token -->
|
||||
<div class="p-3 bg-gray-50 rounded-lg">
|
||||
<h4 class="font-semibold mb-2">Manual Bearer Token</h4>
|
||||
<table class="w-full text-sm">
|
||||
<tr class="border-b border-gray-200">
|
||||
<td class="py-2 font-medium">Status:</td>
|
||||
<td class="py-2">
|
||||
{% if session_bearer and token_source == 'manual' %}
|
||||
<span class="text-green-600">Active manual token</span>
|
||||
{% elif session_bearer %}
|
||||
<span class="text-gray-600">Present but not active</span>
|
||||
{% else %}
|
||||
<span class="text-gray-600">Not present</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if session_bearer %}
|
||||
<tr class="border-b border-gray-200">
|
||||
<td class="py-2 font-medium">Token Preview:</td>
|
||||
<td class="py-2 font-mono">{{ session_bearer[:10] }}...{{ session_bearer[-5:] }}</td>
|
||||
</tr>
|
||||
{% if token_source == 'manual' and active_token_expiry %}
|
||||
<tr class="border-b border-gray-200">
|
||||
<td class="py-2 font-medium">Estimated Expiry:</td>
|
||||
<td class="py-2">
|
||||
{% if active_token_expiry > now %}
|
||||
<span class="text-green-600">{{ active_token_expiry.strftime('%Y-%m-%d %H:%M:%S') }}</span>
|
||||
<span class="text-xs text-gray-500">(Manual tokens typically expire after 1 hour)</span>
|
||||
{% else %}
|
||||
<span class="text-red-600">Likely expired ({{ active_token_expiry.strftime('%Y-%m-%d %H:%M:%S') }})</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if active_username and token_source == 'manual' %}
|
||||
<tr>
|
||||
<td class="py-2 font-medium">Associated User:</td>
|
||||
<td class="py-2">
|
||||
<div class="flex items-center">
|
||||
{% if active_user_image %}
|
||||
<img src="{{ active_user_image }}" alt="User" class="w-6 h-6 rounded-full mr-2">
|
||||
{% endif %}
|
||||
<span>{{ active_username }} ({{ active_user_id }})</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Current Active Token -->
|
||||
<div class="p-3 bg-gray-50 rounded-lg">
|
||||
<h4 class="font-semibold mb-2">Current Active Spotify User</h4>
|
||||
|
||||
{% if active_username %}
|
||||
<div class="flex items-center mb-3">
|
||||
{% if active_user_image %}
|
||||
<img src="{{ active_user_image }}" alt="Profile" class="w-10 h-10 rounded-full mr-3">
|
||||
{% else %}
|
||||
<div class="w-10 h-10 bg-gray-300 rounded-full flex items-center justify-center mr-3">
|
||||
<i class="fab fa-spotify text-gray-600"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<p class="font-medium">{{ active_username }}</p>
|
||||
<p class="text-sm text-gray-600">ID: {{ active_user_id }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% elif token_source == 'client_credentials' %}
|
||||
<div class="flex items-center mb-3">
|
||||
<div class="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center mr-3">
|
||||
<i class="fab fa-spotify text-purple-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium">App Client Credentials</p>
|
||||
<p class="text-sm text-gray-600">Using application's credentials (limited access)</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-600 italic">No active Spotify user detected</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-3">
|
||||
<h5 class="text-sm font-medium mb-1">Token Source</h5>
|
||||
<div class="text-sm">
|
||||
{% if token_source %}
|
||||
<span class="px-2 py-1 rounded-full text-xs
|
||||
{% if token_source == 'user' %}
|
||||
bg-green-100 text-green-800
|
||||
{% elif token_source == 'system' %}
|
||||
bg-yellow-100 text-yellow-800
|
||||
{% elif token_source == 'client_credentials' %}
|
||||
bg-purple-100 text-purple-800
|
||||
{% else %}
|
||||
bg-blue-100 text-blue-800
|
||||
{% endif %}">
|
||||
{{ token_source }}
|
||||
</span>
|
||||
|
||||
{% if token_source == 'client_credentials' and client_token_expiry %}
|
||||
<span class="text-xs text-gray-600 ml-2">
|
||||
Expires: {{ client_token_expiry|timestamp_to_datetime|format_datetime }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<span class="text-gray-600 italic">Unknown source</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Priority Order -->
|
||||
<div class="p-3 bg-gray-50 rounded-lg">
|
||||
<h4 class="font-semibold mb-2">Precedence</h4>
|
||||
<p class="text-sm mb-2">Connection priority: Manual Bearer → Your Account → Client Credentials</p>
|
||||
<div class="flex flex-col space-y-2 text-sm">
|
||||
<div class="flex items-center">
|
||||
<div class="w-3 h-3 rounded-full
|
||||
{% if spotify_status == 'bearer' %}bg-blue-500{% else %}bg-gray-300{% endif %}
|
||||
mr-2"></div>
|
||||
<span>1. Manual Bearer Token {% if spotify_status == 'bearer' %}(active){% endif %}</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="w-3 h-3 rounded-full
|
||||
{% if spotify_status == 'user' %}bg-green-500{% else %}bg-gray-300{% endif %}
|
||||
mr-2"></div>
|
||||
<span>2. User Account {% if spotify_status == 'user' %}(active){% endif %}</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="w-3 h-3 rounded-full
|
||||
{% if spotify_status == 'client_credentials' %}bg-purple-500{% else %}bg-gray-300{% endif %}
|
||||
mr-2"></div>
|
||||
<span>3. App Client Credentials {% if spotify_status == 'client_credentials' %}(active){% endif %}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button id="closeModalBtn" class="bg-gray-300 hover:bg-gray-400 text-gray-800 py-2 px-4 rounded-md">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dropbox Debug Modal - Only for Admins -->
|
||||
<div id="dropboxDebugModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
||||
<div class="bg-white rounded-lg shadow-lg w-full max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-xl font-bold text-navy-800">Dropbox Connection Details</h3>
|
||||
<button id="closeDropboxDebugBtn" class="text-gray-500 hover:text-gray-700">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Connection Status -->
|
||||
<div class="mb-4 p-3 rounded-lg
|
||||
{% if current_user.dropbox_token and current_user.dropbox_token_expiry and current_user.dropbox_token_expiry > now %}
|
||||
bg-green-100 border border-green-300
|
||||
{% elif current_user.dropbox_token %}
|
||||
bg-yellow-100 border border-yellow-300
|
||||
{% else %}
|
||||
bg-red-100 border border-red-300
|
||||
{% endif %}">
|
||||
<div class="flex items-center font-semibold mb-2">
|
||||
<div class="w-4 h-4 rounded-full mr-2
|
||||
{% if current_user.dropbox_token and current_user.dropbox_token_expiry and current_user.dropbox_token_expiry > now %}
|
||||
bg-green-500
|
||||
{% elif current_user.dropbox_token %}
|
||||
bg-yellow-500
|
||||
{% else %}
|
||||
bg-red-500
|
||||
{% endif %}"></div>
|
||||
<span>
|
||||
{% if current_user.dropbox_token and current_user.dropbox_token_expiry and current_user.dropbox_token_expiry > now %}
|
||||
Active Dropbox connection
|
||||
{% elif current_user.dropbox_token %}
|
||||
Dropbox token expired or expiring soon
|
||||
{% else %}
|
||||
No Dropbox connection
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm">
|
||||
{% if current_user.dropbox_token and current_user.dropbox_token_expiry and current_user.dropbox_token_expiry > now %}
|
||||
Your Dropbox account is connected and has a valid access token
|
||||
{% elif current_user.dropbox_token %}
|
||||
Your Dropbox account is connected but the token needs to be refreshed
|
||||
{% else %}
|
||||
No Dropbox credentials available
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Debug Information -->
|
||||
<div class="space-y-4">
|
||||
<!-- Dropbox Account Details -->
|
||||
<div class="p-3 bg-gray-50 rounded-lg">
|
||||
<h4 class="font-semibold mb-2">Your Dropbox Account</h4>
|
||||
<table class="w-full text-sm">
|
||||
<tr class="border-b border-gray-200">
|
||||
<td class="py-2 font-medium">Status:</td>
|
||||
<td class="py-2">
|
||||
{% if current_user.dropbox_id %}
|
||||
<span class="text-green-600">Connected</span>
|
||||
{% else %}
|
||||
<span class="text-red-600">Not connected</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if current_user.dropbox_id %}
|
||||
<tr class="border-b border-gray-200">
|
||||
<td class="py-2 font-medium">User Info:</td>
|
||||
<td class="py-2">
|
||||
<div class="flex items-center">
|
||||
{% if session.get('dropbox_user_info') and session.get('dropbox_user_info').get('picture') %}
|
||||
<img src="{{ session.get('dropbox_user_info').get('picture') }}" alt="Profile" class="w-8 h-8 rounded-full mr-2">
|
||||
{% else %}
|
||||
<div class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center mr-2">
|
||||
<i class="fab fa-dropbox text-blue-600"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
{% if session.get('dropbox_user_info') %}
|
||||
<div class="font-medium">{{ session.get('dropbox_user_info').get('name') }}</div>
|
||||
<div class="text-xs text-gray-500">{{ session.get('dropbox_user_info').get('email') }}</div>
|
||||
{% else %}
|
||||
<div class="text-gray-500 italic">User info not available</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-200">
|
||||
<td class="py-2 font-medium">Dropbox ID:</td>
|
||||
<td class="py-2">{{ current_user.dropbox_id }}</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-200">
|
||||
<td class="py-2 font-medium">Token Expiry:</td>
|
||||
<td class="py-2">
|
||||
{% if current_user.dropbox_token_expiry %}
|
||||
{% if current_user.dropbox_token_expiry > now %}
|
||||
<span class="text-green-600">{{ current_user.dropbox_token_expiry.strftime('%Y-%m-%d %H:%M:%S') }}</span>
|
||||
{% else %}
|
||||
<span class="text-red-600">Expired ({{ current_user.dropbox_token_expiry.strftime('%Y-%m-%d %H:%M:%S') }})</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
Unknown
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-200">
|
||||
<td class="py-2 font-medium">Has Refresh Token:</td>
|
||||
<td class="py-2">
|
||||
{% if current_user.dropbox_refresh_token %}
|
||||
<span class="text-green-600">Yes</span>
|
||||
{% else %}
|
||||
<span class="text-red-600">No</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Dropbox Token Details -->
|
||||
{% if current_user.dropbox_token %}
|
||||
<div class="p-3 bg-gray-50 rounded-lg">
|
||||
<h4 class="font-semibold mb-2">Token Information</h4>
|
||||
<table class="w-full text-sm">
|
||||
<tr class="border-b border-gray-200">
|
||||
<td class="py-2 font-medium">Access Token Preview:</td>
|
||||
<td class="py-2 font-mono">
|
||||
{{ current_user.dropbox_token[:10] }}...{{ current_user.dropbox_token[-5:] if current_user.dropbox_token|length > 15 else '' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% if current_user.dropbox_refresh_token %}
|
||||
<tr class="border-b border-gray-200">
|
||||
<td class="py-2 font-medium">Refresh Token Preview:</td>
|
||||
<td class="py-2 font-mono">
|
||||
{{ current_user.dropbox_refresh_token[:10] }}...{{ current_user.dropbox_refresh_token[-5:] if current_user.dropbox_refresh_token|length > 15 else '' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td class="py-2 font-medium">Token Status:</td>
|
||||
<td class="py-2">
|
||||
{% if current_user.dropbox_token_expiry %}
|
||||
{% if current_user.dropbox_token_expiry > now %}
|
||||
<span class="text-green-600">Valid for {{ ((current_user.dropbox_token_expiry - now).total_seconds() / 60)|int }} more minutes</span>
|
||||
{% else %}
|
||||
<span class="text-red-600">Expired {{ ((now - current_user.dropbox_token_expiry).total_seconds() / 60)|int }} minutes ago</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-gray-600">Unknown expiry time</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Test Actions -->
|
||||
<div class="p-3 bg-gray-50 rounded-lg">
|
||||
<h4 class="font-semibold mb-2">Connection Test</h4>
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
The following actions can help test your Dropbox connection:
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<a href="{{ url_for('users.dropbox_auth') }}" class="inline-block bg-blue-500 hover:bg-blue-600 text-white py-1 px-3 rounded-md text-sm transition-colors">
|
||||
Reconnect Dropbox
|
||||
</a>
|
||||
|
||||
{% if current_user.dropbox_id %}
|
||||
<form action="{{ url_for('users.dropbox_disconnect') }}" method="POST" class="inline-block">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button type="submit" class="bg-red-500 hover:bg-red-600 text-white py-1 px-3 rounded-md text-sm transition-colors">
|
||||
Disconnect Dropbox
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button id="closeDropboxModalBtn" class="bg-gray-300 hover:bg-gray-400 text-gray-800 py-2 px-4 rounded-md">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% if current_user.is_admin() %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const modal = document.getElementById('spotifyDebugModal');
|
||||
const showBtn = document.getElementById('showSpotifyDebugBtn');
|
||||
const closeBtn = document.getElementById('closeSpotifyDebugBtn');
|
||||
const closeModalBtn = document.getElementById('closeModalBtn');
|
||||
|
||||
// Show modal
|
||||
showBtn.addEventListener('click', function() {
|
||||
modal.classList.remove('hidden');
|
||||
});
|
||||
|
||||
// Close modal
|
||||
closeBtn.addEventListener('click', function() {
|
||||
modal.classList.add('hidden');
|
||||
});
|
||||
|
||||
closeModalBtn.addEventListener('click', function() {
|
||||
modal.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Close modal when clicking outside
|
||||
modal.addEventListener('click', function(event) {
|
||||
if (event.target === modal) {
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
const dropboxModal = document.getElementById('dropboxDebugModal');
|
||||
const showDropboxBtn = document.getElementById('showDropboxDebugBtn');
|
||||
const closeDropboxBtn = document.getElementById('closeDropboxDebugBtn');
|
||||
const closeDropboxModalBtn = document.getElementById('closeDropboxModalBtn');
|
||||
|
||||
// Show Dropbox modal
|
||||
showDropboxBtn.addEventListener('click', function() {
|
||||
dropboxModal.classList.remove('hidden');
|
||||
});
|
||||
|
||||
// Close Dropbox modal
|
||||
closeDropboxBtn.addEventListener('click', function() {
|
||||
dropboxModal.classList.add('hidden');
|
||||
});
|
||||
|
||||
closeDropboxModalBtn.addEventListener('click', function() {
|
||||
dropboxModal.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Close Dropbox modal when clicking outside
|
||||
dropboxModal.addEventListener('click', function(event) {
|
||||
if (event.target === dropboxModal) {
|
||||
dropboxModal.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,71 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Register{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-md mx-auto my-8 p-6 bg-white rounded-lg shadow-md">
|
||||
<h2 class="text-2xl font-bold mb-6 text-navy-800">Create an Account</h2>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="mb-4 p-3 {% if category == 'danger' %}bg-red-100 text-red-700{% elif category == 'success' %}bg-green-100 text-green-700{% else %}bg-blue-100 text-blue-700{% endif %} rounded">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" action="{{ url_for('users.register') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-gray-700 text-sm font-bold mb-2" for="username">
|
||||
Username
|
||||
</label>
|
||||
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="username" name="username" type="text" placeholder="Choose a username">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-gray-700 text-sm font-bold mb-2" for="email">
|
||||
Email
|
||||
</label>
|
||||
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="email" name="email" type="email" placeholder="Enter your email">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-gray-700 text-sm font-bold mb-2" for="password">
|
||||
Password
|
||||
</label>
|
||||
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="password" name="password" type="password" placeholder="Enter your password">
|
||||
<p class="text-xs text-gray-500 mt-1">Must be at least 8 characters long.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="block text-gray-700 text-sm font-bold mb-2" for="confirm_password">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="confirm_password" name="confirm_password" type="password" placeholder="Confirm your password">
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<button class="w-full bg-teal-500 hover:bg-teal-600 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline" type="submit">
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<p class="text-gray-700">
|
||||
Already have an account?
|
||||
<a href="{{ url_for('users.login') }}" class="font-bold text-teal-500 hover:text-teal-800">
|
||||
Login
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,67 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Reset Password - Quizzical Beats{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex min-h-screen items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="w-full max-w-md space-y-8">
|
||||
<div class="text-center">
|
||||
<h2 class="mt-6 text-center text-3xl font-bold tracking-tight text-navy-800 font-montserrat">
|
||||
Reset Your Password
|
||||
</h2>
|
||||
<p class="mt-2 text-center text-sm text-gray-600">
|
||||
Please enter your new password below
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 bg-white py-8 px-6 shadow-md rounded-lg">
|
||||
{% for category, message in get_flashed_messages(with_categories=true) %}
|
||||
<div class="mb-4 p-4 rounded-md {% if category == 'danger' %}bg-red-50 text-red-700{% elif category == 'success' %}bg-green-50 text-green-700{% else %}bg-blue-50 text-blue-700{% endif %}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<form class="space-y-6" action="{{ url_for('users.reset_password', token=token) }}" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700">New Password</label>
|
||||
<div class="mt-1">
|
||||
<input id="password" name="password" type="password" autocomplete="new-password" required
|
||||
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-orange-500 focus:border-orange-500"
|
||||
minlength="8">
|
||||
<p class="text-xs text-gray-500 mt-1">Password must be at least 8 characters</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="confirm_password" class="block text-sm font-medium text-gray-700">Confirm Password</label>
|
||||
<div class="mt-1">
|
||||
<input id="confirm_password" name="confirm_password" type="password" autocomplete="new-password" required
|
||||
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-orange-500 focus:border-orange-500"
|
||||
minlength="8">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit" class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-orange-500 hover:bg-orange-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500">
|
||||
Reset Password
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-6">
|
||||
<div class="text-center">
|
||||
<a href="{{ url_for('users.login') }}" class="text-sm text-orange-600 hover:text-orange-500">
|
||||
Return to login page
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-4 text-sm text-gray-500">
|
||||
<p>Need a new reset link? <a href="{{ url_for('users.forgot_password') }}" class="text-orange-600 hover:text-orange-500">Request password reset again</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,143 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Connect Spotify - Quizzical Beats{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto my-8 p-6 bg-white rounded-lg shadow-md">
|
||||
<h2 class="text-2xl font-bold mb-6 text-navy-800">Connect Spotify Account</h2>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="mb-4 p-3 {% if category == 'danger' %}bg-red-100 text-red-700{% elif category == 'success' %}bg-green-100 text-green-700{% else %}bg-blue-100 text-blue-700{% endif %} rounded">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<!-- Left column: Current status -->
|
||||
<div class="bg-gray-50 p-6 rounded-lg">
|
||||
<h3 class="text-lg font-semibold mb-4 text-navy-700">Spotify Connection Status</h3>
|
||||
|
||||
{% if current_user.spotify_token %}
|
||||
<div class="mb-6 flex items-center">
|
||||
<div class="mr-4 bg-green-100 p-3 rounded-full">
|
||||
<i class="fab fa-spotify text-2xl text-green-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-green-700">Connected to Spotify</p>
|
||||
<p class="text-sm text-gray-600">Your account is linked to Spotify</p>
|
||||
{% if current_user.oauth_id %}
|
||||
<p class="text-sm text-gray-600 mt-1">Spotify ID: {{ current_user.oauth_id }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="text-md font-medium mb-2">Token Information</h4>
|
||||
<div class="bg-white p-4 rounded-lg border border-gray-200 text-sm">
|
||||
<p class="flex justify-between mb-2">
|
||||
<span class="font-medium">Token Status:</span>
|
||||
<span class="{% if current_user.spotify_token_expiry and current_user.spotify_token_expiry > now %}text-green-600{% else %}text-red-600{% endif %}">
|
||||
{% if current_user.spotify_token_expiry and current_user.spotify_token_expiry > now %}
|
||||
Valid
|
||||
{% else %}
|
||||
Expired
|
||||
{% endif %}
|
||||
</span>
|
||||
</p>
|
||||
{% if current_user.spotify_token_expiry %}
|
||||
<p class="flex justify-between">
|
||||
<span class="font-medium">Expires:</span>
|
||||
<span>{{ current_user.spotify_token_expiry.strftime('%Y-%m-%d %H:%M:%S') }}</span>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('users.spotify_link') }}" class="mt-6">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input type="hidden" name="action" value="disconnect">
|
||||
<button type="submit" class="bg-red-500 hover:bg-red-600 text-white py-2 px-4 rounded-md font-medium">
|
||||
<i class="fas fa-unlink mr-2"></i> Disconnect Spotify
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{% else %}
|
||||
<div class="mb-6 flex items-center">
|
||||
<div class="mr-4 bg-gray-200 p-3 rounded-full">
|
||||
<i class="fab fa-spotify text-2xl text-gray-500"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-700">Not Connected</p>
|
||||
<p class="text-sm text-gray-600">Your account is not linked to Spotify</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('users.spotify_auth') }}" class="inline-block bg-[#1DB954] hover:bg-[#1ed760] text-white font-medium py-2 px-4 rounded-md">
|
||||
<i class="fab fa-spotify mr-2"></i> Connect with Spotify
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Right column: Info and benefits -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-4 text-navy-700">Why Connect Spotify?</h3>
|
||||
|
||||
<div class="bg-teal-50 border-l-4 border-teal-500 p-4 rounded mb-6">
|
||||
<p class="text-teal-700">
|
||||
Connecting your Spotify account enhances your music quiz creation experience
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex">
|
||||
<div class="mr-3 text-teal-500">
|
||||
<i class="fas fa-music"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium">Access Your Playlists</h4>
|
||||
<p class="text-gray-600 text-sm">Import songs from your personal Spotify playlists</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<div class="mr-3 text-teal-500">
|
||||
<i class="fas fa-search"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium">Search the Spotify Catalog</h4>
|
||||
<p class="text-gray-600 text-sm">Find and import any track from Spotify's huge library</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<div class="mr-3 text-teal-500">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium">Trending Playlists</h4>
|
||||
<p class="text-gray-600 text-sm">Access Spotify's official and trending playlists</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 bg-gray-50 p-4 rounded-lg">
|
||||
<h4 class="font-medium mb-2">Privacy Note</h4>
|
||||
<p class="text-sm text-gray-600">
|
||||
We only access your Spotify data to help you create music quizzes.
|
||||
We don't share your information or post to your account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||
<a href="{{ url_for('users.profile') }}" class="text-teal-600 hover:text-teal-800">
|
||||
<i class="fas fa-arrow-left mr-2"></i> Back to Profile
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user