Initial commit: enterprise digital platform with portal SSO, DigiServer, IT Assets, NetworkView, Server Monitor

This commit is contained in:
ske087
2026-05-10 21:07:50 +03:00
commit 8d9df56b0b
364 changed files with 73655 additions and 0 deletions
@@ -0,0 +1,205 @@
{% extends "base.html" %}
{% block title %}Content Library - DigiServer v2{% endblock %}
{% block content %}
<div class="container">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h1>Content Library</h1>
<a href="{{ url_for('content.upload_content') }}" class="btn btn-success">+ Upload Content</a>
</div>
{% if content_list %}
<div class="card">
<div style="margin-bottom: 15px; padding: 15px; background: #f8f9fa; border-radius: 5px;">
<strong>Total Files:</strong> {{ content_list|length }} |
<strong>Total Assignments:</strong> {% set total = namespace(count=0) %}{% for item in content_list %}{% set total.count = total.count + item.player_count %}{% endfor %}{{ total.count }}
</div>
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background: #f8f9fa; text-align: left;">
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">File Name</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Type</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Duration</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Size</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Assigned To</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Uploaded</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Actions</th>
</tr>
</thead>
<tbody>
{% for item in content_list %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 12px;">
<strong>{{ item.filename }}</strong>
</td>
<td style="padding: 12px;">
{% if item.content_type == 'image' %}
<span style="background: #28a745; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📷 Image</span>
{% elif item.content_type == 'video' %}
<span style="background: #007bff; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">🎬 Video</span>
{% elif item.content_type == 'pdf' %}
<span style="background: #dc3545; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📄 PDF</span>
{% elif item.content_type == 'presentation' %}
<span style="background: #ffc107; color: black; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📊 PPT</span>
{% else %}
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📁 Other</span>
{% endif %}
</td>
<td style="padding: 12px;">
{{ item.duration }}s
</td>
<td style="padding: 12px;">
{{ item.file_size }} MB
</td>
<td style="padding: 12px;">
{% if item.player_count == 0 %}
<span style="color: #6c757d; font-style: italic;">Not assigned</span>
{% else %}
<div style="max-height: 100px; overflow-y: auto;">
{% for player in item.players %}
<div style="margin-bottom: 5px;">
<strong>{{ player.name }}</strong>
{% if player.group %}
<span style="color: #6c757d; font-size: 12px;">({{ player.group }})</span>
{% endif %}
</div>
{% endfor %}
</div>
<div style="margin-top: 5px;">
<span style="background: #007bff; color: white; padding: 2px 6px; border-radius: 3px; font-size: 11px;">
{{ item.player_count }} player{% if item.player_count != 1 %}s{% endif %}
</span>
</div>
{% endif %}
</td>
<td style="padding: 12px;">
<small style="color: #6c757d;">{{ item.uploaded_at | localtime }}</small>
</td>
<td style="padding: 12px;">
{% if item.player_count > 0 %}
{% set first_player = item.players[0] %}
<a href="{{ url_for('players.player_page', player_id=first_player.id) }}"
class="btn btn-primary btn-sm"
title="Manage Playlist for {{ first_player.name }}"
style="margin-bottom: 5px;">
📝 Manage Playlist
</a>
{% if item.player_count > 1 %}
<button onclick="showAllPlayers('{{ item.filename|replace("'", "\\'") }}', {{ item.players|tojson }})"
class="btn btn-info btn-sm"
title="View all players with this content">
👥 View All ({{ item.player_count }})
</button>
{% endif %}
{% endif %}
<button onclick="deleteContent('{{ item.filename|replace("'", "\\'") }}')"
class="btn btn-danger btn-sm"
title="Delete this content from all playlists"
style="margin-top: 5px;">
🗑️ Delete
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="background: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460; padding: 15px; border-radius: 5px;">
️ No content uploaded yet. <a href="{{ url_for('content.upload_content') }}" style="color: #0c5460; text-decoration: underline;">Upload your first content</a>
</div>
{% endif %}
</div>
<!-- Modal for viewing all players -->
<div id="playersModal" class="modal" style="display: none;">
<div class="modal-content" style="max-width: 600px; margin: 100px auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);">
<h2 id="modalTitle" style="margin-bottom: 20px; color: #2c3e50;">Players with this content</h2>
<div id="playersList" style="max-height: 400px; overflow-y: auto;"></div>
<div style="text-align: center; margin-top: 20px;">
<button type="button" class="btn" onclick="closePlayersModal()">Close</button>
</div>
</div>
</div>
<style>
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
overflow-y: auto;
}
</style>
<script>
function showAllPlayers(filename, players) {
document.getElementById('modalTitle').textContent = 'Players with: ' + filename;
const playersList = document.getElementById('playersList');
playersList.innerHTML = '<table style="width: 100%; border-collapse: collapse;">';
playersList.innerHTML += '<thead><tr style="background: #f8f9fa;"><th style="padding: 10px; text-align: left;">Player Name</th><th style="padding: 10px; text-align: left;">Group</th><th style="padding: 10px; text-align: left;">Action</th></tr></thead><tbody>';
players.forEach(player => {
playersList.innerHTML += `
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px;"><strong>${player.name}</strong></td>
<td style="padding: 10px;">${player.group || '-'}</td>
<td style="padding: 10px;">
<a href="/players/${player.id}" class="btn btn-sm" style="background: #007bff; color: white; padding: 5px 10px; text-decoration: none; border-radius: 3px;">
Manage Playlist
</a>
</td>
</tr>
`;
});
playersList.innerHTML += '</tbody></table>';
document.getElementById('playersModal').style.display = 'block';
}
function closePlayersModal() {
document.getElementById('playersModal').style.display = 'none';
}
function deleteContent(filename) {
if (confirm(`Are you sure you want to delete "${filename}"?\n\nThis will remove it from ALL player playlists!`)) {
fetch('/content/delete-by-filename', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
filename: filename
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(`Successfully deleted "${filename}" from ${data.deleted_count} playlist(s)`);
location.reload();
} else {
alert('Error deleting content: ' + data.message);
}
})
.catch(error => {
alert('Error deleting content: ' + error);
});
}
}
// Close modal when clicking outside
window.onclick = function(event) {
const modal = document.getElementById('playersModal');
if (event.target == modal) {
closePlayersModal();
}
}
</script>
{% endblock %}
@@ -0,0 +1,550 @@
{% extends "base.html" %}
{% block title %}Playlist Management - DigiServer v2{% endblock %}
{% block content %}
<style>
.main-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 20px;
}
.full-width {
grid-column: 1 / -1;
}
.card-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 8px 8px 0 0;
margin: -1.5rem -1.5rem 1.5rem -1.5rem;
}
.card-header h2 {
margin: 0;
color: white;
}
.playlist-list {
max-height: 400px;
overflow-y: auto;
}
.playlist-item {
background: #f8f9fa;
padding: 15px;
margin-bottom: 10px;
border-radius: 6px;
border-left: 4px solid #667eea;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.2s;
}
.playlist-item:hover {
background: #e9ecef;
transform: translateX(5px);
}
body.dark-mode .playlist-item {
background: #1a202c;
border-left-color: #7c3aed;
}
body.dark-mode .playlist-item:hover {
background: #2d3748;
}
.playlist-info h3 {
margin: 0 0 5px 0;
font-size: 18px;
}
.playlist-stats {
font-size: 14px;
color: #6c757d;
}
body.dark-mode .playlist-stats {
color: #a0aec0;
}
.playlist-actions {
display: flex;
gap: 10px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 600;
}
body.dark-mode .form-group label {
color: #e2e8f0;
}
body.dark-mode small {
color: #a0aec0;
}
.form-control {
width: 100%;
padding: 10px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 14px;
background: white;
color: #2d3748;
}
.form-control:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
body.dark-mode .form-control {
background: #1a202c;
border-color: #4a5568;
color: #e2e8f0;
}
body.dark-mode .form-control:focus {
border-color: #7c3aed;
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.2);
}
textarea.form-control {
resize: vertical;
min-height: 80px;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.btn-sm {
padding: 5px 10px;
font-size: 13px;
}
.upload-zone {
border: 2px dashed #dee2e6;
border-radius: 8px;
padding: 30px;
text-align: center;
background: #f8f9fa;
transition: all 0.3s;
}
.upload-zone:hover {
border-color: #667eea;
background: #f0f2ff;
}
.upload-zone.drag-over {
border-color: #667eea;
background: #e7e9ff;
}
.file-input-wrapper {
position: relative;
overflow: hidden;
display: inline-block;
}
.file-input-wrapper input[type=file] {
position: absolute;
left: -9999px;
}
.media-library {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 15px;
margin-top: 20px;
}
.media-item {
background: white;
border: 2px solid #dee2e6;
border-radius: 8px;
padding: 10px;
text-align: center;
transition: all 0.2s;
cursor: pointer;
}
.media-item:hover {
border-color: #667eea;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
body.dark-mode .media-item {
background: #2d3748;
border-color: #4a5568;
}
body.dark-mode .media-item:hover {
border-color: #7c3aed;
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
}
.media-thumbnail {
background: #f0f0f0;
}
body.dark-mode .media-thumbnail {
background: #1a202c;
}
.media-icon {
font-size: 48px;
margin-bottom: 10px;
}
.media-name {
font-size: 12px;
word-break: break-word;
}
body.dark-mode .media-name {
color: #e2e8f0;
}
/* Table styling for dark mode */
body.dark-mode table thead tr {
background: #1a202c !important;
}
body.dark-mode table th {
color: #e2e8f0;
border-bottom-color: #4a5568 !important;
}
body.dark-mode table tbody tr {
border-bottom-color: #4a5568 !important;
}
body.dark-mode table td {
color: #e2e8f0;
}
body.dark-mode code {
background: #1a202c !important;
color: #e2e8f0;
}
/* Dark mode for upload section */
body.dark-mode .card > div[style*="background: #f8f9fa"] {
background: #2d3748 !important;
border: 1px solid #4a5568;
}
</style>
<div class="container" style="max-width: 1400px;">
<h1 style="margin-bottom: 25px; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 32px; height: 32px;">
Playlist Management
</h1>
<div class="main-grid">
<!-- Create Playlist Card -->
<div class="card">
<div class="card-header">
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px; filter: brightness(0) invert(1);">
Create New Playlist
</h2>
</div>
<!-- Create New Playlist Form -->
<form method="POST" action="{{ url_for('content.create_playlist') }}">
<div class="form-group">
<label for="playlist_name">Playlist Name *</label>
<input type="text" name="name" id="playlist_name" class="form-control" required
placeholder="e.g., Main Lobby Display">
</div>
<div class="form-group">
<label for="playlist_orientation">Content Orientation *</label>
<select name="orientation" id="playlist_orientation" class="form-control" required>
<option value="Landscape">Landscape (Horizontal)</option>
<option value="Portrait">Portrait (Vertical)</option>
</select>
<small style="color: #6c757d; font-size: 12px; display: block; margin-top: 5px;">
Select the orientation that matches your display screens
</small>
</div>
<div class="form-group">
<label for="playlist_description">Description (Optional)</label>
<textarea name="description" id="playlist_description" class="form-control"
placeholder="Describe the purpose of this playlist..."></textarea>
</div>
<button type="submit" class="btn btn-primary">
Create Playlist
</button>
</form>
</div>
<!-- Upload Media Card -->
<div class="card">
<div class="card-header">
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 24px; height: 24px; filter: brightness(0) invert(1);">
Media Library
</h2>
</div>
<!-- Compact Upload Section -->
<div style="text-align: center; padding: 20px; background: #f8f9fa; border-radius: 8px; margin-bottom: 20px;">
<a href="{{ url_for('content.upload_media_page') }}" class="btn btn-success" style="display: inline-flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 16px; height: 16px; filter: brightness(0) invert(1);">
Upload New Media
</a>
</div>
<!-- Media Library with Thumbnails -->
<h3 style="margin-bottom: 15px; display: flex; align-items: center; justify-content: space-between;">
<span>📚 Last 3 Added Media</span>
<small style="color: #6c757d; font-size: 0.85rem;">Total: {{ total_media_count }}</small>
</h3>
<div class="media-library" style="max-height: 350px; overflow-y: auto;">
{% if media_files %}
{% for media in media_files %}
<div class="media-item" title="{{ media.filename }}">
{% if media.content_type == 'image' %}
<div class="media-thumbnail" style="width: 100%; height: 100px; overflow: hidden; border-radius: 6px; margin-bottom: 8px; background: #f0f0f0; display: flex; align-items: center; justify-content: center;">
<img src="{{ url_for('static', filename='uploads/' + media.filename) }}"
alt="{{ media.filename }}"
style="max-width: 100%; max-height: 100%; object-fit: cover;"
onerror="this.style.display='none'; this.parentElement.innerHTML='<span style=\'font-size: 48px;\'>📷</span>'">
</div>
{% elif media.content_type == 'video' %}
<div class="media-icon" style="height: 100px; display: flex; align-items: center; justify-content: center; background: #f0f0f0; border-radius: 6px; margin-bottom: 8px;">
🎥
</div>
{% elif media.content_type == 'pdf' %}
<div class="media-icon" style="height: 100px; display: flex; align-items: center; justify-content: center; background: #f0f0f0; border-radius: 6px; margin-bottom: 8px;">
📄
</div>
{% else %}
<div class="media-icon" style="height: 100px; display: flex; align-items: center; justify-content: center; background: #f0f0f0; border-radius: 6px; margin-bottom: 8px;">
📁
</div>
{% endif %}
<div class="media-name" style="font-size: 11px; line-height: 1.3;">{{ media.filename[:25] }}{% if media.filename|length > 25 %}...{% endif %}</div>
<div style="font-size: 10px; color: #999; margin-top: 4px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
</div>
{% endfor %}
{% else %}
<div style="text-align: center; padding: 40px; color: #999; grid-column: 1 / -1;">
<div style="font-size: 48px; margin-bottom: 10px;">📭</div>
<p>No media files yet. Upload your first file!</p>
</div>
{% endif %}
</div>
<!-- View All Media Button -->
{% if total_media_count > 3 %}
<div style="text-align: center; padding: 15px; border-top: 1px solid #dee2e6; margin-top: 10px;">
<a href="{{ url_for('content.media_library') }}" class="btn btn-primary" style="display: inline-flex; align-items: center; gap: 0.5rem;">
<span>📚</span>
View All Media ({{ total_media_count }} files)
</a>
</div>
{% endif %}
</div>
</div>
<!-- Existing Playlists Card -->
<div class="card full-width">
<div class="card-header">
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px; filter: brightness(0) invert(1);">
Existing Playlists
</h2>
</div>
<div class="playlist-list">
{% if playlists %}
{% for playlist in playlists %}
<div class="playlist-item">
<div class="playlist-info">
<h3>{{ playlist.name }}</h3>
<div class="playlist-stats">
📊 {{ playlist.content_count }} items |
👥 {{ playlist.player_count }} players |
🔄 v{{ playlist.version }}
</div>
</div>
<div class="playlist-actions">
<a href="{{ url_for('content.manage_playlist_content', playlist_id=playlist.id) }}"
class="btn btn-primary btn-sm">
✏️ Manage
</a>
<form method="POST"
action="{{ url_for('content.delete_playlist', playlist_id=playlist.id) }}"
style="display: inline;"
onsubmit="return confirm('Delete playlist {{ playlist.name }}?');">
<button type="submit" class="btn btn-danger btn-sm" style="display: flex; align-items: center; gap: 0.3rem;">
<img src="{{ url_for('static', filename='icons/trash.svg') }}" alt="" style="width: 14px; height: 14px; filter: brightness(0) invert(1);">
Delete
</button>
</form>
</div>
</div>
{% endfor %}
{% else %}
<div style="text-align: center; padding: 40px; color: #999;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 64px; height: 64px; opacity: 0.3; margin-bottom: 10px;">
<p>No playlists yet. Create your first playlist above!</p>
</div>
{% endif %}
</div>
</div>
<!-- Assign Players to Playlists Card -->
<div class="card full-width">
<div class="card-header">
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 24px; height: 24px; filter: brightness(0) invert(1);">
Player Assignments
</h2>
</div>
<div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background: #f8f9fa; text-align: left;">
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Player Name</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Hostname</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Location</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Assigned Playlist</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Status</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Actions</th>
</tr>
</thead>
<tbody>
{% for player in players %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 12px;"><strong>{{ player.name }}</strong></td>
<td style="padding: 12px;">
<code style="background: #f8f9fa; padding: 2px 6px; border-radius: 3px;">
{{ player.hostname }}
</code>
</td>
<td style="padding: 12px;">{{ player.location or '-' }}</td>
<td style="padding: 12px;">
<form method="POST" action="{{ url_for('content.assign_player_to_playlist', player_id=player.id) }}"
style="display: inline;">
<select name="playlist_id" class="form-control" style="width: auto; display: inline-block;"
onchange="this.form.submit()">
<option value="">No Playlist</option>
{% for playlist in playlists %}
<option value="{{ playlist.id }}"
{% if player.playlist_id == playlist.id %}selected{% endif %}>
{{ playlist.name }}
</option>
{% endfor %}
</select>
</form>
</td>
<td style="padding: 12px;">
{% if player.is_online %}
<span style="background: #28a745; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">
🟢 Online
</span>
{% else %}
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">
⚫ Offline
</span>
{% endif %}
</td>
<td style="padding: 12px;">
<div style="display: flex; gap: 0.5rem;">
<a href="{{ url_for('players.player_page', player_id=player.id) }}"
class="btn btn-primary btn-sm">
⚙️ Manage
</a>
{% if player.playlist_id %}
<a href="{{ url_for('players.player_fullscreen', player_id=player.id) }}"
class="btn btn-success btn-sm" target="_blank"
title="View live content preview">
🖥️ Live
</a>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<script>
// File upload handling
const fileInput = document.getElementById('file-input');
const uploadZone = document.getElementById('upload-zone');
const fileList = document.getElementById('file-list');
const uploadBtn = document.getElementById('upload-btn');
fileInput.addEventListener('change', handleFiles);
// Drag and drop
uploadZone.addEventListener('dragover', (e) => {
e.preventDefault();
uploadZone.classList.add('drag-over');
});
uploadZone.addEventListener('dragleave', () => {
uploadZone.classList.remove('drag-over');
});
uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
uploadZone.classList.remove('drag-over');
fileInput.files = e.dataTransfer.files;
handleFiles();
});
function handleFiles() {
const files = fileInput.files;
fileList.innerHTML = '';
if (files.length > 0) {
uploadBtn.disabled = false;
const ul = document.createElement('ul');
ul.style.cssText = 'list-style: none; padding: 0;';
for (let file of files) {
const li = document.createElement('li');
li.style.cssText = 'padding: 8px; background: #f8f9fa; margin-bottom: 5px; border-radius: 4px;';
li.textContent = `📎 ${file.name} (${(file.size / 1024 / 1024).toFixed(2)} MB)`;
ul.appendChild(li);
}
fileList.appendChild(ul);
} else {
uploadBtn.disabled = true;
}
}
</script>
{% endblock %}
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}Edit Content{% endblock %}
{% block content %}
<div class="container">
<h2>Edit Content</h2>
<p>Edit content functionality - placeholder</p>
<a href="{{ url_for('content.list') }}" class="btn btn-secondary">Back to Content</a>
</div>
{% endblock %}
@@ -0,0 +1,719 @@
{% 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 %}
@@ -0,0 +1,806 @@
{% extends "base.html" %}
{% block title %}Media Library - DigiServer v2{% endblock %}
{% block content %}
<style>
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 15px;
margin-top: 20px;
}
.media-card {
background: white;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 10px;
transition: all 0.3s ease;
cursor: pointer;
position: relative;
}
.media-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
transform: translateY(-2px);
}
body.dark-mode .media-card {
background: #1a202c;
border-color: #4a5568;
}
.media-thumbnail {
width: 100%;
height: 150px;
border-radius: 6px;
overflow: hidden;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 10px;
}
body.dark-mode .media-thumbnail {
background: #2d3748;
}
.media-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.media-icon {
font-size: 64px;
}
.media-info {
font-size: 12px;
color: #6c757d;
}
body.dark-mode .media-info {
color: #a0aec0;
}
.media-filename {
font-weight: 500;
margin-bottom: 5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
body.dark-mode .media-filename {
color: #e2e8f0;
}
.delete-btn {
position: absolute;
top: 5px;
right: 5px;
background: #dc3545;
color: white;
border: none;
border-radius: 50%;
width: 28px;
height: 28px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
opacity: 0;
transition: opacity 0.3s ease;
}
.media-card:hover .delete-btn {
opacity: 1;
}
.delete-btn:hover {
background: #a02834;
}
.playlist-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 600;
margin-top: 5px;
}
.playlist-badge.in-use {
background: #ffc107;
color: #000;
}
.playlist-badge.unused {
background: #28a745;
color: white;
}
body.dark-mode .playlist-badge.in-use {
background: #856404;
color: #ffc107;
}
body.dark-mode .upload-section {
background: #2d3748 !important;
border: 1px solid #4a5568;
}
body.dark-mode .playlist-badge.unused {
background: #1a4d2e;
color: #86efac;
}
.type-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
}
.type-badge.image { background: #d4edda; color: #155724; }
.type-badge.video { background: #cce5ff; color: #004085; }
.type-badge.pdf { background: #fff3cd; color: #856404; }
.type-badge.pptx { background: #f8d7da; color: #721c24; }
body.dark-mode .type-badge.image { background: #1a4d2e; color: #86efac; }
body.dark-mode .type-badge.video { background: #1e3a5f; color: #93c5fd; }
body.dark-mode .type-badge.pdf { background: #4a3800; color: #fbbf24; }
body.dark-mode .type-badge.pptx { background: #4a1a1a; color: #fca5a5; }
.stats-box {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
margin-bottom: 30px;
}
.stat-item {
background: white;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 15px;
text-align: center;
}
body.dark-mode .stat-item {
background: #1a202c;
border-color: #4a5568;
}
.stat-value {
font-size: 32px;
font-weight: bold;
color: #7c3aed;
}
body.dark-mode .stat-value {
color: #a78bfa;
}
.stat-label {
font-size: 14px;
color: #6c757d;
margin-top: 5px;
}
body.dark-mode .stat-label {
color: #a0aec0;
}
.section-header {
display: flex;
align-items: center;
gap: 10px;
margin: 30px 0 15px 0;
padding-bottom: 10px;
border-bottom: 2px solid #7c3aed;
}
body.dark-mode .section-header {
border-bottom-color: #a78bfa;
}
.section-header h2 {
margin: 0;
font-size: 24px;
}
body.dark-mode .section-header h2 {
color: #e2e8f0;
}
</style>
<div style="margin-bottom: 2rem;">
<h1 style="display: inline-block; margin-right: 1rem; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 32px; height: 32px;">
📚 Media Library
</h1>
<a href="{{ url_for('content.content_list') }}" class="btn" style="float: right; display: inline-flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Back to Playlists
</a>
</div>
<!-- Statistics -->
<div class="stats-box">
<div class="stat-item">
<div class="stat-value">{{ media_files|length }}</div>
<div class="stat-label">Total Files</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ images|length }}</div>
<div class="stat-label">Images</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ videos|length }}</div>
<div class="stat-label">Videos</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ pdfs|length }}</div>
<div class="stat-label">PDFs</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ presentations|length }}</div>
<div class="stat-label">Presentations</div>
</div>
</div>
<!-- Upload Button -->
<div class="upload-section" style="text-align: center; padding: 20px; background: #f8f9fa; border-radius: 8px; margin-bottom: 30px;">
<a href="{{ url_for('content.upload_media_page') }}" class="btn btn-success" style="display: inline-flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Upload New Media
</a>
</div>
<!-- Images Section -->
{% if images %}
<div class="section-header">
<span style="font-size: 32px;">📷</span>
<h2>Images ({{ images|length }})</h2>
</div>
<div class="media-grid">
{% for media in images %}
<div class="media-card">
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
<div class="media-thumbnail">
<img src="{{ url_for('static', filename='uploads/' + media.filename) }}"
alt="{{ media.filename }}"
onerror="this.style.display='none'; this.parentElement.innerHTML='<span class=\'media-icon\'>📷</span>'">
</div>
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
<div class="media-info">
<span class="type-badge image">Image</span>
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
<div style="font-size: 10px; margin-top: 3px;">{{ media.uploaded_at | localtime('%Y-%m-%d') }}</div>
{% if media.playlists.count() > 0 %}
<div class="playlist-badge in-use">📋 In {{ media.playlists.count() }} playlist(s)</div>
{% else %}
<div class="playlist-badge unused">✓ Not in use</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Videos Section -->
{% if videos %}
<div class="section-header">
<span style="font-size: 32px;">🎥</span>
<h2>Videos ({{ videos|length }})</h2>
</div>
<div class="media-grid">
{% for media in videos %}
<div class="media-card">
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
<div class="media-thumbnail">
<span class="media-icon">🎥</span>
</div>
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
<div class="media-info">
<span class="type-badge video">Video</span>
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
<div style="font-size: 10px; margin-top: 3px;">{{ media.uploaded_at | localtime('%Y-%m-%d') }}</div>
{% if media.playlists.count() > 0 %}
<div class="playlist-badge in-use">📋 In {{ media.playlists.count() }} playlist(s)</div>
{% else %}
<div class="playlist-badge unused">✓ Not in use</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- PDFs Section -->
{% if pdfs %}
<div class="section-header">
<span style="font-size: 32px;">📄</span>
<h2>PDFs ({{ pdfs|length }})</h2>
</div>
<div class="media-grid">
{% for media in pdfs %}
<div class="media-card">
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
<div class="media-thumbnail">
<span class="media-icon">📄</span>
</div>
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
<div class="media-info">
<span class="type-badge pdf">PDF</span>
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
<div style="font-size: 10px; margin-top: 3px;">{{ media.uploaded_at | localtime('%Y-%m-%d') }}</div>
{% if media.playlists.count() > 0 %}
<div class="playlist-badge in-use">📋 In {{ media.playlists.count() }} playlist(s)</div>
{% else %}
<div class="playlist-badge unused">✓ Not in use</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Presentations Section -->
{% if presentations %}
<div class="section-header">
<span style="font-size: 32px;">📊</span>
<h2>Presentations ({{ presentations|length }})</h2>
</div>
<div class="media-grid">
{% for media in presentations %}
<div class="media-card">
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
<div class="media-thumbnail">
<span class="media-icon">📊</span>
</div>
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
<div class="media-info">
<span class="type-badge pptx">PPTX</span>
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
<div style="font-size: 10px; margin-top: 3px;">{{ media.uploaded_at | localtime('%Y-%m-%d') }}</div>
{% if media.playlists.count() > 0 %}
<div class="playlist-badge in-use">📋 In {{ media.playlists.count() }} playlist(s)</div>
{% else %}
<div class="playlist-badge unused">✓ Not in use</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Others Section -->
{% if others %}
<div class="section-header">
<span style="font-size: 32px;">📁</span>
<h2>Other Files ({{ others|length }})</h2>
</div>
<div class="media-grid">
{% for media in others %}
<div class="media-card">
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
<div class="media-thumbnail">
<span class="media-icon">📁</span>
</div>
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
<div class="media-info">
<span class="type-badge">{{ media.content_type }}</span>
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
<div style="font-size: 10px; margin-top: 3px;">{{ media.uploaded_at | localtime('%Y-%m-%d') }}</div>
{% if media.playlists.count() > 0 %}
<div class="playlist-badge in-use">📋 In {{ media.playlists.count() }} playlist(s)</div>
{% else %}
<div class="playlist-badge unused">✓ Not in use</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% if not media_files %}
<div style="text-align: center; padding: 80px 20px;">
<div style="font-size: 96px; margin-bottom: 20px;">📭</div>
<h2 style="color: #6c757d;">No Media Files Yet</h2>
<p style="color: #999; margin-bottom: 30px;">Start by uploading your first media file!</p>
<a href="{{ url_for('content.upload_media_page') }}" class="btn btn-success" style="display: inline-flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Upload Media
</a>
</div>
{% endif %}
<!-- Delete Confirmation Modal -->
<div id="deleteModal" class="delete-modal">
<div class="delete-modal-content">
<div class="delete-modal-header">
<span class="delete-icon">⚠️</span>
<h2>Confirm Delete</h2>
</div>
<div class="delete-modal-body">
<p class="delete-question">
Are you sure you want to delete <strong id="deleteFilename" class="delete-filename"></strong>?
</p>
<div id="playlistWarning" class="warning-box warning-playlist">
<div class="warning-header">
<span>⚠️</span>
<strong>Playlist Warning</strong>
</div>
<p>This file is used in <strong id="playlistCount"></strong> playlist(s). Deleting it will remove it from all playlists and increment their version numbers.</p>
</div>
<div id="editWarning" class="warning-box warning-edit">
<div class="warning-header">
<span>✏️</span>
<strong>Edited Versions</strong>
</div>
<p>This file has <strong id="editCount"></strong> edited version(s) from player devices. All edited versions and their metadata will also be permanently deleted.</p>
</div>
<div class="delete-final-warning">
<strong>⚠️ This action cannot be undone!</strong>
</div>
</div>
<div class="delete-modal-footer">
<button onclick="closeDeleteModal()" class="btn btn-cancel">
Cancel
</button>
<form id="deleteForm" method="POST">
<button type="submit" class="btn btn-delete">
Yes, Delete File
</button>
</form>
</div>
</div>
</div>
<style>
/* Delete Modal - Light Mode Styles */
.delete-modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(2px);
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.delete-modal-content {
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
margin: 8% auto;
padding: 0;
border-radius: 16px;
width: 90%;
max-width: 550px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
animation: slideDown 0.3s ease-out;
border: 1px solid rgba(0, 0, 0, 0.1);
}
@keyframes slideDown {
from {
transform: translateY(-50px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.delete-modal-header {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
color: white;
padding: 24px 30px;
border-radius: 16px 16px 0 0;
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.3);
}
.delete-icon {
font-size: 2rem;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
}
.delete-modal-header h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
.delete-modal-body {
padding: 30px;
}
.delete-question {
font-size: 1.1rem;
margin: 0 0 20px 0;
color: #2d3748;
line-height: 1.6;
}
.delete-filename {
color: #dc3545;
word-break: break-word;
}
.warning-box {
display: none;
padding: 16px;
margin: 16px 0;
border-radius: 10px;
border-left: 4px solid;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: transform 0.2s ease;
}
.warning-box:hover {
transform: translateX(4px);
}
.warning-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.warning-header span {
font-size: 1.2rem;
}
.warning-header strong {
font-size: 1rem;
font-weight: 600;
}
.warning-box p {
margin: 0;
line-height: 1.5;
font-size: 0.95rem;
}
.warning-playlist {
background: linear-gradient(135deg, #fff8e1 0%, #fff3cd 100%);
border-color: #ffc107;
}
.warning-playlist .warning-header,
.warning-playlist p {
color: #856404;
}
.warning-edit {
background: linear-gradient(135deg, #f3e8ff 0%, #e9d5ff 100%);
border-color: #7c3aed;
}
.warning-edit .warning-header,
.warning-edit p {
color: #5b21b6;
}
.delete-final-warning {
background: linear-gradient(135deg, #fee 0%, #fdd 100%);
border: 2px solid #dc3545;
border-radius: 8px;
padding: 12px 16px;
margin: 20px 0 0 0;
text-align: center;
}
.delete-final-warning strong {
color: #dc3545;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.delete-modal-footer {
padding: 20px 30px;
display: flex;
gap: 12px;
justify-content: flex-end;
background: rgba(0, 0, 0, 0.02);
border-radius: 0 0 16px 16px;
border-top: 1px solid rgba(0, 0, 0, 0.1);
}
.delete-modal-footer form {
margin: 0;
}
.btn-cancel {
background: linear-gradient(135deg, #6c757d 0%, #5a6268 100%);
color: white;
padding: 12px 24px;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
transition: all 0.3s ease;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
.btn-cancel:hover {
background: linear-gradient(135deg, #5a6268 0%, #4e555b 100%);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
transform: translateY(-2px);
}
.btn-delete {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
color: white;
padding: 12px 28px;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: all 0.3s ease;
box-shadow: 0 2px 6px rgba(220, 53, 69, 0.3);
}
.btn-delete:hover {
background: linear-gradient(135deg, #c82333 0%, #bd2130 100%);
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.5);
transform: translateY(-2px);
}
/* Delete Modal - Dark Mode Styles */
body.dark-mode .delete-modal {
background-color: rgba(0, 0, 0, 0.8);
}
body.dark-mode .delete-modal-content {
background: linear-gradient(135deg, #1a202c 0%, #2d3748 100%);
border: 1px solid #4a5568;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
}
body.dark-mode .delete-modal-header {
background: linear-gradient(135deg, #b91c1c 0%, #991b1b 100%);
box-shadow: 0 4px 12px rgba(185, 28, 28, 0.4);
}
body.dark-mode .delete-question {
color: #e2e8f0;
}
body.dark-mode .delete-filename {
color: #f87171;
}
body.dark-mode .warning-playlist {
background: linear-gradient(135deg, #422006 0%, #713f12 100%);
border-color: #fbbf24;
}
body.dark-mode .warning-playlist .warning-header,
body.dark-mode .warning-playlist p {
color: #fde68a;
}
body.dark-mode .warning-edit {
background: linear-gradient(135deg, #3b0764 0%, #581c87 100%);
border-color: #a78bfa;
}
body.dark-mode .warning-edit .warning-header,
body.dark-mode .warning-edit p {
color: #ddd6fe;
}
body.dark-mode .delete-final-warning {
background: linear-gradient(135deg, #450a0a 0%, #7f1d1d 100%);
border-color: #f87171;
}
body.dark-mode .delete-final-warning strong {
color: #fca5a5;
}
body.dark-mode .delete-modal-footer {
background: rgba(0, 0, 0, 0.3);
border-top: 1px solid #4a5568;
}
body.dark-mode .btn-cancel {
background: linear-gradient(135deg, #374151 0%, #1f2937 100%);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
}
body.dark-mode .btn-cancel:hover {
background: linear-gradient(135deg, #4b5563 0%, #374151 100%);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
body.dark-mode .btn-delete {
background: linear-gradient(135deg, #b91c1c 0%, #991b1b 100%);
box-shadow: 0 2px 6px rgba(185, 28, 28, 0.4);
}
body.dark-mode .btn-delete:hover {
background: linear-gradient(135deg, #991b1b 0%, #7f1d1d 100%);
box-shadow: 0 4px 12px rgba(185, 28, 28, 0.6);
}
</style>
<script>
let deleteMediaId = null;
function confirmDelete(mediaId, filename, playlistCount, editCount) {
deleteMediaId = mediaId;
document.getElementById('deleteFilename').textContent = filename;
document.getElementById('deleteForm').action = "{{ url_for('content.delete_media', media_id=0) }}".replace('/0', '/' + mediaId);
// Show playlist warning if file is in use
if (playlistCount > 0) {
document.getElementById('playlistWarning').style.display = 'block';
document.getElementById('playlistCount').textContent = playlistCount;
} else {
document.getElementById('playlistWarning').style.display = 'none';
}
// Show edit versions warning if file has been edited
if (editCount > 0) {
document.getElementById('editWarning').style.display = 'block';
document.getElementById('editCount').textContent = editCount;
} else {
document.getElementById('editWarning').style.display = 'none';
}
document.getElementById('deleteModal').style.display = 'block';
}
function closeDeleteModal() {
document.getElementById('deleteModal').style.display = 'none';
deleteMediaId = null;
}
// Close modal when clicking outside
window.onclick = function(event) {
const modal = document.getElementById('deleteModal');
if (event.target == modal) {
closeDeleteModal();
}
}
// Close modal with ESC key
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
closeDeleteModal();
}
});
</script>
{% endblock %}
@@ -0,0 +1,278 @@
{% extends "base.html" %}
{% block title %}Upload Content - DigiServer v2{% endblock %}
{% block content %}
<div class="container" style="max-width: 1200px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h1>Upload Content</h1>
</div>
<form id="upload-form" method="POST" enctype="multipart/form-data" onsubmit="handleFormSubmit(event)">
<input type="hidden" name="return_url" value="{{ return_url or url_for('content.content_list') }}">
<div class="card" style="margin-bottom: 20px;">
<h3 style="margin-bottom: 15px;">Select Player</h3>
<div>
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Player:</label>
<select name="player_id" id="player_id" class="form-control" required>
<option value="" disabled {% if not selected_player_id %}selected{% endif %}>Select a Player</option>
{% for player in players %}
<option value="{{ player.id }}" {% if selected_player_id == player.id %}selected{% endif %}>
{{ player.name }} - {{ player.location or 'No location' }}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="card" style="margin-bottom: 20px;">
<h3 style="margin-bottom: 15px;">Media Details</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px;">
<div>
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Media Type:</label>
<select name="media_type" id="media_type" class="form-control" required onchange="handleMediaTypeChange()">
<option value="image">Image (JPG, PNG, GIF)</option>
<option value="video">Video (MP4, AVI, MOV)</option>
<option value="pdf">PDF Document</option>
<option value="ppt">PowerPoint (PPT/PPTX)</option>
</select>
<small style="color: #6c757d; display: block; margin-top: 5px;" id="media-type-hint">
Images will be displayed as-is
</small>
</div>
<div>
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Duration (seconds):</label>
<input type="number" name="duration" id="duration" class="form-control" required min="1" value="10">
<small style="color: #6c757d; display: block; margin-top: 5px;">
How long to display each image/slide (videos use actual length)
</small>
</div>
</div>
<div>
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Files:</label>
<input type="file" name="files" id="files" class="form-control" multiple required
accept="image/*,video/*,.pdf,.ppt,.pptx" onchange="handleFileChange()">
<small style="color: #6c757d; display: block; margin-top: 5px;">
Select multiple files. Supported: JPG, PNG, GIF, MP4, PDF, PPT, PPTX
</small>
<div id="file-list" style="margin-top: 10px;"></div>
</div>
</div>
<div style="text-align: center;">
<button type="submit" id="submit-button" class="btn btn-success" style="padding: 10px 30px; font-size: 16px;">
📤 Upload Files
</button>
<a href="{{ return_url or url_for('content.content_list') }}" class="btn" style="padding: 10px 30px; font-size: 16px;">
← Back
</a>
</div>
</form>
</div>
<!-- Modal for Status Updates -->
<div id="statusModal" class="modal" style="display: none;">
<div class="modal-content" style="max-width: 800px; margin: 50px auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);">
<h2 style="margin-bottom: 20px; color: #2c3e50;">Processing Files</h2>
<div style="margin-bottom: 20px;">
<p id="status-message" style="font-size: 16px; color: #555;">Uploading and processing your files. Please wait...</p>
</div>
<!-- Progress Bar -->
<div style="margin-bottom: 30px;">
<label style="display: block; margin-bottom: 10px; font-weight: bold;">File Processing Progress</label>
<div style="width: 100%; height: 30px; background: #e9ecef; border-radius: 5px; overflow: hidden;">
<div id="progress-bar" style="width: 0%; height: 100%; background: linear-gradient(90deg, #007bff, #0056b3); transition: width 0.3s ease; display: flex; align-items: center; justify-content: center; color: white; font-weight: bold; font-size: 14px;">
0%
</div>
</div>
</div>
<div style="text-align: center; margin-top: 20px;">
<button type="button" class="btn" onclick="closeModal()" disabled id="close-modal-btn">Close</button>
</div>
</div>
</div>
<style>
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
overflow-y: auto;
}
</style>
<script>
let progressInterval = null;
let sessionId = null;
let returnUrl = '{{ return_url or url_for("content.content_list") }}';
function generateSessionId() {
return 'upload_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
}
function handleFormSubmit(event) {
event.preventDefault();
sessionId = generateSessionId();
const form = document.getElementById('upload-form');
let sessionInput = document.getElementById('session_id_input');
if (!sessionInput) {
sessionInput = document.createElement('input');
sessionInput.type = 'hidden';
sessionInput.name = 'session_id';
sessionInput.id = 'session_id_input';
form.appendChild(sessionInput);
}
sessionInput.value = sessionId;
showStatusModal();
const formData = new FormData(form);
fetch(form.action, {
method: 'POST',
body: formData
})
.then(response => {
if (!response.ok) {
throw new Error('Upload failed');
}
console.log('Form submitted successfully');
})
.catch(error => {
console.error('Form submission error:', error);
document.getElementById('status-message').textContent = 'Upload failed: ' + error.message;
document.getElementById('progress-bar').style.background = '#dc3545';
document.getElementById('close-modal-btn').disabled = false;
});
}
function showStatusModal() {
const modal = document.getElementById('statusModal');
modal.style.display = 'block';
const mediaType = document.getElementById('media_type').value;
const statusMessage = document.getElementById('status-message');
switch(mediaType) {
case 'image':
statusMessage.textContent = 'Uploading images...';
break;
case 'video':
statusMessage.textContent = 'Uploading and converting video. This may take several minutes...';
break;
case 'pdf':
statusMessage.textContent = 'Uploading and converting PDF to images...';
break;
case 'ppt':
statusMessage.textContent = 'Uploading and converting PowerPoint to images...';
break;
default:
statusMessage.textContent = 'Uploading and processing your files. Please wait...';
}
pollUploadProgress();
}
function closeModal() {
const modal = document.getElementById('statusModal');
modal.style.display = 'none';
if (progressInterval) {
clearInterval(progressInterval);
}
window.location.href = returnUrl;
}
function pollUploadProgress() {
progressInterval = setInterval(() => {
fetch(`/api/upload-progress/${sessionId}`)
.then(response => response.json())
.then(data => {
const progressBar = document.getElementById('progress-bar');
progressBar.style.width = `${data.progress}%`;
progressBar.textContent = `${data.progress}%`;
document.getElementById('status-message').textContent = data.message;
if (data.status === 'complete' || data.status === 'error') {
clearInterval(progressInterval);
progressInterval = null;
const closeBtn = document.getElementById('close-modal-btn');
closeBtn.disabled = false;
if (data.status === 'complete') {
progressBar.style.background = '#28a745';
setTimeout(() => closeModal(), 2000);
} else if (data.status === 'error') {
progressBar.style.background = '#dc3545';
}
}
})
.catch(error => console.error('Error fetching progress:', error));
}, 500);
}
function handleMediaTypeChange() {
const mediaType = document.getElementById('media_type').value;
const hint = document.getElementById('media-type-hint');
switch(mediaType) {
case 'image':
hint.textContent = 'Images will be displayed as-is';
break;
case 'video':
hint.textContent = 'Videos will be converted to optimized format';
break;
case 'pdf':
hint.textContent = 'PDF will be converted to images (one per page)';
break;
case 'ppt':
hint.textContent = 'PowerPoint will be converted to images (one per slide)';
break;
}
}
function handleFileChange() {
const filesInput = document.getElementById('files');
const fileList = document.getElementById('file-list');
const mediaType = document.getElementById('media_type').value;
const durationInput = document.getElementById('duration');
fileList.innerHTML = '';
if (filesInput.files.length > 0) {
fileList.innerHTML = '<strong>Selected files:</strong><ul style="margin: 5px 0; padding-left: 20px;">';
for (let i = 0; i < filesInput.files.length; i++) {
const file = filesInput.files[i];
const sizeMB = (file.size / (1024 * 1024)).toFixed(2);
fileList.innerHTML += `<li>${file.name} (${sizeMB} MB)</li>`;
}
fileList.innerHTML += '</ul>';
}
if (mediaType === 'video' && filesInput.files.length > 0) {
const file = filesInput.files[0];
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = function() {
window.URL.revokeObjectURL(video.src);
const duration = Math.round(video.duration);
durationInput.value = duration;
};
video.src = URL.createObjectURL(file);
}
}
</script>
{% endblock %}
@@ -0,0 +1,514 @@
{% extends "base.html" %}
{% block title %}Upload Media - DigiServer v2{% endblock %}
{% block content %}
<style>
.upload-container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.upload-zone {
border: 2px dashed #ced4da;
border-radius: 8px;
padding: 25px 20px;
text-align: center;
background: #f8f9fa;
transition: all 0.3s;
cursor: pointer;
}
body.dark-mode .upload-zone {
background: #1a202c;
border-color: #4a5568;
}
.upload-zone:hover {
border-color: #667eea;
background: #f0f2ff;
}
body.dark-mode .upload-zone:hover {
border-color: #7c3aed;
background: #2d3748;
}
.upload-zone.dragover {
border-color: #667eea;
background: #e8ebff;
transform: scale(1.02);
}
body.dark-mode .upload-zone.dragover {
border-color: #7c3aed;
background: #2d3748;
}
.file-input-wrapper {
margin: 20px 0;
}
.file-input-wrapper input[type="file"] {
display: none;
}
.file-list {
margin-top: 15px;
max-height: 200px;
overflow-y: auto;
}
.file-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
background: #fff;
border: 1px solid #dee2e6;
border-radius: 4px;
margin-bottom: 6px;
font-size: 13px;
}
body.dark-mode .file-item {
background: #2d3748;
border-color: #4a5568;
}
.file-info {
display: flex;
align-items: center;
gap: 10px;
}
.file-icon {
font-size: 20px;
}
.remove-file {
cursor: pointer;
color: #dc3545;
font-weight: bold;
padding: 5px 10px;
}
.remove-file:hover {
background: #dc3545;
color: white;
border-radius: 4px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #333;
}
body.dark-mode .form-group label {
color: #e2e8f0;
}
.form-control {
width: 100%;
padding: 12px;
border: 1px solid #ced4da;
border-radius: 6px;
font-size: 14px;
}
body.dark-mode .form-control {
background: #1a202c;
border-color: #4a5568;
color: #e2e8f0;
}
.form-control:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
body.dark-mode .form-control:focus {
border-color: #7c3aed;
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.2);
}
.btn-upload {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 15px 40px;
font-size: 16px;
font-weight: 600;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
width: 100%;
}
.btn-upload:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
.btn-upload:disabled {
background: #6c757d;
cursor: not-allowed;
transform: none;
}
/* Dark mode text colors */
body.dark-mode h1,
body.dark-mode h2,
body.dark-mode h3 {
color: #e2e8f0;
}
body.dark-mode p,
body.dark-mode small {
color: #a0aec0;
}
body.dark-mode .file-info > div > div {
color: #e2e8f0;
}
body.dark-mode .file-info > div > div:last-child {
color: #718096;
}
.playlist-selector {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-bottom: 30px;
border: 2px solid #dee2e6;
}
body.dark-mode .playlist-selector {
background: #1a202c;
border-color: #4a5568;
}
.playlist-selector.selected {
border-color: #667eea;
background: #f0f2ff;
}
body.dark-mode .playlist-selector.selected {
border-color: #7c3aed;
background: #2d3748;
}
</style>
<div class="upload-container">
<div style="margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center;">
<h1 style="display: flex; align-items: center; gap: 0.5rem; font-size: 24px; margin: 0;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 28px; height: 28px;">
Upload Media
</h1>
<a href="{{ url_for('content.content_list') }}" class="btn" style="display: flex; align-items: center; gap: 0.5rem; padding: 8px 16px;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 16px; height: 16px; filter: brightness(0) invert(1);">
Back to Playlists
</a>
</div>
<form id="upload-form" method="POST" action="{{ url_for('content.upload_media') }}" enctype="multipart/form-data">
<!-- Compact Two-Column Layout -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
<!-- Left Column: Upload Zone -->
<div class="card">
<h2 style="margin-bottom: 15px; font-size: 18px; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 20px; height: 20px;">
Select Files
</h2>
<div class="upload-zone" id="upload-zone">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 48px; height: 48px; opacity: 0.3; margin-bottom: 10px;">
<h3 style="margin-bottom: 8px; font-size: 16px;">Drag & Drop</h3>
<p style="color: #6c757d; margin: 8px 0; font-size: 13px;">or</p>
<div class="file-input-wrapper">
<label for="file-input" class="btn btn-primary" style="display: inline-flex; align-items: center; gap: 0.5rem; padding: 8px 16px; font-size: 14px;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 16px; height: 16px; filter: brightness(0) invert(1);">
Browse
</label>
<input type="file" id="file-input" name="files" multiple
accept="image/*,video/*,.pdf,.ppt,.pptx">
</div>
<p style="font-size: 11px; color: #999; margin-top: 12px; line-height: 1.4;">
<strong>Supported:</strong> JPG, PNG, GIF, MP4, AVI, MOV, PDF, PPT
</p>
</div>
<div id="file-list" class="file-list"></div>
</div>
<!-- Right Column: Settings -->
<div class="card">
<h2 style="margin-bottom: 15px; font-size: 18px; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 20px; height: 20px;">
Upload Settings
</h2>
<div class="form-group">
<label for="playlist_id">Target Playlist (Optional)</label>
<select name="playlist_id" id="playlist_id" class="form-control">
<option value="">-- Media Library Only --</option>
{% for playlist in playlists %}
<option value="{{ playlist.id }}">
{{ playlist.name }} ({{ playlist.orientation }}) - {{ playlist.content_count }} items
</option>
{% endfor %}
</select>
<small style="color: #6c757d; display: block; margin-top: 4px; font-size: 11px;">
💡 Add to playlists later if needed
</small>
</div>
<div class="form-group">
<label for="content_type">Media Type</label>
<select name="content_type" id="content_type" class="form-control">
<option value="image">Image</option>
<option value="video">Video</option>
<option value="pdf">PDF</option>
<option value="pptx">PPTX</option>
</select>
<small style="color: #6c757d; display: block; margin-top: 4px; font-size: 11px;">
Auto-detected from file extension
</small>
</div>
<div class="form-group">
<label for="duration">Default Duration (seconds)</label>
<input type="number" name="duration" id="duration" class="form-control"
value="10" min="1" max="300">
<small style="color: #6c757d; display: block; margin-top: 4px; font-size: 11px;">
Display time for images and PDFs
</small>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; cursor: pointer; user-select: none;">
<input type="checkbox" name="edit_on_player_enabled" id="edit_on_player_enabled"
value="1" style="margin-right: 8px; width: 18px; height: 18px; cursor: pointer;">
<span>Allow editing on player (PDF, Images, PPTX)</span>
</label>
<small style="color: #6c757d; display: block; margin-top: 4px; font-size: 11px;">
✏️ Enable local editing of this media on the player device
</small>
</div>
<!-- Upload Button -->
<button type="submit" class="btn-upload" id="upload-btn" disabled style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; margin-top: 20px; padding: 12px 30px;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Upload Files
</button>
</div>
</div>
</form>
</div>
<script>
const uploadZone = document.getElementById('upload-zone');
const fileInput = document.getElementById('file-input');
const fileList = document.getElementById('file-list');
const uploadBtn = document.getElementById('upload-btn');
const uploadForm = document.getElementById('upload-form');
let selectedFiles = [];
// Prevent default drag behaviors
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
uploadZone.addEventListener(eventName, preventDefaults, false);
document.body.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
// Highlight drop zone when item is dragged over it
['dragenter', 'dragover'].forEach(eventName => {
uploadZone.addEventListener(eventName, () => {
uploadZone.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach(eventName => {
uploadZone.addEventListener(eventName, () => {
uploadZone.classList.remove('dragover');
});
});
// Handle dropped files
uploadZone.addEventListener('drop', (e) => {
const dt = e.dataTransfer;
const files = dt.files;
handleFiles(files);
});
// Handle file input
fileInput.addEventListener('change', (e) => {
console.log('File input changed, files:', e.target.files.length);
if (e.target.files.length > 0) {
handleFiles(e.target.files);
}
});
// Click upload zone to trigger file input (but not if clicking on the label)
uploadZone.addEventListener('click', (e) => {
// Don't trigger if clicking on the label or button
if (e.target.tagName === 'LABEL' || e.target.closest('label') || e.target.tagName === 'INPUT') {
return;
}
fileInput.click();
});
function handleFiles(files) {
console.log('handleFiles called with', files.length, 'file(s)');
selectedFiles = Array.from(files);
displayFiles();
uploadBtn.disabled = selectedFiles.length === 0;
// Auto-detect media type and duration from first file
if (selectedFiles.length > 0) {
console.log('Auto-detecting for first file:', selectedFiles[0].name);
autoDetectMediaType(selectedFiles[0]);
autoDetectDuration(selectedFiles[0]);
}
}
function autoDetectMediaType(file) {
const ext = file.name.split('.').pop().toLowerCase();
const contentTypeSelect = document.getElementById('content_type');
console.log('Auto-detecting media type for:', file.name, 'Extension:', ext);
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(ext)) {
contentTypeSelect.value = 'image';
console.log('Set type to: image');
} else if (['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv'].includes(ext)) {
contentTypeSelect.value = 'video';
console.log('Set type to: video');
} else if (ext === 'pdf') {
contentTypeSelect.value = 'pdf';
console.log('Set type to: pdf');
} else if (['ppt', 'pptx'].includes(ext)) {
contentTypeSelect.value = 'pptx';
console.log('Set type to: pptx');
}
}
function autoDetectDuration(file) {
const ext = file.name.split('.').pop().toLowerCase();
const durationInput = document.getElementById('duration');
console.log('Auto-detecting duration for:', file.name, 'Extension:', ext);
// For videos, try to get actual duration
if (['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv'].includes(ext)) {
console.log('Processing as video...');
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = function() {
window.URL.revokeObjectURL(video.src);
const duration = Math.ceil(video.duration);
console.log('Video duration detected:', duration, 'seconds');
if (duration && duration > 0) {
durationInput.value = duration;
}
};
video.onerror = function() {
console.log('Video loading error, using default 30s');
window.URL.revokeObjectURL(video.src);
durationInput.value = 30; // Default for videos if can't read duration
};
video.src = URL.createObjectURL(file);
} else if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(ext)) {
// Images: default 10 seconds
console.log('Setting image duration: 10s');
durationInput.value = 10;
} else if (ext === 'pdf') {
// PDFs: default 15 seconds per page (estimate)
console.log('Setting PDF duration: 15s');
durationInput.value = 15;
} else if (['ppt', 'pptx'].includes(ext)) {
// Presentations: default 20 seconds per slide (estimate)
console.log('Setting presentation duration: 20s');
durationInput.value = 20;
}
}
function displayFiles() {
fileList.innerHTML = '';
if (selectedFiles.length === 0) {
return;
}
selectedFiles.forEach((file, index) => {
const fileItem = document.createElement('div');
fileItem.className = 'file-item';
const ext = file.name.split('.').pop().toLowerCase();
let icon = '📁';
if (['jpg', 'jpeg', 'png', 'gif', 'bmp'].includes(ext)) icon = '📷';
else if (['mp4', 'avi', 'mov', 'mkv', 'webm'].includes(ext)) icon = '🎥';
else if (ext === 'pdf') icon = '📄';
else if (['ppt', 'pptx'].includes(ext)) icon = '📊';
const sizeInMB = (file.size / (1024 * 1024)).toFixed(2);
fileItem.innerHTML = `
<div class="file-info">
<span class="file-icon">${icon}</span>
<div>
<div style="font-weight: 600;">${file.name}</div>
<div style="font-size: 12px; color: #6c757d;">${sizeInMB} MB</div>
</div>
</div>
<span class="remove-file" onclick="removeFile(${index})">✕</span>
`;
fileList.appendChild(fileItem);
});
}
function removeFile(index) {
selectedFiles.splice(index, 1);
displayFiles();
uploadBtn.disabled = selectedFiles.length === 0;
// Update file input
const dt = new DataTransfer();
selectedFiles.forEach(file => dt.items.add(file));
fileInput.files = dt.files;
}
// Form submission
uploadForm.addEventListener('submit', (e) => {
if (selectedFiles.length === 0) {
e.preventDefault();
alert('Please select files to upload');
return;
}
uploadBtn.disabled = true;
uploadBtn.innerHTML = '⏳ Uploading...';
});
</script>
{% endblock %}