Replace emoji icons with local SVG files for consistent rendering
- Created 10 SVG icon files in app/static/icons/ (Feather Icons style) - Updated base.html with SVG icons in navigation and dark mode toggle - Updated dashboard.html with icons in stats cards and quick actions - Updated content_list_new.html (playlist management) with SVG icons - Updated upload_media.html with upload-related icons - Updated manage_player.html with player management icons - Icons use currentColor for automatic theme adaptation - Removed emoji dependency for better Raspberry Pi compatibility - Added ICON_INTEGRATION.md documentation
This commit is contained in:
@@ -5,12 +5,53 @@
|
||||
<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: #333;
|
||||
background: #f5f5f5;
|
||||
color: var(--text-color);
|
||||
background: var(--bg-color);
|
||||
transition: background 0.3s, color 0.3s;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
@@ -18,81 +59,248 @@
|
||||
padding: 20px;
|
||||
}
|
||||
header {
|
||||
background: #2c3e50;
|
||||
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;
|
||||
}
|
||||
header h1 { font-size: 1.5rem; }
|
||||
header h1 {
|
||||
font-size: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
nav a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
margin-left: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
transition: background 0.3s;
|
||||
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);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
nav a:hover { background: rgba(255,255,255,0.1); }
|
||||
.alert {
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 4px;
|
||||
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;
|
||||
}
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
body.dark-mode .alert-info {
|
||||
background: rgba(23, 162, 184, 0.2);
|
||||
border-color: #17a2b8;
|
||||
color: #7dd3e0;
|
||||
}
|
||||
.card h2 { margin-bottom: 1rem; color: #2c3e50; }
|
||||
.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: #3498db;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
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:hover { background: #2980b9; }
|
||||
.btn-danger { background: #e74c3c; }
|
||||
.btn-danger:hover { background: #c0392b; }
|
||||
.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; }
|
||||
.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: #7f8c8d;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
@@ -101,17 +309,31 @@
|
||||
<body>
|
||||
<header>
|
||||
<div class="container">
|
||||
<h1>📺 DigiServer v2</h1>
|
||||
<h1>
|
||||
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="DigiServer" style="width: 28px; height: 28px; filter: brightness(0) invert(1);">
|
||||
DigiServer v2
|
||||
</h1>
|
||||
<nav>
|
||||
{% if current_user.is_authenticated %}
|
||||
<a href="{{ url_for('main.dashboard') }}">Dashboard</a>
|
||||
<a href="{{ url_for('players.list') }}">Players</a>
|
||||
<a href="{{ url_for('groups.groups_list') }}">Groups</a>
|
||||
<a href="{{ url_for('content.content_list') }}">Content</a>
|
||||
<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>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('admin.admin_panel') }}">Admin</a>
|
||||
{% endif %}
|
||||
<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>
|
||||
@@ -140,6 +362,38 @@
|
||||
</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>
|
||||
|
||||
438
app/templates/content/content_list_new.html
Normal file
438
app/templates/content/content_list_new.html
Normal file
@@ -0,0 +1,438 @@
|
||||
{% 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);
|
||||
}
|
||||
|
||||
.playlist-info h3 {
|
||||
margin: 0 0 5px 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.playlist-stats {
|
||||
font-size: 14px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.playlist-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
.media-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.media-name {
|
||||
font-size: 12px;
|
||||
word-break: break-word;
|
||||
}
|
||||
</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/Manage Playlists 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);">
|
||||
Playlists
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Create New Playlist Form -->
|
||||
<form method="POST" action="{{ url_for('content.create_playlist') }}" style="margin-bottom: 25px;">
|
||||
<h3 style="margin-bottom: 15px;">Create New Playlist</h3>
|
||||
<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>
|
||||
|
||||
<hr style="margin: 25px 0;">
|
||||
|
||||
<!-- Existing Playlists -->
|
||||
<h3 style="margin-bottom: 15px;">Existing Playlists</h3>
|
||||
<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>
|
||||
|
||||
<!-- 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);">
|
||||
Upload Media
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; padding: 40px 20px;">
|
||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 96px; height: 96px; opacity: 0.5; margin-bottom: 20px;">
|
||||
<h3 style="margin-bottom: 15px;">Upload Media Files</h3>
|
||||
<p style="color: #6c757d; margin-bottom: 25px;">
|
||||
Upload images, videos, and PDFs to your media library.<br>
|
||||
Assign them to playlists during or after upload.
|
||||
</p>
|
||||
<a href="{{ url_for('content.upload_media_page') }}" class="btn btn-success" style="padding: 15px 40px; font-size: 16px; 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);">
|
||||
Go to Upload Page
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Media Library Preview -->
|
||||
<hr style="margin: 25px 0;">
|
||||
<h3 style="margin-bottom: 15px;">Media Library ({{ media_files|length }} files)</h3>
|
||||
<div class="media-library">
|
||||
{% if media_files %}
|
||||
{% for media in media_files[:12] %}
|
||||
<div class="media-item" title="{{ media.filename }}">
|
||||
<div class="media-icon">
|
||||
{% if media.content_type == 'image' %}
|
||||
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="Image" style="width: 48px; height: 48px; opacity: 0.5;">
|
||||
{% elif media.content_type == 'video' %}
|
||||
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="Video" style="width: 48px; height: 48px; opacity: 0.5;">
|
||||
{% elif media.content_type == 'pdf' %}
|
||||
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="PDF" style="width: 48px; height: 48px; opacity: 0.5;">
|
||||
{% else %}
|
||||
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="File" style="width: 48px; height: 48px; opacity: 0.5;">
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="media-name">{{ media.filename[:20] }}...</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div style="text-align: center; padding: 20px; color: #999;">
|
||||
<p>No media files yet. Upload your first file!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if media_files|length > 12 %}
|
||||
<p style="text-align: center; margin-top: 15px; color: #999;">
|
||||
+ {{ media_files|length - 12 }} more files
|
||||
</p>
|
||||
{% 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;">
|
||||
<a href="{{ url_for('players.player_page', player_id=player.id) }}"
|
||||
class="btn btn-primary btn-sm">
|
||||
👁️ View
|
||||
</a>
|
||||
</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 %}
|
||||
304
app/templates/content/manage_playlist_content.html
Normal file
304
app/templates/content/manage_playlist_content.html
Normal file
@@ -0,0 +1,304 @@
|
||||
{% 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;
|
||||
}
|
||||
</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">
|
||||
<h2 style="margin-bottom: 20px;">📋 Playlist Content (Drag to Reorder)</h2>
|
||||
|
||||
{% if playlist_content %}
|
||||
<table class="playlist-table" id="playlist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<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: 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><span class="drag-handle">⋮⋮</span></td>
|
||||
<td>{{ loop.index }}</td>
|
||||
<td>{{ content.filename }}</td>
|
||||
<td>
|
||||
{% if content.content_type == 'image' %}📷 Image
|
||||
{% elif content.content_type == 'video' %}🎥 Video
|
||||
{% elif content.content_type == 'pdf' %}📄 PDF
|
||||
{% else %}📁 Other{% endif %}
|
||||
</td>
|
||||
<td>{{ content._playlist_duration or content.duration }}s</td>
|
||||
<td>
|
||||
<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);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -12,32 +12,17 @@
|
||||
<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;">Target Selection</h3>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
|
||||
<div>
|
||||
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Target Type:</label>
|
||||
<select name="target_type" id="target_type" class="form-control" required onchange="updateTargetIdOptions()">
|
||||
<option value="" disabled selected>Select Target Type</option>
|
||||
<option value="player" {% if target_type == 'player' %}selected{% endif %}>Player</option>
|
||||
<option value="group" {% if target_type == 'group' %}selected{% endif %}>Group</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Target ID:</label>
|
||||
<select name="target_id" id="target_id" class="form-control" required>
|
||||
{% if target_type == 'player' %}
|
||||
{% for player in players %}
|
||||
<option value="{{ player.id }}" {% if target_id == player.id %}selected{% endif %}>{{ player.name }}</option>
|
||||
{% endfor %}
|
||||
{% elif target_type == 'group' %}
|
||||
{% for group in groups %}
|
||||
<option value="{{ group.id }}" {% if target_id == group.id %}selected{% endif %}>{{ group.name }}</option>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<option value="" disabled selected>Select a Target ID</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -238,29 +223,7 @@ function pollUploadProgress() {
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function updateTargetIdOptions() {
|
||||
const targetType = document.getElementById('target_type').value;
|
||||
const targetIdSelect = document.getElementById('target_id');
|
||||
targetIdSelect.innerHTML = '';
|
||||
|
||||
if (targetType === 'player') {
|
||||
const players = {{ players|tojson }};
|
||||
players.forEach(player => {
|
||||
const option = document.createElement('option');
|
||||
option.value = player.id;
|
||||
option.textContent = player.name;
|
||||
targetIdSelect.appendChild(option);
|
||||
});
|
||||
} else if (targetType === 'group') {
|
||||
const groups = {{ groups|tojson }};
|
||||
groups.forEach(group => {
|
||||
const option = document.createElement('option');
|
||||
option.value = group.id;
|
||||
option.textContent = group.name;
|
||||
targetIdSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function handleMediaTypeChange() {
|
||||
const mediaType = document.getElementById('media_type').value;
|
||||
|
||||
361
app/templates/content/upload_media.html
Normal file
361
app/templates/content/upload_media.html
Normal file
@@ -0,0 +1,361 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Upload Media - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.upload-container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.upload-zone {
|
||||
border: 3px dashed #ced4da;
|
||||
border-radius: 12px;
|
||||
padding: 60px 40px;
|
||||
text-align: center;
|
||||
background: #f8f9fa;
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-zone:hover {
|
||||
border-color: #667eea;
|
||||
background: #f0f2ff;
|
||||
}
|
||||
|
||||
.upload-zone.dragover {
|
||||
border-color: #667eea;
|
||||
background: #e8ebff;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.file-input-wrapper {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.file-input-wrapper input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.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: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.playlist-selector {
|
||||
background: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 30px;
|
||||
border: 2px solid #dee2e6;
|
||||
}
|
||||
|
||||
.playlist-selector.selected {
|
||||
border-color: #667eea;
|
||||
background: #f0f2ff;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="upload-container">
|
||||
<div style="margin-bottom: 30px; display: flex; justify-content: space-between; align-items: center;">
|
||||
<h1 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 32px; height: 32px;">
|
||||
Upload Media Files
|
||||
</h1>
|
||||
<a href="{{ url_for('content.content_list') }}" class="btn" 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);">
|
||||
Back to Playlists
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form id="upload-form" method="POST" action="{{ url_for('content.upload_media') }}" enctype="multipart/form-data">
|
||||
|
||||
<!-- Playlist Selector -->
|
||||
<div class="card" style="margin-bottom: 30px;">
|
||||
<h2 style="margin-bottom: 15px;">📋 Select Target Playlist (Optional)</h2>
|
||||
<p style="color: #6c757d; margin-bottom: 20px;">
|
||||
Choose a playlist to directly add uploaded files, or leave blank to add to media library only.
|
||||
</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="playlist_id">Target Playlist</label>
|
||||
<select name="playlist_id" id="playlist_id" class="form-control">
|
||||
<option value="">-- Media Library Only (Don't add to playlist) --</option>
|
||||
{% for playlist in playlists %}
|
||||
<option value="{{ playlist.id }}">
|
||||
{{ playlist.name }} ({{ playlist.orientation }}) - v{{ playlist.version }} - {{ playlist.content_count }} items
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small style="color: #6c757d; display: block; margin-top: 5px;">
|
||||
💡 Tip: You can add files to playlists later from the media library
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Zone -->
|
||||
<div class="card" style="margin-bottom: 30px;">
|
||||
<h2 style="margin-bottom: 20px; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Select Files
|
||||
</h2>
|
||||
|
||||
<div class="upload-zone" id="upload-zone">
|
||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 96px; height: 96px; opacity: 0.3; margin-bottom: 20px;">
|
||||
<h3 style="margin-bottom: 10px;">Drag and Drop Files Here</h3>
|
||||
<p style="color: #6c757d; margin: 15px 0;">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;">
|
||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Browse Files
|
||||
</label>
|
||||
<input type="file" id="file-input" name="files" multiple
|
||||
accept="image/*,video/*,.pdf,.ppt,.pptx">
|
||||
</div>
|
||||
<p style="font-size: 14px; color: #999; margin-top: 20px;">
|
||||
<strong>Supported formats:</strong><br>
|
||||
Images: JPG, PNG, GIF, BMP<br>
|
||||
Videos: MP4, AVI, MOV, MKV, WEBM<br>
|
||||
Documents: PDF, PPT, PPTX
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="file-list" class="file-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Settings -->
|
||||
<div class="card" style="margin-bottom: 30px;">
|
||||
<h2 style="margin-bottom: 20px; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Upload Settings
|
||||
</h2>
|
||||
|
||||
<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 Document</option>
|
||||
</select>
|
||||
<small style="color: #6c757d; display: block; margin-top: 5px;">
|
||||
This will be 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: 5px;">
|
||||
How long each item should display (for images and PDFs)
|
||||
</small>
|
||||
</div>
|
||||
</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;">
|
||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 20px; height: 20px; filter: brightness(0) invert(1);">
|
||||
Upload Files
|
||||
</button>
|
||||
</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) => {
|
||||
handleFiles(e.target.files);
|
||||
});
|
||||
|
||||
// Click upload zone to trigger file input
|
||||
uploadZone.addEventListener('click', () => {
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
function handleFiles(files) {
|
||||
selectedFiles = Array.from(files);
|
||||
displayFiles();
|
||||
uploadBtn.disabled = selectedFiles.length === 0;
|
||||
}
|
||||
|
||||
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 %}
|
||||
@@ -7,42 +7,80 @@
|
||||
|
||||
<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;">👥 Players</h3>
|
||||
<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;">📁 Groups</h3>
|
||||
<p style="font-size: 2rem; font-weight: bold;">{{ total_groups or 0 }}</p>
|
||||
<a href="{{ url_for('groups.groups_list') }}" class="btn" style="margin-top: 1rem;">View Groups</a>
|
||||
<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;">🎬 Content</h3>
|
||||
<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>
|
||||
<a href="{{ url_for('content.content_list') }}" class="btn" style="margin-top: 1rem;">View Content</a>
|
||||
<p style="font-size: 0.9rem; color: #7f8c8d; margin-top: 0.5rem;">Unique media files</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 style="color: #27ae60; margin-bottom: 0.5rem;">💾 Storage</h3>
|
||||
<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>
|
||||
<a href="{{ url_for('content.upload_content') }}" class="btn btn-success" style="margin-top: 1rem;">Upload Content</a>
|
||||
<p style="font-size: 0.9rem; color: #7f8c8d; 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">➕ Add Player</a>
|
||||
<a href="{{ url_for('groups.create_group') }}" class="btn btn-success">➕ Create Group</a>
|
||||
<a href="{{ url_for('content.upload_content') }}" class="btn btn-success">⬆️ Upload Content</a>
|
||||
<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>
|
||||
<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 style="margin-top: 1rem; padding: 1rem; background: #f8f9fa; border-radius: 4px;">
|
||||
<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>
|
||||
|
||||
{% if recent_logs %}
|
||||
<div class="card">
|
||||
<h2>Recent Activity</h2>
|
||||
@@ -63,7 +101,8 @@
|
||||
<div class="card">
|
||||
<h2>System Status</h2>
|
||||
<p>✅ All systems operational</p>
|
||||
<p>🔄 Blueprint architecture active</p>
|
||||
<p>⚡ Flask {{ config.get('FLASK_VERSION', '3.1.0') }}</p>
|
||||
<p><EFBFBD> Playlist-centric architecture active</p>
|
||||
<p>🔄 Groups removed - Streamlined workflow</p>
|
||||
<p>⚡ DigiServer v2.0</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
252
app/templates/players/manage_player.html
Normal file
252
app/templates/players/manage_player.html
Normal file
@@ -0,0 +1,252 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Manage Player - {{ player.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<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/monitor.svg') }}" alt="" style="width: 32px; height: 32px;">
|
||||
Manage Player: {{ player.name }}
|
||||
</h1>
|
||||
<a href="{{ url_for('players.list') }}" class="btn" style="float: right; 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>
|
||||
|
||||
<!-- Player Status Overview -->
|
||||
<div class="card" style="margin-bottom: 2rem; background: {% if player.status == 'online' %}#d4edda{% elif player.status == 'offline' %}#f8d7da{% else %}#fff3cd{% 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.strftime('%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>
|
||||
|
||||
<!-- 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 style="margin-bottom: 1rem;">
|
||||
<label for="name" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">Player Name *</label>
|
||||
<input type="text" id="name" name="name" value="{{ player.name }}"
|
||||
required minlength="3"
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="location" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">Location</label>
|
||||
<input type="text" id="location" name="location" value="{{ player.location or '' }}"
|
||||
placeholder="e.g., Main Lobby"
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="orientation" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">Orientation</label>
|
||||
<select id="orientation" name="orientation"
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
|
||||
<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="padding: 1rem; background: #f8f9fa; border-radius: 4px; margin-bottom: 1rem;">
|
||||
<p style="margin: 0; font-size: 0.9rem;"><strong>Hostname:</strong> {{ player.hostname }}</p>
|
||||
<p style="margin: 0.5rem 0 0 0; font-size: 0.9rem;"><strong>Auth Code:</strong> <code>{{ player.auth_code }}</code></p>
|
||||
<p style="margin: 0.5rem 0 0 0; font-size: 0.9rem;"><strong>Quick Connect:</strong> {{ player.quickconnect_code or 'Not set' }}</p>
|
||||
</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 style="margin-bottom: 1rem;">
|
||||
<label for="playlist_id" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">Select Playlist</label>
|
||||
<select id="playlist_id" name="playlist_id"
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
|
||||
<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 style="padding: 1rem; background: #d4edda; border-radius: 4px; margin-bottom: 1rem;">
|
||||
<h4 style="margin: 0 0 0.5rem 0; color: #155724;">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.strftime('%Y-%m-%d %H:%M') }}
|
||||
</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="padding: 1rem; background: #fff3cd; border-radius: 4px; margin-bottom: 1rem;">
|
||||
<p style="margin: 0; color: #856404; 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 %}
|
||||
</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.strftime('%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>
|
||||
|
||||
<!-- 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.strftime('%Y-%m-%d %H:%M') 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.strftime('%Y-%m-%d %H:%M:%S') }}
|
||||
{% else %}
|
||||
Never
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -27,8 +27,8 @@
|
||||
<a href="{{ url_for('players.edit_player', player_id=player.id) }}" class="btn btn-primary">
|
||||
✏️ Edit Player
|
||||
</a>
|
||||
<a href="{{ url_for('content.upload_content', target_type='player', target_id=player.id, return_url=url_for('players.player_page', player_id=player.id)) }}" class="btn btn-success">
|
||||
📤 Upload Content
|
||||
<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
|
||||
@@ -62,18 +62,6 @@
|
||||
<td style="padding: 10px; font-weight: bold;">Orientation:</td>
|
||||
<td style="padding: 10px;">{{ player.orientation or 'Landscape' }}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||
<td style="padding: 10px; font-weight: bold;">Group:</td>
|
||||
<td style="padding: 10px;">
|
||||
{% if player.group %}
|
||||
<span style="background: #007bff; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">
|
||||
{{ player.group.name }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span style="color: #6c757d;">No group</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 10px; font-weight: bold;">Created:</td>
|
||||
<td style="padding: 10px;">{{ player.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
@@ -138,77 +126,43 @@
|
||||
<!-- 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;">🎬 Current Playlist</h3>
|
||||
<div>
|
||||
<a href="{{ url_for('content.upload_content', target_type='player', target_id=player.id, return_url=url_for('players.player_page', player_id=player.id)) }}"
|
||||
class="btn btn-success btn-sm">
|
||||
+ Add Content
|
||||
</a>
|
||||
</div>
|
||||
<h3 style="margin: 0;">🎬 Playlist Management</h3>
|
||||
</div>
|
||||
|
||||
{% if playlist %}
|
||||
<div style="background: #f8f9fa; padding: 10px; border-radius: 5px; margin-bottom: 15px;">
|
||||
<strong>Total Items:</strong> {{ playlist|length }} |
|
||||
<strong>Total Duration:</strong> {% 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 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 %}
|
||||
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="background: #f8f9fa; text-align: left;">
|
||||
<th style="padding: 10px; border-bottom: 2px solid #dee2e6; width: 50px;">Order</th>
|
||||
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">File Name</th>
|
||||
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Type</th>
|
||||
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Duration</th>
|
||||
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="playlist-items">
|
||||
{% for item in playlist %}
|
||||
<tr style="border-bottom: 1px solid #dee2e6;" data-content-id="{{ item.id }}">
|
||||
<td style="padding: 10px; text-align: center;">
|
||||
<strong>{{ loop.index }}</strong>
|
||||
</td>
|
||||
<td style="padding: 10px;">
|
||||
{{ item.filename }}
|
||||
</td>
|
||||
<td style="padding: 10px;">
|
||||
{% if item.type == 'image' %}
|
||||
<span style="background: #28a745; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📷 Image</span>
|
||||
{% elif item.type == 'video' %}
|
||||
<span style="background: #007bff; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">🎬 Video</span>
|
||||
{% elif item.type == 'pdf' %}
|
||||
<span style="background: #dc3545; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📄 PDF</span>
|
||||
{% else %}
|
||||
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📁 Other</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding: 10px;">
|
||||
{{ item.duration or 10 }}s
|
||||
</td>
|
||||
<td style="padding: 10px;">
|
||||
<button onclick="moveUp({{ item.id }})" class="btn btn-sm"
|
||||
style="background: #007bff; color: white; padding: 3px 8px; margin-right: 5px;"
|
||||
{% if loop.first %}disabled{% endif %}>
|
||||
↑
|
||||
</button>
|
||||
<button onclick="moveDown({{ item.id }})" class="btn btn-sm"
|
||||
style="background: #007bff; color: white; padding: 3px 8px; margin-right: 5px;"
|
||||
{% if loop.last %}disabled{% endif %}>
|
||||
↓
|
||||
</button>
|
||||
<button onclick="removeFromPlaylist({{ item.id }}, '{{ item.filename }}')"
|
||||
class="btn btn-danger btn-sm" style="padding: 3px 8px;">
|
||||
🗑️ Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div style="background: #fff3cd; border: 1px solid #ffc107; color: #856404; padding: 15px; border-radius: 5px; text-align: center;">
|
||||
⚠️ No content in playlist. <a href="{{ url_for('content.upload_content', target_type='player', target_id=player.id, return_url=url_for('players.player_page', player_id=player.id)) }}" style="color: #856404; text-decoration: underline;">Upload content</a> to get started.
|
||||
<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>
|
||||
@@ -270,62 +224,4 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function moveUp(contentId) {
|
||||
updatePlaylistOrder(contentId, 'up');
|
||||
}
|
||||
|
||||
function moveDown(contentId) {
|
||||
updatePlaylistOrder(contentId, 'down');
|
||||
}
|
||||
|
||||
function updatePlaylistOrder(contentId, direction) {
|
||||
fetch('/players/{{ player.id }}/playlist/reorder', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content_id: contentId,
|
||||
direction: direction
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error reordering playlist: ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('Error reordering playlist: ' + error);
|
||||
});
|
||||
}
|
||||
|
||||
function removeFromPlaylist(contentId, filename) {
|
||||
if (confirm(`Remove "${filename}" from this player's playlist?`)) {
|
||||
fetch('/players/{{ player.id }}/playlist/remove', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content_id: contentId
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error removing content: ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('Error removing content: ' + error);
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">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;">Group</th>
|
||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Orientation</th>
|
||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Status</th>
|
||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Last Seen</th>
|
||||
@@ -36,13 +35,6 @@
|
||||
<td style="padding: 12px;">
|
||||
{{ player.location or '-' }}
|
||||
</td>
|
||||
<td style="padding: 12px;">
|
||||
{% if player.group %}
|
||||
<span style="background: #007bff; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">{{ player.group.name }}</span>
|
||||
{% else %}
|
||||
<span style="color: #6c757d;">No group</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding: 12px;">
|
||||
{{ player.orientation or 'Landscape' }}
|
||||
</td>
|
||||
@@ -62,25 +54,10 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding: 12px;">
|
||||
<a href="{{ url_for('players.player_page', player_id=player.id) }}"
|
||||
class="btn btn-info btn-sm" title="View" style="margin-right: 5px;">
|
||||
👁️ View
|
||||
<a href="{{ url_for('players.manage_player', player_id=player.id) }}"
|
||||
class="btn btn-info btn-sm" title="Manage Player">
|
||||
⚙️ Manage
|
||||
</a>
|
||||
<a href="{{ url_for('players.edit_player', player_id=player.id) }}"
|
||||
class="btn btn-primary btn-sm" title="Edit" style="margin-right: 5px;">
|
||||
✏️ Edit
|
||||
</a>
|
||||
<a href="{{ url_for('players.player_fullscreen', player_id=player.id) }}"
|
||||
class="btn btn-success btn-sm" title="Fullscreen" target="_blank" style="margin-right: 5px;">
|
||||
⛶ Full
|
||||
</a>
|
||||
<form method="POST" action="{{ url_for('players.delete_player', player_id=player.id) }}"
|
||||
style="display: inline;"
|
||||
onsubmit="return confirm('Are you sure you want to delete player \'{{ player.name }}\'?');">
|
||||
<button type="submit" class="btn btn-danger btn-sm" title="Delete">
|
||||
🗑️ Delete
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
492
app/templates/playlist/manage_playlist.html
Normal file
492
app/templates/playlist/manage_playlist.html
Normal file
@@ -0,0 +1,492 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Manage Playlist - {{ player.name }} - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.playlist-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.player-info-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 25px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 25px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.player-info-card h1 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.player-info-card p {
|
||||
margin: 5px 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.playlist-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 25px;
|
||||
margin-bottom: 25px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.playlist-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.playlist-table thead {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.playlist-table th {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
}
|
||||
|
||||
.playlist-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.playlist-table tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.draggable-row {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.draggable-row.dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
font-size: 18px;
|
||||
color: #999;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #5568d3;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.add-content-form {
|
||||
background: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 5px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 15px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.duration-input {
|
||||
width: 80px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="playlist-container">
|
||||
<!-- Player Info Card -->
|
||||
<div class="player-info-card">
|
||||
<h1>🎬 {{ player.name }}</h1>
|
||||
<p>📍 {{ player.location or 'No location' }}</p>
|
||||
<p>🖥️ Hostname: {{ player.hostname }}</p>
|
||||
<p>📊 Status: {{ '🟢 Online' if player.is_online else '🔴 Offline' }}</p>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Playlist Items</div>
|
||||
<div class="stat-value">{{ playlist_content|length }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Playlist Version</div>
|
||||
<div class="stat-value">{{ player.playlist_version }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Total Duration</div>
|
||||
<div class="stat-value">{{ playlist_content|sum(attribute='duration') }}s</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div style="margin-bottom: 20px; display: flex; gap: 10px;">
|
||||
<a href="{{ url_for('players.player_page', player_id=player.id) }}" class="btn btn-secondary">
|
||||
← Back to Player
|
||||
</a>
|
||||
<a href="{{ url_for('content.upload_content', player_id=player.id, return_url=url_for('playlist.manage_playlist', player_id=player.id)) }}"
|
||||
class="btn btn-success">
|
||||
➕ Upload New Content
|
||||
</a>
|
||||
{% if playlist_content %}
|
||||
<form method="POST" action="{{ url_for('playlist.clear_playlist', player_id=player.id) }}"
|
||||
style="display: inline;"
|
||||
onsubmit="return confirm('Are you sure you want to clear the entire playlist?');">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
🗑️ Clear Playlist
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Current Playlist -->
|
||||
<div class="playlist-section">
|
||||
<div class="section-header">
|
||||
<h2>📋 Current Playlist</h2>
|
||||
<span style="color: #999; font-size: 14px;">
|
||||
Drag and drop to reorder
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if playlist_content %}
|
||||
<table class="playlist-table" id="playlist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40px;"></th>
|
||||
<th style="width: 50px;">#</th>
|
||||
<th>Filename</th>
|
||||
<th style="width: 100px;">Type</th>
|
||||
<th style="width: 120px;">Duration (s)</th>
|
||||
<th style="width: 100px;">Size</th>
|
||||
<th style="width: 150px;">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>
|
||||
<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 %}📁 {{ content.content_type }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<input type="number"
|
||||
class="form-control duration-input"
|
||||
value="{{ content.duration }}"
|
||||
min="1"
|
||||
onchange="updateDuration({{ content.id }}, this.value)">
|
||||
</td>
|
||||
<td>{{ "%.2f"|format(content.file_size_mb) }} MB</td>
|
||||
<td>
|
||||
<form method="POST"
|
||||
action="{{ url_for('playlist.remove_from_playlist', player_id=player.id, content_id=content.id) }}"
|
||||
style="display: inline;"
|
||||
onsubmit="return confirm('Remove {{ content.filename }} from playlist?');">
|
||||
<button type="submit" class="btn btn-danger btn-sm">
|
||||
✕ Remove
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">📭</div>
|
||||
<h3>No content in playlist</h3>
|
||||
<p>Upload content or add existing files to get started</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Add Content Section -->
|
||||
{% if available_files %}
|
||||
<div class="playlist-section">
|
||||
<div class="section-header">
|
||||
<h2>➕ Add Existing Content</h2>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('playlist.add_to_playlist', player_id=player.id) }}"
|
||||
class="add-content-form">
|
||||
<div class="form-group">
|
||||
<label for="filename">Select File:</label>
|
||||
<select name="filename" id="filename" class="form-control" required>
|
||||
<option value="" disabled selected>Choose a file...</option>
|
||||
{% for filename in available_files %}
|
||||
<option value="{{ filename }}">{{ filename }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="duration">Display Duration (seconds):</label>
|
||||
<input type="number"
|
||||
name="duration"
|
||||
id="duration"
|
||||
class="form-control"
|
||||
value="10"
|
||||
min="1"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-success">
|
||||
➕ Add to Playlist
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let draggedElement = null;
|
||||
|
||||
// Initialize drag and drop
|
||||
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.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
|
||||
function handleDragOver(e) {
|
||||
if (e.preventDefault) {
|
||||
e.preventDefault();
|
||||
}
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
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.classList.remove('dragging');
|
||||
}
|
||||
|
||||
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("playlist.reorder_playlist", player_id=player.id) }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ content_ids: contentIds })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
console.log('Playlist reordered successfully');
|
||||
} else {
|
||||
alert('Error reordering playlist: ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Error reordering playlist');
|
||||
});
|
||||
}
|
||||
|
||||
function updateDuration(contentId, duration) {
|
||||
const formData = new FormData();
|
||||
formData.append('duration', duration);
|
||||
|
||||
fetch(`{{ url_for("playlist.update_duration", player_id=player.id, content_id=0) }}`.replace('/0', `/${contentId}`), {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
console.log('Duration updated successfully');
|
||||
// Update total duration
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error updating duration: ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Error updating duration');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user