Initial commit: enterprise digital platform with portal SSO, DigiServer, IT Assets, NetworkView, Server Monitor
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Admin Panel - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Admin Panel</h1>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<!-- System Overview Card -->
|
||||
<div class="card">
|
||||
<h2>📊 System Overview</h2>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon">👥</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Total Users</span>
|
||||
<span class="stat-value">{{ total_users or 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon">🖥️</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Total Players</span>
|
||||
<span class="stat-value">{{ total_players or 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon">📋</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Total Playlists</span>
|
||||
<span class="stat-value">{{ total_playlists or 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon">📁</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Media Files</span>
|
||||
<span class="stat-value">{{ total_content or 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon">💾</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Storage Used</span>
|
||||
<span class="stat-value">{{ storage_mb or 0 }} MB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if current_user.is_admin %}
|
||||
<!-- User Management Card (Admin Only) -->
|
||||
<div class="card management-card">
|
||||
<h2>👥 User Management</h2>
|
||||
<p>Manage application users, roles and permissions</p>
|
||||
<div class="card-actions">
|
||||
<a href="{{ url_for('admin.user_management') }}" class="btn btn-primary">
|
||||
Manage Users
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Editing Users Card -->
|
||||
<div class="card management-card">
|
||||
<h2>✏️ Editing Users</h2>
|
||||
<p>Manage user codes from players that edit images on-screen</p>
|
||||
<div class="card-actions">
|
||||
<a href="{{ url_for('admin.manage_editing_users') }}" class="btn btn-primary">
|
||||
Manage Editing Users
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Leftover Media Management Card -->
|
||||
<div class="card management-card">
|
||||
<h2>🗑️ Manage Leftover Media</h2>
|
||||
<p>Clean up media files not assigned to any playlist</p>
|
||||
<div class="card-actions">
|
||||
<a href="{{ url_for('admin.leftover_media') }}" class="btn btn-warning">
|
||||
Manage Leftover Files
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if current_user.is_admin %}
|
||||
<!-- System Dependencies Card (Admin Only) -->
|
||||
<div class="card management-card" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">
|
||||
<h2>🔧 System Dependencies</h2>
|
||||
<p>Check and install required software dependencies</p>
|
||||
<div class="card-actions">
|
||||
<a href="{{ url_for('admin.dependencies') }}" class="btn btn-primary">
|
||||
View Dependencies
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logo Customization Card (Admin Only) -->
|
||||
<div class="card management-card" style="background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);">
|
||||
<h2>🎨 Logo Customization</h2>
|
||||
<p>Upload custom logos for header and login page</p>
|
||||
<div class="card-actions">
|
||||
<a href="{{ url_for('admin.customize_logos') }}" class="btn btn-primary">
|
||||
Customize Logos
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HTTPS Configuration Card (Admin Only) -->
|
||||
<div class="card management-card" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
|
||||
<h2>🔒 HTTPS Configuration</h2>
|
||||
<p>Manage SSL/HTTPS settings, domain, and access points</p>
|
||||
<div class="card-actions">
|
||||
<a href="{{ url_for('admin.https_config') }}" class="btn btn-primary">
|
||||
Configure HTTPS
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Quick Actions Card -->
|
||||
<div class="card">
|
||||
<h2>⚡ Quick Actions</h2>
|
||||
<div class="quick-actions">
|
||||
<a href="{{ url_for('players.list') }}" class="btn btn-secondary">🖥️ View Players</a>
|
||||
<a href="{{ url_for('content.content_list') }}" class="btn btn-secondary">📋 View Playlists</a>
|
||||
<a href="{{ url_for('content.content_list') }}" class="btn btn-secondary">📁 View Media Library</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.stat-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
body.dark-mode .stat-item {
|
||||
background: #1a202c;
|
||||
border-color: #4a5568;
|
||||
}
|
||||
|
||||
body.dark-mode .stat-item:hover {
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-weight: 500;
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
body.dark-mode .stat-label {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: bold;
|
||||
font-size: 1.5rem;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
body.dark-mode .stat-value {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.management-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.management-card h2 {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.management-card p {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
display: inline-block;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: white;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,101 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Logo Customization - DigiServer{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container" style="max-width: 900px;">
|
||||
<h1 style="margin-bottom: 25px;">🎨 Logo Customization</h1>
|
||||
|
||||
<div class="card" style="margin-bottom: 20px;">
|
||||
<h2 style="margin-bottom: 20px;">📸 Upload Custom Logos</h2>
|
||||
|
||||
<!-- Header Logo -->
|
||||
<div style="margin-bottom: 30px; padding: 20px; background: #f8f9fa; border-radius: 8px;">
|
||||
<h3 style="margin-bottom: 15px;">Header Logo (Small)</h3>
|
||||
<p style="color: #666; margin-bottom: 15px;">
|
||||
This logo appears in the top header next to "DigiServer" text.<br>
|
||||
<strong>Recommended:</strong> 150x40 pixels (or similar aspect ratio), transparent background PNG
|
||||
</p>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 20px; margin-bottom: 15px;">
|
||||
<div style="flex: 1;">
|
||||
<img src="{{ url_for('static', filename='uploads/header_logo.png') }}?v={{ version }}"
|
||||
alt="Current Header Logo"
|
||||
style="max-height: 50px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 10px; border-radius: 4px;"
|
||||
onerror="this.src='{{ url_for('static', filename='icons/monitor.svg') }}'; this.style.filter='brightness(0) invert(1)'; this.style.maxWidth='50px';">
|
||||
<p style="margin-top: 5px; font-size: 0.9rem; color: #888;">Current Header Logo</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('admin.upload_header_logo') }}" enctype="multipart/form-data">
|
||||
<div style="margin-bottom: 10px;">
|
||||
<input type="file" name="header_logo" accept="image/png,image/jpeg,image/svg+xml" required
|
||||
style="padding: 10px; border: 2px solid #ddd; border-radius: 4px; width: 100%;">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">📤 Upload Header Logo</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Login Logo -->
|
||||
<div style="margin-bottom: 30px; padding: 20px; background: #f8f9fa; border-radius: 8px;">
|
||||
<h3 style="margin-bottom: 15px;">Login Page Logo (Large)</h3>
|
||||
<p style="color: #666; margin-bottom: 15px;">
|
||||
This logo appears on the left side of the login page (2/3 of screen).<br>
|
||||
<strong>Recommended:</strong> 800x600 pixels (or similar), transparent background PNG
|
||||
</p>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 20px; margin-bottom: 15px;">
|
||||
<div style="flex: 1;">
|
||||
<img src="{{ url_for('static', filename='uploads/login_logo.png') }}?v={{ version }}"
|
||||
alt="Current Login Logo"
|
||||
style="max-width: 300px; max-height: 200px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; border-radius: 8px;"
|
||||
onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">
|
||||
<p style="margin-top: 10px; font-size: 0.9rem; color: #888; display: none;">No login logo uploaded yet</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('admin.upload_login_logo') }}" enctype="multipart/form-data">
|
||||
<div style="margin-bottom: 10px;">
|
||||
<input type="file" name="login_logo" accept="image/png,image/jpeg,image/svg+xml" required
|
||||
style="padding: 10px; border: 2px solid #ddd; border-radius: 4px; width: 100%;">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">📤 Upload Login Logo</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style="padding: 15px; background: #e7f3ff; border-radius: 8px; border-left: 4px solid #0066cc;">
|
||||
<h4 style="margin: 0 0 10px 0;">ℹ️ Logo Guidelines</h4>
|
||||
<ul style="margin: 5px 0; padding-left: 25px; color: #555;">
|
||||
<li><strong>Header Logo:</strong> Keep it simple and small (max 200px width recommended)</li>
|
||||
<li><strong>Login Logo:</strong> Can be larger and more detailed (800x600px works great)</li>
|
||||
<li><strong>Format:</strong> PNG with transparent background recommended, or JPG/SVG</li>
|
||||
<li><strong>File Size:</strong> Keep under 2MB for optimal performance</li>
|
||||
<li>Logos are cached - clear browser cache if changes don't appear immediately</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px;">
|
||||
<a href="{{ url_for('admin.admin_panel') }}" class="btn btn-secondary">
|
||||
← Back to Admin Panel
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
body.dark-mode div[style*="background: #f8f9fa"] {
|
||||
background: #2d3748 !important;
|
||||
}
|
||||
|
||||
body.dark-mode div[style*="background: #e7f3ff"] {
|
||||
background: #1e3a5f !important;
|
||||
border-left-color: #64b5f6 !important;
|
||||
}
|
||||
|
||||
body.dark-mode p[style*="color: #666"],
|
||||
body.dark-mode p[style*="color: #888"],
|
||||
body.dark-mode ul[style*="color: #555"] {
|
||||
color: #cbd5e0 !important;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,176 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}System Dependencies - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container" style="max-width: 1000px;">
|
||||
<h1 style="margin-bottom: 25px;">🔧 System Dependencies</h1>
|
||||
|
||||
<div class="card" style="margin-bottom: 20px;">
|
||||
<h2 style="margin-bottom: 20px;">📦 Installed Dependencies</h2>
|
||||
|
||||
<!-- LibreOffice -->
|
||||
<div class="dependency-card" style="background: {% if libreoffice_installed %}#d4edda{% else %}#f8d7da{% endif %}; padding: 20px; border-radius: 8px; margin-bottom: 15px; border-left: 4px solid {% if libreoffice_installed %}#28a745{% else %}#dc3545{% endif %};">
|
||||
<div style="display: flex; justify-content: space-between; align-items: start;">
|
||||
<div style="flex: 1;">
|
||||
<h3 style="margin: 0 0 10px 0; display: flex; align-items: center; gap: 10px;">
|
||||
{% if libreoffice_installed %}
|
||||
<span style="font-size: 24px;">✅</span>
|
||||
{% else %}
|
||||
<span style="font-size: 24px;">❌</span>
|
||||
{% endif %}
|
||||
LibreOffice
|
||||
</h3>
|
||||
<p style="margin: 5px 0; color: #555;">
|
||||
<strong>Purpose:</strong> Required for PowerPoint (PPTX/PPT) to image conversion
|
||||
</p>
|
||||
<p style="margin: 5px 0; color: #555;">
|
||||
<strong>Status:</strong> {{ libreoffice_version }}
|
||||
</p>
|
||||
{% if not libreoffice_installed %}
|
||||
<p style="margin: 10px 0 0 0; color: #721c24;">
|
||||
⚠️ Without LibreOffice, you cannot upload or convert PowerPoint presentations.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not libreoffice_installed %}
|
||||
<form method="POST" action="{{ url_for('admin.install_libreoffice') }}" style="margin-left: 20px;">
|
||||
<button type="submit" class="btn btn-success" onclick="return confirm('Install LibreOffice? This may take 2-5 minutes.');">
|
||||
📥 Install LibreOffice
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Poppler Utils -->
|
||||
<div class="dependency-card" style="background: {% if poppler_installed %}#d4edda{% else %}#fff3cd{% endif %}; padding: 20px; border-radius: 8px; margin-bottom: 15px; border-left: 4px solid {% if poppler_installed %}#28a745{% else %}#ffc107{% endif %};">
|
||||
<div style="display: flex; justify-content: space-between; align-items: start;">
|
||||
<div style="flex: 1;">
|
||||
<h3 style="margin: 0 0 10px 0; display: flex; align-items: center; gap: 10px;">
|
||||
{% if poppler_installed %}
|
||||
<span style="font-size: 24px;">✅</span>
|
||||
{% else %}
|
||||
<span style="font-size: 24px;">⚠️</span>
|
||||
{% endif %}
|
||||
Poppler Utils
|
||||
</h3>
|
||||
<p style="margin: 5px 0; color: #555;">
|
||||
<strong>Purpose:</strong> Required for PDF to image conversion
|
||||
</p>
|
||||
<p style="margin: 5px 0; color: #555;">
|
||||
<strong>Status:</strong> {{ poppler_version }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FFmpeg -->
|
||||
<div class="dependency-card" style="background: {% if ffmpeg_installed %}#d4edda{% else %}#fff3cd{% endif %}; padding: 20px; border-radius: 8px; margin-bottom: 15px; border-left: 4px solid {% if ffmpeg_installed %}#28a745{% else %}#ffc107{% endif %};">
|
||||
<div style="display: flex; justify-content: space-between; align-items: start;">
|
||||
<div style="flex: 1;">
|
||||
<h3 style="margin: 0 0 10px 0; display: flex; align-items: center; gap: 10px;">
|
||||
{% if ffmpeg_installed %}
|
||||
<span style="font-size: 24px;">✅</span>
|
||||
{% else %}
|
||||
<span style="font-size: 24px;">⚠️</span>
|
||||
{% endif %}
|
||||
FFmpeg
|
||||
</h3>
|
||||
<p style="margin: 5px 0; color: #555;">
|
||||
<strong>Purpose:</strong> Required for video processing and validation
|
||||
</p>
|
||||
<p style="margin: 5px 0; color: #555;">
|
||||
<strong>Status:</strong> {{ ffmpeg_version }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Emoji Fonts -->
|
||||
<div class="dependency-card" style="background: {% if emoji_installed %}#d4edda{% else %}#fff3cd{% endif %}; padding: 20px; border-radius: 8px; margin-bottom: 15px; border-left: 4px solid {% if emoji_installed %}#28a745{% else %}#ffc107{% endif %};">
|
||||
<div style="display: flex; justify-content: space-between; align-items: start;">
|
||||
<div style="flex: 1;">
|
||||
<h3 style="margin: 0 0 10px 0; display: flex; align-items: center; gap: 10px;">
|
||||
{% if emoji_installed %}
|
||||
<span style="font-size: 24px;">✅</span>
|
||||
{% else %}
|
||||
<span style="font-size: 24px;">⚠️</span>
|
||||
{% endif %}
|
||||
Emoji Fonts
|
||||
</h3>
|
||||
<p style="margin: 5px 0; color: #555;">
|
||||
<strong>Purpose:</strong> Better emoji display in UI (optional, mainly for Raspberry Pi)
|
||||
</p>
|
||||
<p style="margin: 5px 0; color: #555;">
|
||||
<strong>Status:</strong> {{ emoji_version }}
|
||||
</p>
|
||||
{% if not emoji_installed %}
|
||||
<p style="margin: 10px 0 0 0; color: #856404;">
|
||||
ℹ️ Optional: Improves emoji rendering on systems without native emoji support.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not emoji_installed %}
|
||||
<form method="POST" action="{{ url_for('admin.install_emoji_fonts') }}" style="margin-left: 20px;">
|
||||
<button type="submit" class="btn btn-warning" onclick="return confirm('Install emoji fonts? This may take 1-2 minutes.');">
|
||||
📥 Install Emoji Fonts
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 25px; padding: 15px; background: #e7f3ff; border-radius: 8px; border-left: 4px solid #0066cc;">
|
||||
<h4 style="margin: 0 0 10px 0;">ℹ️ Installation Notes</h4>
|
||||
<ul style="margin: 5px 0; padding-left: 25px; color: #555;">
|
||||
<li>LibreOffice can be installed using the button above (requires sudo access)</li>
|
||||
<li>Emoji fonts improve UI display, especially on Raspberry Pi systems</li>
|
||||
<li>Installation may take 1-5 minutes depending on your internet connection</li>
|
||||
<li>After installation, refresh this page to verify the status</li>
|
||||
<li>Docker containers may require rebuilding to include dependencies</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px;">
|
||||
<a href="{{ url_for('admin.admin_panel') }}" class="btn btn-secondary">
|
||||
← Back to Admin Panel
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
body.dark-mode .dependency-card {
|
||||
color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .dependency-card p {
|
||||
color: #cbd5e0 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .dependency-card[style*="#d4edda"] {
|
||||
background: #1e4620 !important;
|
||||
border-left-color: #48bb78 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .dependency-card[style*="#f8d7da"] {
|
||||
background: #5a1e1e !important;
|
||||
border-left-color: #ef5350 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .dependency-card[style*="#fff3cd"] {
|
||||
background: #5a4a1e !important;
|
||||
border-left-color: #ffc107 !important;
|
||||
}
|
||||
|
||||
body.dark-mode div[style*="#e7f3ff"] {
|
||||
background: #1e3a5f !important;
|
||||
border-left-color: #64b5f6 !important;
|
||||
}
|
||||
|
||||
body.dark-mode div[style*="#e7f3ff"] ul {
|
||||
color: #cbd5e0 !important;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,105 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Manage Editing Users{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="margin-bottom: 2rem;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem;">
|
||||
<div style="display: flex; align-items: center; gap: 1rem;">
|
||||
<a href="{{ url_for('admin.admin_panel') }}"
|
||||
class="btn"
|
||||
style="background: #6c757d; color: white; padding: 0.5rem 1rem; text-decoration: none; border-radius: 6px;">
|
||||
← Back to Admin
|
||||
</a>
|
||||
<h1 style="margin: 0;">👤 Manage Editing Users</h1>
|
||||
</div>
|
||||
</div>
|
||||
<p style="color: #6c757d;">Manage users who edit images on players. User codes are automatically created from player metadata.</p>
|
||||
</div>
|
||||
|
||||
{% if users %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 style="margin: 0;">Editing Users ({{ users|length }})</h3>
|
||||
</div>
|
||||
<div class="card-body" style="padding: 0;">
|
||||
<table class="table" style="margin: 0;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 30%;">User Code</th>
|
||||
<th style="width: 30%;">Display Name</th>
|
||||
<th style="width: 15%;">Edits Count</th>
|
||||
<th style="width: 15%;">Created</th>
|
||||
<th style="width: 10%;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td style="font-family: monospace; font-weight: 600;">{{ user.user_code }}</td>
|
||||
<td>
|
||||
<form method="POST" action="{{ url_for('admin.update_editing_user', user_id=user.id) }}" style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<input type="text"
|
||||
name="user_name"
|
||||
value="{{ user.user_name or '' }}"
|
||||
placeholder="Enter display name"
|
||||
class="form-control"
|
||||
style="flex: 1;">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Save</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-info">{{ user_stats.get(user.user_code, 0) }} edits</span>
|
||||
</td>
|
||||
<td>{{ user.created_at | localtime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
<form method="POST"
|
||||
action="{{ url_for('admin.delete_editing_user', user_id=user.id) }}"
|
||||
style="display: inline;"
|
||||
onsubmit="return confirm('Are you sure you want to delete this user? This will not delete their edit history.');">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card" style="text-align: center; padding: 4rem 2rem;">
|
||||
<div style="font-size: 4rem; margin-bottom: 1rem; opacity: 0.5;">👤</div>
|
||||
<h2 style="color: #6c757d; margin-bottom: 1rem;">No Editing Users Yet</h2>
|
||||
<p style="color: #6c757d; font-size: 1.1rem;">
|
||||
User codes will appear here automatically when players edit media files.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<style>
|
||||
body.dark-mode .table {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .table thead th {
|
||||
background: #2d3748;
|
||||
color: #e2e8f0;
|
||||
border-color: #4a5568;
|
||||
}
|
||||
|
||||
body.dark-mode .table tbody tr {
|
||||
border-color: #4a5568;
|
||||
}
|
||||
|
||||
body.dark-mode .table tbody tr:hover {
|
||||
background: #2d3748;
|
||||
}
|
||||
|
||||
body.dark-mode .form-control {
|
||||
background: #2d3748;
|
||||
color: #e2e8f0;
|
||||
border-color: #4a5568;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,654 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}HTTPS Configuration - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<a href="{{ url_for('admin.admin_panel') }}" class="back-link">← Back to Admin Panel</a>
|
||||
<h1>🔒 HTTPS Configuration</h1>
|
||||
</div>
|
||||
|
||||
<div class="https-config-container">
|
||||
<!-- Status Display -->
|
||||
<div class="card status-card">
|
||||
<h2>Current Status</h2>
|
||||
|
||||
<!-- Real-time HTTPS detection -->
|
||||
<div class="status-detection">
|
||||
<p class="detection-info">
|
||||
<strong>🔍 Detected Connection:</strong>
|
||||
{% if is_https_active %}
|
||||
<span class="badge badge-success">🔒 HTTPS ({{ request.scheme.upper() }})</span>
|
||||
{% else %}
|
||||
<span class="badge badge-warning">🔓 HTTP</span>
|
||||
{% endif %}
|
||||
<br>
|
||||
<small>Current host: <code>{{ current_host }}</code> via {{ request.host }}</small>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if config and config.https_enabled %}
|
||||
<div class="status-enabled">
|
||||
<span class="status-badge">✅ HTTPS ENABLED</span>
|
||||
<div class="status-details">
|
||||
<p><strong>Domain:</strong> {{ config.domain }}</p>
|
||||
<p><strong>Hostname:</strong> {{ config.hostname }}</p>
|
||||
<p><strong>Email:</strong> {{ config.email }}</p>
|
||||
<p><strong>IP Address:</strong> {{ config.ip_address }}</p>
|
||||
<p><strong>Port:</strong> {{ config.port }}</p>
|
||||
<p><strong>Access URL:</strong> <code>https://{{ config.domain }}</code></p>
|
||||
<p><strong>Last Updated:</strong> {{ config.updated_at.strftime('%Y-%m-%d %H:%M:%S') }} by {{ config.updated_by }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="status-disabled">
|
||||
<span class="status-badge-inactive">⚠️ HTTPS DISABLED</span>
|
||||
{% if is_https_active %}
|
||||
<p style="color: #156b2e; background: #d1f0e0; padding: 10px; border-radius: 4px; margin: 10px 0;">
|
||||
✅ <strong>Note:</strong> You are currently accessing this page via HTTPS, but the configuration shows as disabled.
|
||||
This configuration will be automatically updated. Please refresh the page.
|
||||
</p>
|
||||
{% else %}
|
||||
<p>The application is currently running on HTTP only (port 80)</p>
|
||||
<p>Enable HTTPS below to secure your application.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Configuration Form -->
|
||||
<div class="card config-card">
|
||||
<h2>Configure HTTPS Settings</h2>
|
||||
<p class="info-text">
|
||||
💡 <strong>Workflow:</strong> First, the app runs on HTTP (port 80). After you configure the HTTPS settings below,
|
||||
the application will be available over HTTPS (port 443) using the domain and hostname you specify.
|
||||
</p>
|
||||
|
||||
<form method="POST" action="{{ url_for('admin.update_https_config') }}" class="https-form">
|
||||
<!-- Enable HTTPS Toggle -->
|
||||
<div class="form-group">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" name="https_enabled" id="https_enabled"
|
||||
{% if config and config.https_enabled %}checked{% endif %}
|
||||
class="toggle-input">
|
||||
<span class="toggle-slider"></span>
|
||||
<span class="toggle-text">Enable HTTPS</span>
|
||||
</label>
|
||||
<p class="form-hint">Check this box to enable HTTPS/SSL for your application</p>
|
||||
</div>
|
||||
|
||||
<!-- Hostname Field -->
|
||||
<div class="form-group">
|
||||
<label for="hostname">Hostname <span class="required">*</span></label>
|
||||
<input type="text" id="hostname" name="hostname"
|
||||
value="{{ config.hostname or 'digiserver' }}"
|
||||
placeholder="e.g., digiserver"
|
||||
class="form-input"
|
||||
required>
|
||||
<p class="form-hint">Short name for your server (e.g., 'digiserver')</p>
|
||||
</div>
|
||||
|
||||
<!-- Domain Field -->
|
||||
<div class="form-group">
|
||||
<label for="domain">Full Domain Name <span class="required">*</span></label>
|
||||
<input type="text" id="domain" name="domain"
|
||||
value="{{ config.domain or 'digiserver.sibiusb.harting.intra' }}"
|
||||
placeholder="e.g., digiserver.sibiusb.harting.intra"
|
||||
class="form-input"
|
||||
required>
|
||||
<p class="form-hint">Complete domain name (e.g., digiserver.sibiusb.harting.intra)</p>
|
||||
</div>
|
||||
|
||||
<!-- IP Address Field -->
|
||||
<div class="form-group">
|
||||
<label for="ip_address">IP Address <span class="required">*</span></label>
|
||||
<input type="text" id="ip_address" name="ip_address"
|
||||
value="{{ config.ip_address or '10.76.152.164' }}"
|
||||
placeholder="e.g., 10.76.152.164"
|
||||
class="form-input"
|
||||
required>
|
||||
<p class="form-hint">Server's IP address for direct access (e.g., 10.76.152.164)</p>
|
||||
</div>
|
||||
|
||||
<!-- Email Field -->
|
||||
<div class="form-group">
|
||||
<label for="email">Email Address <span class="required">*</span></label>
|
||||
<input type="email" id="email" name="email"
|
||||
value="{{ config.email or '' }}"
|
||||
placeholder="e.g., admin@example.com"
|
||||
class="form-input"
|
||||
required>
|
||||
<p class="form-hint">Email address for SSL certificate notifications and Let's Encrypt communications</p>
|
||||
</div>
|
||||
|
||||
<!-- Port Field -->
|
||||
<div class="form-group">
|
||||
<label for="port">HTTPS Port</label>
|
||||
<input type="number" id="port" name="port"
|
||||
value="{{ config.port or 443 }}"
|
||||
placeholder="443"
|
||||
min="1" max="65535"
|
||||
class="form-input">
|
||||
<p class="form-hint">Port for HTTPS connections (default: 443)</p>
|
||||
</div>
|
||||
|
||||
<!-- Preview Section -->
|
||||
<div class="preview-section">
|
||||
<h3>Access Points After Configuration:</h3>
|
||||
<ul class="access-points">
|
||||
<li>
|
||||
<strong>HTTPS (Recommended):</strong>
|
||||
<code>https://<span id="preview-domain">digiserver.sibiusb.harting.intra</span></code>
|
||||
</li>
|
||||
<li>
|
||||
<strong>HTTP (Fallback):</strong>
|
||||
<code>http://<span id="preview-ip">10.76.152.164</span></code>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
💾 Save HTTPS Configuration
|
||||
</button>
|
||||
<a href="{{ url_for('admin.admin_panel') }}" class="btn btn-secondary">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Nginx Status Card -->
|
||||
<div class="card nginx-status-card">
|
||||
<h2>🔧 Nginx Reverse Proxy Status</h2>
|
||||
{% if nginx_status.available %}
|
||||
<div class="nginx-status-content">
|
||||
<div class="status-item">
|
||||
<strong>Status:</strong>
|
||||
<span class="badge badge-success">✅ Nginx Configured</span>
|
||||
</div>
|
||||
|
||||
<div class="status-item">
|
||||
<strong>Configuration Path:</strong>
|
||||
<code>{{ nginx_status.path }}</code>
|
||||
</div>
|
||||
|
||||
{% if nginx_status.ssl_enabled %}
|
||||
<div class="status-item">
|
||||
<strong>SSL/TLS:</strong>
|
||||
<span class="badge badge-success">🔒 Enabled</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="status-item">
|
||||
<strong>SSL/TLS:</strong>
|
||||
<span class="badge badge-warning">⚠️ Not Configured</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if nginx_status.http_ports %}
|
||||
<div class="status-item">
|
||||
<strong>HTTP Ports:</strong>
|
||||
<code>{{ nginx_status.http_ports|join(', ') }}</code>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if nginx_status.https_ports %}
|
||||
<div class="status-item">
|
||||
<strong>HTTPS Ports:</strong>
|
||||
<code>{{ nginx_status.https_ports|join(', ') }}</code>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if nginx_status.server_names %}
|
||||
<div class="status-item">
|
||||
<strong>Server Names:</strong>
|
||||
{% for name in nginx_status.server_names %}
|
||||
<code>{{ name }}</code>{% if not loop.last %}<br>{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if nginx_status.upstream_servers %}
|
||||
<div class="status-item">
|
||||
<strong>Upstream Servers:</strong>
|
||||
{% for server in nginx_status.upstream_servers %}
|
||||
<code>{{ server }}</code>{% if not loop.last %}<br>{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if nginx_status.ssl_protocols %}
|
||||
<div class="status-item">
|
||||
<strong>SSL Protocols:</strong>
|
||||
<code>{{ nginx_status.ssl_protocols|join(', ') }}</code>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if nginx_status.client_max_body_size %}
|
||||
<div class="status-item">
|
||||
<strong>Max Body Size:</strong>
|
||||
<code>{{ nginx_status.client_max_body_size }}</code>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if nginx_status.gzip_enabled %}
|
||||
<div class="status-item">
|
||||
<strong>Gzip Compression:</strong>
|
||||
<span class="badge badge-success">✅ Enabled</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="status-disabled">
|
||||
<p>⚠️ <strong>Nginx configuration not accessible</strong></p>
|
||||
<p>Error: {{ nginx_status.error|default('Unknown error') }}</p>
|
||||
<p style="font-size: 12px; color: #666;">Path checked: {{ nginx_status.path }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Information Section -->
|
||||
<div class="card info-card">
|
||||
<h2>ℹ️ Important Information</h2>
|
||||
<div class="info-sections">
|
||||
<div class="info-section">
|
||||
<h3>📝 Before You Start</h3>
|
||||
<ul>
|
||||
<li>Ensure your DNS is configured to resolve the domain to your server</li>
|
||||
<li>Verify the IP address matches your server's actual network interface</li>
|
||||
<li>Check that ports 80, 443, and 443/UDP are open for traffic</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="info-section">
|
||||
<h3>🔐 HTTPS Setup</h3>
|
||||
<ul>
|
||||
<li>SSL certificates are automatically managed by Caddy</li>
|
||||
<li>Certificates are obtained from Let's Encrypt</li>
|
||||
<li>Automatic renewal is handled by the system</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="info-section">
|
||||
<h3>✅ After Configuration</h3>
|
||||
<ul>
|
||||
<li>Your app will restart with the new settings</li>
|
||||
<li>Both HTTP and HTTPS access points will be available</li>
|
||||
<li>HTTP requests will be redirected to HTTPS</li>
|
||||
<li>Check the status above for current configuration</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.https-config-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-bottom: 15px;
|
||||
color: #0066cc;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.status-detection {
|
||||
background: #f0f7ff;
|
||||
border-left: 4px solid #0066cc;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.detection-info {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: #d1f0e0;
|
||||
color: #156b2e;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
margin-bottom: 30px;
|
||||
border-left: 5px solid #ddd;
|
||||
}
|
||||
|
||||
.status-enabled {
|
||||
background: linear-gradient(135deg, #d4edda 0%, #c3e6cb 100%);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
border-left: 5px solid #28a745;
|
||||
}
|
||||
|
||||
.status-disabled {
|
||||
background: linear-gradient(135deg, #fff3cd 0%, #ffeaa7 100%);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
border-left: 5px solid #ffc107;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
background: #28a745;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 15px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-badge-inactive {
|
||||
display: inline-block;
|
||||
background: #ffc107;
|
||||
color: #333;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 15px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-details {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.status-details p {
|
||||
margin: 8px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-details code {
|
||||
background: rgba(0,0,0,0.1);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.config-card {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
background: #e7f3ff;
|
||||
border-left: 4px solid #0066cc;
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 25px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.https-form {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #0066cc;
|
||||
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* Toggle Switch Styling */
|
||||
.toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.toggle-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 28px;
|
||||
background: #ccc;
|
||||
border-radius: 14px;
|
||||
position: relative;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.toggle-slider::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.toggle-input:checked + .toggle-slider {
|
||||
background: #28a745;
|
||||
}
|
||||
|
||||
.toggle-input:checked + .toggle-slider::after {
|
||||
left: 24px;
|
||||
}
|
||||
|
||||
.toggle-text {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.preview-section {
|
||||
background: #f8f9fa;
|
||||
border: 2px dashed #0066cc;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 25px 0;
|
||||
}
|
||||
|
||||
.preview-section h3 {
|
||||
margin-top: 0;
|
||||
color: #0066cc;
|
||||
}
|
||||
|
||||
.access-points {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.access-points li {
|
||||
padding: 10px;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
border-left: 4px solid #0066cc;
|
||||
}
|
||||
|
||||
.access-points code {
|
||||
background: #e7f3ff;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #0066cc;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 30px;
|
||||
padding-top: 25px;
|
||||
border-top: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 12px 30px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: linear-gradient(135deg, #e7f3ff 0%, #f0f7ff 100%);
|
||||
}
|
||||
|
||||
.info-card h2 {
|
||||
color: #0066cc;
|
||||
}
|
||||
|
||||
.nginx-status-card {
|
||||
background: linear-gradient(135deg, #f0f7ff 0%, #e7f3ff 100%);
|
||||
border-left: 5px solid #0066cc;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.nginx-status-card h2 {
|
||||
color: #0066cc;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.nginx-status-content {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
padding: 12px;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
border-left: 3px solid #0066cc;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-item strong {
|
||||
display: inline-block;
|
||||
min-width: 150px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.status-item code {
|
||||
background: #f0f7ff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #0066cc;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.info-sections {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.info-section h3 {
|
||||
color: #0066cc;
|
||||
margin-top: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.info-section ul {
|
||||
padding-left: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.info-section li {
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.https-config-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.info-sections {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Update preview in real-time
|
||||
document.getElementById('domain').addEventListener('input', function() {
|
||||
document.getElementById('preview-domain').textContent = this.value || 'digiserver.sibiusb.harting.intra';
|
||||
});
|
||||
|
||||
document.getElementById('ip_address').addEventListener('input', function() {
|
||||
document.getElementById('preview-ip').textContent = this.value || '10.76.152.164';
|
||||
});
|
||||
|
||||
// Load initial preview
|
||||
document.getElementById('preview-domain').textContent = document.getElementById('domain').value || 'digiserver.sibiusb.harting.intra';
|
||||
document.getElementById('preview-ip').textContent = document.getElementById('ip_address').value || '10.76.152.164';
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,215 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Leftover Media - Admin - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="max-width: 1400px; margin: 0 auto;">
|
||||
<div style="margin-bottom: 30px; display: flex; justify-content: space-between; align-items: center;">
|
||||
<h1 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
🗑️ Manage Leftover Media
|
||||
</h1>
|
||||
<a href="{{ url_for('admin.admin_panel') }}" class="btn btn-secondary">
|
||||
← Back to Admin
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Overview Stats -->
|
||||
<div class="card" style="margin-bottom: 30px;">
|
||||
<h2>📊 Overview</h2>
|
||||
<p style="color: #6c757d; margin-bottom: 20px;">
|
||||
Media files that are not assigned to any playlist. These can be safely deleted to free up storage.
|
||||
</p>
|
||||
<div class="stats-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px;">
|
||||
<div class="stat-item" style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
|
||||
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Total Leftover Files</div>
|
||||
<div style="font-size: 24px; font-weight: bold;">{{ total_leftover }}</div>
|
||||
</div>
|
||||
<div class="stat-item" style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
|
||||
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Total Size</div>
|
||||
<div style="font-size: 24px; font-weight: bold;">{{ "%.2f"|format(total_leftover_size_mb) }} MB</div>
|
||||
</div>
|
||||
<div class="stat-item" style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
|
||||
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Images</div>
|
||||
<div style="font-size: 24px; font-weight: bold;">{{ leftover_images|length }} ({{ "%.2f"|format(images_size_mb) }} MB)</div>
|
||||
</div>
|
||||
<div class="stat-item" style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
|
||||
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Videos</div>
|
||||
<div style="font-size: 24px; font-weight: bold;">{{ leftover_videos|length }} ({{ "%.2f"|format(videos_size_mb) }} MB)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Images Section -->
|
||||
<div class="card" style="margin-bottom: 30px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||
<h2>📷 Leftover Images ({{ leftover_images|length }})</h2>
|
||||
{% if leftover_images %}
|
||||
<form method="POST" action="{{ url_for('admin.delete_leftover_images') }}"
|
||||
onsubmit="return confirm('Are you sure you want to delete ALL {{ leftover_images|length }} leftover images? This cannot be undone!');"
|
||||
style="display: inline;">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
🗑️ Delete All Images
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if leftover_images %}
|
||||
<div style="max-height: 400px; overflow-y: auto;">
|
||||
<table class="table" style="width: 100%; border-collapse: collapse;">
|
||||
<thead style="position: sticky; top: 0; background: #f8f9fa;">
|
||||
<tr>
|
||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Filename</th>
|
||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Size</th>
|
||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Duration</th>
|
||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Uploaded</th>
|
||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6; width: 80px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for img in leftover_images %}
|
||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||
<td style="padding: 10px;">📷 {{ img.filename }}</td>
|
||||
<td style="padding: 10px;">{{ "%.2f"|format(img.file_size_mb) }} MB</td>
|
||||
<td style="padding: 10px;">{{ img.duration }}s</td>
|
||||
<td style="padding: 10px;">{{ img.uploaded_at | localtime if img.uploaded_at else 'N/A' }}</td>
|
||||
<td style="padding: 10px;">
|
||||
<form method="POST" action="{{ url_for('admin.delete_single_leftover', content_id=img.id) }}" style="display: inline;" onsubmit="return confirm('Delete this image?');">
|
||||
<button type="submit" class="btn btn-danger btn-sm" style="padding: 4px 8px; font-size: 12px;">🗑️</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="text-align: center; padding: 40px; color: #999;">
|
||||
<div style="font-size: 48px; margin-bottom: 15px;">✓</div>
|
||||
<p>No leftover images found. All images are assigned to playlists!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Videos Section -->
|
||||
<div class="card" style="margin-bottom: 30px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
||||
<h2 style="margin: 0;">🎥 Leftover Videos ({{ leftover_videos|length }})</h2>
|
||||
{% if leftover_videos %}
|
||||
<form method="POST" action="{{ url_for('admin.delete_leftover_videos') }}" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete ALL {{ leftover_videos|length }} leftover videos? This action cannot be undone!');">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 8px 16px; font-size: 14px;">
|
||||
🗑️ Delete All Videos
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if leftover_videos %}
|
||||
<div style="max-height: 400px; overflow-y: auto; margin-top: 20px;">
|
||||
<table class="table" style="width: 100%; border-collapse: collapse;">
|
||||
<thead style="position: sticky; top: 0; background: #f8f9fa;">
|
||||
<tr>
|
||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Filename</th>
|
||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Size</th>
|
||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Duration</th>
|
||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Uploaded</th>
|
||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6; width: 80px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for video in leftover_videos %}
|
||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||
<td style="padding: 10px;">🎥 {{ video.filename }}</td>
|
||||
<td style="padding: 10px;">{{ "%.2f"|format(video.file_size_mb) }} MB</td>
|
||||
<td style="padding: 10px;">{{ video.duration }}s</td>
|
||||
<td style="padding: 10px;">{{ video.uploaded_at | localtime if video.uploaded_at else 'N/A' }}</td>
|
||||
<td style="padding: 10px;">
|
||||
<form method="POST" action="{{ url_for('admin.delete_single_leftover', content_id=video.id) }}" style="display: inline;" onsubmit="return confirm('Delete this video?');">
|
||||
<button type="submit" class="btn btn-danger btn-sm" style="padding: 4px 8px; font-size: 12px;">🗑️</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="text-align: center; padding: 40px; color: #999;">
|
||||
<div style="font-size: 48px; margin-bottom: 15px;">✓</div>
|
||||
<p>No leftover videos found. All videos are assigned to playlists!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- PDFs Section -->
|
||||
<div class="card" style="margin-bottom: 30px;">
|
||||
<h2>📄 Leftover PDFs ({{ leftover_pdfs|length }})</h2>
|
||||
|
||||
{% if leftover_pdfs %}
|
||||
<div style="max-height: 400px; overflow-y: auto; margin-top: 20px;">
|
||||
<table class="table" style="width: 100%; border-collapse: collapse;">
|
||||
<thead style="position: sticky; top: 0; background: #f8f9fa;">
|
||||
<tr>
|
||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Filename</th>
|
||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Size</th>
|
||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Duration</th>
|
||||
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Uploaded</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for pdf in leftover_pdfs %}
|
||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||
<td style="padding: 10px;">📄 {{ pdf.filename }}</td>
|
||||
<td style="padding: 10px;">{{ "%.2f"|format(pdf.file_size_mb) }} MB</td>
|
||||
<td style="padding: 10px;">{{ pdf.duration }}s</td>
|
||||
<td style="padding: 10px;">{{ pdf.uploaded_at | localtime if pdf.uploaded_at else 'N/A' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="text-align: center; padding: 40px; color: #999;">
|
||||
<div style="font-size: 48px; margin-bottom: 15px;">✓</div>
|
||||
<p>No leftover PDFs found. All PDFs are assigned to playlists!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
body.dark-mode .card {
|
||||
background: #2d3748;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode h1,
|
||||
body.dark-mode h2 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .stat-item {
|
||||
background: #1a202c !important;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .table thead {
|
||||
background: #1a202c !important;
|
||||
}
|
||||
|
||||
body.dark-mode .table th,
|
||||
body.dark-mode .table td {
|
||||
color: #e2e8f0;
|
||||
border-color: #4a5568 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .table tr {
|
||||
border-color: #4a5568 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .table tr:hover {
|
||||
background: #1a202c;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,524 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}User Management - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>👥 User Management</h1>
|
||||
<button class="btn btn-primary" onclick="showCreateUserModal()">+ Create New User</button>
|
||||
</div>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div class="card">
|
||||
<h2>All Users</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="user-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Role</th>
|
||||
<th>Created At</th>
|
||||
<th>Last Login</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if users %}
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ user.username }}</strong>
|
||||
{% if user.id == current_user.id %}
|
||||
<span class="badge badge-info">You</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-{{ 'success' if user.role == 'admin' else 'secondary' }}">
|
||||
{{ user.role|capitalize }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ user.created_at | localtime if user.created_at else 'N/A' }}</td>
|
||||
<td>{{ user.last_login | localtime if user.last_login else 'Never' }}</td>
|
||||
<td class="actions">
|
||||
{% if user.id != current_user.id %}
|
||||
<button class="btn btn-sm btn-warning" onclick="showEditUserModal({{ user.id }}, '{{ user.username }}', '{{ user.role }}')">
|
||||
Edit Role
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick="showResetPasswordModal({{ user.id }}, '{{ user.username }}')">
|
||||
Reset Password
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="confirmDeleteUser({{ user.id }}, '{{ user.username }}')">
|
||||
Delete
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="text-muted">Current User</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center">No users found</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Role Descriptions -->
|
||||
<div class="card">
|
||||
<h2>📖 Role Descriptions</h2>
|
||||
<div class="role-descriptions">
|
||||
<div class="role-item">
|
||||
<h3>👤 Normal User</h3>
|
||||
<ul>
|
||||
<li>Upload media content</li>
|
||||
<li>Add/remove media from playlists</li>
|
||||
<li>Edit media in playlists</li>
|
||||
<li>Set display time for media items</li>
|
||||
<li>View players and groups</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="role-item">
|
||||
<h3>👑 Admin User</h3>
|
||||
<ul>
|
||||
<li>All normal user permissions</li>
|
||||
<li>Create and manage users</li>
|
||||
<li>Manage players and groups</li>
|
||||
<li>Delete content</li>
|
||||
<li>Access system settings</li>
|
||||
<li>View system logs</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create User Modal -->
|
||||
<div id="createUserModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Create New User</h2>
|
||||
<span class="close" onclick="closeModal('createUserModal')">×</span>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('admin.create_user') }}">
|
||||
<div class="form-group">
|
||||
<label for="username">Username *</label>
|
||||
<input type="text" id="username" name="username" required minlength="3"
|
||||
placeholder="Enter username" class="form-control">
|
||||
<small>Minimum 3 characters</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password *</label>
|
||||
<input type="password" id="password" name="password" required minlength="6"
|
||||
placeholder="Enter password" class="form-control">
|
||||
<small>Minimum 6 characters</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="role">Role *</label>
|
||||
<select id="role" name="role" required class="form-control">
|
||||
<option value="user">Normal User</option>
|
||||
<option value="admin">Admin User</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal('createUserModal')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Create User</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Role Modal -->
|
||||
<div id="editRoleModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Edit User Role</h2>
|
||||
<span class="close" onclick="closeModal('editRoleModal')">×</span>
|
||||
</div>
|
||||
<form method="POST" id="editRoleForm">
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
<input type="text" id="edit_username" readonly class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit_role">New Role *</label>
|
||||
<select id="edit_role" name="role" required class="form-control">
|
||||
<option value="user">Normal User</option>
|
||||
<option value="admin">Admin User</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal('editRoleModal')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Update Role</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reset Password Modal -->
|
||||
<div id="resetPasswordModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Reset User Password</h2>
|
||||
<span class="close" onclick="closeModal('resetPasswordModal')">×</span>
|
||||
</div>
|
||||
<form method="POST" id="resetPasswordForm">
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
<input type="text" id="reset_username" readonly class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new_password">New Password *</label>
|
||||
<input type="password" id="new_password" name="password" required minlength="6"
|
||||
placeholder="Enter new password" class="form-control">
|
||||
<small>Minimum 6 characters</small>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal('resetPasswordModal')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Reset Password</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-header .btn-primary {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.page-header .btn-primary:hover {
|
||||
background: #5568d3;
|
||||
}
|
||||
|
||||
body.dark-mode .page-header .btn-primary {
|
||||
background: #7c3aed;
|
||||
}
|
||||
|
||||
body.dark-mode .page-header .btn-primary:hover {
|
||||
background: #6d28d9;
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.user-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.user-table th,
|
||||
.user-table td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.user-table th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.user-table tbody tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
body.dark-mode .user-table th,
|
||||
body.dark-mode .user-table td {
|
||||
border-bottom: 1px solid #4a5568;
|
||||
}
|
||||
|
||||
body.dark-mode .user-table th {
|
||||
background: #1a202c;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .user-table tbody tr:hover {
|
||||
background: #1a202c;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background: #17a2b8;
|
||||
color: white;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #ffc107;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: #e0a800;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.role-descriptions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.role-item {
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.role-item h3 {
|
||||
margin-bottom: 10px;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.role-item ul {
|
||||
list-style-position: inside;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.role-item li {
|
||||
padding: 5px 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
body.dark-mode .role-item {
|
||||
background: #1a202c;
|
||||
border: 1px solid #4a5568;
|
||||
}
|
||||
|
||||
body.dark-mode .role-item h3 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .role-item li {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: #fff;
|
||||
margin: 5% auto;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
body.dark-mode .modal-content {
|
||||
background-color: #2d3748;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
body.dark-mode .modal-header {
|
||||
border-bottom: 1px solid #4a5568;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
body.dark-mode .modal-header h2 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.close {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #aaa;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.modal form {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
body.dark-mode .form-group label {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
body.dark-mode .form-control {
|
||||
background: #1a202c;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .form-control:focus {
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: #6c757d;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
body.dark-mode .form-group small {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
body.dark-mode .modal-footer {
|
||||
border-top: 1px solid #4a5568;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function showCreateUserModal() {
|
||||
document.getElementById('createUserModal').style.display = 'block';
|
||||
}
|
||||
|
||||
function showEditUserModal(userId, username, currentRole) {
|
||||
document.getElementById('edit_username').value = username;
|
||||
document.getElementById('edit_role').value = currentRole;
|
||||
document.getElementById('editRoleForm').action = `/admin/user/${userId}/role`;
|
||||
document.getElementById('editRoleModal').style.display = 'block';
|
||||
}
|
||||
|
||||
function showResetPasswordModal(userId, username) {
|
||||
document.getElementById('reset_username').value = username;
|
||||
document.getElementById('resetPasswordForm').action = `/admin/user/${userId}/password`;
|
||||
document.getElementById('resetPasswordModal').style.display = 'block';
|
||||
}
|
||||
|
||||
function closeModal(modalId) {
|
||||
document.getElementById(modalId).style.display = 'none';
|
||||
}
|
||||
|
||||
function confirmDeleteUser(userId, username) {
|
||||
if (confirm(`Are you sure you want to delete user "${username}"? This action cannot be undone.`)) {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = `/admin/user/${userId}/delete`;
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal when clicking outside
|
||||
window.onclick = function(event) {
|
||||
const modals = document.getElementsByClassName('modal');
|
||||
for (let modal of modals) {
|
||||
if (event.target === modal) {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Change Password{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container" style="max-width: 500px; margin-top: 50px;">
|
||||
<h2>Change Password</h2>
|
||||
<form method="POST">
|
||||
<div class="form-group">
|
||||
<label>Current Password</label>
|
||||
<input type="password" name="current_password" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>New Password</label>
|
||||
<input type="password" name="new_password" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Confirm New Password</label>
|
||||
<input type="password" name="confirm_password" class="form-control" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Change Password</button>
|
||||
<a href="{{ url_for('main.dashboard') }}" class="btn btn-secondary">Cancel</a>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,229 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - DigiServer</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.login-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
flex: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.logo-section img {
|
||||
max-width: 70%;
|
||||
max-height: 70%;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 10px 30px rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
|
||||
.form-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-form h2 {
|
||||
color: #2d3748;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #4a5568;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.form-group input[type="text"]:focus,
|
||||
.form-group input[type="password"]:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.remember-me {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.remember-me input {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.remember-me label {
|
||||
color: #4a5568;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.btn-login:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.register-link {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.register-link a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.register-link a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.flash-messages {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background: #fed7d7;
|
||||
color: #c53030;
|
||||
border: 1px solid #fc8181;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #c6f6d5;
|
||||
color: #2f855a;
|
||||
border: 1px solid #68d391;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background: #feebc8;
|
||||
color: #c05621;
|
||||
border: 1px solid #f6ad55;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.login-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
flex: 2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<!-- Logo Section (Left - 2/3) -->
|
||||
<div class="logo-section">
|
||||
<img src="{{ url_for('static', filename='uploads/login_logo.png') }}?v={{ range(1, 999999) | random }}"
|
||||
alt="DigiServer Logo"
|
||||
onerror="this.style.display='none';">
|
||||
</div>
|
||||
|
||||
<!-- Form Section (Right - 1/3) -->
|
||||
<div class="form-section">
|
||||
<div class="login-form">
|
||||
<h2>Welcome Back</h2>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flash-messages">
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" action="{{ url_for('auth.login') }}">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
|
||||
<div class="remember-me">
|
||||
<input type="checkbox" id="remember" name="remember" value="yes">
|
||||
<label for="remember">Remember me</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-login">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Register - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card" style="max-width: 400px; margin: 2rem auto;">
|
||||
<h2>Register</h2>
|
||||
<form method="POST" action="{{ url_for('auth.register') }}">
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="username" style="display: block; margin-bottom: 0.5rem;">Username</label>
|
||||
<input type="text" id="username" name="username" required minlength="3"
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
|
||||
<small style="color: #7f8c8d;">Minimum 3 characters</small>
|
||||
</div>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="password" style="display: block; margin-bottom: 0.5rem;">Password</label>
|
||||
<input type="password" id="password" name="password" required minlength="6"
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
|
||||
<small style="color: #7f8c8d;">Minimum 6 characters</small>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success" style="width: 100%;">Register</button>
|
||||
</form>
|
||||
<p style="margin-top: 1rem; text-align: center;">
|
||||
Already have an account? <a href="{{ url_for('auth.login') }}">Login here</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,463 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}DigiServer v2{% endblock %}</title>
|
||||
<style>
|
||||
/* Ensure emoji font support */
|
||||
@supports (font-family: "Apple Color Emoji") {
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji";
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Light Mode Colors */
|
||||
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--primary-color: #667eea;
|
||||
--primary-dark: #5568d3;
|
||||
--secondary-color: #764ba2;
|
||||
--bg-color: #f5f7fa;
|
||||
--card-bg: #ffffff;
|
||||
--text-color: #2d3748;
|
||||
--text-secondary: #718096;
|
||||
--border-color: #e2e8f0;
|
||||
--header-bg: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--shadow: 0 4px 6px rgba(0, 0, 0, 0.07);
|
||||
--shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
body.dark-mode {
|
||||
/* Dark Mode Colors */
|
||||
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--primary-color: #7c3aed;
|
||||
--primary-dark: #6d28d9;
|
||||
--secondary-color: #8b5cf6;
|
||||
--bg-color: #1a202c;
|
||||
--card-bg: #2d3748;
|
||||
--text-color: #e2e8f0;
|
||||
--text-secondary: #a0aec0;
|
||||
--border-color: #4a5568;
|
||||
--header-bg: linear-gradient(135deg, #7c3aed 0%, #8b5cf6 100%);
|
||||
--shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--text-color);
|
||||
background: var(--bg-color);
|
||||
transition: background 0.3s, color 0.3s;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
header {
|
||||
background: var(--header-bg);
|
||||
color: white;
|
||||
padding: 1rem 0;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
header .container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
header h1 {
|
||||
font-size: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
nav a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
nav a:hover {
|
||||
background: rgba(255,255,255,0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
nav a img {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
header .container {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.25rem;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
nav {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
nav a {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.dark-mode-toggle {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 15px;
|
||||
margin: -1rem -1rem 1rem -1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
header h1 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
nav a {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.6rem 0.8rem;
|
||||
}
|
||||
|
||||
nav a img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.dark-mode-toggle {
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
margin-left: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.dark-mode-toggle:hover {
|
||||
background: rgba(255,255,255,0.3);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.dark-mode-toggle img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
/* Emoji fallback for systems without emoji fonts */
|
||||
.emoji-fallback::before {
|
||||
content: attr(data-emoji);
|
||||
font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif;
|
||||
}
|
||||
.alert {
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.alert-success {
|
||||
background: #d4edda;
|
||||
border-color: #28a745;
|
||||
color: #155724;
|
||||
}
|
||||
body.dark-mode .alert-success {
|
||||
background: rgba(40, 167, 69, 0.2);
|
||||
border-color: #28a745;
|
||||
color: #7ce3a3;
|
||||
}
|
||||
.alert-danger {
|
||||
background: #f8d7da;
|
||||
border-color: #dc3545;
|
||||
color: #721c24;
|
||||
}
|
||||
body.dark-mode .alert-danger {
|
||||
background: rgba(220, 53, 69, 0.2);
|
||||
border-color: #dc3545;
|
||||
color: #f88f9a;
|
||||
}
|
||||
.alert-warning {
|
||||
background: #fff3cd;
|
||||
border-color: #ffc107;
|
||||
color: #856404;
|
||||
}
|
||||
body.dark-mode .alert-warning {
|
||||
background: rgba(255, 193, 7, 0.2);
|
||||
border-color: #ffc107;
|
||||
color: #ffd454;
|
||||
}
|
||||
.alert-info {
|
||||
background: #d1ecf1;
|
||||
border-color: #17a2b8;
|
||||
color: #0c5460;
|
||||
}
|
||||
body.dark-mode .alert-info {
|
||||
background: rgba(23, 162, 184, 0.2);
|
||||
border-color: #17a2b8;
|
||||
color: #7dd3e0;
|
||||
}
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
transition: all 0.3s;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.card h2 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
.card h3 {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Card with gradient header */
|
||||
.card-header {
|
||||
background: var(--primary-gradient);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
border-radius: 12px 12px 0 0;
|
||||
margin: -1.5rem -1.5rem 1.5rem -1.5rem;
|
||||
}
|
||||
.card-header h2 {
|
||||
margin: 0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn:hover {
|
||||
background: var(--primary-dark);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--primary-gradient);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
.btn-danger { background: #e74c3c; }
|
||||
.btn-danger:hover {
|
||||
background: #c0392b;
|
||||
box-shadow: 0 4px 12px rgba(231, 76, 60, 0.4);
|
||||
}
|
||||
.btn-success { background: #27ae60; }
|
||||
.btn-success:hover {
|
||||
background: #229954;
|
||||
box-shadow: 0 4px 12px rgba(39, 174, 96, 0.4);
|
||||
}
|
||||
.btn-info {
|
||||
background: #3498db;
|
||||
}
|
||||
.btn-info:hover {
|
||||
background: #2980b9;
|
||||
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.4);
|
||||
}
|
||||
.btn-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Form Inputs */
|
||||
input, textarea, select {
|
||||
background: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
input:focus, textarea:focus, select:focus {
|
||||
border-color: var(--primary-color);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
body.dark-mode input:focus,
|
||||
body.dark-mode textarea:focus,
|
||||
body.dark-mode select:focus {
|
||||
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.3);
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
background: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
}
|
||||
th {
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
tr {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
code, pre {
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 2rem;
|
||||
padding: 1rem 0;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="container">
|
||||
<h1>
|
||||
<img src="{{ url_for('static', filename='uploads/header_logo.png?v=1') }}" alt="DigiServer" style="height: 32px; width: auto; margin-right: 8px;" onerror="this.style.display='none';" onload="this.style.display='inline';">
|
||||
DigiServer
|
||||
</h1>
|
||||
<nav>
|
||||
{% if current_user.is_authenticated %}
|
||||
<a href="{{ url_for('main.dashboard') }}">
|
||||
<img src="{{ url_for('static', filename='icons/home.svg') }}" alt="">
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="{{ url_for('players.list') }}">
|
||||
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="">
|
||||
Players
|
||||
</a>
|
||||
<a href="{{ url_for('content.content_list') }}">
|
||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="">
|
||||
Playlists
|
||||
</a>
|
||||
<a href="{{ url_for('admin.admin_panel') }}">Admin</a>
|
||||
<a href="{{ url_for('auth.logout') }}">Logout ({{ current_user.username }})</a>
|
||||
<button class="dark-mode-toggle" onclick="toggleDarkMode()" title="Toggle Dark Mode">
|
||||
<img id="theme-icon" src="{{ url_for('static', filename='icons/moon.svg') }}" alt="Toggle theme">
|
||||
</button>
|
||||
{% else %}
|
||||
<a href="{{ url_for('auth.login') }}">Login</a>
|
||||
<a href="{{ url_for('auth.register') }}">Register</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
DigiServer v2.0.0-alpha | Blueprint Architecture | {{ server_version if server_version else 'Development' }}
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Dark Mode Toggle
|
||||
function toggleDarkMode() {
|
||||
const body = document.body;
|
||||
const themeIcon = document.getElementById('theme-icon');
|
||||
|
||||
body.classList.toggle('dark-mode');
|
||||
|
||||
// Update icon
|
||||
if (body.classList.contains('dark-mode')) {
|
||||
themeIcon.src = "{{ url_for('static', filename='icons/sun.svg') }}";
|
||||
localStorage.setItem('darkMode', 'enabled');
|
||||
} else {
|
||||
themeIcon.src = "{{ url_for('static', filename='icons/moon.svg') }}";
|
||||
localStorage.setItem('darkMode', 'disabled');
|
||||
}
|
||||
}
|
||||
|
||||
// Load saved theme preference
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const darkMode = localStorage.getItem('darkMode');
|
||||
const themeIcon = document.getElementById('theme-icon');
|
||||
|
||||
if (darkMode === 'enabled') {
|
||||
document.body.classList.add('dark-mode');
|
||||
if (themeIcon) {
|
||||
themeIcon.src = "{{ url_for('static', filename='icons/sun.svg') }}";
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1,140 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 style="margin-bottom: 2rem;">Dashboard</h1>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; margin-bottom: 2rem;">
|
||||
<div class="card">
|
||||
<h3 style="color: #3498db; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Players
|
||||
</h3>
|
||||
<p style="font-size: 2rem; font-weight: bold;">{{ total_players or 0 }}</p>
|
||||
<a href="{{ url_for('players.list') }}" class="btn" style="margin-top: 1rem;">View Players</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 style="color: #9b59b6; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Playlists
|
||||
</h3>
|
||||
<p style="font-size: 2rem; font-weight: bold;">{{ total_playlists or 0 }}</p>
|
||||
<a href="{{ url_for('content.content_list') }}" class="btn" style="margin-top: 1rem;">Manage Playlists</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 style="color: #e67e22; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Media Library
|
||||
</h3>
|
||||
<p style="font-size: 2rem; font-weight: bold;">{{ total_content or 0 }}</p>
|
||||
<p class="secondary-text" style="font-size: 0.9rem; margin-top: 0.5rem;">Unique media files</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 style="color: #27ae60; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Storage
|
||||
</h3>
|
||||
<p style="font-size: 2rem; font-weight: bold;">{{ storage_mb or 0 }} MB</p>
|
||||
<p class="secondary-text" style="font-size: 0.9rem; margin-top: 0.5rem;">Total uploads</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Quick Actions</h2>
|
||||
<div style="display: flex; gap: 1rem; flex-wrap: wrap; margin-top: 1rem;">
|
||||
<a href="{{ url_for('players.add_player') }}" class="btn btn-success" style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Add Player
|
||||
</a>
|
||||
<a href="{{ url_for('content.content_list') }}" class="btn btn-success" style="display: 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);">
|
||||
Create Playlist
|
||||
</a>
|
||||
<a href="{{ url_for('content.content_list') }}" class="btn btn-success" style="display: 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>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('admin.admin_panel') }}" class="btn">Admin Panel</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Workflow Guide
|
||||
</h2>
|
||||
<div class="workflow-guide">
|
||||
<ol style="line-height: 2; margin: 0; padding-left: 1.5rem;">
|
||||
<li><strong>Create a Playlist</strong> - Group your content into themed collections</li>
|
||||
<li><strong>Upload Media</strong> - Add images, videos, or PDFs to your media library</li>
|
||||
<li><strong>Add Content to Playlist</strong> - Build your playlist with drag-and-drop ordering</li>
|
||||
<li><strong>Add Player</strong> - Register physical display devices</li>
|
||||
<li><strong>Assign Playlist</strong> - Connect players to their playlists</li>
|
||||
<li><strong>Players Auto-Download</strong> - Devices fetch and display content automatically</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.workflow-guide {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .workflow-guide {
|
||||
background: #1a202c;
|
||||
border: 1px solid #4a5568;
|
||||
}
|
||||
|
||||
.secondary-text {
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
body.dark-mode .secondary-text {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.log-item {
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .log-item {
|
||||
border-bottom: 1px solid #4a5568;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% if recent_logs %}
|
||||
<div class="card">
|
||||
<h2>Recent Activity</h2>
|
||||
<div style="margin-top: 1rem;">
|
||||
{% for log in recent_logs %}
|
||||
<div class="log-item">
|
||||
<span style="color: {% if log.level == 'error' %}#e74c3c{% elif log.level == 'warning' %}#f39c12{% else %}#27ae60{% endif %}; font-weight: bold;">
|
||||
[{{ log.level.upper() }}]
|
||||
</span>
|
||||
{{ log.message }}
|
||||
<small class="secondary-text" style="float: right;">{{ log.timestamp | localtime('%Y-%m-%d %H:%M:%S') }}</small>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<h2>System Status</h2>
|
||||
<p>✅ All systems operational</p>
|
||||
<p>� Playlist-centric architecture active</p>
|
||||
<p>🔄 Groups removed - Streamlined workflow</p>
|
||||
<p>⚡ DigiServer v2.0</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}403 - Access Denied{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container" style="max-width: 600px; margin-top: 100px; text-align: center;">
|
||||
<h1 style="font-size: 72px; color: #ffc107;">403</h1>
|
||||
<h2>Access Denied</h2>
|
||||
<p style="color: #6c757d; margin: 20px 0;">You don't have permission to access this resource.</p>
|
||||
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">Go to Dashboard</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}404 - Page Not Found{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container" style="max-width: 600px; margin-top: 100px; text-align: center;">
|
||||
<h1 style="font-size: 72px; color: #dc3545;">404</h1>
|
||||
<h2>Page Not Found</h2>
|
||||
<p style="color: #6c757d; margin: 20px 0;">The page you're looking for doesn't exist.</p>
|
||||
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">Go to Dashboard</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}500 - Server Error{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container" style="max-width: 600px; margin-top: 100px; text-align: center;">
|
||||
<h1 style="font-size: 72px; color: #dc3545;">500</h1>
|
||||
<h2>Internal Server Error</h2>
|
||||
<p style="color: #6c757d; margin: 20px 0;">Something went wrong on our end. Please try again later.</p>
|
||||
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">Go to Dashboard</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,235 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Add Player - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
body.dark-mode .form-group label {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
body.dark-mode .form-control {
|
||||
background: #1a202c;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .form-control:focus {
|
||||
border-color: #7c3aed;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-help {
|
||||
color: #6c757d;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
body.dark-mode .form-help {
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-top: 2rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid;
|
||||
}
|
||||
|
||||
.section-header.blue {
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
body.dark-mode .section-header.blue {
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.section-header.green {
|
||||
border-color: #28a745;
|
||||
}
|
||||
|
||||
body.dark-mode .section-header.green {
|
||||
border-color: #48bb78;
|
||||
}
|
||||
|
||||
.section-header.yellow {
|
||||
border-color: #ffc107;
|
||||
}
|
||||
|
||||
body.dark-mode .section-header.yellow {
|
||||
border-color: #ecc94b;
|
||||
}
|
||||
|
||||
body.dark-mode h1,
|
||||
body.dark-mode h3,
|
||||
body.dark-mode h4 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode p {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background-color: #e7f3ff;
|
||||
border-left: 4px solid #007bff;
|
||||
padding: 1rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
body.dark-mode .info-box {
|
||||
background-color: #1a365d;
|
||||
border-left-color: #667eea;
|
||||
}
|
||||
|
||||
.info-box h4 {
|
||||
margin-top: 0;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
body.dark-mode .info-box h4 {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.info-box code {
|
||||
background: #f4f4f4;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
body.dark-mode .info-box code {
|
||||
background: #2d3748;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode small {
|
||||
color: #718096;
|
||||
}
|
||||
</style>
|
||||
<div class="container" style="max-width: 800px; margin-top: 2rem;">
|
||||
<h1>Add New Player</h1>
|
||||
<p style="color: #6c757d; margin-bottom: 2rem;">
|
||||
Create a new digital signage player with authentication credentials
|
||||
</p>
|
||||
|
||||
<div class="card">
|
||||
<form method="POST">
|
||||
<h3 class="section-header blue" style="margin-top: 0;">
|
||||
Basic Information
|
||||
</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Display Name *</label>
|
||||
<input type="text" name="name" required class="form-control"
|
||||
placeholder="e.g., Office Reception Player">
|
||||
<small class="form-help">Friendly name for the player</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Hostname *</label>
|
||||
<input type="text" name="hostname" required class="form-control"
|
||||
placeholder="e.g., office-player-001">
|
||||
<small class="form-help">
|
||||
Unique identifier for this player (must match screen_name in player config)
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Location</label>
|
||||
<input type="text" name="location" class="form-control"
|
||||
placeholder="e.g., Main Office - Reception Area">
|
||||
<small class="form-help">Physical location of the player (optional)</small>
|
||||
</div>
|
||||
|
||||
<h3 class="section-header green">
|
||||
Authentication
|
||||
</h3>
|
||||
<p class="form-help" style="margin-bottom: 1rem;">
|
||||
Choose one authentication method (Quick Connect recommended for easy setup)
|
||||
</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Password</label>
|
||||
<input type="password" name="password" id="password" class="form-control"
|
||||
placeholder="Leave empty to use Quick Connect only">
|
||||
<small class="form-help">
|
||||
Secure password for player authentication (optional if using Quick Connect)
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Quick Connect Code *</label>
|
||||
<input type="text" name="quickconnect_code" required class="form-control"
|
||||
placeholder="e.g., OFFICE123">
|
||||
<small class="form-help">
|
||||
Easy pairing code for quick setup (must match quickconnect_key in player config)
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<h3 class="section-header yellow">
|
||||
Display Settings
|
||||
</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Orientation</label>
|
||||
<select name="orientation" class="form-control">
|
||||
<option value="Landscape" selected>Landscape</option>
|
||||
<option value="Portrait">Portrait</option>
|
||||
</select>
|
||||
<small class="form-help">Display orientation for the player</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Assign Playlist</label>
|
||||
<select name="playlist_id" class="form-control">
|
||||
<option value="">No Playlist (Unassigned)</option>
|
||||
{% for playlist in playlists %}
|
||||
<option value="{{ playlist.id }}">{{ playlist.name }} ({{ playlist.orientation }}) - {{ playlist.content_count }} items</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-help">Assign player to a playlist (optional)</small>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<h4>📋 Setup Instructions</h4>
|
||||
<ol style="margin: 0.5rem 0; padding-left: 1.5rem;">
|
||||
<li>Create the player with the form above</li>
|
||||
<li>Note the generated <strong>Auth Code</strong> (shown after creation)</li>
|
||||
<li>Configure the player's <code>app_config.json</code> with:
|
||||
<ul style="margin-top: 0.5rem;">
|
||||
<li><code>server_ip</code>: Your server address</li>
|
||||
<li><code>screen_name</code>: Same as <strong>Hostname</strong> above</li>
|
||||
<li><code>quickconnect_key</code>: Same as <strong>Quick Connect Code</strong> above</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Start the player - it will authenticate automatically</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #ddd;">
|
||||
<button type="submit" class="btn btn-success" style="padding: 0.75rem 2rem;">
|
||||
✓ Create Player
|
||||
</button>
|
||||
<a href="{{ url_for('players.list') }}" class="btn" style="padding: 0.75rem 2rem; margin-left: 1rem;">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Edit Player{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h2>Edit Player</h2>
|
||||
<p>Edit player functionality - placeholder</p>
|
||||
<a href="{{ url_for('players.list') }}" class="btn btn-secondary">Back to Players</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,525 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Edited Media - {{ player.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.expandable-card {
|
||||
width: 100%;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
|
||||
border: 2px solid #cbd5e1;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1.5rem;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.expandable-card:hover {
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.2);
|
||||
}
|
||||
|
||||
.expandable-card.expanded {
|
||||
border-color: #7c3aed;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.expandable-card:not(.expanded) .card-header:hover {
|
||||
background: rgba(124, 58, 237, 0.05);
|
||||
}
|
||||
|
||||
.card-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
.card-header-icon {
|
||||
font-size: 1.5rem;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.expandable-card.expanded .card-header-icon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.card-content {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.4s ease;
|
||||
}
|
||||
|
||||
.expandable-card.expanded .card-content {
|
||||
max-height: 800px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 0 1.5rem 1.5rem 1.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: 350px 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.preview-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.preview-container img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.preview-info {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.preview-info-item {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.preview-info-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.preview-info-label {
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.preview-info-value {
|
||||
color: #1a202c;
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.versions-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.versions-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #7c3aed;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.versions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 1rem;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.version-item {
|
||||
background: white;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.version-item:hover {
|
||||
border-color: #7c3aed;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.2);
|
||||
}
|
||||
|
||||
.version-item.active {
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1);
|
||||
}
|
||||
|
||||
.version-thumbnail {
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
background: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.version-thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.version-label {
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: #1a202c;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.version-item.active .version-label {
|
||||
background: #7c3aed;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.badge-latest {
|
||||
background: #7c3aed;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-original {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.download-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(124, 58, 237, 0.4);
|
||||
}
|
||||
|
||||
/* Dark mode styles */
|
||||
body.dark-mode .expandable-card {
|
||||
background: linear-gradient(135deg, #1a202c 0%, #2d3748 100%);
|
||||
border-color: #4a5568;
|
||||
}
|
||||
|
||||
body.dark-mode .expandable-card:hover,
|
||||
body.dark-mode .expandable-card.expanded {
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
|
||||
body.dark-mode .card-header-title {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .expandable-card:not(.expanded) .card-header:hover {
|
||||
background: rgba(124, 58, 237, 0.15);
|
||||
}
|
||||
|
||||
body.dark-mode .preview-info {
|
||||
background: #1a202c;
|
||||
}
|
||||
|
||||
body.dark-mode .preview-info-item {
|
||||
border-bottom-color: #4a5568;
|
||||
}
|
||||
|
||||
body.dark-mode .preview-info-label {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
body.dark-mode .preview-info-value {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .versions-title {
|
||||
color: #a78bfa;
|
||||
}
|
||||
|
||||
body.dark-mode .version-item {
|
||||
background: #2d3748;
|
||||
border-color: #4a5568;
|
||||
}
|
||||
|
||||
body.dark-mode .version-item:hover,
|
||||
body.dark-mode .version-item.active {
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
|
||||
body.dark-mode .version-label {
|
||||
background: #1a202c;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .version-item.active .version-label {
|
||||
background: #7c3aed;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div style="margin-bottom: 2rem;">
|
||||
<div style="display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem;">
|
||||
<a href="{{ url_for('players.manage_player', player_id=player.id) }}"
|
||||
class="btn"
|
||||
style="background: #6c757d; color: white; padding: 0.5rem 1rem; text-decoration: none; border-radius: 6px; display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||
← Back to Player
|
||||
</a>
|
||||
<h1 style="margin: 0; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 32px; height: 32px;">
|
||||
Edited Media - {{ player.name }}
|
||||
</h1>
|
||||
</div>
|
||||
<p style="color: #6c757d; font-size: 1rem;">Complete history of media files edited on this player</p>
|
||||
</div>
|
||||
|
||||
{% if edited_media %}
|
||||
{% set edited_by_content = {} %}
|
||||
{% for edit in edited_media %}
|
||||
{% if edit.content_id not in edited_by_content %}
|
||||
{% set _ = edited_by_content.update({edit.content_id: {'original_name': edit.original_name, 'content_id': edit.content_id, 'versions': []}}) %}
|
||||
{% endif %}
|
||||
{% set _ = edited_by_content[edit.content_id]['versions'].append(edit) %}
|
||||
{% endfor %}
|
||||
|
||||
<!-- Expandable Cards -->
|
||||
{% for content_id, data in edited_by_content.items() %}
|
||||
{% set original_content = content_files.get(content_id) %}
|
||||
<div class="expandable-card" id="card-{{ content_id }}" onclick="toggleCard({{ content_id }})">
|
||||
<div class="card-header">
|
||||
<div class="card-header-title">
|
||||
<span class="card-header-icon">▶</span>
|
||||
<span>📄 {{ original_content.filename if original_content else data.original_name }}</span>
|
||||
<span style="font-size: 0.9rem; color: #64748b; font-weight: normal;">
|
||||
({{ data.versions|length + 1 }} version{{ 's' if (data.versions|length + 1) > 1 else '' }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<div class="card-body">
|
||||
<!-- Preview Area (Left Column) -->
|
||||
<div class="preview-area">
|
||||
<div class="preview-container" id="preview-{{ content_id }}">
|
||||
{% set latest = data.versions|sort(attribute='version', reverse=True)|first %}
|
||||
{% if latest.new_name.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')) %}
|
||||
<img src="{{ url_for('static', filename='uploads/edited_media/' ~ latest.content_id ~ '/' ~ latest.new_name) }}"
|
||||
alt="{{ latest.new_name }}"
|
||||
id="preview-img-{{ content_id }}">
|
||||
{% else %}
|
||||
<div style="color: white; text-align: center;">
|
||||
<div style="font-size: 3rem; margin-bottom: 0.5rem;">📄</div>
|
||||
<div>No preview available</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('static', filename='uploads/edited_media/' ~ latest.content_id ~ '/' ~ latest.new_name) }}"
|
||||
download
|
||||
class="download-btn"
|
||||
id="download-btn-{{ content_id }}">
|
||||
💾 Download File
|
||||
</a>
|
||||
|
||||
<div class="preview-info" id="info-{{ content_id }}">
|
||||
<div class="preview-info-item">
|
||||
<span class="preview-info-label">📄 Filename:</span>
|
||||
<span class="preview-info-value" id="info-filename-{{ content_id }}">{{ latest.new_name }}</span>
|
||||
</div>
|
||||
<div class="preview-info-item">
|
||||
<span class="preview-info-label">📦 Version:</span>
|
||||
<span class="preview-info-value" id="info-version-{{ content_id }}">v{{ latest.version }}</span>
|
||||
</div>
|
||||
<div class="preview-info-item">
|
||||
<span class="preview-info-label">👤 Edited by:</span>
|
||||
<span class="preview-info-value" id="info-user-{{ content_id }}">{{ user_mappings.get(latest.user, latest.user or 'Unknown') }}</span>
|
||||
</div>
|
||||
<div class="preview-info-item">
|
||||
<span class="preview-info-label">🕒 Modified:</span>
|
||||
<span class="preview-info-value" id="info-date-{{ content_id }}">{{ latest.time_of_modification | localtime('%Y-%m-%d %H:%M') if latest.time_of_modification else 'N/A' }}</span>
|
||||
</div>
|
||||
<div class="preview-info-item">
|
||||
<span class="preview-info-label">📅 Uploaded:</span>
|
||||
<span class="preview-info-value" id="info-created-{{ content_id }}">{{ latest.created_at | localtime('%Y-%m-%d %H:%M') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Versions Area (Right Column) -->
|
||||
<div class="versions-area">
|
||||
<div class="versions-title">
|
||||
📚 All Versions
|
||||
</div>
|
||||
<div class="versions-grid">
|
||||
{% for edit in data.versions|sort(attribute='version', reverse=True) %}
|
||||
<div class="version-item {% if loop.first %}active{% endif %}"
|
||||
id="version-{{ content_id }}-{{ edit.version }}"
|
||||
onclick="event.stopPropagation(); selectVersion({{ content_id }}, {{ edit.version }}, '{{ edit.new_name }}', '{{ user_mappings.get(edit.user, edit.user or 'Unknown') }}', '{{ edit.time_of_modification | localtime('%Y-%m-%d %H:%M') if edit.time_of_modification else 'N/A' }}', '{{ edit.created_at | localtime('%Y-%m-%d %H:%M') }}', '{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}')">
|
||||
<div class="version-thumbnail">
|
||||
{% if edit.new_name.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')) %}
|
||||
<img src="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
|
||||
alt="Version {{ edit.version }}">
|
||||
{% else %}
|
||||
<div style="color: white; font-size: 2rem;">📄</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="version-label">
|
||||
v{{ edit.version }}
|
||||
{% if loop.first %}
|
||||
<span class="version-badge badge-latest">Latest</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Original File -->
|
||||
{% if original_content %}
|
||||
<div class="version-item"
|
||||
id="version-{{ content_id }}-original"
|
||||
onclick="event.stopPropagation(); selectVersion({{ content_id }}, 'original', '{{ original_content.filename }}', 'System', 'N/A', '{{ original_content.uploaded_at | localtime('%Y-%m-%d %H:%M') }}', '{{ url_for('static', filename='uploads/' ~ original_content.filename) }}')">
|
||||
<div class="version-thumbnail">
|
||||
{% if original_content.filename.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')) %}
|
||||
<img src="{{ url_for('static', filename='uploads/' ~ original_content.filename) }}"
|
||||
alt="Original">
|
||||
{% else %}
|
||||
<div style="color: white; font-size: 2rem;">📄</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="version-label">
|
||||
Original
|
||||
<span class="version-badge badge-original">Source</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Summary Statistics -->
|
||||
<div class="card" style="margin-top: 2rem; background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%); color: white; border-radius: 12px; overflow: hidden;">
|
||||
<div style="padding: 1.5rem; display: flex; justify-content: space-around; align-items: center; flex-wrap: wrap; gap: 2rem;">
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 2.5rem; font-weight: bold; margin-bottom: 0.5rem;">{{ edited_by_content|length }}</div>
|
||||
<div style="font-size: 0.9rem; opacity: 0.9;">Total Files Edited</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 2.5rem; font-weight: bold; margin-bottom: 0.5rem;">{{ edited_media|length }}</div>
|
||||
<div style="font-size: 0.9rem; opacity: 0.9;">Total Versions</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 2.5rem; font-weight: bold; margin-bottom: 0.5rem;">{{ ((edited_media|length / edited_by_content|length) | round(1)) if edited_by_content else 0 }}</div>
|
||||
<div style="font-size: 0.9rem; opacity: 0.9;">Avg Versions per File</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="card" style="text-align: center; padding: 4rem 2rem;">
|
||||
<div style="font-size: 4rem; margin-bottom: 1rem; opacity: 0.5;">📭</div>
|
||||
<h2 style="color: #6c757d; margin-bottom: 1rem;">No Edited Media Yet</h2>
|
||||
<p style="color: #6c757d; font-size: 1.1rem;">
|
||||
This player hasn't edited any media files yet. When the player edits content,<br>
|
||||
all versions will be tracked and displayed here.
|
||||
</p>
|
||||
<a href="{{ url_for('players.manage_player', player_id=player.id) }}"
|
||||
class="btn"
|
||||
style="margin-top: 2rem; display: inline-block; background: #7c3aed; color: white; padding: 0.75rem 1.5rem; text-decoration: none; border-radius: 6px; font-size: 1rem;">
|
||||
← Back to Player Management
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function toggleCard(contentId) {
|
||||
const card = document.getElementById('card-' + contentId);
|
||||
const wasExpanded = card.classList.contains('expanded');
|
||||
|
||||
// Close all cards
|
||||
document.querySelectorAll('.expandable-card').forEach(c => {
|
||||
c.classList.remove('expanded');
|
||||
});
|
||||
|
||||
// If this card wasn't expanded, expand it
|
||||
if (!wasExpanded) {
|
||||
card.classList.add('expanded');
|
||||
}
|
||||
}
|
||||
|
||||
function selectVersion(contentId, version, filename, user, modifiedDate, createdDate, fileUrl) {
|
||||
// Update preview image
|
||||
const previewImg = document.getElementById('preview-img-' + contentId);
|
||||
if (previewImg) {
|
||||
previewImg.src = fileUrl;
|
||||
}
|
||||
|
||||
// Update download button
|
||||
const downloadBtn = document.getElementById('download-btn-' + contentId);
|
||||
if (downloadBtn) {
|
||||
downloadBtn.href = fileUrl;
|
||||
}
|
||||
|
||||
// Update metadata
|
||||
document.getElementById('info-filename-' + contentId).textContent = filename;
|
||||
document.getElementById('info-version-' + contentId).textContent = 'v' + version;
|
||||
document.getElementById('info-user-' + contentId).textContent = user;
|
||||
document.getElementById('info-date-' + contentId).textContent = modifiedDate;
|
||||
document.getElementById('info-created-' + contentId).textContent = createdDate;
|
||||
|
||||
// Update active state on version items
|
||||
document.querySelectorAll('.version-item').forEach(item => {
|
||||
if (item.id.startsWith('version-' + contentId + '-')) {
|
||||
item.classList.remove('active');
|
||||
}
|
||||
});
|
||||
document.getElementById('version-' + contentId + '-' + version).classList.add('active');
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,770 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Manage Player - {{ player.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
body.dark-mode .form-group label {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
body.dark-mode .form-control {
|
||||
background: #1a202c;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .form-control:focus {
|
||||
border-color: #7c3aed;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.info-box.neutral {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
body.dark-mode .info-box.neutral {
|
||||
background: #1a202c;
|
||||
border: 1px solid #4a5568;
|
||||
}
|
||||
|
||||
.info-box.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
body.dark-mode .info-box.success {
|
||||
background: #1a4d2e;
|
||||
color: #86efac;
|
||||
border: 1px solid #48bb78;
|
||||
}
|
||||
|
||||
.info-box.warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
body.dark-mode .info-box.warning {
|
||||
background: #4a3800;
|
||||
color: #fbbf24;
|
||||
border: 1px solid #ecc94b;
|
||||
}
|
||||
|
||||
.log-item {
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: 4px;
|
||||
background: #f8f9fa;
|
||||
border-left: 4px solid;
|
||||
}
|
||||
|
||||
body.dark-mode .log-item {
|
||||
background: #2d3748;
|
||||
}
|
||||
|
||||
.log-item pre {
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
body.dark-mode .log-item pre {
|
||||
background: #1a202c;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode h1,
|
||||
body.dark-mode h2,
|
||||
body.dark-mode h3,
|
||||
body.dark-mode h4 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode p {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
body.dark-mode strong {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode code {
|
||||
background: #1a202c;
|
||||
color: #e2e8f0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
body.dark-mode small {
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.credential-item {
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .credential-item {
|
||||
border-bottom-color: #4a5568;
|
||||
}
|
||||
|
||||
.credential-item:last-child {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.credential-label {
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
body.dark-mode .credential-label {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.credential-value {
|
||||
font-family: 'Courier New', monospace;
|
||||
background: #f8f9fa;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
word-break: break-all;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
body.dark-mode .credential-value {
|
||||
background: #0d1117;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.status-card.online {
|
||||
background: #d4edda;
|
||||
}
|
||||
|
||||
body.dark-mode .status-card.online {
|
||||
background: #1a4d2e;
|
||||
border: 1px solid #48bb78;
|
||||
}
|
||||
|
||||
.status-card.offline {
|
||||
background: #f8d7da;
|
||||
}
|
||||
|
||||
body.dark-mode .status-card.offline {
|
||||
background: #4a1a1a;
|
||||
border: 1px solid #dc3545;
|
||||
}
|
||||
|
||||
.status-card.other {
|
||||
background: #fff3cd;
|
||||
}
|
||||
|
||||
body.dark-mode .status-card.other {
|
||||
background: #4a3800;
|
||||
border: 1px solid #ecc94b;
|
||||
}
|
||||
|
||||
.playlist-stats {
|
||||
padding: 1rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
body.dark-mode .playlist-stats {
|
||||
background: #1a202c;
|
||||
border: 1px solid #4a5568;
|
||||
}
|
||||
|
||||
body.dark-mode .playlist-stats > div > div:first-child {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
/* Player Logs Dark Mode */
|
||||
body.dark-mode .card p[style*="color: #6c757d"] {
|
||||
color: #a0aec0 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] {
|
||||
background: #2d3748 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] p {
|
||||
color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] p[style*="color: #6c757d"] {
|
||||
color: #a0aec0 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] small {
|
||||
color: #a0aec0 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] details summary {
|
||||
color: #f87171 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] pre {
|
||||
background: #1a202c !important;
|
||||
border-color: #4a5568 !important;
|
||||
color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .card > div[style*="text-align: center"] {
|
||||
color: #a0aec0 !important;
|
||||
}
|
||||
|
||||
/* Edited Media Cards Dark Mode */
|
||||
body.dark-mode .card[style*="border: 2px solid #7c3aed"] {
|
||||
background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%) !important;
|
||||
border-color: #8b5cf6 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .card[style*="border: 2px solid #7c3aed"] h3 {
|
||||
color: #a78bfa !important;
|
||||
}
|
||||
|
||||
body.dark-mode .card[style*="border: 2px solid #7c3aed"] div[style*="background: white"] {
|
||||
background: #374151 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .card[style*="border: 2px solid #7c3aed"] strong {
|
||||
color: #a78bfa !important;
|
||||
}
|
||||
|
||||
body.dark-mode .card[style*="border: 2px solid #7c3aed"] p[style*="color: #475569"],
|
||||
body.dark-mode .card[style*="border: 2px solid #7c3aed"] p[style*="color: #64748b"] {
|
||||
color: #cbd5e1 !important;
|
||||
}
|
||||
|
||||
body.dark-mode div[style*="background: #f8f9fa; border-radius: 8px"] {
|
||||
background: #2d3748 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .card > div[style*="text-align: center"] p {
|
||||
color: #a0aec0 !important;
|
||||
}
|
||||
</style>
|
||||
<div style="margin-bottom: 2rem; display: flex; justify-content: space-between; align-items: flex-start;">
|
||||
<h1 style="margin: 0; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 32px; height: 32px;">
|
||||
Manage Player: {{ player.name }}
|
||||
</h1>
|
||||
<a href="{{ url_for('players.list') }}" class="btn" style="display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Back to Players
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div id="deleteModal" style="display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.4);">
|
||||
<div style="background-color: #fefefe; margin: 15% auto; padding: 20px; border: 1px solid #888; border-radius: 8px; width: 90%; max-width: 500px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
|
||||
<h2 style="margin-top: 0; color: #dc3545; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<span style="font-size: 1.5rem;">⚠️</span>
|
||||
Confirm Delete
|
||||
</h2>
|
||||
<p style="font-size: 1.1rem; margin: 1.5rem 0;">
|
||||
Are you sure you want to delete player <strong>"{{ player.name }}"</strong>?
|
||||
</p>
|
||||
<p style="color: #dc3545; margin: 1rem 0;">
|
||||
<strong>Warning:</strong> This action cannot be undone. All feedback logs for this player will also be deleted.
|
||||
</p>
|
||||
<div style="display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 2rem;">
|
||||
<button onclick="closeDeleteModal()" class="btn" style="background: #6c757d;">
|
||||
Cancel
|
||||
</button>
|
||||
<form method="POST" action="{{ url_for('players.delete_player', player_id=player.id) }}" style="margin: 0;">
|
||||
<button type="submit" class="btn" style="background: #dc3545;">
|
||||
Yes, Delete Player
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
body.dark-mode #deleteModal > div {
|
||||
background-color: #1a202c;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode #deleteModal h2 {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
body.dark-mode #deleteModal p {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode #deleteModal p strong {
|
||||
color: #fbbf24;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function confirmDelete() {
|
||||
document.getElementById('deleteModal').style.display = 'block';
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
document.getElementById('deleteModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// Close modal when clicking outside of it
|
||||
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>
|
||||
|
||||
<!-- Player Status Overview -->
|
||||
<div class="card status-card {% if player.status == 'online' %}online{% elif player.status == 'offline' %}offline{% else %}other{% endif %}">
|
||||
<h3 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
Status:
|
||||
{% if player.status == 'online' %}
|
||||
<span style="color: #28a745; display: flex; align-items: center; gap: 0.3rem;">
|
||||
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 20px; height: 20px; color: #28a745;">
|
||||
Online
|
||||
</span>
|
||||
{% elif player.status == 'offline' %}
|
||||
<span style="color: #dc3545; display: flex; align-items: center; gap: 0.3rem;">
|
||||
<img src="{{ url_for('static', filename='icons/warning.svg') }}" alt="" style="width: 20px; height: 20px; color: #dc3545;">
|
||||
Offline
|
||||
</span>
|
||||
{% else %}
|
||||
<span style="color: #ffc107; display: flex; align-items: center; gap: 0.3rem;">
|
||||
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 20px; height: 20px; color: #ffc107;">
|
||||
{{ player.status|title }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</h3>
|
||||
<p><strong>Hostname:</strong> {{ player.hostname }}</p>
|
||||
<p><strong>Last Seen:</strong>
|
||||
{% if player.last_seen %}
|
||||
{{ player.last_seen | localtime('%Y-%m-%d %H:%M:%S') }}
|
||||
{% else %}
|
||||
Never
|
||||
{% endif %}
|
||||
</p>
|
||||
<p><strong>Assigned Playlist:</strong>
|
||||
{% if current_playlist %}
|
||||
<span style="color: #28a745; font-weight: bold;">{{ current_playlist.name }} (v{{ current_playlist.version }})</span>
|
||||
{% else %}
|
||||
<span style="color: #dc3545;">No playlist assigned</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Playlist Overview Card -->
|
||||
{% if current_playlist %}
|
||||
<div class="card" style="margin-bottom: 2rem;">
|
||||
<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;">
|
||||
Current Playlist: {{ current_playlist.name }}
|
||||
</h2>
|
||||
<div class="playlist-stats" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; margin-top: 1rem;">
|
||||
<div>
|
||||
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Total Items</div>
|
||||
<div style="font-size: 1.5rem; font-weight: bold;">{{ current_playlist.contents.count() }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Playlist Version</div>
|
||||
<div style="font-size: 1.5rem; font-weight: bold;">v{{ current_playlist.version }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Last Updated</div>
|
||||
<div style="font-size: 1rem; font-weight: bold;">{{ current_playlist.updated_at | localtime }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Orientation</div>
|
||||
<div style="font-size: 1.5rem; font-weight: bold;">{{ current_playlist.orientation }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 1rem; display: flex; gap: 0.5rem;">
|
||||
<a href="{{ url_for('content.manage_playlist_content', playlist_id=current_playlist.id) }}"
|
||||
class="btn btn-primary" style="display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Edit Playlist Content
|
||||
</a>
|
||||
<a href="{{ url_for('players.player_fullscreen', player_id=player.id) }}"
|
||||
class="btn btn-success" style="display: inline-flex; align-items: center; gap: 0.5rem;"
|
||||
target="_blank">
|
||||
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
View Live Content
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Three Column Layout -->
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 1.5rem;">
|
||||
|
||||
<!-- Card 1: Edit Credentials -->
|
||||
<div class="card">
|
||||
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Edit Credentials
|
||||
</h2>
|
||||
<form method="POST" style="margin-top: 1rem;">
|
||||
<input type="hidden" name="action" value="update_credentials">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name">Player Name *</label>
|
||||
<input type="text" id="name" name="name" value="{{ player.name }}"
|
||||
required minlength="3" class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="location">Location</label>
|
||||
<input type="text" id="location" name="location" value="{{ player.location or '' }}"
|
||||
placeholder="e.g., Main Lobby" class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="orientation">Orientation</label>
|
||||
<select id="orientation" name="orientation" class="form-control">
|
||||
<option value="Landscape" {% if player.orientation == 'Landscape' %}selected{% endif %}>Landscape</option>
|
||||
<option value="Portrait" {% if player.orientation == 'Portrait' %}selected{% endif %}>Portrait</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="border-top: 1px solid #ddd; margin: 1.5rem 0; padding-top: 1.5rem;">
|
||||
<h4 style="margin: 0 0 1rem 0; font-size: 0.95rem;">🔑 Authentication Settings</h4>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="hostname">Hostname *</label>
|
||||
<input type="text" id="hostname" name="hostname" value="{{ player.hostname }}"
|
||||
required minlength="3" class="form-control"
|
||||
placeholder="e.g., tv-terasa">
|
||||
<small style="display: block; margin-top: 0.25rem; color: #6c757d;">
|
||||
ℹ️ This is the unique identifier for the player
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">New Password (leave blank to keep current)</label>
|
||||
<input type="password" id="password" name="password" class="form-control"
|
||||
placeholder="Enter new password">
|
||||
<small style="display: block; margin-top: 0.25rem; color: #6c757d;">
|
||||
🔒 Optional: Set a new password for player authentication
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="quickconnect_code">Quick Connect Code</label>
|
||||
<input type="text" id="quickconnect_code" name="quickconnect_code" class="form-control"
|
||||
placeholder="e.g., 8887779">
|
||||
<small style="display: block; margin-top: 0.25rem; color: #6c757d;">
|
||||
🔗 Enter the plain text code (e.g., 8887779) - will be hashed automatically
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-success" style="width: 100%; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Save Changes
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Card 2: Assign Playlist -->
|
||||
<div class="card">
|
||||
<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;">
|
||||
Assign Playlist
|
||||
</h2>
|
||||
<form method="POST" style="margin-top: 1rem;">
|
||||
<input type="hidden" name="action" value="assign_playlist">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="playlist_id">Select Playlist</label>
|
||||
<select id="playlist_id" name="playlist_id" class="form-control">
|
||||
<option value="">-- No Playlist (Unassign) --</option>
|
||||
{% for playlist in playlists %}
|
||||
<option value="{{ playlist.id }}"
|
||||
{% if player.playlist_id == playlist.id %}selected{% endif %}>
|
||||
{{ playlist.name }} (v{{ playlist.version }}) - {{ playlist.contents.count() }} items
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{% if current_playlist %}
|
||||
<div class="info-box success">
|
||||
<h4 style="margin: 0 0 0.5rem 0;">Currently Assigned:</h4>
|
||||
<p style="margin: 0;"><strong>{{ current_playlist.name }}</strong></p>
|
||||
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem;">Version: {{ current_playlist.version }}</p>
|
||||
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem;">Content Items: {{ current_playlist.contents.count() }}</p>
|
||||
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem; color: #6c757d;">
|
||||
Updated: {{ current_playlist.updated_at | localtime }}
|
||||
</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="info-box warning">
|
||||
<p style="margin: 0; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/warning.svg') }}" alt="" style="width: 18px; height: 18px;">
|
||||
No playlist currently assigned to this player.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<button type="submit" class="btn btn-success" style="width: 100%; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Assign Playlist
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style="margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid #ddd;">
|
||||
<h4>Quick Actions:</h4>
|
||||
<a href="{{ url_for('content.content_list') }}" class="btn" style="width: 100%; margin-top: 0.5rem; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Create New Playlist
|
||||
</a>
|
||||
{% if current_playlist %}
|
||||
<a href="{{ url_for('content.manage_playlist_content', playlist_id=current_playlist.id) }}"
|
||||
class="btn" style="width: 100%; margin-top: 0.5rem; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Edit Current Playlist
|
||||
</a>
|
||||
{% endif %}
|
||||
<button onclick="confirmDelete()" class="btn" style="width: 100%; margin-top: 0.5rem; background: #dc3545; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
|
||||
<span style="font-size: 1.2rem;">🗑️</span>
|
||||
Delete Player
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card 3: Player Logs -->
|
||||
<div class="card">
|
||||
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Player Logs
|
||||
</h2>
|
||||
<p style="color: #6c757d; font-size: 0.9rem;">Recent feedback from the player device</p>
|
||||
|
||||
<div style="max-height: 500px; overflow-y: auto; margin-top: 1rem;">
|
||||
{% if recent_logs %}
|
||||
{% for log in recent_logs %}
|
||||
<div style="padding: 0.75rem; margin-bottom: 0.5rem; border-left: 4px solid
|
||||
{% if log.status == 'error' %}#dc3545
|
||||
{% elif log.status == 'warning' %}#ffc107
|
||||
{% elif log.status == 'playing' %}#28a745
|
||||
{% else %}#17a2b8{% endif %};
|
||||
background: #f8f9fa; border-radius: 4px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: start;">
|
||||
<div style="flex: 1;">
|
||||
<strong style="color:
|
||||
{% if log.status == 'error' %}#dc3545
|
||||
{% elif log.status == 'warning' %}#ffc107
|
||||
{% elif log.status == 'playing' %}#28a745
|
||||
{% else %}#17a2b8{% endif %};">
|
||||
{% if log.status == 'error' %}❌
|
||||
{% elif log.status == 'warning' %}⚠️
|
||||
{% elif log.status == 'playing' %}▶️
|
||||
{% elif log.status == 'restarting' %}🔄
|
||||
{% else %}ℹ️{% endif %}
|
||||
{{ log.status|upper }}
|
||||
</strong>
|
||||
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem;">{{ log.message }}</p>
|
||||
{% if log.playlist_version %}
|
||||
<p style="margin: 0.25rem 0 0 0; font-size: 0.85rem; color: #6c757d;">
|
||||
Playlist v{{ log.playlist_version }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if log.error_details %}
|
||||
<details style="margin-top: 0.5rem;">
|
||||
<summary style="cursor: pointer; font-size: 0.85rem; color: #dc3545;">Error Details</summary>
|
||||
<pre style="margin: 0.5rem 0 0 0; padding: 0.5rem; background: #fff; border: 1px solid #ddd; border-radius: 4px; font-size: 0.8rem; overflow-x: auto;">{{ log.error_details }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
<small style="color: #6c757d; white-space: nowrap; margin-left: 1rem;">
|
||||
{{ log.timestamp | localtime('%m/%d %H:%M') }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div style="text-align: center; padding: 2rem; color: #6c757d;">
|
||||
<p>📭 No logs received yet</p>
|
||||
<p style="font-size: 0.9rem;">Logs will appear here once the player starts sending feedback</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Edited Media Section - Full Width -->
|
||||
<div class="card" style="margin-top: 2rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
|
||||
<h2 style="display: flex; align-items: center; gap: 0.5rem; margin: 0;">
|
||||
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Edited Media on the Player
|
||||
</h2>
|
||||
{% if edited_media %}
|
||||
<a href="{{ url_for('players.edited_media', player_id=player.id) }}"
|
||||
class="btn"
|
||||
style="background: #7c3aed; color: white; padding: 0.5rem 1rem; text-decoration: none; border-radius: 6px; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.5rem; transition: background 0.2s;"
|
||||
onmouseover="this.style.background='#6d28d9'"
|
||||
onmouseout="this.style.background='#7c3aed'">
|
||||
📋 View All Edited Media
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p style="color: #6c757d; font-size: 0.9rem; margin-top: 0.5rem;">Latest 3 edited files with their most recent versions</p>
|
||||
|
||||
{% if edited_media %}
|
||||
{% set edited_by_content = {} %}
|
||||
{% for edit in edited_media %}
|
||||
{% if edit.content_id not in edited_by_content %}
|
||||
{% set _ = edited_by_content.update({edit.content_id: {'original_name': edit.original_name, 'content_id': edit.content_id, 'latest_version': edit}}) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 1.5rem; margin-top: 1.5rem;">
|
||||
{% for content_id, data in edited_by_content.items() %}
|
||||
{% if loop.index <= 3 %}
|
||||
<div class="card" style="background: linear-gradient(135deg, #f8f9fa 0%, #fff 100%); border: 2px solid #7c3aed; box-shadow: 0 2px 8px rgba(124, 58, 237, 0.1);">
|
||||
{% set edit = data.latest_version %}
|
||||
|
||||
<!-- Image Preview if it's an image -->
|
||||
{% if edit.new_name.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')) %}
|
||||
<div style="width: 100%; height: 200px; overflow: hidden; border-radius: 8px 8px 0 0; background: #000;">
|
||||
<img src="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
|
||||
alt="{{ edit.new_name }}"
|
||||
style="width: 100%; height: 100%; object-fit: contain;">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="padding: 1rem;">
|
||||
<h3 style="margin: 0 0 0.75rem 0; color: #7c3aed; font-size: 1rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
✏️ {{ data.original_name }}
|
||||
</h3>
|
||||
|
||||
<div style="padding: 0.75rem; background: white; border-radius: 6px; border-left: 4px solid #7c3aed; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 0.5rem;">
|
||||
<strong style="color: #7c3aed; font-size: 0.95rem;">
|
||||
Version {{ edit.version }}
|
||||
<span style="background: #7c3aed; color: white; padding: 0.15rem 0.5rem; border-radius: 12px; font-size: 0.75rem; margin-left: 0.5rem;">Latest</span>
|
||||
</strong>
|
||||
<small style="color: #6c757d; white-space: nowrap;">
|
||||
{{ edit.created_at | localtime('%m/%d %H:%M') }}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<p style="margin: 0.25rem 0; font-size: 0.85rem; color: #475569;">
|
||||
📄 {{ edit.new_name }}
|
||||
</p>
|
||||
|
||||
{% if edit.user %}
|
||||
<p style="margin: 0.25rem 0; font-size: 0.8rem; color: #64748b;">
|
||||
👤 {{ edit.user }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if edit.time_of_modification %}
|
||||
<p style="margin: 0.25rem 0; font-size: 0.8rem; color: #64748b;">
|
||||
🕒 {{ edit.time_of_modification | localtime('%Y-%m-%d %H:%M') }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<div style="margin-top: 0.75rem; display: flex; gap: 0.5rem;">
|
||||
<a href="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
|
||||
target="_blank"
|
||||
style="display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.4rem 0.75rem; background: #7c3aed; color: white; text-decoration: none; border-radius: 4px; font-size: 0.8rem; transition: background 0.2s;"
|
||||
onmouseover="this.style.background='#6d28d9'"
|
||||
onmouseout="this.style.background='#7c3aed'">
|
||||
📥 View File
|
||||
</a>
|
||||
<a href="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
|
||||
download
|
||||
style="display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.4rem 0.75rem; background: #64748b; color: white; text-decoration: none; border-radius: 4px; font-size: 0.8rem; transition: background 0.2s;"
|
||||
onmouseover="this.style.background='#475569'"
|
||||
onmouseout="this.style.background='#64748b'">
|
||||
💾 Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="text-align: center; padding: 3rem; color: #6c757d; background: #f8f9fa; border-radius: 8px; margin-top: 1.5rem;">
|
||||
<p style="font-size: 2rem; margin: 0;">📝</p>
|
||||
<p style="margin: 0.5rem 0 0 0; font-size: 1.1rem; font-weight: 500;">No edited media yet</p>
|
||||
<p style="font-size: 0.9rem; margin: 0.5rem 0 0 0;">Media edits will appear here once the player sends edited files</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Additional Info Section -->
|
||||
<div class="card" style="margin-top: 2rem;">
|
||||
<h2>ℹ️ Player Information</h2>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; margin-top: 1rem;">
|
||||
<div>
|
||||
<p><strong>Player ID:</strong> {{ player.id }}</p>
|
||||
<p><strong>Created:</strong> {{ (player.created_at | localtime) if player.created_at else 'N/A' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p><strong>Orientation:</strong> {{ player.orientation }}</p>
|
||||
<p><strong>Location:</strong> {{ player.location or 'Not set' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p><strong>Last Heartbeat:</strong>
|
||||
{% if player.last_heartbeat %}
|
||||
{{ player.last_heartbeat | localtime('%Y-%m-%d %H:%M:%S') }}
|
||||
{% else %}
|
||||
Never
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,235 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ player.name }} - Live Preview</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
#player-container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#media-display {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#media-display.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.info-overlay {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
z-index: 1000;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.controls button {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
padding: 10px 15px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.controls button:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.no-content {
|
||||
color: white;
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="player-container">
|
||||
<div class="loading" id="loading">Loading playlist...</div>
|
||||
<img id="media-display" alt="Content">
|
||||
<video id="video-display" muted autoplay playsinline style="display: none; max-width: 100%; max-height: 100%; width: 100%; height: 100%; object-fit: contain;"></video>
|
||||
<div class="no-content" id="no-content" style="display: none;">
|
||||
<p>💭 No content in playlist</p>
|
||||
<p style="font-size: 16px; margin-top: 10px; opacity: 0.7;">Add content to the playlist to preview</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-overlay" id="info-overlay" style="display: none;">
|
||||
<div id="current-item">Item: -</div>
|
||||
<div id="playlist-info">Playlist: {{ playlist|length }} items</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button onclick="toggleFullscreen()">🔳 Fullscreen</button>
|
||||
<button onclick="restartPlaylist()">🔄 Restart</button>
|
||||
<button onclick="window.close()">✖️ Close</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const playlist = {{ playlist|tojson }};
|
||||
let currentIndex = 0;
|
||||
let timer = null;
|
||||
let inactivityTimer = null;
|
||||
|
||||
const imgDisplay = document.getElementById('media-display');
|
||||
const videoDisplay = document.getElementById('video-display');
|
||||
const loading = document.getElementById('loading');
|
||||
const noContent = document.getElementById('no-content');
|
||||
const infoOverlay = document.getElementById('info-overlay');
|
||||
const currentItemDiv = document.getElementById('current-item');
|
||||
const controls = document.querySelector('.controls');
|
||||
|
||||
function playNext() {
|
||||
if (!playlist || playlist.length === 0) {
|
||||
loading.style.display = 'none';
|
||||
noContent.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
const item = playlist[currentIndex];
|
||||
loading.style.display = 'none';
|
||||
infoOverlay.style.display = 'block';
|
||||
|
||||
// Update info
|
||||
currentItemDiv.textContent = `Item ${currentIndex + 1}/${playlist.length}: ${item.filename}`;
|
||||
|
||||
// Hide both displays
|
||||
imgDisplay.style.display = 'none';
|
||||
videoDisplay.style.display = 'none';
|
||||
videoDisplay.pause();
|
||||
|
||||
if (item.type === 'video') {
|
||||
videoDisplay.src = item.url;
|
||||
videoDisplay.muted = item.muted !== false; // Muted unless explicitly set to false
|
||||
videoDisplay.style.display = 'block';
|
||||
videoDisplay.play();
|
||||
|
||||
// When video ends, move to next
|
||||
videoDisplay.onended = () => {
|
||||
currentIndex = (currentIndex + 1) % playlist.length;
|
||||
playNext();
|
||||
};
|
||||
} else {
|
||||
// Image or PDF
|
||||
imgDisplay.src = item.url;
|
||||
imgDisplay.style.display = 'block';
|
||||
imgDisplay.classList.add('active');
|
||||
|
||||
// Clear any existing timer
|
||||
if (timer) clearTimeout(timer);
|
||||
|
||||
// Show for specified duration
|
||||
timer = setTimeout(() => {
|
||||
currentIndex = (currentIndex + 1) % playlist.length;
|
||||
playNext();
|
||||
}, (item.duration || 10) * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
function restartPlaylist() {
|
||||
currentIndex = 0;
|
||||
if (timer) clearTimeout(timer);
|
||||
videoDisplay.pause();
|
||||
playNext();
|
||||
}
|
||||
|
||||
// Auto-hide controls after 5 seconds of inactivity
|
||||
function resetInactivityTimer() {
|
||||
// Show controls
|
||||
controls.style.opacity = '1';
|
||||
controls.style.pointerEvents = 'auto';
|
||||
|
||||
// Clear existing timer
|
||||
if (inactivityTimer) {
|
||||
clearTimeout(inactivityTimer);
|
||||
}
|
||||
|
||||
// Set new timer to hide controls after 5 seconds
|
||||
inactivityTimer = setTimeout(() => {
|
||||
controls.style.opacity = '0';
|
||||
controls.style.pointerEvents = 'none';
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Track user activity to show/hide controls
|
||||
document.addEventListener('mousemove', resetInactivityTimer);
|
||||
document.addEventListener('mousedown', resetInactivityTimer);
|
||||
document.addEventListener('keydown', resetInactivityTimer);
|
||||
document.addEventListener('touchstart', resetInactivityTimer);
|
||||
|
||||
// Start playing when page loads
|
||||
window.addEventListener('load', () => {
|
||||
playNext();
|
||||
resetInactivityTimer(); // Start inactivity timer
|
||||
});
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'f' || e.key === 'F') {
|
||||
toggleFullscreen();
|
||||
} else if (e.key === 'r' || e.key === 'R') {
|
||||
restartPlaylist();
|
||||
} else if (e.key === 'Escape' && document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,227 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ player.name }} - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container" style="max-width: 1400px;">
|
||||
<!-- Header -->
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||
<div>
|
||||
<h1>{{ player.name }}</h1>
|
||||
<div style="margin-top: 10px;">
|
||||
{% if status_info.online %}
|
||||
<span style="background: #28a745; color: white; padding: 5px 12px; border-radius: 3px; font-size: 14px; margin-right: 10px;">
|
||||
🟢 Online
|
||||
</span>
|
||||
{% else %}
|
||||
<span style="background: #6c757d; color: white; padding: 5px 12px; border-radius: 3px; font-size: 14px; margin-right: 10px;">
|
||||
⚫ Offline
|
||||
</span>
|
||||
{% endif %}
|
||||
<span style="color: #6c757d; font-size: 14px;">
|
||||
Last seen: {{ status_info.last_seen_ago }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('players.edit_player', player_id=player.id) }}" class="btn btn-primary">
|
||||
✏️ Edit Player
|
||||
</a>
|
||||
<a href="{{ url_for('playlist.manage_playlist', player_id=player.id) }}" class="btn btn-success">
|
||||
🎬 Manage Playlist
|
||||
</a>
|
||||
<a href="{{ url_for('players.list') }}" class="btn">
|
||||
← Back to Players
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px;">
|
||||
<!-- Player Information Card -->
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid #dee2e6;">
|
||||
📋 Player Information
|
||||
</h3>
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||
<td style="padding: 10px; font-weight: bold; width: 40%;">Display Name:</td>
|
||||
<td style="padding: 10px;">{{ player.name }}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||
<td style="padding: 10px; font-weight: bold;">Hostname:</td>
|
||||
<td style="padding: 10px;">
|
||||
<code style="background: #f8f9fa; padding: 3px 8px; border-radius: 3px;">{{ player.hostname }}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||
<td style="padding: 10px; font-weight: bold;">Location:</td>
|
||||
<td style="padding: 10px;">{{ player.location or '-' }}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||
<td style="padding: 10px; font-weight: bold;">Orientation:</td>
|
||||
<td style="padding: 10px;">{{ player.orientation or 'Landscape' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 10px; font-weight: bold;">Created:</td>
|
||||
<td style="padding: 10px;">{{ player.created_at | localtime }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Authentication Details Card -->
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid #dee2e6;">
|
||||
🔐 Authentication Details
|
||||
</h3>
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||
<td style="padding: 10px; font-weight: bold; width: 40%;">Password Set:</td>
|
||||
<td style="padding: 10px;">
|
||||
{% if player.password_hash %}
|
||||
<span style="color: #28a745;">✓ Yes</span>
|
||||
{% else %}
|
||||
<span style="color: #dc3545;">✗ No</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||
<td style="padding: 10px; font-weight: bold;">Quick Connect Code:</td>
|
||||
<td style="padding: 10px;">
|
||||
{% if player.quickconnect_code %}
|
||||
<span style="color: #28a745;">✓ Yes</span>
|
||||
{% else %}
|
||||
<span style="color: #dc3545;">✗ No</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||
<td style="padding: 10px; font-weight: bold;">Auth Code:</td>
|
||||
<td style="padding: 10px;">
|
||||
{% if player.auth_code %}
|
||||
<span style="color: #28a745;">✓ Yes</span>
|
||||
<form method="POST" action="{{ url_for('players.regenerate_auth_code', player_id=player.id) }}" style="display: inline; margin-left: 10px;">
|
||||
<button type="submit" class="btn btn-sm" style="background: #ffc107; padding: 3px 8px;"
|
||||
onclick="return confirm('Regenerate auth code? The player will need to authenticate again.')">
|
||||
🔄 Regenerate
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span style="color: #dc3545;">✗ No</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" style="padding: 15px 10px;">
|
||||
<a href="{{ url_for('players.edit_player', player_id=player.id) }}"
|
||||
class="btn btn-primary" style="width: 100%; text-align: center;">
|
||||
✏️ Edit Authentication Settings
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Playlist Management Card -->
|
||||
<div class="card" style="margin-bottom: 20px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
||||
<h3 style="margin: 0;">🎬 Playlist Management</h3>
|
||||
</div>
|
||||
|
||||
{% if playlist %}
|
||||
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin-bottom: 15px;">
|
||||
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px;">
|
||||
<div>
|
||||
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Total Items</div>
|
||||
<div style="font-size: 24px; font-weight: bold; color: #333;">{{ playlist|length }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Total Duration</div>
|
||||
<div style="font-size: 24px; font-weight: bold; color: #333;">
|
||||
{% set total_duration = namespace(value=0) %}
|
||||
{% for item in playlist %}
|
||||
{% set total_duration.value = total_duration.value + (item.duration or 10) %}
|
||||
{% endfor %}
|
||||
{{ total_duration.value }}s
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Playlist Version</div>
|
||||
<div style="font-size: 24px; font-weight: bold; color: #333;">{{ player.playlist_version }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ url_for('playlist.manage_playlist', player_id=player.id) }}"
|
||||
class="btn btn-primary"
|
||||
style="display: inline-block; width: 100%; text-align: center; padding: 15px; font-size: 16px;">
|
||||
🎬 Open Playlist Manager
|
||||
</a>
|
||||
|
||||
{% if not playlist %}
|
||||
<div style="background: #fff3cd; border: 1px solid #ffc107; color: #856404; padding: 15px; border-radius: 5px; text-align: center; margin-top: 15px;">
|
||||
⚠️ No content in playlist. Open the playlist manager to add content.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Player Activity Log Card -->
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid #dee2e6;">
|
||||
📊 Recent Activity & Feedback
|
||||
</h3>
|
||||
|
||||
{% if recent_feedback %}
|
||||
<div style="max-height: 400px; overflow-y: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead style="position: sticky; top: 0; background: white;">
|
||||
<tr style="background: #f8f9fa; text-align: left;">
|
||||
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Time</th>
|
||||
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Status</th>
|
||||
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Message</th>
|
||||
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for feedback in recent_feedback %}
|
||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||
<td style="padding: 10px; white-space: nowrap;">
|
||||
<small style="color: #6c757d;">{{ feedback.timestamp | localtime('%Y-%m-%d %H:%M:%S') }}</small>
|
||||
</td>
|
||||
<td style="padding: 10px;">
|
||||
{% if feedback.status == 'playing' %}
|
||||
<span style="background: #28a745; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">▶️ Playing</span>
|
||||
{% elif feedback.status == 'idle' %}
|
||||
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">⏸️ Idle</span>
|
||||
{% elif feedback.status == 'error' %}
|
||||
<span style="background: #dc3545; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">❌ Error</span>
|
||||
{% else %}
|
||||
<span style="background: #007bff; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">{{ feedback.status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding: 10px;">
|
||||
{{ feedback.message or '-' }}
|
||||
</td>
|
||||
<td style="padding: 10px;">
|
||||
{% if feedback.error %}
|
||||
<span style="color: #dc3545; font-family: monospace; font-size: 12px;">{{ feedback.error[:50] }}...</span>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="background: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460; padding: 15px; border-radius: 5px; text-align: center;">
|
||||
ℹ️ No activity logs yet. The player will send feedback once it starts playing content.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,195 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Players - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
body.dark-mode h1 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.players-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.players-table thead tr {
|
||||
background: #f8f9fa;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
body.dark-mode .players-table thead tr {
|
||||
background: #1a202c;
|
||||
}
|
||||
|
||||
.players-table th {
|
||||
padding: 12px;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
body.dark-mode .players-table th {
|
||||
border-bottom-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.players-table tbody tr {
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
body.dark-mode .players-table tbody tr {
|
||||
border-bottom-color: #4a5568;
|
||||
}
|
||||
|
||||
.players-table tbody tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
body.dark-mode .players-table tbody tr:hover {
|
||||
background: #2d3748;
|
||||
}
|
||||
|
||||
.players-table td {
|
||||
padding: 12px;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
body.dark-mode .players-table td {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.players-table td strong {
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
body.dark-mode .players-table td strong {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.players-table code {
|
||||
background: #f8f9fa;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
body.dark-mode .players-table code {
|
||||
background: #1a202c;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.online {
|
||||
background: #28a745;
|
||||
}
|
||||
|
||||
.status-badge.offline {
|
||||
background: #6c757d;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
body.dark-mode .text-muted {
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: #d1ecf1;
|
||||
border: 1px solid #bee5eb;
|
||||
color: #0c5460;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
body.dark-mode .info-box {
|
||||
background: #1a365d;
|
||||
border-color: #2c5282;
|
||||
color: #90cdf4;
|
||||
}
|
||||
|
||||
.info-box a {
|
||||
color: #0c5460;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
body.dark-mode .info-box a {
|
||||
color: #90cdf4;
|
||||
}
|
||||
</style>
|
||||
<div class="container">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||
<h1>Players</h1>
|
||||
<a href="{{ url_for('players.add_player') }}" class="btn btn-success">+ Add New Player</a>
|
||||
</div>
|
||||
|
||||
{% if players %}
|
||||
<div class="card">
|
||||
<table class="players-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Hostname</th>
|
||||
<th>Location</th>
|
||||
<th>Orientation</th>
|
||||
<th>Status</th>
|
||||
<th>Last Seen</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for player in players %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ player.name }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
<code>{{ player.hostname }}</code>
|
||||
</td>
|
||||
<td>
|
||||
{{ player.location or '-' }}
|
||||
</td>
|
||||
<td>
|
||||
{{ player.orientation or 'Landscape' }}
|
||||
</td>
|
||||
<td>
|
||||
{% if player.is_online %}
|
||||
<span class="status-badge online">Online</span>
|
||||
{% else %}
|
||||
<span class="status-badge offline">Offline</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if player.last_seen %}
|
||||
{{ player.last_seen | localtime }}
|
||||
{% else %}
|
||||
<span class="text-muted">Never</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('players.manage_player', player_id=player.id) }}"
|
||||
class="btn btn-info btn-sm" title="Manage Player">
|
||||
⚙️ Manage
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="info-box">
|
||||
ℹ️ No players yet. <a href="{{ url_for('players.add_player') }}">Add your first player</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user