Files
digiserver-v2/app/templates/content/manage_playlist_content.html
Deployment System 49393d9a73 Final: Complete modernization - Option 1 deployment, unified persistence, migration scripts
- Implement Docker image-based deployment (Option 1)
  * Code immutable in image, no volume override
  * Eliminated init-data.sh manual step
  * Simplified deployment process

- Unified persistence in data/ folder
  * Moved nginx.conf and nginx-custom-domains.conf to data/
  * All runtime configs and data in single location
  * Clear separation: repo (source) vs data/ (runtime)

- Archive legacy features
  * Groups blueprint and templates removed
  * Legacy playlist routes redirected to content area
  * Organized in old_code_documentation/

- Added network migration support
  * New migrate_network.sh script for IP changes
  * Regenerates SSL certs for new IP
  * Updates database configuration
  * Tested workflow: clone → deploy → migrate

- Enhanced deploy.sh
  * Creates data directories
  * Copies nginx configs from repo to data/
  * Validates file existence before deployment
  * Prevents incomplete deployments

- Updated documentation
  * QUICK_DEPLOYMENT.md shows 4-step workflow
  * Complete deployment workflow documented
  * Migration procedures included

- Production ready deployment workflow:
  1. Clone & setup (.env configuration)
  2. Deploy (./deploy.sh)
  3. Migrate network (./migrate_network.sh if needed)
  4. Normal operations (docker compose restart)
2026-01-17 10:30:42 +02:00

720 lines
24 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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;
}
/* Duration spinner control */
.duration-spinner {
display: flex;
align-items: center;
gap: 8px;
pointer-events: auto;
}
.duration-display {
min-width: 60px;
text-align: center;
font-weight: 500;
font-size: 16px;
padding: 6px 12px;
background: #f5f5f5;
border-radius: 4px;
border: 1px solid #ddd;
pointer-events: auto;
}
.duration-spinner button {
width: 36px;
height: 36px;
padding: 0;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
pointer-events: auto;
}
.duration-spinner button:hover {
background: #f0f0f0;
border-color: #999;
}
.duration-spinner button:active {
background: #e0e0e0;
transform: scale(0.95);
}
.duration-spinner button.btn-increase {
color: #28a745;
}
.duration-spinner button.btn-decrease {
color: #dc3545;
}
.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;
}
/* Dark mode for duration spinner */
body.dark-mode .duration-display {
background: #2d3748;
border-color: #4a5568;
color: #e2e8f0;
}
body.dark-mode .duration-spinner button {
background: #2d3748;
border-color: #4a5568;
color: #e2e8f0;
}
body.dark-mode .duration-spinner button:hover {
background: #4a5568;
border-color: #718096;
}
body.dark-mode .duration-spinner button:active {
background: #5a6a78;
}
body.dark-mode .duration-spinner button.btn-increase {
color: #48bb78;
}
body.dark-mode .duration-spinner button.btn-decrease {
color: #f56565;
}
</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>
<div class="duration-spinner">
<button type="button"
class="btn-decrease"
onclick="event.stopPropagation(); changeDuration({{ content.id }}, -1)"
onmousedown="event.stopPropagation()"
title="Decrease duration by 1 second">
⬇️
</button>
<div class="duration-display" id="duration-display-{{ content.id }}">
{{ content._playlist_duration or content.duration }}s
</div>
<button type="button"
class="btn-increase"
onclick="event.stopPropagation(); changeDuration({{ content.id }}, 1)"
onmousedown="event.stopPropagation()"
title="Increase duration by 1 second">
⬆️
</button>
</div>
</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);
});
}
// Change duration with spinner buttons
function changeDuration(contentId, change) {
const displayElement = document.getElementById(`duration-display-${contentId}`);
const currentText = displayElement.textContent;
const currentDuration = parseInt(currentText);
const newDuration = currentDuration + change;
// Validate duration (minimum 1 second)
if (newDuration < 1) {
alert('Duration must be at least 1 second');
return;
}
// Update display immediately for visual feedback
displayElement.style.opacity = '0.7';
displayElement.textContent = newDuration + 's';
// Save to server
const playlistId = {{ playlist.id }};
const url = `/content/playlist/${playlistId}/update-duration/${contentId}`;
const formData = new FormData();
formData.append('duration', newDuration);
fetch(url, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
console.log('Duration updated successfully');
displayElement.style.opacity = '1';
displayElement.style.color = '#28a745';
setTimeout(() => {
displayElement.style.color = '';
}, 1000);
} else {
// Revert on error
displayElement.textContent = currentDuration + 's';
displayElement.style.opacity = '1';
alert('Error updating duration: ' + (data.message || 'Unknown error'));
}
})
.catch(error => {
// Revert on error
displayElement.textContent = currentDuration + 's';
displayElement.style.opacity = '1';
console.error('Error:', error);
alert('Error updating duration');
});
}
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 %}