- Added edit_on_player_enabled column to playlist_content table - Updated playlist model to track edit enablement per content item - Added UI checkbox on upload media page to enable/disable editing - Added toggle column on manage playlist page for existing content - Updated API endpoint to return edit_on_player_enabled flag to players - Fixed docker-entrypoint.sh to use production config - Supports PDF, Images, and PPTX content types
557 lines
18 KiB
HTML
557 lines
18 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block title %}Manage {{ playlist.name }} - DigiServer v2{% endblock %}
|
||
|
||
{% block content %}
|
||
<style>
|
||
.playlist-header {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
padding: 30px;
|
||
border-radius: 12px;
|
||
margin-bottom: 25px;
|
||
}
|
||
|
||
.playlist-header h1 {
|
||
margin: 0 0 10px 0;
|
||
}
|
||
|
||
.stats-row {
|
||
display: flex;
|
||
gap: 30px;
|
||
margin-top: 15px;
|
||
}
|
||
|
||
.stat-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.stat-label {
|
||
font-size: 12px;
|
||
opacity: 0.9;
|
||
}
|
||
|
||
.stat-value {
|
||
font-size: 24px;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.content-grid {
|
||
display: grid;
|
||
grid-template-columns: 2fr 1fr;
|
||
gap: 20px;
|
||
}
|
||
|
||
.playlist-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
}
|
||
|
||
.playlist-table th {
|
||
background: #f8f9fa;
|
||
padding: 12px;
|
||
text-align: left;
|
||
border-bottom: 2px solid #dee2e6;
|
||
}
|
||
|
||
.playlist-table td {
|
||
padding: 12px;
|
||
border-bottom: 1px solid #dee2e6;
|
||
}
|
||
|
||
.draggable-row {
|
||
cursor: move;
|
||
}
|
||
|
||
.draggable-row:hover {
|
||
background: #f8f9fa;
|
||
}
|
||
|
||
.drag-handle {
|
||
cursor: grab;
|
||
font-size: 18px;
|
||
color: #999;
|
||
}
|
||
|
||
.available-content {
|
||
max-height: 500px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.content-item {
|
||
background: #f8f9fa;
|
||
padding: 12px;
|
||
margin-bottom: 10px;
|
||
border-radius: 6px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
/* Audio toggle styles */
|
||
.audio-toggle {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
|
||
.audio-checkbox {
|
||
display: none;
|
||
}
|
||
|
||
.audio-label {
|
||
font-size: 20px;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.audio-checkbox + .audio-label .audio-on {
|
||
display: none;
|
||
}
|
||
|
||
.audio-checkbox + .audio-label .audio-off {
|
||
display: inline;
|
||
}
|
||
|
||
.audio-checkbox:checked + .audio-label .audio-on {
|
||
display: inline;
|
||
}
|
||
|
||
.audio-checkbox:checked + .audio-label .audio-off {
|
||
display: none;
|
||
}
|
||
|
||
.audio-label:hover {
|
||
transform: scale(1.2);
|
||
}
|
||
|
||
/* Dark mode support */
|
||
body.dark-mode .playlist-table th {
|
||
background: #1a202c;
|
||
color: #cbd5e0;
|
||
border-bottom-color: #4a5568;
|
||
}
|
||
|
||
body.dark-mode .playlist-table td {
|
||
border-bottom-color: #4a5568;
|
||
color: #e2e8f0;
|
||
}
|
||
|
||
body.dark-mode .draggable-row:hover {
|
||
background: #1a202c;
|
||
}
|
||
|
||
body.dark-mode .drag-handle {
|
||
color: #718096;
|
||
}
|
||
|
||
body.dark-mode .content-item {
|
||
background: #1a202c;
|
||
color: #e2e8f0;
|
||
}
|
||
|
||
body.dark-mode .available-content {
|
||
color: #e2e8f0;
|
||
}
|
||
</style>
|
||
|
||
<div class="container" style="max-width: 1400px;">
|
||
<div class="playlist-header">
|
||
<h1>🎬 {{ playlist.name }}</h1>
|
||
{% if playlist.description %}
|
||
<p style="margin: 5px 0; opacity: 0.9;">{{ playlist.description }}</p>
|
||
{% endif %}
|
||
|
||
<div class="stats-row">
|
||
<div class="stat-item">
|
||
<span class="stat-label">Content Items</span>
|
||
<span class="stat-value">{{ playlist_content|length }}</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
<span class="stat-label">Total Duration</span>
|
||
<span class="stat-value">{{ playlist.total_duration }}s</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
<span class="stat-label">Version</span>
|
||
<span class="stat-value">{{ playlist.version }}</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
<span class="stat-label">Players Assigned</span>
|
||
<span class="stat-value">{{ playlist.player_count }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="margin-bottom: 20px;">
|
||
<a href="{{ url_for('content.content_list') }}" class="btn btn-secondary">
|
||
← Back to Playlists
|
||
</a>
|
||
</div>
|
||
|
||
<div class="content-grid">
|
||
<div class="card">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||
<h2 style="margin: 0;">📋 Playlist Content (Drag to Reorder)</h2>
|
||
<button id="bulk-delete-btn" class="btn btn-danger" style="display: none;" onclick="bulkDeleteSelected()">
|
||
🗑️ Delete Selected (<span id="selected-count">0</span>)
|
||
</button>
|
||
</div>
|
||
|
||
{% if playlist_content %}
|
||
<table class="playlist-table" id="playlist-table">
|
||
<thead>
|
||
<tr>
|
||
<th style="width: 40px;">
|
||
<input type="checkbox" id="select-all" onchange="toggleSelectAll(this)" title="Select all">
|
||
</th>
|
||
<th style="width: 40px;"></th>
|
||
<th style="width: 50px;">#</th>
|
||
<th>Filename</th>
|
||
<th style="width: 100px;">Type</th>
|
||
<th style="width: 100px;">Duration</th>
|
||
<th style="width: 80px;">Audio</th>
|
||
<th style="width: 80px;">Edit</th>
|
||
<th style="width: 100px;">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="playlist-tbody">
|
||
{% for content in playlist_content %}
|
||
<tr class="draggable-row" draggable="true" data-content-id="{{ content.id }}">
|
||
<td>
|
||
<input type="checkbox" class="content-checkbox" data-content-id="{{ content.id }}" onchange="updateBulkDeleteButton()">
|
||
</td>
|
||
<td><span class="drag-handle">⋮⋮</span></td>
|
||
<td>{{ loop.index }}</td>
|
||
<td>{{ content.filename }}</td>
|
||
<td>
|
||
{% if content.content_type == 'image' %}📷 Image
|
||
{% elif content.content_type == 'video' %}🎥 Video
|
||
{% elif content.content_type == 'pdf' %}📄 PDF
|
||
{% else %}📁 Other{% endif %}
|
||
</td>
|
||
<td>{{ content._playlist_duration or content.duration }}s</td>
|
||
<td>
|
||
{% if content.content_type == 'video' %}
|
||
<label class="audio-toggle">
|
||
<input type="checkbox"
|
||
class="audio-checkbox"
|
||
data-content-id="{{ content.id }}"
|
||
{{ 'checked' if not content._playlist_muted else '' }}
|
||
onchange="toggleAudio({{ content.id }}, this.checked)">
|
||
<span class="audio-label">
|
||
<span class="audio-on">🔊</span>
|
||
<span class="audio-off">🔇</span>
|
||
</span>
|
||
</label>
|
||
{% else %}
|
||
<span style="color: #999;">—</span>
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
{% if content.content_type in ['image', 'pdf'] %}
|
||
<label class="audio-toggle">
|
||
<input type="checkbox"
|
||
class="edit-checkbox"
|
||
data-content-id="{{ content.id }}"
|
||
{{ 'checked' if content._playlist_edit_on_player_enabled else '' }}
|
||
onchange="toggleEdit({{ content.id }}, this.checked)">
|
||
<span class="audio-label">
|
||
<span class="audio-on">✏️</span>
|
||
<span class="audio-off">🔒</span>
|
||
</span>
|
||
</label>
|
||
{% else %}
|
||
<span style="color: #999;">—</span>
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
<form method="POST"
|
||
action="{{ url_for('content.remove_content_from_playlist', playlist_id=playlist.id, content_id=content.id) }}"
|
||
style="display: inline;"
|
||
onsubmit="return confirm('Remove from playlist?');">
|
||
<button type="submit" class="btn btn-danger btn-sm">
|
||
✕
|
||
</button>
|
||
</form>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
{% else %}
|
||
<div style="text-align: center; padding: 40px; color: #999;">
|
||
<div style="font-size: 48px;">📭</div>
|
||
<p>No content in playlist yet. Add content from the right panel.</p>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2 style="margin-bottom: 20px;">➕ Add Content</h2>
|
||
|
||
{% if available_content %}
|
||
<div class="available-content">
|
||
{% for content in available_content %}
|
||
<div class="content-item">
|
||
<div>
|
||
<div>
|
||
{% if content.content_type == 'image' %}📷
|
||
{% elif content.content_type == 'video' %}🎥
|
||
{% elif content.content_type == 'pdf' %}📄
|
||
{% else %}📁{% endif %}
|
||
{{ content.filename }}
|
||
</div>
|
||
<div style="font-size: 12px; color: #999;">
|
||
{{ content.file_size_mb }} MB
|
||
</div>
|
||
</div>
|
||
<form method="POST"
|
||
action="{{ url_for('content.add_content_to_playlist', playlist_id=playlist.id) }}"
|
||
style="display: inline;">
|
||
<input type="hidden" name="content_id" value="{{ content.id }}">
|
||
<input type="hidden" name="duration" value="{{ content.duration }}">
|
||
<button type="submit" class="btn btn-success btn-sm">
|
||
+ Add
|
||
</button>
|
||
</form>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% else %}
|
||
<div style="text-align: center; padding: 40px; color: #999;">
|
||
<p>All available content has been added to this playlist!</p>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let draggedElement = null;
|
||
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
const tbody = document.getElementById('playlist-tbody');
|
||
if (!tbody) return;
|
||
|
||
const rows = tbody.querySelectorAll('.draggable-row');
|
||
|
||
rows.forEach(row => {
|
||
row.addEventListener('dragstart', handleDragStart);
|
||
row.addEventListener('dragover', handleDragOver);
|
||
row.addEventListener('drop', handleDrop);
|
||
row.addEventListener('dragend', handleDragEnd);
|
||
});
|
||
});
|
||
|
||
function handleDragStart(e) {
|
||
draggedElement = this;
|
||
this.style.opacity = '0.5';
|
||
}
|
||
|
||
function handleDragOver(e) {
|
||
if (e.preventDefault) {
|
||
e.preventDefault();
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function handleDrop(e) {
|
||
if (e.stopPropagation) {
|
||
e.stopPropagation();
|
||
}
|
||
|
||
if (draggedElement !== this) {
|
||
const tbody = document.getElementById('playlist-tbody');
|
||
const allRows = [...tbody.querySelectorAll('.draggable-row')];
|
||
const draggedIndex = allRows.indexOf(draggedElement);
|
||
const targetIndex = allRows.indexOf(this);
|
||
|
||
if (draggedIndex < targetIndex) {
|
||
this.parentNode.insertBefore(draggedElement, this.nextSibling);
|
||
} else {
|
||
this.parentNode.insertBefore(draggedElement, this);
|
||
}
|
||
|
||
updateRowNumbers();
|
||
saveOrder();
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
function handleDragEnd(e) {
|
||
this.style.opacity = '1';
|
||
}
|
||
|
||
function updateRowNumbers() {
|
||
const rows = document.querySelectorAll('#playlist-tbody tr');
|
||
rows.forEach((row, index) => {
|
||
row.querySelector('td:nth-child(2)').textContent = index + 1;
|
||
});
|
||
}
|
||
|
||
function saveOrder() {
|
||
const rows = document.querySelectorAll('#playlist-tbody .draggable-row');
|
||
const contentIds = Array.from(rows).map(row => parseInt(row.dataset.contentId));
|
||
|
||
fetch('{{ url_for("content.reorder_playlist_content", playlist_id=playlist.id) }}', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ content_ids: contentIds })
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (!data.success) {
|
||
alert('Error reordering: ' + data.message);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Error:', error);
|
||
});
|
||
}
|
||
|
||
function toggleAudio(contentId, enabled) {
|
||
const muted = !enabled; // Checkbox is "enabled audio", but backend stores "muted"
|
||
const playlistId = {{ playlist.id }};
|
||
const url = `/content/playlist/${playlistId}/update-muted/${contentId}`;
|
||
|
||
const formData = new FormData();
|
||
formData.append('muted', muted ? 'true' : 'false');
|
||
|
||
fetch(url, {
|
||
method: 'POST',
|
||
body: formData
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
console.log('Audio setting updated:', enabled ? 'Enabled' : 'Muted');
|
||
} else {
|
||
alert('Error updating audio setting: ' + data.message);
|
||
// Revert checkbox on error
|
||
const checkbox = document.querySelector(`.audio-checkbox[data-content-id="${contentId}"]`);
|
||
if (checkbox) checkbox.checked = !enabled;
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Error:', error);
|
||
alert('Error updating audio setting');
|
||
// Revert checkbox on error
|
||
const checkbox = document.querySelector(`.audio-checkbox[data-content-id="${contentId}"]`);
|
||
if (checkbox) checkbox.checked = !enabled;
|
||
});
|
||
}
|
||
|
||
function toggleEdit(contentId, enabled) {
|
||
const playlistId = {{ playlist.id }};
|
||
const url = `/content/playlist/${playlistId}/update-edit-enabled/${contentId}`;
|
||
|
||
const formData = new FormData();
|
||
formData.append('edit_enabled', enabled ? 'true' : 'false');
|
||
|
||
fetch(url, {
|
||
method: 'POST',
|
||
body: formData
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
console.log('Edit setting updated:', enabled ? 'Enabled' : 'Disabled');
|
||
} else {
|
||
alert('Error updating edit setting: ' + data.message);
|
||
// Revert checkbox on error
|
||
const checkbox = document.querySelector(`.edit-checkbox[data-content-id="${contentId}"]`);
|
||
if (checkbox) checkbox.checked = !enabled;
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Error:', error);
|
||
alert('Error updating edit setting');
|
||
// Revert checkbox on error
|
||
const checkbox = document.querySelector(`.edit-checkbox[data-content-id="${contentId}"]`);
|
||
if (checkbox) checkbox.checked = !enabled;
|
||
});
|
||
}
|
||
|
||
function toggleSelectAll(checkbox) {
|
||
const checkboxes = document.querySelectorAll('.content-checkbox');
|
||
checkboxes.forEach(cb => {
|
||
cb.checked = checkbox.checked;
|
||
});
|
||
updateBulkDeleteButton();
|
||
}
|
||
|
||
function updateBulkDeleteButton() {
|
||
const checkboxes = document.querySelectorAll('.content-checkbox:checked');
|
||
const count = checkboxes.length;
|
||
const bulkDeleteBtn = document.getElementById('bulk-delete-btn');
|
||
const selectedCount = document.getElementById('selected-count');
|
||
|
||
if (count > 0) {
|
||
bulkDeleteBtn.style.display = 'block';
|
||
selectedCount.textContent = count;
|
||
} else {
|
||
bulkDeleteBtn.style.display = 'none';
|
||
}
|
||
|
||
// Update select-all checkbox state
|
||
const allCheckboxes = document.querySelectorAll('.content-checkbox');
|
||
const selectAllCheckbox = document.getElementById('select-all');
|
||
if (selectAllCheckbox) {
|
||
selectAllCheckbox.checked = allCheckboxes.length > 0 && count === allCheckboxes.length;
|
||
selectAllCheckbox.indeterminate = count > 0 && count < allCheckboxes.length;
|
||
}
|
||
}
|
||
|
||
function bulkDeleteSelected() {
|
||
const checkboxes = document.querySelectorAll('.content-checkbox:checked');
|
||
const contentIds = Array.from(checkboxes).map(cb => parseInt(cb.dataset.contentId));
|
||
|
||
if (contentIds.length === 0) {
|
||
alert('No items selected');
|
||
return;
|
||
}
|
||
|
||
const confirmMsg = `Are you sure you want to remove ${contentIds.length} item(s) from this playlist?`;
|
||
if (!confirm(confirmMsg)) {
|
||
return;
|
||
}
|
||
|
||
// Show loading state
|
||
const bulkDeleteBtn = document.getElementById('bulk-delete-btn');
|
||
const originalText = bulkDeleteBtn.innerHTML;
|
||
bulkDeleteBtn.disabled = true;
|
||
bulkDeleteBtn.innerHTML = '⏳ Removing...';
|
||
|
||
fetch('{{ url_for("content.bulk_remove_from_playlist", playlist_id=playlist.id) }}', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ content_ids: contentIds })
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
// Reload page to show updated playlist
|
||
window.location.reload();
|
||
} else {
|
||
alert('Error removing items: ' + data.message);
|
||
bulkDeleteBtn.disabled = false;
|
||
bulkDeleteBtn.innerHTML = originalText;
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Error:', error);
|
||
alert('Error removing items from playlist');
|
||
bulkDeleteBtn.disabled = false;
|
||
bulkDeleteBtn.innerHTML = originalText;
|
||
});
|
||
}
|
||
</script>
|
||
|
||
{% endblock %}
|