Add stream uptime formatting to Twitch moderation and enhance template layout

- Introduced a new helper function `_format_stream_uptime` to calculate and format the stream uptime based on the start time.
- Updated the Twitch moderation route to include the formatted stream uptime in the rendered template.
- Enhanced the Twitch moderation template layout for better responsiveness and visual clarity, including adjustments to the live status display and overall structure.
This commit is contained in:
2026-03-25 22:00:29 +01:00
parent f90c8eed81
commit 9cb4186bb8
2 changed files with 93 additions and 59 deletions
+65 -57
View File
@@ -75,62 +75,60 @@
transition: opacity 0.2s;
}
#editModal.hidden, #addCommandModal.hidden { display: none; }
@keyframes tmOfflineGlow {
0%, 100% { opacity: 0.55; }
50% { opacity: 1; }
}
.tm-offline-icon { animation: tmOfflineGlow 3s ease-in-out infinite; }
</style>
<div id="notificationStack"></div>
<div class="space-y-4">
<div>
<h1 class="text-xl font-bold text-gray-900 dark:text-white">Modération Twitch</h1>
<p class="text-sm text-gray-600 dark:text-gray-400">Commandes et logs de modération pour {{ twitch_channel }}</p>
</div>
<!-- Info bricks -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg {{ 'bg-red-100 dark:bg-red-900/30' if is_live else 'bg-gray-100 dark:bg-gray-700' }}">
<svg class="w-5 h-5 {{ 'text-red-600 dark:text-red-400' if is_live else 'text-gray-500' }}" fill="currentColor" viewBox="0 0 20 20"><circle cx="10" cy="10" r="8"/></svg>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-white text-sm">Live</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ 'En ligne' if is_live else 'Hors ligne' }}</p>
</div>
</div>
{% if is_live %}
<div class="text-right">
<div class="text-lg font-bold text-purple-600 dark:text-purple-400">{{ viewer_count }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">viewers</div>
</div>
{% endif %}
</div>
<div class="flex flex-col gap-2 sm:gap-3 lg:flex-row lg:items-center lg:justify-between lg:gap-4">
<div class="min-w-0 flex-shrink">
<h1 class="text-xl font-bold text-gray-900 dark:text-white leading-tight">Modération Twitch</h1>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-0.5">Commandes et logs de modération pour {{ twitch_channel }}</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg {{ 'bg-green-100 dark:bg-green-900/30' if link_filter_enabled else 'bg-gray-100 dark:bg-gray-700' }}">
<svg class="w-5 h-5 {{ 'text-green-600 dark:text-green-400' if link_filter_enabled else 'text-gray-500' }}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path></svg>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-white text-sm">Filtre de liens</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ 'Actif' if link_filter_enabled else 'Inactif' }}</p>
</div>
<div class="flex flex-wrap gap-1.5 sm:gap-2 lg:justify-end lg:max-w-[min(100%,40rem)] lg:flex-shrink-0">
<!-- Diffusion : live / hors ligne (compact, aligné au titre) -->
<div class="flex flex-1 min-w-[128px] max-w-full sm:flex-initial sm:max-w-[11.5rem] items-center gap-1.5 rounded-md border shadow-sm px-2 py-1.5 {{ 'border-violet-200/80 dark:border-violet-500/25 bg-gradient-to-br from-violet-50/90 to-white dark:from-violet-950/40 dark:to-gray-900/90' if is_live else 'border-indigo-200/60 dark:border-indigo-400/20 bg-gradient-to-br from-slate-100/90 to-indigo-50/50 dark:from-slate-800/80 dark:to-indigo-950/40' }}">
<div class="p-1 rounded shrink-0 {{ 'bg-violet-500/15 dark:bg-violet-500/20' if is_live else 'bg-indigo-500/10 dark:bg-indigo-400/15' }}">
{% if is_live %}
<svg class="w-3 h-3 text-violet-600 dark:text-violet-400" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"></path></svg>
{% else %}
<svg class="w-3 h-3 text-indigo-500 dark:text-indigo-300 tm-offline-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path></svg>
{% endif %}
</div>
<a href="{{ url_for('link_filter') }}" class="text-xs text-purple-600 dark:text-purple-400 hover:underline">Configurer</a>
<div class="min-w-0 flex-1">
<h3 class="font-semibold text-gray-900 dark:text-white text-[11px] leading-tight tracking-tight">{{ 'En direct' if is_live else 'Hors ligne' }}</h3>
{% if is_live %}
<p class="text-[10px] text-gray-600 dark:text-gray-300 mt-0.5 line-clamp-1 leading-tight" title="{{ stream_title or '' }}">{{ stream_title or 'Sans titre' }}</p>
{% else %}
<p class="text-[10px] text-indigo-600/80 dark:text-indigo-300/80 mt-0.5 leading-tight">Repos — à bientôt sur Twitch</p>
{% endif %}
</div>
<a href="https://www.twitch.tv/{{ twitch_channel }}" target="_blank" rel="noopener noreferrer" class="text-[10px] text-purple-600 dark:text-purple-400 hover:underline shrink-0" title="Ouvrir la chaîne Twitch">Chaîne</a>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg {{ 'bg-orange-100 dark:bg-orange-900/30' if banned_words else 'bg-gray-100 dark:bg-gray-700' }}">
<svg class="w-5 h-5 {{ 'text-orange-600 dark:text-orange-400' if banned_words else 'text-gray-500' }}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path></svg>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-white text-sm">Mots interdits</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ banned_words|length }} mot{{ 's' if banned_words|length > 1 else '' }}</p>
</div>
<div class="flex flex-1 min-w-[128px] max-w-full sm:flex-initial sm:max-w-[10rem] items-center gap-1.5 rounded-md border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-2 py-1.5 shadow-sm">
<div class="p-1 rounded shrink-0 {{ 'bg-green-100 dark:bg-green-900/30' if link_filter_enabled else 'bg-gray-100 dark:bg-gray-700' }}">
<svg class="w-3 h-3 {{ 'text-green-600 dark:text-green-400' if link_filter_enabled else 'text-gray-500' }}" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path></svg>
</div>
<div class="min-w-0 flex-1">
<h3 class="font-semibold text-gray-900 dark:text-white text-[11px] leading-tight">Filtre liens</h3>
<p class="text-[10px] text-gray-500 dark:text-gray-400">{{ 'Actif' if link_filter_enabled else 'Inactif' }}</p>
</div>
<a href="{{ url_for('link_filter') }}" class="text-[10px] text-purple-600 dark:text-purple-400 hover:underline shrink-0">Réglages</a>
</div>
<div class="flex flex-1 min-w-[128px] max-w-full sm:flex-initial sm:max-w-[10rem] items-center gap-1.5 rounded-md border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-2 py-1.5 shadow-sm">
<div class="p-1 rounded shrink-0 {{ 'bg-orange-100 dark:bg-orange-900/30' if banned_words else 'bg-gray-100 dark:bg-gray-700' }}">
<svg class="w-3 h-3 {{ 'text-orange-600 dark:text-orange-400' if banned_words else 'text-gray-500' }}" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path></svg>
</div>
<div class="min-w-0 flex-1">
<h3 class="font-semibold text-gray-900 dark:text-white text-[11px] leading-tight">Mots interdits</h3>
<p class="text-[10px] text-gray-500 dark:text-gray-400">{{ banned_words|length }} actif{{ 's' if banned_words|length != 1 else '' }}</p>
</div>
<a href="#section-mots-interdits" class="text-[10px] text-purple-600 dark:text-purple-400 hover:underline shrink-0">Liste</a>
</div>
</div>
</div>
@@ -146,27 +144,37 @@
</iframe>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 min-w-0">
{% if is_live %}
<span class="flex h-2 w-2 relative" id="liveIndicator">
<span class="flex h-2 w-2 relative shrink-0" id="liveIndicator">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
</span>
<span class="inline-flex items-center gap-1.5 rounded-full bg-red-500/10 dark:bg-red-500/15 px-2 py-0.5 border border-red-500/25 dark:border-red-400/20 shrink-0" id="liveStatusPill">
<span class="text-xs font-semibold text-red-700 dark:text-red-300 tracking-wide" id="liveStatusLabel">EN DIRECT</span>
</span>
{% else %}
<span class="relative inline-flex rounded-full h-2 w-2 bg-gray-400"></span>
<span class="inline-flex items-center gap-1.5 rounded-full bg-indigo-500/10 dark:bg-indigo-400/10 px-2 py-0.5 border border-indigo-400/20 dark:border-indigo-400/25 shrink-0" id="liveStatusPill">
<svg class="w-3.5 h-3.5 text-indigo-500 dark:text-indigo-300 tm-offline-icon shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path></svg>
<span class="text-xs font-medium text-indigo-800 dark:text-indigo-200" id="liveStatusLabel">Pas en direct</span>
</span>
{% endif %}
<span class="text-sm font-medium text-gray-900 dark:text-white" id="liveStatusLabel">{{ 'EN DIRECT' if is_live else 'HORS LIGNE' }}</span>
<span class="text-sm text-gray-500 dark:text-gray-400" id="playerViewerCount">&#8226; {{ viewer_count }} viewers</span>
<span class="text-sm text-gray-500 dark:text-gray-400 truncate" id="playerViewerCount">&#8226; {{ viewer_count }} viewers</span>
</div>
<a href="https://www.twitch.tv/{{ twitch_channel }}" target="_blank" class="text-xs text-purple-600 dark:text-purple-400 hover:underline flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>
Ouvrir
</a>
</div>
<div class="flex-1 p-3 flex items-center justify-center">
<div class="text-center">
<div class="text-sm font-medium text-gray-900 dark:text-white">{{ 'Live en cours' if is_live else 'En attente du prochain live' }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Lecture Twitch intégrée</div>
<div class="flex-1 p-3 min-h-[3.5rem] flex items-center">
<div class="min-w-0 text-left w-full">
{% if is_live %}
<p class="text-sm font-medium text-gray-900 dark:text-white line-clamp-2" title="{{ stream_title or '' }}">{{ stream_title or 'Sans titre' }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate">{{ game_name or 'Catégorie non renseignée' }}{% if stream_uptime %}<span class="text-gray-400 dark:text-gray-500"> · {{ stream_uptime }}</span>{% endif %}</p>
{% else %}
<p class="text-sm text-gray-600 dark:text-gray-300">En attente du prochain live</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Lecteur intégré — son activé automatiquement quand la chaîne est en ligne</p>
{% endif %}
</div>
</div>
</div>
@@ -426,7 +434,7 @@
</div>
<!-- Section mots interdits -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div id="section-mots-interdits" class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden scroll-mt-4">
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
<h3 class="font-semibold text-gray-900 dark:text-white">Mots interdits ({{ banned_words|length }})</h3>
</div>
+28 -2
View File
@@ -5,7 +5,31 @@ from database import db
from database.models import Commande, TwitchModerationLog, TwitchLinkFilter, TwitchBannedWord, ModShoutboxMessage
from flask_login import current_user
from database.helpers import ConfigurationHelper
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
def _format_stream_uptime(started_at_iso):
"""Durée depuis le début du live (texte court pour le panneau)."""
if not started_at_iso:
return None
try:
s = str(started_at_iso).replace("Z", "+00:00")
started = datetime.fromisoformat(s)
if started.tzinfo is None:
started = started.replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
sec = int((now - started).total_seconds())
if sec < 0:
return None
h, sec = divmod(sec, 3600)
m, sec = divmod(sec, 60)
if h > 0:
return f"{h}h {m}min"
if m > 0:
return f"{m} min"
return "< 1 min"
except (ValueError, TypeError):
return None
import asyncio
MODERATION_COMMANDS = [
@@ -139,7 +163,8 @@ def twitch_moderation():
stream_title = bot_status.get("twitch_stream_title", "")
game_name = bot_status.get("twitch_game_name", "")
started_at = bot_status.get("twitch_started_at")
stream_uptime = _format_stream_uptime(started_at) if is_live else None
return render_template(
"twitch-moderation.html",
commands=MODERATION_COMMANDS,
@@ -155,6 +180,7 @@ def twitch_moderation():
stream_title=stream_title,
game_name=game_name,
started_at=started_at,
stream_uptime=stream_uptime,
)
@webapp.route("/twitch-moderation/logs/clear")