4dc8506c9b
Follow-up to the player-modal / maintenance-page fix: the same root cause --
colours hard-coded for the light theme -- affected the whole app, in two
places.
1) Inline style attributes (inline outranks the theme rules, so the light
value survives dark mode and the inherited text colour turns
light-on-light). base.html now carries a single, property-scoped
dark-mode override layer that maps every light literal found in the
templates to the theme variables: light backgrounds/whites -> dark
surfaces, semantic pastels -> dark equivalents, dark text -> light text,
light borders -> dark borders, plus .back-link. Scoped to
body.dark-mode, so the light theme is untouched. This covers all
templates, including ones not edited here and any added later.
2) Page-level <style> blocks with no dark support:
* admin/https_config.html hard-coded a light-only palette (blue-tint
panels, dark labels) -- added a body.dark-mode block for it.
* dashboard.html / help.html / manage_player.html coloured text links
with --primary-color (#7c3aed in dark = too dim). Introduced a
dedicated --link-color (#667eea light / #a78bfa dark) and used it for
text links, leaving --primary-color for button/background use.
Verified with an automated luminance scan of all 18 pages in both themes:
dark mode has 0 light backgrounds and 0 dark-on-dark text (the only
remaining light surfaces are the intentional white buttons on the admin
gradient cards), and light mode is unchanged.
510 lines
18 KiB
HTML
510 lines
18 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Dashboard - DigiServer v2{% endblock %}
|
|
|
|
{% block content %}
|
|
<h1 style="margin-bottom: 2rem;">Dashboard</h1>
|
|
|
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; margin-bottom: 2rem;">
|
|
<div class="card">
|
|
<h3 style="color: #3498db; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
|
|
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 24px; height: 24px;">
|
|
Players
|
|
</h3>
|
|
<p style="font-size: 2rem; font-weight: bold;">{{ total_players or 0 }}</p>
|
|
<a href="{{ url_for('players.list') }}" class="btn" style="margin-top: 1rem;">View Players</a>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3 style="color: #9b59b6; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
|
|
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px;">
|
|
Playlists
|
|
</h3>
|
|
<p style="font-size: 2rem; font-weight: bold;">{{ total_playlists or 0 }}</p>
|
|
<a href="{{ url_for('content.content_list') }}" class="btn" style="margin-top: 1rem;">Manage Playlists</a>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3 style="color: #e67e22; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
|
|
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 24px; height: 24px;">
|
|
Media Library
|
|
</h3>
|
|
<p style="font-size: 2rem; font-weight: bold;">{{ total_content or 0 }}</p>
|
|
<p class="secondary-text" style="font-size: 0.9rem; margin-top: 0.5rem;">Unique media files</p>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3 style="color: #27ae60; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
|
|
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 24px; height: 24px;">
|
|
Storage
|
|
</h3>
|
|
<p style="font-size: 2rem; font-weight: bold;">{{ storage_mb or 0 }} MB</p>
|
|
<p class="secondary-text" style="font-size: 0.9rem; margin-top: 0.5rem;">Total uploads</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="dash-grid">
|
|
<!-- ── Main column: where content actually lives ─────────────────────── -->
|
|
<div class="dash-main">
|
|
<div class="card">
|
|
<h2 style="display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;">
|
|
<span style="display: flex; align-items: center; gap: 0.5rem;">
|
|
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px;">
|
|
Playlist Overview
|
|
</span>
|
|
<a href="{{ url_for('content.content_list') }}" class="btn btn-sm btn-primary">Manage</a>
|
|
</h2>
|
|
<p class="secondary-text" style="font-size: 0.9rem; margin-top: -0.5rem; margin-bottom: 1rem;">
|
|
Pick a playlist to add media to or edit. Players listed here are the devices
|
|
that will show it.
|
|
</p>
|
|
|
|
{% if playlist_overview %}
|
|
{% for item in playlist_overview %}
|
|
<div class="playlist-row">
|
|
<div class="playlist-row-head">
|
|
<div style="min-width: 0;">
|
|
<a href="{{ url_for('content.manage_playlist_content', playlist_id=item.playlist.id) }}"
|
|
class="playlist-name">{{ item.playlist.name }}</a>
|
|
<div class="secondary-text" style="font-size: 0.82rem; margin-top: 2px;">
|
|
{{ item.content_count }} item{{ '' if item.content_count == 1 else 's' }}
|
|
· {{ item.total_duration }}s
|
|
· v{{ item.playlist.version }}
|
|
·
|
|
{% if item.player_count %}
|
|
<span style="color: #27ae60; font-weight: 600;">
|
|
{{ item.online_count }}/{{ item.player_count }} online
|
|
</span>
|
|
{% else %}
|
|
<span style="color: #e67e22; font-weight: 600;">no players</span>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
<a href="{{ url_for('content.manage_playlist_content', playlist_id=item.playlist.id) }}"
|
|
class="btn btn-sm">Edit</a>
|
|
</div>
|
|
|
|
{% if item.players %}
|
|
<div class="player-chips">
|
|
{% for player in item.players %}
|
|
<a href="{{ url_for('players.manage_player', player_id=player.id) }}"
|
|
class="player-chip {{ 'is-online' if player.is_online else 'is-offline' }}"
|
|
title="{{ player.name }}{% if player.location %} — {{ player.location }}{% endif %} ({{ 'online' if player.is_online else 'offline' }})">
|
|
<span class="status-dot"></span>
|
|
<span class="player-chip-name">{{ player.name }}</span>
|
|
</a>
|
|
{% endfor %}
|
|
</div>
|
|
{% else %}
|
|
<p class="empty-hint">
|
|
No players assigned — content here is not shown on any screen yet.
|
|
<a href="{{ url_for('players.list') }}">Assign a player →</a>
|
|
</p>
|
|
{% endif %}
|
|
</div>
|
|
{% endfor %}
|
|
{% else %}
|
|
<p class="empty-hint">
|
|
No playlists yet.
|
|
<a href="{{ url_for('content.content_list') }}">Create your first playlist →</a>
|
|
</p>
|
|
{% endif %}
|
|
|
|
{% if unassigned_players %}
|
|
<div class="unassigned-note">
|
|
<strong>{{ unassigned_players | length }} player{{ '' if unassigned_players | length == 1 else 's' }}
|
|
not assigned to any playlist:</strong>
|
|
{% for player in unassigned_players %}
|
|
<a href="{{ url_for('players.manage_player', player_id=player.id) }}">{{ player.name }}</a>{{ ', ' if not loop.last else '' }}
|
|
{% endfor %}
|
|
— these will not display content until a playlist is assigned.
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Recent Activity sits under Playlist Overview: both describe what
|
|
the server and its players are actually doing. -->
|
|
{% if recent_logs or heartbeat_logs %}
|
|
<div class="card">
|
|
<h2 style="display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;">
|
|
<span>Recent Activity</span>
|
|
<span class="log-tabs">
|
|
<button type="button" class="log-tab is-active" data-log-tab="activity">
|
|
Activity <span class="log-count">{{ recent_logs | length }}</span>
|
|
</button>
|
|
<button type="button" class="log-tab" data-log-tab="heartbeat">
|
|
Player heartbeats <span class="log-count">{{ heartbeat_logs | length }}</span>
|
|
</button>
|
|
</span>
|
|
</h2>
|
|
|
|
<div class="log-pane" data-log-pane="activity">
|
|
{% if recent_logs %}
|
|
{% for log in recent_logs %}
|
|
<div class="log-item">
|
|
<span class="log-level" style="color: {% if log.level == 'error' %}#e74c3c{% elif log.level == 'warning' %}#f39c12{% elif log.level == 'debug' %}#718096{% else %}#27ae60{% endif %};">
|
|
[{{ log.level.upper() }}]
|
|
</span>
|
|
<span class="log-message">{{ log.message }}</span>
|
|
<small class="secondary-text log-time">{{ log.timestamp | localtime('%Y-%m-%d %H:%M:%S') }}</small>
|
|
</div>
|
|
{% endfor %}
|
|
{% else %}
|
|
<p class="empty-hint">No activity recorded yet.</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<div class="log-pane" data-log-pane="heartbeat" hidden>
|
|
{% if heartbeat_logs %}
|
|
<p class="empty-hint" style="margin-top: 0;">
|
|
Routine player status reports — newest first.
|
|
</p>
|
|
{% for log in heartbeat_logs %}
|
|
<div class="log-item">
|
|
<span class="log-level" style="color: #27ae60;">[{{ log.level.upper() }}]</span>
|
|
<span class="log-message">{{ log.message }}</span>
|
|
<small class="secondary-text log-time">{{ log.timestamp | localtime('%Y-%m-%d %H:%M:%S') }}</small>
|
|
</div>
|
|
{% endfor %}
|
|
{% else %}
|
|
<p class="empty-hint">No player heartbeats recorded yet.</p>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- ── Right sidebar: small, fixed-width helper cards ─────────────────── -->
|
|
<aside class="dash-side">
|
|
<div class="card card-compact">
|
|
<h3 class="compact-title">Quick Actions</h3>
|
|
<div class="compact-actions">
|
|
<a href="{{ url_for('players.add_player') }}" class="btn btn-sm btn-success compact-btn">
|
|
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" class="compact-btn-icon">
|
|
Add Player
|
|
</a>
|
|
<a href="{{ url_for('content.content_list') }}" class="btn btn-sm btn-success compact-btn">
|
|
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" class="compact-btn-icon">
|
|
Create Playlist
|
|
</a>
|
|
<a href="{{ url_for('content.upload_media_page') }}" class="btn btn-sm btn-success compact-btn">
|
|
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" class="compact-btn-icon">
|
|
Upload Media
|
|
</a>
|
|
{% if current_user.is_admin %}
|
|
<a href="{{ url_for('admin.admin_panel') }}" class="btn btn-sm compact-btn">
|
|
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" class="compact-btn-icon">
|
|
Admin Panel
|
|
</a>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card card-compact">
|
|
<h3 class="compact-title">
|
|
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 18px; height: 18px;">
|
|
Workflow Guide
|
|
</h3>
|
|
<div class="workflow-guide">
|
|
<ol>
|
|
<li><strong>Create a Playlist</strong></li>
|
|
<li><strong>Upload Media</strong></li>
|
|
<li><strong>Add Content</strong></li>
|
|
<li><strong>Add Player</strong></li>
|
|
<li><strong>Assign Playlist</strong></li>
|
|
<li><strong>Auto-Download</strong></li>
|
|
</ol>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
|
|
<style>
|
|
/* Dashboard two-column layout: main content + narrow right sidebar.
|
|
`minmax(0, 1fr)` keeps long playlist/player names from widening the grid. */
|
|
.dash-grid {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) 220px;
|
|
gap: 1.5rem;
|
|
align-items: start;
|
|
}
|
|
.dash-side {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
/* Stick the sidebar while the playlist card scrolls, on tall screens. */
|
|
position: sticky;
|
|
top: 1rem;
|
|
}
|
|
|
|
/* Compact card used by the sidebar widgets. */
|
|
.card-compact {
|
|
padding: 0.85rem;
|
|
margin-bottom: 0;
|
|
}
|
|
.card-compact:hover {
|
|
/* The global .card lift is distracting on small utility cards. */
|
|
transform: none;
|
|
}
|
|
.compact-title {
|
|
margin: 0 0 0.6rem 0;
|
|
font-size: 0.95rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
}
|
|
|
|
.compact-actions {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.45rem;
|
|
}
|
|
.compact-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
width: 100%;
|
|
padding: 0.45rem 0.6rem;
|
|
font-size: 0.82rem;
|
|
text-align: left;
|
|
justify-content: flex-start;
|
|
}
|
|
.compact-btn-icon {
|
|
width: 15px;
|
|
height: 15px;
|
|
flex: 0 0 auto;
|
|
filter: brightness(0) invert(1);
|
|
}
|
|
/* The Admin Panel button keeps the default (non-success) background, so its
|
|
icon must stay in the button's text colour instead of being forced white. */
|
|
.compact-btn:not(.btn-success) .compact-btn-icon {
|
|
filter: none;
|
|
}
|
|
body.dark-mode .compact-btn:not(.btn-success) .compact-btn-icon {
|
|
filter: brightness(0) invert(1);
|
|
}
|
|
|
|
.workflow-guide {
|
|
margin-top: 0.4rem;
|
|
padding: 0.6rem 0.7rem;
|
|
background: var(--bg-color);
|
|
border-radius: 6px;
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
.workflow-guide ol {
|
|
margin: 0;
|
|
padding-left: 1.2rem;
|
|
line-height: 1.65;
|
|
font-size: 0.82rem;
|
|
}
|
|
|
|
/* ── Playlist overview rows ───────────────────────────────────────────── */
|
|
.playlist-row {
|
|
padding: 0.75rem 0;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
.playlist-row:last-of-type {
|
|
border-bottom: none;
|
|
}
|
|
.playlist-row-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.75rem;
|
|
}
|
|
.playlist-name {
|
|
font-weight: 600;
|
|
font-size: 1rem;
|
|
color: var(--link-color);
|
|
text-decoration: none;
|
|
word-break: break-word;
|
|
}
|
|
.playlist-name:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.player-chips {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.35rem;
|
|
margin-top: 0.5rem;
|
|
}
|
|
.player-chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
padding: 0.15rem 0.55rem;
|
|
border-radius: 999px;
|
|
font-size: 0.78rem;
|
|
text-decoration: none;
|
|
border: 1px solid var(--border-color);
|
|
background: var(--bg-color);
|
|
color: var(--text-color);
|
|
max-width: 100%;
|
|
}
|
|
.player-chip:hover {
|
|
border-color: var(--primary-color);
|
|
}
|
|
.player-chip-name {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.status-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
flex: 0 0 auto;
|
|
}
|
|
.player-chip.is-online .status-dot { background: #27ae60; }
|
|
.player-chip.is-offline .status-dot { background: #cbd5e0; }
|
|
body.dark-mode .player-chip.is-offline .status-dot { background: #718096; }
|
|
|
|
.empty-hint {
|
|
margin-top: 0.5rem;
|
|
font-size: 0.85rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
.unassigned-note {
|
|
margin-top: 1rem;
|
|
padding: 0.7rem 0.85rem;
|
|
border-radius: 8px;
|
|
border-left: 4px solid #e67e22;
|
|
background: rgba(230, 126, 34, 0.1);
|
|
font-size: 0.85rem;
|
|
}
|
|
.unassigned-note a {
|
|
color: var(--link-color);
|
|
}
|
|
|
|
/* Sidebar collapses below the main column when space runs out. */
|
|
@media (max-width: 900px) {
|
|
.dash-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.dash-side {
|
|
position: static;
|
|
/* Narrow screens: let the small cards sit side by side. */
|
|
flex-direction: row;
|
|
flex-wrap: wrap;
|
|
}
|
|
.dash-side .card-compact {
|
|
flex: 1 1 200px;
|
|
}
|
|
}
|
|
|
|
/* Shared helpers used by the cards below. */
|
|
.secondary-text {
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
/* ── Recent Activity: tabbed list, scrollable so it cannot dominate ────── */
|
|
.log-tabs {
|
|
display: flex;
|
|
gap: 0.25rem;
|
|
flex-shrink: 0;
|
|
}
|
|
.log-tab {
|
|
background: var(--bg-color);
|
|
color: var(--text-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 999px;
|
|
padding: 0.25rem 0.7rem;
|
|
font-size: 0.78rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
font-family: inherit;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
transition: all 0.2s;
|
|
}
|
|
.log-tab:hover {
|
|
border-color: var(--primary-color);
|
|
color: var(--text-color);
|
|
}
|
|
.log-tab.is-active {
|
|
background: var(--primary-gradient);
|
|
border-color: transparent;
|
|
color: #fff;
|
|
}
|
|
.log-count {
|
|
background: rgba(0, 0, 0, 0.12);
|
|
border-radius: 999px;
|
|
padding: 0 0.4rem;
|
|
font-size: 0.72rem;
|
|
line-height: 1.5;
|
|
}
|
|
.log-tab.is-active .log-count {
|
|
background: rgba(255, 255, 255, 0.25);
|
|
}
|
|
|
|
/* Cap the height and scroll: a fixed number of rows stay readable. */
|
|
.log-pane {
|
|
max-height: 320px;
|
|
overflow-y: auto;
|
|
margin-top: 0.5rem;
|
|
}
|
|
.log-item {
|
|
padding: 0.5rem 0.25rem;
|
|
border-bottom: 1px solid var(--border-color);
|
|
font-size: 0.86rem;
|
|
line-height: 1.5;
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 0.5rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.log-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
.log-level {
|
|
font-weight: 700;
|
|
flex-shrink: 0;
|
|
}
|
|
.log-message {
|
|
/* Long messages wrap instead of pushing the timestamp off-screen. */
|
|
flex: 1 1 auto;
|
|
min-width: 0;
|
|
word-break: break-word;
|
|
}
|
|
.log-time {
|
|
margin-left: auto;
|
|
flex-shrink: 0;
|
|
font-variant-numeric: tabular-nums;
|
|
white-space: nowrap;
|
|
}
|
|
</style>
|
|
|
|
<div class="card">
|
|
<h2>System Status</h2>
|
|
<p>✅ All systems operational</p>
|
|
<p>📋 Playlist-centric architecture active</p>
|
|
<p>🔄 Groups removed - Streamlined workflow</p>
|
|
<p>⚡ DigiServer v2.0</p>
|
|
</div>
|
|
|
|
<script>
|
|
/* Recent Activity tabs. Toggles the `hidden` attribute so the panes work even
|
|
if this script is blocked — the activity pane is simply always shown. */
|
|
(function () {
|
|
var tabs = document.querySelectorAll('.log-tab');
|
|
var panes = document.querySelectorAll('.log-pane');
|
|
if (!tabs.length) return;
|
|
|
|
tabs.forEach(function (tab) {
|
|
tab.addEventListener('click', function () {
|
|
var target = tab.getAttribute('data-log-tab');
|
|
|
|
tabs.forEach(function (t) {
|
|
t.classList.toggle('is-active', t === tab);
|
|
});
|
|
panes.forEach(function (p) {
|
|
p.hidden = p.getAttribute('data-log-pane') !== target;
|
|
});
|
|
});
|
|
});
|
|
})();
|
|
</script>
|
|
{% endblock %}
|