Features: - Real-time upload progress tracking with AJAX polling and session-based monitoring - API endpoint /api/upload_progress/<session_id> for progress updates - Video conversion progress tracking with background threads - Mobile-responsive design for manage_group page - Player status cards with feedback, playlist sync, and last activity - Bootstrap Icons integration throughout UI - Responsive layout (1/4 group info, 3/4 players on desktop) - Video thumbnails with play icon, image thumbnails in media lists - Bulk selection and delete for group media - Enhanced logging for video conversion debugging
495 lines
22 KiB
HTML
495 lines
22 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Manage Group - {{ group.name }}</title>
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
|
<style>
|
|
body.dark-mode {
|
|
background-color: #121212;
|
|
color: #ffffff;
|
|
}
|
|
.card.dark-mode {
|
|
background-color: #1e1e1e;
|
|
color: #ffffff;
|
|
}
|
|
.dark-mode label, .dark-mode th, .dark-mode td {
|
|
color: #ffffff;
|
|
}
|
|
|
|
/* Logo styling */
|
|
.logo {
|
|
max-height: 80px;
|
|
margin-right: 15px;
|
|
}
|
|
|
|
/* Mobile optimizations */
|
|
@media (max-width: 768px) {
|
|
.logo {
|
|
max-height: 50px;
|
|
margin-right: 10px;
|
|
}
|
|
h1 {
|
|
font-size: 1.3rem;
|
|
}
|
|
h5 {
|
|
font-size: 1rem;
|
|
}
|
|
h6 {
|
|
font-size: 0.9rem;
|
|
}
|
|
.btn {
|
|
font-size: 0.85rem;
|
|
padding: 0.4rem 0.8rem;
|
|
}
|
|
.btn-sm {
|
|
font-size: 0.75rem;
|
|
padding: 0.25rem 0.5rem;
|
|
}
|
|
.card {
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
.card-body {
|
|
padding: 0.75rem;
|
|
}
|
|
.badge {
|
|
font-size: 0.75rem;
|
|
}
|
|
/* Stack buttons vertically on mobile */
|
|
.action-buttons .btn {
|
|
display: block;
|
|
width: 100%;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
/* Smaller text on mobile */
|
|
small {
|
|
font-size: 0.75rem;
|
|
}
|
|
/* Reduce padding in tables */
|
|
.list-group-item {
|
|
padding: 0.5rem;
|
|
}
|
|
}
|
|
|
|
/* Smaller screens - further optimization */
|
|
@media (max-width: 576px) {
|
|
.container-fluid {
|
|
padding-left: 10px;
|
|
padding-right: 10px;
|
|
}
|
|
h1 {
|
|
font-size: 1.1rem;
|
|
}
|
|
.card-header h5 {
|
|
font-size: 0.95rem;
|
|
}
|
|
}
|
|
|
|
.sortable-list li {
|
|
cursor: move;
|
|
transition: background-color 0.2s ease;
|
|
}
|
|
|
|
.sortable-list li.dragging {
|
|
opacity: 0.5;
|
|
background-color: #f8f9fa;
|
|
}
|
|
|
|
.drag-handle {
|
|
cursor: grab;
|
|
color: #aaa;
|
|
font-size: 1.2rem;
|
|
}
|
|
|
|
.drag-over {
|
|
border-top: 2px solid #0d6efd;
|
|
}
|
|
|
|
/* Player status card compact design */
|
|
.player-status-card {
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.player-status-card {
|
|
font-size: 0.85rem;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body class="{{ 'dark-mode' if theme == 'dark' else '' }}">
|
|
<div class="container-fluid py-3 py-md-4 py-lg-5">
|
|
<!-- Header with Logo and Title -->
|
|
<div class="d-flex justify-content-start align-items-center mb-3 mb-md-4">
|
|
{% if logo_exists %}
|
|
<img src="{{ url_for('static', filename='resurse/logo.png') }}" alt="Logo" class="logo">
|
|
{% endif %}
|
|
<div>
|
|
<h1 class="mb-1">Manage Group</h1>
|
|
<p class="text-muted mb-0 d-none d-md-block">{{ group.name }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Mobile: Show group name if not shown in header -->
|
|
<div class="d-md-none mb-3">
|
|
<div class="badge bg-primary fs-6">{{ group.name }}</div>
|
|
</div>
|
|
|
|
<!-- Row with Group Info (left) and Players Status (right) -->
|
|
<div class="row mb-3 mb-md-4">
|
|
<!-- Group Information Card - Responsive width -->
|
|
<div class="col-lg-3 col-md-4 col-12 mb-3">
|
|
<div class="card h-100 {{ 'dark-mode' if theme == 'dark' else '' }}">
|
|
<div class="card-header bg-info text-white">
|
|
<h5 class="mb-0"><i class="bi bi-info-circle me-2"></i>Group Info</h5>
|
|
</div>
|
|
<div class="card-body p-3">
|
|
<div class="mb-2">
|
|
<small class="text-muted">Group Name</small>
|
|
<p class="mb-0"><strong>{{ group.name }}</strong></p>
|
|
</div>
|
|
<div class="mb-2">
|
|
<small class="text-muted">Players</small>
|
|
<p class="mb-0"><strong>{{ group.players|length }}</strong></p>
|
|
</div>
|
|
<div class="mb-0">
|
|
<small class="text-muted">Playlist Version</small>
|
|
<p class="mb-0"><span class="badge bg-info mt-1">v{{ group.playlist_version }}</span></p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Players Status Cards Container - 3/4 width on large screens -->
|
|
<div class="col-lg-9 col-md-8 col-12">
|
|
<div class="card {{ 'dark-mode' if theme == 'dark' else '' }}">
|
|
<div class="card-header bg-success text-white">
|
|
<h5 class="mb-0"><i class="bi bi-display me-2"></i>Players ({{ group.players|length }})</h5>
|
|
</div>
|
|
<div class="card-body p-2 p-md-3">
|
|
{% if players_status %}
|
|
<div class="row g-2 g-md-3">
|
|
{% for player_status in players_status %}
|
|
<div class="col-xl-4 col-lg-6 col-12 mb-2">
|
|
<div class="card h-100 border-primary player-status-card {{ 'dark-mode' if theme == 'dark' else '' }}">
|
|
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center py-2">
|
|
<h6 class="mb-0"><i class="bi bi-tv me-1"></i>{{ player_status.player.username }}</h6>
|
|
<a href="{{ url_for('player_page', player_id=player_status.player.id) }}"
|
|
class="btn btn-sm btn-light py-0 px-2" title="View Details">
|
|
<i class="bi bi-eye"></i>
|
|
</a>
|
|
</div>
|
|
<div class="card-body p-2">
|
|
<div class="mb-2">
|
|
<small class="text-muted"><i class="bi bi-hdd-network me-1"></i>Hostname:</small>
|
|
<small class="d-block">{{ player_status.player.hostname }}</small>
|
|
</div>
|
|
|
|
{% if player_status.feedback %}
|
|
<div class="mb-2">
|
|
<small class="text-muted"><i class="bi bi-activity me-1"></i>Status:</small>
|
|
<span class="badge bg-{{ 'success' if player_status.feedback[0].status in ['active', 'playing'] else 'danger' }}">
|
|
{{ player_status.feedback[0].status|title }}
|
|
</span>
|
|
</div>
|
|
<div class="mb-2">
|
|
<small class="text-muted"><i class="bi bi-clock me-1"></i>Last Activity:</small>
|
|
<small class="d-block">{{ player_status.feedback[0].timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</small>
|
|
</div>
|
|
<div class="mb-2">
|
|
<small class="text-muted"><i class="bi bi-chat-dots me-1"></i>Message:</small>
|
|
<small class="d-block text-muted">{{ player_status.feedback[0].message[:50] }}{% if player_status.feedback[0].message|length > 50 %}...{% endif %}</small>
|
|
</div>
|
|
<div class="mb-0">
|
|
<small class="text-muted"><i class="bi bi-list-check me-1"></i>Playlist:</small>
|
|
{% if player_status.feedback[0].playlist_version %}
|
|
{% if player_status.feedback[0].playlist_version|int == player_status.server_playlist_version %}
|
|
<span class="badge bg-success">v{{ player_status.feedback[0].playlist_version }} ✓</span>
|
|
<small class="text-success d-block">In sync</small>
|
|
{% else %}
|
|
<span class="badge bg-warning text-dark">v{{ player_status.feedback[0].playlist_version }}</span>
|
|
<small class="text-warning d-block">⚠ Out of sync (server: v{{ player_status.server_playlist_version }})</small>
|
|
{% endif %}
|
|
{% else %}
|
|
<span class="badge bg-secondary">Unknown</span>
|
|
{% endif %}
|
|
</div>
|
|
{% else %}
|
|
<div class="text-center text-muted py-2">
|
|
<p class="mb-1"><small>No status data</small></p>
|
|
<small>Player hasn't reported yet</small>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% else %}
|
|
<div class="text-center text-muted py-3">
|
|
<i class="bi bi-inbox display-4 d-block mb-2"></i>
|
|
<p>No players in this group</p>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Manage Media Section -->
|
|
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
|
|
<div class="card-header bg-info text-white">
|
|
<h2>Manage Media</h2>
|
|
</div>
|
|
<div class="card-body">
|
|
{% if content %}
|
|
<!-- Bulk Actions Controls -->
|
|
<div class="row mb-3">
|
|
<div class="col-md-6">
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" id="selectAll">
|
|
<label class="form-check-label" for="selectAll">
|
|
Select All
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6 text-end">
|
|
<button type="button" class="btn btn-danger" id="bulkDeleteBtn" style="display:none;" onclick="confirmBulkDelete()">
|
|
<i class="bi bi-trash"></i> Delete Selected
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<ul class="list-group sortable-list" id="groupMediaList">
|
|
{% for media in content %}
|
|
<li class="list-group-item d-flex align-items-center {{ 'dark-mode' if theme == 'dark' else '' }}"
|
|
draggable="true"
|
|
data-id="{{ media.id }}"
|
|
data-position="{{ loop.index0 }}">
|
|
<!-- Checkbox for bulk selection -->
|
|
<div class="me-2">
|
|
<input class="form-check-input media-checkbox"
|
|
type="checkbox"
|
|
name="selected_content"
|
|
value="{{ media.id }}">
|
|
</div>
|
|
|
|
<!-- Drag handle -->
|
|
<div class="drag-handle me-2" title="Drag to reorder">
|
|
<i class="bi bi-grip-vertical"></i>
|
|
☰
|
|
</div>
|
|
|
|
<!-- Media Thumbnail and Name -->
|
|
<div class="flex-grow-1 mb-2 mb-md-0 d-flex align-items-center">
|
|
{% set file_ext = media.file_name.lower().split('.')[-1] %}
|
|
{% if file_ext in ['mp4', 'avi', 'mkv', 'mov', 'webm'] %}
|
|
<!-- Video file - show generic video icon -->
|
|
<div style="width: 48px; height: 48px; margin-right: 10px; border-radius: 4px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center;">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M8 5v14l11-7z" fill="white"/>
|
|
</svg>
|
|
</div>
|
|
{% else %}
|
|
<!-- Image file - show actual thumbnail -->
|
|
<img src="{{ url_for('static', filename='uploads/' ~ media.file_name) }}"
|
|
alt="thumbnail"
|
|
style="width: 48px; height: 48px; object-fit: cover; margin-right: 10px; border-radius: 4px;"
|
|
onerror="this.style.display='none';">
|
|
{% endif %}
|
|
<p class="mb-0"><strong>Media Name:</strong> {{ media.file_name }}</p>
|
|
</div>
|
|
<form action="{{ url_for('edit_group_media_route', group_id=group.id, content_id=media.id) }}" method="post" class="d-flex align-items-center">
|
|
<div class="input-group me-2">
|
|
<span class="input-group-text">seconds</span>
|
|
<input type="number" class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" name="duration" value="{{ media.duration }}" required>
|
|
</div>
|
|
<button type="submit" class="btn btn-warning me-2">Edit</button>
|
|
</form>
|
|
<form action="{{ url_for('delete_group_media_route', group_id=group.id, content_id=media.id) }}" method="post" style="display:inline;">
|
|
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to delete this media?');">Delete</button>
|
|
</form>
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
<!-- Add a save button for the reordering -->
|
|
<button id="saveGroupOrder" class="btn btn-success mt-3">Save Playlist Order</button>
|
|
{% else %}
|
|
<p class="text-center">No media uploaded for this group.</p>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Upload Media Button -->
|
|
<div class="text-center mb-3 action-buttons">
|
|
<a href="{{ url_for('upload_content', target_type='group', target_id=group.id, return_url=url_for('manage_group', group_id=group.id)) }}"
|
|
class="btn btn-primary btn-lg">
|
|
<i class="bi bi-cloud-upload me-2"></i>Upload Media
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Back to Dashboard Button -->
|
|
<div class="text-center mb-3">
|
|
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">
|
|
<i class="bi bi-arrow-left me-2"></i>Back to Dashboard
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
const groupMediaList = document.getElementById('groupMediaList');
|
|
let draggedItem = null;
|
|
|
|
// Initialize drag events for all items
|
|
const items = groupMediaList.querySelectorAll('li');
|
|
items.forEach(item => {
|
|
// Drag start
|
|
item.addEventListener('dragstart', function(e) {
|
|
draggedItem = item;
|
|
setTimeout(() => {
|
|
item.classList.add('dragging');
|
|
}, 0);
|
|
});
|
|
|
|
// Drag end
|
|
item.addEventListener('dragend', function() {
|
|
item.classList.remove('dragging');
|
|
draggedItem = null;
|
|
updatePositions();
|
|
});
|
|
|
|
// Drag over
|
|
item.addEventListener('dragover', function(e) {
|
|
e.preventDefault();
|
|
if (item !== draggedItem) {
|
|
const rect = item.getBoundingClientRect();
|
|
const y = e.clientY - rect.top;
|
|
const height = rect.height;
|
|
|
|
if (y < height / 2) {
|
|
groupMediaList.insertBefore(draggedItem, item);
|
|
} else {
|
|
groupMediaList.insertBefore(draggedItem, item.nextSibling);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// Save button click handler
|
|
document.getElementById('saveGroupOrder').addEventListener('click', function() {
|
|
// Collect new order
|
|
const newOrder = [];
|
|
groupMediaList.querySelectorAll('li').forEach((item, index) => {
|
|
newOrder.push({
|
|
id: item.dataset.id,
|
|
position: index
|
|
});
|
|
});
|
|
|
|
// Send to server
|
|
fetch('{{ url_for("update_group_content_order_route", group_id=group.id) }}', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': '{{ csrf_token() if csrf_token else "" }}'
|
|
},
|
|
body: JSON.stringify({items: newOrder})
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
alert('Playlist order updated successfully!');
|
|
console.log('Group playlist update successful:', data);
|
|
} else {
|
|
alert('Error updating playlist order: ' + (data.error || 'Unknown error'));
|
|
console.error('Failed to update group playlist:', data);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error:', error);
|
|
alert('An error occurred while updating the playlist order.');
|
|
});
|
|
});
|
|
|
|
// Update positions in the UI
|
|
function updatePositions() {
|
|
groupMediaList.querySelectorAll('li').forEach((item, index) => {
|
|
item.dataset.position = index;
|
|
});
|
|
}
|
|
|
|
// Bulk selection functionality
|
|
const selectAllCheckbox = document.getElementById('selectAll');
|
|
const mediaCheckboxes = document.querySelectorAll('.media-checkbox');
|
|
const bulkDeleteBtn = document.getElementById('bulkDeleteBtn');
|
|
|
|
// Select all functionality
|
|
if (selectAllCheckbox) {
|
|
selectAllCheckbox.addEventListener('change', function() {
|
|
mediaCheckboxes.forEach(checkbox => {
|
|
checkbox.checked = this.checked;
|
|
});
|
|
updateBulkDeleteButton();
|
|
});
|
|
}
|
|
|
|
// Individual checkbox change
|
|
mediaCheckboxes.forEach(checkbox => {
|
|
checkbox.addEventListener('change', function() {
|
|
updateSelectAllState();
|
|
updateBulkDeleteButton();
|
|
});
|
|
});
|
|
|
|
function updateSelectAllState() {
|
|
const checkedBoxes = Array.from(mediaCheckboxes).filter(cb => cb.checked);
|
|
|
|
if (selectAllCheckbox) {
|
|
selectAllCheckbox.checked = checkedBoxes.length === mediaCheckboxes.length && mediaCheckboxes.length > 0;
|
|
selectAllCheckbox.indeterminate = checkedBoxes.length > 0 && checkedBoxes.length < mediaCheckboxes.length;
|
|
}
|
|
}
|
|
|
|
function updateBulkDeleteButton() {
|
|
const checkedBoxes = Array.from(mediaCheckboxes).filter(cb => cb.checked);
|
|
if (bulkDeleteBtn) {
|
|
bulkDeleteBtn.style.display = checkedBoxes.length > 0 ? 'inline-block' : 'none';
|
|
}
|
|
}
|
|
});
|
|
|
|
function confirmBulkDelete() {
|
|
const checkedBoxes = Array.from(document.querySelectorAll('.media-checkbox:checked'));
|
|
if (checkedBoxes.length === 0) {
|
|
alert('No media files selected.');
|
|
return;
|
|
}
|
|
|
|
const count = checkedBoxes.length;
|
|
const message = `Are you sure you want to delete ${count} selected media file${count > 1 ? 's' : ''}? This action cannot be undone.`;
|
|
|
|
if (confirm(message)) {
|
|
// Create a form with selected IDs
|
|
const form = document.createElement('form');
|
|
form.method = 'POST';
|
|
form.action = '{{ url_for("bulk_delete_group_content", group_id=group.id) }}';
|
|
|
|
checkedBoxes.forEach(checkbox => {
|
|
const input = document.createElement('input');
|
|
input.type = 'hidden';
|
|
input.name = 'selected_content';
|
|
input.value = checkbox.value;
|
|
form.appendChild(input);
|
|
});
|
|
|
|
document.body.appendChild(form);
|
|
form.submit();
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html> |