Files
MamieHenriette/webapp/templates/twitch-moderation.html
T

1398 lines
70 KiB
HTML

{% extends "template.html" %}
{% block content %}
<style>
.section-card {
background: #fff;
border-radius: 12px;
border: 1px solid #e5e7eb;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,.06);
}
.dark .section-card {
background: #1f2937;
border-color: #374151;
box-shadow: 0 1px 3px rgba(0,0,0,.2);
}
.section-card-header {
padding: 10px 14px;
font-weight: 600;
font-size: 14px;
color: #111827;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
}
.dark .section-card-header {
background: #111827;
color: #f3f4f6;
border-color: #374151;
}
.section-card-body {
max-height: 220px;
overflow: auto;
}
.cmd-table, .logs-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.cmd-table th, .logs-table th {
text-align: left;
padding: 8px 12px;
background: #f3f4f6;
color: #4b5563;
font-weight: 600;
}
.dark .cmd-table th, .dark .logs-table th { background: #374151; color: #9ca3af; }
.cmd-table td, .logs-table td { padding: 6px 12px; border-bottom: 1px solid #e5e7eb; color: #374151; }
.dark .cmd-table td, .dark .logs-table td { border-color: #374151; color: #e5e7eb; }
.cmd-table tr:hover, .logs-table tr:hover { background: #f9fafb; }
.dark .cmd-table tr:hover, .dark .logs-table tr:hover { background: #374151; }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in { animation: fadeIn 0.3s ease-out; }
@keyframes slideInRight {
from { opacity: 0; transform: translateX(100%); }
to { opacity: 1; transform: translateX(0); }
}
.animate-slide-in { animation: slideInRight 0.3s ease-out; }
@keyframes slideOutRight {
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(100%); }
}
.animate-slide-out { animation: slideOutRight 0.3s ease-out forwards; }
#notificationStack {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 0.5rem;
pointer-events: none;
max-width: 380px;
}
#notificationStack > div { pointer-events: auto; }
#editModal, #addCommandModal {
transition: opacity 0.2s;
}
#editModal.hidden, #addCommandModal.hidden { display: none; }
@media (min-width: 1536px) {
/* Étendre uniquement la page modération Twitch au-delà du max-w global */
.twitch-wide-layout {
width: min(1920px, calc(100vw - 24px));
margin-left: 50%;
transform: translateX(-50%);
}
.twitch-wide-layout .twitch-grid {
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1.05fr) minmax(0, 1.05fr) minmax(320px, 0.95fr);
}
}
</style>
<div id="notificationStack"></div>
<div class="space-y-4 twitch-wide-layout">
<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>
<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>
<a href="{{ url_for('link_filter') }}" class="text-xs text-purple-600 dark:text-purple-400 hover:underline">Configurer</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>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-12 gap-4 twitch-grid">
<!-- Colonne 1 : Live Twitch -->
<div class="2xl:col-span-4 bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden flex flex-col 2xl:h-[640px]">
<div class="aspect-video bg-gray-900">
<iframe
src="https://player.twitch.tv/?channel={{ twitch_channel }}&enableExtensions=true&muted={{ 'false' if is_live else 'true' }}&parent={{ embed_parent }}&player=popout&quality=auto&volume={{ '0.5' if is_live else '0' }}"
style="width: 100%; height: 100%; border: none;"
allowfullscreen>
</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">
{% if is_live %}
<span class="flex h-2 w-2 relative" 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>
{% else %}
<span class="relative inline-flex rounded-full h-2 w-2 bg-gray-400"></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>
</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>
</div>
</div>
<!-- Colonne 2 : Chat bot Twitch -->
<div class="2xl:col-span-3 bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden flex flex-col 2xl:h-[640px]">
<!-- === Contenu du chat (commun live / hors live) === -->
<div class="px-4 py-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">
<h3 class="font-semibold text-gray-900 dark:text-white">Chat Twitch (bot)</h3>
<span class="flex h-2 w-2 relative">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
</div>
<div class="text-xs text-gray-600 dark:text-gray-300">Msg/min: <span id="msgPerMinValue" class="font-semibold text-purple-600 dark:text-purple-400">0</span></div>
</div>
<div class="flex-1 overflow-y-auto bg-gray-50 dark:bg-gray-900/50 p-4" id="chatDisplay">
<div class="space-y-2">
<div class="text-center text-sm text-gray-500 dark:text-gray-400 py-8">
<div class="animate-pulse mb-4">
<svg class="w-12 h-12 mx-auto mb-3 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path></svg>
</div>
<p class="font-medium text-gray-700 dark:text-gray-300">Récupération du chat...</p>
<p class="text-xs mt-1">Les messages du chat Twitch apparaîtront ici en temps réel</p>
</div>
</div>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-700/50 border-t border-gray-200 dark:border-gray-600">
<div class="grid grid-cols-4 gap-1.5 mb-3">
<button onclick="executeQuickAction('ban')" class="px-2 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded transition-colors text-xs font-medium flex items-center justify-center gap-1" title="Bannir un utilisateur">
<svg class="w-3.5 h-3.5" 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>
Ban
</button>
<button onclick="executeQuickAction('timeout')" class="px-2 py-1.5 bg-orange-600 hover:bg-orange-700 text-white rounded transition-colors text-xs font-medium flex items-center justify-center gap-1" title="Timeout un utilisateur">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
Timeout
</button>
<button onclick="executeQuickAction('clean')" class="px-2 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors text-xs font-medium flex items-center justify-center gap-1" title="Nettoyer le chat">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
Clean
</button>
<button onclick="executeQuickAction('permit')" class="px-2 py-1.5 bg-teal-600 hover:bg-teal-700 text-white rounded transition-colors text-xs font-medium flex items-center justify-center gap-1" title="Permettre un lien">
<svg class="w-3.5 h-3.5" 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>
Permit
</button>
<button onclick="executeQuickAction('subon')" class="px-2 py-1.5 bg-purple-600 hover:bg-purple-700 text-white rounded transition-colors text-xs font-medium flex items-center justify-center gap-1" title="Activer mode abonnés">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>
Sub ON
</button>
<button onclick="executeQuickAction('suboff')" class="px-2 py-1.5 bg-gray-500 hover:bg-gray-600 text-white rounded transition-colors text-xs font-medium flex items-center justify-center gap-1" title="Désactiver mode abonnés">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z"></path></svg>
Sub OFF
</button>
<button onclick="executeQuickAction('emoteon')" class="px-2 py-1.5 bg-yellow-600 hover:bg-yellow-700 text-white rounded transition-colors text-xs font-medium flex items-center justify-center gap-1" title="Activer mode emotes">
ON
</button>
<button onclick="executeQuickAction('emoteoff')" class="px-2 py-1.5 bg-gray-500 hover:bg-gray-600 text-white rounded transition-colors text-xs font-medium flex items-center justify-center gap-1" title="Désactiver mode emotes">
OFF
</button>
</div>
<form onsubmit="sendMessage(event)" class="flex gap-2">
<input type="text" id="chatMessage" placeholder="Message ou commande..."
class="flex-1 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm" autocomplete="off">
<button type="submit" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors font-medium text-sm">Envoyer</button>
</form>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">Envoyé via le bot &#8226; <a href="https://www.twitch.tv/popout/{{ twitch_channel }}/chat" target="_blank" class="text-purple-600 dark:text-purple-400 hover:underline">Ouvrir le chat</a></p>
</div>
</div>
<!-- Colonne 3 : Chat Twitch (compte personnel) -->
<div class="2xl:col-span-3 bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden flex flex-col 2xl:h-[640px]">
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center justify-between">
<h3 class="font-semibold text-gray-900 dark:text-white">Chat Twitch (compte perso)</h3>
<a href="https://www.twitch.tv/{{ twitch_channel }}/chat" target="_blank" class="text-xs text-purple-600 dark:text-purple-400 hover:underline">Ouvrir</a>
</div>
<div class="flex-1 bg-gray-900">
<iframe
src="https://www.twitch.tv/embed/{{ twitch_channel }}/chat?parent={{ embed_parent }}"
style="width: 100%; height: 100%; border: none;"
allowfullscreen>
</iframe>
</div>
</div>
<!-- Colonne 4 : Shoutbox modérateurs -->
<div class="2xl:col-span-2 section-card flex flex-col 2xl:h-[640px]">
<div class="section-card-header flex items-center justify-between">
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a2 2 0 01-2-2v-6a2 2 0 012-2h8z"></path></svg>
<span>Shoutbox Modos</span>
<span class="text-xs text-gray-400" id="shoutboxCount"></span>
</div>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5">
<button onclick="shoutboxFontSize(-1)" class="text-gray-400 hover:text-white text-xs leading-none px-1" title="Réduire la police">A-</button>
<span class="text-gray-500 text-xs" id="shoutboxFontLabel">12</span>
<button onclick="shoutboxFontSize(1)" class="text-gray-400 hover:text-white text-xs leading-none px-1" title="Agrandir la police">A+</button>
</div>
<button onclick="toggleShoutboxSound()" id="shoutboxSoundBtn" class="text-xs flex items-center gap-1 text-green-400 hover:text-green-300" title="Son activé">
<svg class="w-3.5 h-3.5" id="shoutboxSoundIcon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M17.95 6.05a8 8 0 010 11.9M11 5L6 9H2v6h4l5 4V5z"></path></svg>
<span id="shoutboxSoundLabel">Son</span>
</button>
<button onclick="openShoutboxPopout()" class="text-xs text-indigo-400 hover:text-indigo-300 flex items-center gap-1" title="Ouvrir en popup">
<svg class="w-3.5 h-3.5" 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>
Popout
</button>
<button onclick="clearShoutbox()" class="text-xs text-red-500 hover:text-red-600" title="Effacer">Effacer</button>
</div>
</div>
<div class="flex flex-1 overflow-hidden">
<div class="flex-1 relative">
<div class="absolute inset-0 overflow-y-auto font-mono p-2 bg-gray-950 dark:bg-gray-950 bg-opacity-95" id="shoutboxDisplay" style="scrollbar-width: thin;">
<div class="text-gray-500 text-center py-4" id="shoutboxPlaceholder">Aucun message</div>
</div>
<button id="shoutboxNewMsgBtn" onclick="shoutboxScrollToBottom()" class="hidden absolute bottom-2 left-1/2 -translate-x-1/2 z-10 px-3 py-1 rounded-full bg-indigo-600/90 hover:bg-indigo-500 text-white text-xs font-medium shadow-lg animate-pulse flex items-center gap-1.5 backdrop-blur-sm">
<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="M19 14l-7 7m0 0l-7-7m7 7V3"></path></svg>
<span id="shoutboxNewMsgText">Nouveaux messages</span>
</button>
</div>
<div class="w-24 border-l border-gray-700 bg-gray-900 p-2 overflow-y-auto" style="scrollbar-width: thin;">
<div class="text-xs text-gray-400 font-semibold mb-1.5">En ligne</div>
<div id="shoutboxOnlineList" class="space-y-1">
<div class="text-xs text-gray-600 italic">---</div>
</div>
</div>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 p-2 bg-gray-50 dark:bg-gray-800">
<form onsubmit="sendShoutboxMessage(event)" class="flex gap-1.5">
<span class="text-xs text-indigo-400 font-mono flex items-center">&gt;</span>
<input type="text" id="shoutboxInput" placeholder="Message..." maxlength="500" autocomplete="off"
class="flex-1 px-2 py-1 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 text-xs font-mono focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500">
<button type="submit" class="px-3 py-1 bg-indigo-600 hover:bg-indigo-700 text-white rounded text-xs font-medium transition-colors">Envoyer</button>
</form>
</div>
</div>
</div>
<!-- Commandes de modération & Logs -->
<div class="grid md:grid-cols-2 gap-4">
<div class="section-card">
<div class="section-card-header flex items-center justify-between">
<span>Commandes de modération</span>
<input type="text" id="searchInput" placeholder="Rechercher..." class="w-36 px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white">
</div>
<div class="section-card-body">
<table class="cmd-table" id="commandsTable">
<thead>
<tr>
<th>Commande(s)</th>
<th>Usage</th>
<th>Permission</th>
</tr>
</thead>
<tbody id="commandsBody">
{% for cmd in commands %}
<tr class="command-row" data-search="{{ cmd.commands|join(' ') }} {{ cmd.usage }} {{ cmd.description }}">
<td>
{% for c in cmd.commands %}
<code class="px-1.5 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded text-xs font-mono">{{ c }}</code>
{% if not loop.last %} {% endif %}
{% endfor %}
</td>
<td><code class="text-xs font-mono text-gray-600 dark:text-gray-400">{{ cmd.usage }}</code></td>
<td><span class="text-xs text-gray-500 dark:text-gray-400">{{ cmd.permission }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="section-card">
<div class="section-card-header flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
<span>Logs de modération (<span id="logsCount">{{ logs|length }}</span>)</span>
<div class="flex items-center gap-2">
<input type="text" id="logsSearchInput" placeholder="Rechercher..." class="w-32 px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white">
{% if logs %}
<a href="{{ url_for('clear_twitch_logs') }}" onclick="return confirm('Effacer tous les logs ?')" class="text-xs text-red-600 hover:text-red-700">Effacer</a>
{% endif %}
</div>
</div>
<div class="section-card-body" id="logsContainer">
<table class="logs-table" id="logsTable">
<thead>
<tr>
<th>Date</th>
<th>Action</th>
<th>Modo</th>
<th>Cible</th>
<th>Détails</th>
</tr>
</thead>
<tbody id="logsBody">
{% for log in logs %}
<tr class="log-row" data-search="{{ log.action }} {{ log.moderator }} {{ log.target or '' }} {{ log.details or '' }}" data-log-id="{{ log.id }}">
<td class="whitespace-nowrap text-gray-500 dark:text-gray-400">{{ log.created_at.strftime('%d/%m %H:%M') }}</td>
<td><code class="px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded text-xs">{{ log.action }}</code></td>
<td>{{ log.moderator }}</td>
<td>{{ log.target or '-' }}</td>
<td class="max-w-[120px] truncate" title="{{ log.details or '' }}">{{ log.details or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not logs %}
<div class="px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400" id="noLogsMsg">Aucun log</div>
{% endif %}
</div>
</div>
</div>
<!-- Commandes personnalisées Twitch -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center justify-between">
<h3 class="font-semibold text-gray-900 dark:text-white">Commandes personnalisées Twitch (<span id="customCmdCount">{{ custom_commands|length }}</span>)</h3>
<button onclick="openAddCommandModal()" class="px-3 py-1.5 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors font-medium text-xs flex items-center gap-1.5">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path></svg>
Ajouter
</button>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm" id="customCommandsTable">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300">Commande</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300">Réponse</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300">Permission</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700" id="customCommandsBody">
{% for cmd in custom_commands %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50" id="cmd-row-{{ cmd.id }}">
<td class="px-4 py-2"><code class="px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded text-xs" data-field="trigger">{{ cmd.trigger }}</code></td>
<td class="px-4 py-2 text-gray-700 dark:text-gray-300 max-w-xs truncate" data-field="response">{{ cmd.response }}</td>
<td class="px-4 py-2" data-field="permission" data-value="{{ cmd.twitch_permission or 'viewer' }}">{{ twitch_permissions.get(cmd.twitch_permission or 'viewer', 'Tous') }}</td>
<td class="px-4 py-2">
<div class="flex items-center gap-2">
<button onclick="openEditModal({{ cmd.id }}, '{{ cmd.trigger }}', `{{ cmd.response|replace('`', '\\`')|replace('\\', '\\\\') }}`, '{{ cmd.twitch_permission or 'viewer' }}')" class="text-purple-600 hover:text-purple-700 dark:text-purple-400 text-xs font-medium">Modifier</button>
<a href="{{ url_for('delete_commande', commande_id=cmd.id) }}" onclick="return confirm('Supprimer ?')" class="text-red-600 hover:text-red-700 text-xs">Supprimer</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not custom_commands %}
<div class="px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400" id="noCustomCmds">Aucune commande personnalisée</div>
{% endif %}
</div>
</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 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>
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<form action="{{ url_for('add_banned_word') }}" method="POST" class="flex flex-wrap gap-2 items-end">
<div class="flex-1 min-w-[200px]">
<label for="banned_word" class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Mot interdit</label>
<input type="text" name="word" id="banned_word" placeholder="mot" required
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm">
</div>
<div class="w-32">
<label for="banned_word_timeout" class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Timeout (s)</label>
<input type="number" name="timeout_duration" id="banned_word_timeout" value="60" min="0" max="1209600"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm">
</div>
<button type="submit" class="px-4 py-2 bg-orange-600 hover:bg-orange-700 text-white rounded-lg transition-colors font-medium text-sm">Ajouter</button>
</form>
</div>
{% if banned_words %}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300">Mot</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300">Timeout</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300">Ajouté le</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% for word in banned_words %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td class="px-4 py-2"><code class="px-2 py-1 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded text-xs font-mono">{{ word.word }}</code></td>
<td class="px-4 py-2 text-gray-700 dark:text-gray-300">{{ word.timeout_duration }}s</td>
<td class="px-4 py-2 text-gray-500 dark:text-gray-400 text-xs">{{ word.created_at.strftime('%d/%m/%Y %H:%M') if word.created_at else '-' }}</td>
<td class="px-4 py-2"><a href="{{ url_for('delete_banned_word', word_id=word.id) }}" onclick="return confirm('Supprimer ce mot interdit ?')" class="text-red-600 hover:text-red-700 text-xs">Supprimer</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400">Aucun mot interdit configuré</div>
{% endif %}
</div>
</div>
<!-- Modal d'édition de commande -->
<div id="editModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50" onclick="if(event.target===this)closeEditModal()">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4 animate-fade-in">
<div class="px-5 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 class="font-semibold text-gray-900 dark:text-white">Modifier la commande</h3>
<button onclick="closeEditModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
</div>
<div class="p-5 space-y-4">
<input type="hidden" id="editCmdId">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Commande</label>
<input type="text" id="editTrigger" class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Permission</label>
<select id="editPermission" class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm">
{% for value, label in twitch_permissions.items() %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Réponse</label>
<textarea id="editResponse" rows="3" class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm resize-none"></textarea>
<div class="mt-2">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1.5">Variables disponibles <span class="text-gray-400">(cliquer pour insérer)</span></div>
<div class="flex flex-wrap gap-1">
<button type="button" onclick="insertVariable('editResponse','{user}')" class="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-purple-600 dark:text-purple-400 rounded text-xs font-mono hover:bg-purple-100 dark:hover:bg-purple-900/30 transition-colors" title="Nom de l'utilisateur">{user}</button>
<button type="button" onclick="insertVariable('editResponse','{channel}')" class="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-purple-600 dark:text-purple-400 rounded text-xs font-mono hover:bg-purple-100 dark:hover:bg-purple-900/30 transition-colors" title="Nom de la chaîne">{channel}</button>
<button type="button" onclick="insertVariable('editResponse','{title}')" class="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-purple-600 dark:text-purple-400 rounded text-xs font-mono hover:bg-purple-100 dark:hover:bg-purple-900/30 transition-colors" title="Titre du stream">{title}</button>
<button type="button" onclick="insertVariable('editResponse','{game}')" class="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-purple-600 dark:text-purple-400 rounded text-xs font-mono hover:bg-purple-100 dark:hover:bg-purple-900/30 transition-colors" title="Jeu / catégorie">{game}</button>
<button type="button" onclick="insertVariable('editResponse','{uptime}')" class="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-purple-600 dark:text-purple-400 rounded text-xs font-mono hover:bg-purple-100 dark:hover:bg-purple-900/30 transition-colors" title="Durée du live">{uptime}</button>
<button type="button" onclick="insertVariable('editResponse','{viewers}')" class="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-purple-600 dark:text-purple-400 rounded text-xs font-mono hover:bg-purple-100 dark:hover:bg-purple-900/30 transition-colors" title="Nombre de viewers">{viewers}</button>
</div>
</div>
</div>
</div>
<div class="px-5 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
<button onclick="closeEditModal()" class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">Annuler</button>
<button onclick="saveEditCommand()" id="editSaveBtn" class="px-4 py-2 text-sm bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors font-medium">Enregistrer</button>
</div>
</div>
</div>
<!-- Modal d'ajout de commande -->
<div id="addCommandModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50" onclick="if(event.target===this)closeAddCommandModal()">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4 animate-fade-in">
<div class="px-5 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 class="font-semibold text-gray-900 dark:text-white">Ajouter une commande</h3>
<button onclick="closeAddCommandModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
</div>
<form action="{{ url_for('add_twitch_commande') }}" method="POST">
<div class="p-5 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Commande</label>
<input type="text" name="trigger" placeholder="!commande" required
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Permission</label>
<select name="twitch_permission"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm">
{% for value, label in twitch_permissions.items() %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Réponse</label>
<textarea name="response" id="addResponse" rows="3" placeholder="Réponse du bot..." required
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm resize-none"></textarea>
<div class="mt-2">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1.5">Variables disponibles <span class="text-gray-400">(cliquer pour insérer)</span></div>
<div class="flex flex-wrap gap-1">
<button type="button" onclick="insertVariable('addResponse','{user}')" class="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-purple-600 dark:text-purple-400 rounded text-xs font-mono hover:bg-purple-100 dark:hover:bg-purple-900/30 transition-colors" title="Nom de l'utilisateur">{user}</button>
<button type="button" onclick="insertVariable('addResponse','{channel}')" class="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-purple-600 dark:text-purple-400 rounded text-xs font-mono hover:bg-purple-100 dark:hover:bg-purple-900/30 transition-colors" title="Nom de la chaîne">{channel}</button>
<button type="button" onclick="insertVariable('addResponse','{title}')" class="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-purple-600 dark:text-purple-400 rounded text-xs font-mono hover:bg-purple-100 dark:hover:bg-purple-900/30 transition-colors" title="Titre du stream">{title}</button>
<button type="button" onclick="insertVariable('addResponse','{game}')" class="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-purple-600 dark:text-purple-400 rounded text-xs font-mono hover:bg-purple-100 dark:hover:bg-purple-900/30 transition-colors" title="Jeu / catégorie">{game}</button>
<button type="button" onclick="insertVariable('addResponse','{uptime}')" class="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-purple-600 dark:text-purple-400 rounded text-xs font-mono hover:bg-purple-100 dark:hover:bg-purple-900/30 transition-colors" title="Durée du live">{uptime}</button>
<button type="button" onclick="insertVariable('addResponse','{viewers}')" class="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-purple-600 dark:text-purple-400 rounded text-xs font-mono hover:bg-purple-100 dark:hover:bg-purple-900/30 transition-colors" title="Nombre de viewers">{viewers}</button>
</div>
</div>
</div>
</div>
<div class="px-5 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
<button type="button" onclick="closeAddCommandModal()" class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">Annuler</button>
<button type="submit" class="px-4 py-2 text-sm bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors font-medium">Ajouter</button>
</div>
</form>
</div>
</div>
<script>
(function() {
// --- Recherche commandes de modération ---
var rows = document.querySelectorAll('.command-row');
var searchInput = document.getElementById('searchInput');
if (searchInput) {
searchInput.addEventListener('input', function() {
var q = this.value.toLowerCase();
rows.forEach(function(row) {
row.style.display = row.dataset.search.toLowerCase().indexOf(q) >= 0 ? '' : 'none';
});
});
}
// --- Recherche logs ---
var logsSearchInput = document.getElementById('logsSearchInput');
if (logsSearchInput) {
logsSearchInput.addEventListener('input', function() {
var q = this.value.toLowerCase();
var n = 0;
document.querySelectorAll('.log-row').forEach(function(row) {
var ok = row.dataset.search.toLowerCase().indexOf(q) >= 0;
row.style.display = ok ? '' : 'none';
if (ok) n++;
});
var c = document.getElementById('logsCount');
if (c) c.textContent = n;
});
}
})();
// =============================
// Chat Twitch
// =============================
var displayedMessages = new Set();
var isAutoScroll = true;
var currentUsername = {{ current_user.username | tojson }};
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function escapeJsString(text) {
return String(text || '')
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/\r?\n/g, ' ');
}
function addMessageToDisplay(username, message, timestamp, badges, userColor) {
var chatDisplay = document.getElementById('chatDisplay');
var messagesContainer = chatDisplay.querySelector('.space-y-2');
var helpText = messagesContainer.querySelector('.text-center');
if (helpText) helpText.remove();
var messageId = timestamp + username + message;
if (displayedMessages.has(messageId)) return;
displayedMessages.add(messageId);
var msgDate = new Date(timestamp);
var timeStr = msgDate.getHours().toString().padStart(2, '0') + ':' + msgDate.getMinutes().toString().padStart(2, '0');
var badgeIcons = '';
if (badges && badges.is_mod) badgeIcons += '<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400" title="Modérateur">MOD</span>';
if (badges && badges.is_vip) badgeIcons += '<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-pink-100 text-pink-800 dark:bg-pink-900/30 dark:text-pink-400" title="VIP">VIP</span>';
if (badges && badges.is_subscriber) badgeIcons += '<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400" title="Abonné">SUB</span>';
var safeUser = escapeHtml(username);
var safeUserJs = escapeJsString(username);
var safeMessageJs = escapeJsString(message);
var modButtons = '<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity ml-auto flex-shrink-0">'
+ '<button onclick="transferToShoutbox(\'' + safeUserJs + '\', \'' + safeMessageJs + '\')" class="p-1 rounded hover:bg-indigo-100 dark:hover:bg-indigo-900/30 text-gray-400 hover:text-indigo-500 transition-colors" title="Transférer vers shoutbox">'
+ '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 8h10M7 12h6m7-9l-5 5m0 0l5 5m-5-5h12"/></svg>'
+ '</button>'
+ '<button onclick="executeModerationAction(\'clean\', { username: \'' + safeUserJs + '\' })" class="p-1 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-gray-400 hover:text-red-500 transition-colors" title="Supprimer les messages">'
+ '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>'
+ '</button>'
+ '<button onclick="promptTimeout(\'' + safeUserJs + '\')" class="p-1 rounded hover:bg-yellow-100 dark:hover:bg-yellow-900/30 text-gray-400 hover:text-yellow-500 transition-colors" title="Timeout">'
+ '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>'
+ '</button>'
+ '<button onclick="promptBan(\'' + safeUserJs + '\')" class="p-1 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-gray-400 hover:text-red-600 transition-colors" title="Ban">'
+ '<svg class="w-3.5 h-3.5" 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"/></svg>'
+ '</button>'
+ '</div>';
var messageDiv = document.createElement('div');
messageDiv.className = 'group bg-white dark:bg-gray-800 rounded-lg p-3 text-sm animate-fade-in border-l-2';
messageDiv.style.borderLeftColor = userColor || '#9146FF';
messageDiv.innerHTML = '<div class="flex items-start gap-2"><div class="flex-1 min-w-0"><div class="flex items-center gap-2 mb-1 flex-wrap"><span class="font-bold" style="color: ' + (userColor || '#9146FF') + '">' + safeUser + '</span>' + badgeIcons + '<span class="text-xs text-gray-500 dark:text-gray-400">' + timeStr + '</span>' + modButtons + '</div><div class="text-gray-900 dark:text-gray-100 break-words">' + escapeHtml(message) + '</div></div></div>';
messagesContainer.appendChild(messageDiv);
while (messagesContainer.children.length > 100) messagesContainer.removeChild(messagesContainer.firstChild);
if (isAutoScroll) chatDisplay.scrollTop = chatDisplay.scrollHeight;
}
function addBotMessageToDisplay(message, type) {
var chatDisplay = document.getElementById('chatDisplay');
var messagesContainer = chatDisplay.querySelector('.space-y-2');
var helpText = messagesContainer.querySelector('.text-center');
if (helpText) helpText.remove();
var now = new Date();
var timeStr = now.getHours().toString().padStart(2, '0') + ':' + now.getMinutes().toString().padStart(2, '0');
var bgColor = type === 'command' ? 'bg-purple-50 dark:bg-purple-900/20' : 'bg-blue-50 dark:bg-blue-900/20';
var borderColor = type === 'command' ? '#9146FF' : '#3b82f6';
var messageDiv = document.createElement('div');
messageDiv.className = bgColor + ' rounded-lg p-3 text-sm animate-fade-in border-l-2';
messageDiv.style.borderLeftColor = borderColor;
messageDiv.innerHTML = '<div class="flex items-start gap-2"><div class="flex-1 min-w-0"><div class="flex items-center gap-2 mb-1"><span class="font-bold text-purple-600 dark:text-purple-400">Bot <span class="font-normal text-gray-500 dark:text-gray-400">(' + escapeHtml(currentUsername) + ')</span></span><span class="text-xs text-gray-500 dark:text-gray-400">' + timeStr + '</span></div><div class="text-gray-900 dark:text-gray-100 break-words">' + escapeHtml(message) + '</div></div></div>';
messagesContainer.appendChild(messageDiv);
while (messagesContainer.children.length > 100) messagesContainer.removeChild(messagesContainer.firstChild);
if (isAutoScroll) chatDisplay.scrollTop = chatDisplay.scrollHeight;
}
function fetchChatMessages() {
fetch('{{ url_for("get_twitch_messages") }}')
.then(function(r) { return r.json(); })
.then(function(data) {
var msgPerMinEl = document.getElementById('msgPerMinValue');
if (msgPerMinEl) msgPerMinEl.textContent = String(data.msg_per_min || 0);
if (data.clear_chat) clearChatDisplay(data.clear_reason || 'Chat vidé automatiquement.');
if (data.messages && data.messages.length > 0) {
data.messages.forEach(function(msg) {
addMessageToDisplay(msg.username, msg.text, msg.timestamp, { is_mod: msg.is_mod, is_vip: msg.is_vip, is_subscriber: msg.is_subscriber }, msg.color);
});
}
})
.catch(function(e) { console.error('Erreur chat:', e); });
}
function clearChatDisplay(reason) {
var chatDisplay = document.getElementById('chatDisplay');
var messagesContainer = chatDisplay.querySelector('.space-y-2');
if (!messagesContainer) return;
displayedMessages.clear();
messagesContainer.innerHTML = '';
var notice = document.createElement('div');
notice.className = 'text-center text-sm text-gray-500 dark:text-gray-400 py-8';
notice.textContent = reason || 'Chat vidé.';
messagesContainer.appendChild(notice);
}
function transferToShoutbox(username, message) {
fetch('{{ url_for("shoutbox_transfer") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username, message: message })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
showNotification('Message transféré vers la shoutbox', 'success');
fetchShoutbox();
} else {
showNotification(data.error || 'Erreur de transfert', 'error');
}
})
.catch(function() { showNotification('Erreur réseau', 'error'); });
}
setInterval(fetchChatMessages, 2000);
fetchChatMessages();
document.getElementById('chatDisplay').addEventListener('scroll', function() {
isAutoScroll = this.scrollHeight - this.scrollTop <= this.clientHeight + 50;
});
// =============================
// Envoi de messages
// =============================
function sendMessage(event) {
event.preventDefault();
var input = document.getElementById('chatMessage');
var message = input.value.trim();
if (!message) return;
var submitBtn = event.target.querySelector('button[type="submit"]');
var originalText = submitBtn.textContent;
submitBtn.disabled = true;
submitBtn.textContent = 'Envoi...';
fetch('{{ url_for("send_twitch_message") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: message })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
addBotMessageToDisplay(message, message.startsWith('!') ? 'command' : 'message');
input.value = '';
showNotification('Message envoyé', 'success');
} else {
showNotification(data.error || 'Erreur lors de l\'envoi', 'error');
}
})
.catch(function() { showNotification('Erreur réseau', 'error'); })
.finally(function() { submitBtn.disabled = false; submitBtn.textContent = originalText; });
}
// =============================
// Actions de modération rapides
// =============================
function executeQuickAction(action) {
if (['subon', 'suboff', 'emoteon', 'emoteoff'].includes(action)) {
executeModerationAction(action, {});
return;
}
if (action === 'ban') {
var username = prompt('Nom d\'utilisateur à bannir :', '@');
if (username && username.trim() && username !== '@') {
username = username.trim().replace('@', '');
var reason = prompt('Raison (optionnelle) :', 'Violation des règles');
executeModerationAction('ban', { username: username, reason: reason || 'Ban' });
}
return;
}
if (action === 'timeout') {
var username = prompt('Nom d\'utilisateur à timeout :', '@');
if (username && username.trim() && username !== '@') {
username = username.trim().replace('@', '');
var duration = prompt('Durée en minutes (défaut: 10) :', '10');
duration = parseInt(duration) || 10;
var reason = prompt('Raison (optionnelle) :', 'Timeout');
executeModerationAction('timeout', { username: username, duration: duration * 60, reason: reason || 'Timeout' });
}
return;
}
if (action === 'clean') {
var username = prompt('Nom d\'utilisateur (ou laissez vide pour tout le chat) :', '');
if (username === null) return;
executeModerationAction('clean', { username: username ? username.trim().replace('@', '') : '' });
return;
}
if (action === 'permit') {
var username = prompt('Nom d\'utilisateur à autoriser :', '@');
if (username && username.trim() && username !== '@') {
username = username.trim().replace('@', '');
var duration = prompt('Durée en minutes (défaut: 1) :', '1');
duration = parseInt(duration) || 1;
executeModerationAction('permit', { username: username, duration: duration * 60 });
}
return;
}
}
function promptTimeout(username) {
var duration = prompt('Durée du timeout en secondes pour ' + username + ' :', '600');
if (duration !== null) {
duration = parseInt(duration);
if (duration > 0) {
var reason = prompt('Raison (optionnel) :', 'Timeout via chat') || 'Timeout via chat';
executeModerationAction('timeout', { username: username, duration: duration, reason: reason });
} else {
showNotification('Durée invalide', 'error');
}
}
}
function promptBan(username) {
if (confirm('Êtes-vous sûr de vouloir ban ' + username + ' ?')) {
var reason = prompt('Raison (optionnel) :', 'Ban via chat') || 'Ban via chat';
executeModerationAction('ban', { username: username, reason: reason });
}
}
function executeModerationAction(action, params) {
fetch('{{ url_for("execute_moderation_action") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: action, params: params })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
showNotification(data.message || 'Action exécutée', 'success');
var actionText = action.toUpperCase();
if (params.username) actionText += ' @' + params.username;
if (params.duration) actionText += ' (' + (params.duration / 60) + 'min)';
if (params.reason) actionText += ' - ' + params.reason;
addBotMessageToDisplay(actionText, 'command');
pollModerationLogs();
} else {
showNotification(data.error || 'Erreur lors de l\'exécution', 'error');
}
})
.catch(function() { showNotification('Erreur réseau', 'error'); });
}
// =============================
// Notifications (haut à droite, empilables)
// =============================
function showNotification(message, type, duration) {
var stack = document.getElementById('notificationStack');
var colors = {
success: 'bg-green-500',
error: 'bg-red-500',
info: 'bg-blue-500',
moderation: 'bg-purple-600'
};
var color = colors[type] || colors.info;
var notification = document.createElement('div');
notification.className = color + ' text-white px-4 py-3 rounded-lg shadow-lg animate-slide-in text-sm max-w-sm';
notification.innerHTML = '<div class="flex items-start gap-2"><div class="flex-1">' + message + '</div><button onclick="this.parentElement.parentElement.classList.add(\'animate-slide-out\');setTimeout(function(){this.remove()}.bind(this.parentElement.parentElement),300)" class="text-white/80 hover:text-white ml-2 flex-shrink-0"><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg></button></div>';
stack.appendChild(notification);
var ttl = duration || 5000;
setTimeout(function() {
notification.classList.add('animate-slide-out');
setTimeout(function() { if (notification.parentElement) notification.remove(); }, 300);
}, ttl);
}
// =============================
// Polling des logs de modération (temps réel)
// =============================
var knownLogIds = new Set();
var lastLogTimestamp = '';
(function initKnownLogs() {
document.querySelectorAll('#logsBody .log-row').forEach(function(row) {
var id = row.getAttribute('data-log-id');
if (id) knownLogIds.add(id);
});
{% if logs and logs|length > 0 %}
lastLogTimestamp = {{ logs[0].created_at.isoformat() | tojson }};
{% endif %}
})();
function pollModerationLogs() {
var url = '{{ url_for("poll_twitch_logs") }}';
if (lastLogTimestamp) url += '?since=' + encodeURIComponent(lastLogTimestamp);
fetch(url)
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.logs || data.logs.length === 0) return;
var logsBody = document.getElementById('logsBody');
var noMsg = document.getElementById('noLogsMsg');
if (noMsg) noMsg.remove();
if (!logsBody) {
var container = document.getElementById('logsContainer');
var table = document.createElement('table');
table.className = 'logs-table';
table.id = 'logsTable';
table.innerHTML = '<thead><tr><th>Date</th><th>Action</th><th>Modo</th><th>Cible</th><th>Détails</th></tr></thead><tbody id="logsBody"></tbody>';
container.appendChild(table);
logsBody = document.getElementById('logsBody');
}
data.logs.reverse().forEach(function(log) {
var idStr = String(log.id);
if (knownLogIds.has(idStr)) return;
knownLogIds.add(idStr);
var tr = document.createElement('tr');
tr.className = 'log-row animate-fade-in';
tr.setAttribute('data-search', (log.action || '') + ' ' + (log.moderator || '') + ' ' + (log.target || '') + ' ' + (log.details || ''));
tr.setAttribute('data-log-id', idStr);
tr.innerHTML = '<td class="whitespace-nowrap text-gray-500 dark:text-gray-400">' + escapeHtml(log.created_at) + '</td>' +
'<td><code class="px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded text-xs">' + escapeHtml(log.action) + '</code></td>' +
'<td>' + escapeHtml(log.moderator) + '</td>' +
'<td>' + escapeHtml(log.target) + '</td>' +
'<td class="max-w-[120px] truncate" title="' + escapeHtml(log.details) + '">' + escapeHtml(log.details) + '</td>';
logsBody.insertBefore(tr, logsBody.firstChild);
showNotification(
'<div class="font-semibold">' + escapeHtml(log.action.toUpperCase()) + '</div>' +
'<div class="text-xs text-white/80">' + escapeHtml(log.moderator) + ' &#8594; ' + escapeHtml(log.target) +
(log.details && log.details !== '-' ? ' &mdash; ' + escapeHtml(log.details) : '') + '</div>',
'moderation'
);
});
if (data.timestamp) lastLogTimestamp = data.timestamp;
var countEl = document.getElementById('logsCount');
if (countEl && data.total !== undefined) countEl.textContent = data.total;
})
.catch(function(e) { console.error('Erreur polling logs:', e); });
}
setInterval(pollModerationLogs, 5000);
// =============================
// Édition des commandes
// =============================
function openEditModal(id, trigger, response, permission) {
document.getElementById('editCmdId').value = id;
document.getElementById('editTrigger').value = trigger;
document.getElementById('editResponse').value = response;
document.getElementById('editPermission').value = permission;
document.getElementById('editModal').classList.remove('hidden');
}
function closeEditModal() {
document.getElementById('editModal').classList.add('hidden');
}
function saveEditCommand() {
var id = document.getElementById('editCmdId').value;
var trigger = document.getElementById('editTrigger').value.trim();
var response = document.getElementById('editResponse').value.trim();
var permission = document.getElementById('editPermission').value;
if (!trigger || !response) {
showNotification('Commande et réponse sont requises', 'error');
return;
}
var btn = document.getElementById('editSaveBtn');
btn.disabled = true;
btn.textContent = 'Enregistrement...';
fetch('/twitch-moderation/edit/' + id, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ trigger: trigger, response: response, twitch_permission: permission })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
var row = document.getElementById('cmd-row-' + id);
if (row) {
row.querySelector('[data-field="trigger"]').textContent = data.command.trigger;
row.querySelector('[data-field="response"]').textContent = data.command.response;
var permTd = row.querySelector('[data-field="permission"]');
permTd.textContent = data.command.permission_label;
permTd.setAttribute('data-value', data.command.twitch_permission);
var editBtn = row.querySelector('button');
if (editBtn) {
editBtn.setAttribute('onclick', "openEditModal(" + id + ", '" + data.command.trigger.replace(/'/g, "\\'") + "', `" + data.command.response.replace(/`/g, "\\`") + "`, '" + data.command.twitch_permission + "')");
}
}
closeEditModal();
showNotification('Commande modifiée', 'success');
} else {
showNotification(data.error || 'Erreur lors de la modification', 'error');
}
})
.catch(function() { showNotification('Erreur réseau', 'error'); })
.finally(function() { btn.disabled = false; btn.textContent = 'Enregistrer'; });
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeEditModal();
});
// =============================
// Shoutbox modérateurs (IRC)
// =============================
var shoutboxCurrentUser = {{ current_user.username | tojson }};
var shoutboxKnownIds = new Set();
var shoutboxLastTimestamp = '';
var shoutboxAutoScroll = true;
var shoutboxTabVisible = true;
var shoutboxAudioCtx = null;
var shoutboxUnreadCount = 0;
var shoutboxSoundEnabled = localStorage.getItem('shoutbox_sound') !== 'off';
var shoutboxFontSizePx = parseInt(localStorage.getItem('shoutbox_fontsize')) || 14;
document.addEventListener('visibilitychange', function() {
shoutboxTabVisible = !document.hidden;
});
function shoutboxApplyFontSize() {
var display = document.getElementById('shoutboxDisplay');
if (display) display.style.fontSize = shoutboxFontSizePx + 'px';
var label = document.getElementById('shoutboxFontLabel');
if (label) label.textContent = shoutboxFontSizePx;
localStorage.setItem('shoutbox_fontsize', shoutboxFontSizePx);
}
function shoutboxFontSize(delta) {
shoutboxFontSizePx = Math.max(8, Math.min(20, shoutboxFontSizePx + delta));
shoutboxApplyFontSize();
}
function shoutboxUpdateSoundUI() {
var btn = document.getElementById('shoutboxSoundBtn');
if (!btn) return;
btn.className = 'text-xs flex items-center gap-1 ' + (shoutboxSoundEnabled ? 'text-green-400 hover:text-green-300' : 'text-red-400 hover:text-red-300');
btn.title = shoutboxSoundEnabled ? 'Son activé' : 'Son désactivé';
}
function toggleShoutboxSound() {
shoutboxSoundEnabled = !shoutboxSoundEnabled;
localStorage.setItem('shoutbox_sound', shoutboxSoundEnabled ? 'on' : 'off');
shoutboxUpdateSoundUI();
}
function shoutboxBeep() {
if (!shoutboxSoundEnabled) return;
try {
if (!shoutboxAudioCtx) shoutboxAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
var osc = shoutboxAudioCtx.createOscillator();
var gain = shoutboxAudioCtx.createGain();
osc.connect(gain);
gain.connect(shoutboxAudioCtx.destination);
osc.frequency.value = 660;
osc.type = 'sine';
gain.gain.setValueAtTime(0.15, shoutboxAudioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, shoutboxAudioCtx.currentTime + 0.3);
osc.start(shoutboxAudioCtx.currentTime);
osc.stop(shoutboxAudioCtx.currentTime + 0.3);
} catch(e) {}
}
var SHOUTBOX_COLORS = [
'#6366f1', '#8b5cf6', '#ec4899', '#14b8a6', '#f59e0b',
'#3b82f6', '#10b981', '#ef4444', '#06b6d4', '#84cc16',
];
var shoutboxColorMap = {};
function shoutboxColor(name) {
if (!shoutboxColorMap[name]) {
var hash = 0;
for (var i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
shoutboxColorMap[name] = SHOUTBOX_COLORS[Math.abs(hash) % SHOUTBOX_COLORS.length];
}
return shoutboxColorMap[name];
}
function shoutboxTime(isoStr) {
var d = new Date(isoStr);
return d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0');
}
function shoutboxMention(username) {
var input = document.getElementById('shoutboxInput');
var val = input.value;
var mention = '@' + username + ' ';
if (val && !val.endsWith(' ')) mention = ' ' + mention;
input.value = val + mention;
input.focus();
}
function shoutboxRenderText(text) {
var escaped = escapeHtml(text);
var isMentioned = shoutboxCurrentUser && text.toLowerCase().indexOf('@' + shoutboxCurrentUser.toLowerCase()) >= 0;
escaped = escaped.replace(/@(\w+)/g, '<span class="text-indigo-400 font-semibold">@$1</span>');
if (isMentioned) return { html: escaped, mentioned: true };
return { html: escaped, mentioned: false };
}
function addShoutboxItem(item) {
if (shoutboxKnownIds.has(item.id)) return;
shoutboxKnownIds.add(item.id);
var display = document.getElementById('shoutboxDisplay');
var ph = document.getElementById('shoutboxPlaceholder');
if (ph) ph.remove();
var line = document.createElement('div');
line.className = 'py-0.5 leading-relaxed rounded px-1';
var time = '<span class="text-gray-500">[' + shoutboxTime(item.created_at) + ']</span> ';
if (item.type === 'message') {
var color = shoutboxColor(item.author);
var rendered = shoutboxRenderText(item.text);
line.innerHTML = time +
'<span style="color:' + color + '" class="font-semibold cursor-pointer hover:underline" onclick="shoutboxMention(\'' + escapeHtml(item.author).replace(/'/g, "\\'") + '\')">&lt;' + escapeHtml(item.author) + '&gt;</span> ' +
'<span class="text-gray-200">' + rendered.html + '</span>';
if (rendered.mentioned) {
line.className += ' bg-red-900/40 font-bold';
}
} else {
var actionUpper = (item.action || '').toUpperCase();
var sanctionColor = 'text-red-400';
if (['timeout', 'clean', 'permit'].indexOf(item.action) >= 0) sanctionColor = 'text-orange-400';
if (['subon', 'suboff', 'emoteon', 'emoteoff', 'follon', 'folloff'].indexOf(item.action) >= 0) sanctionColor = 'text-blue-400';
if (item.action === 'unban') sanctionColor = 'text-green-400';
var text = '*** ' + actionUpper;
if (item.moderator) text += ' par ' + item.moderator;
if (item.target) text += ' \u2192 ' + item.target;
if (item.details && item.details !== '-') text += ' (' + item.details + ')';
text += ' ***';
line.innerHTML = time + '<span class="' + sanctionColor + ' font-semibold">' + escapeHtml(text) + '</span>';
}
display.appendChild(line);
if (!shoutboxTabVisible) shoutboxBeep();
while (display.children.length > 200) display.removeChild(display.firstChild);
if (shoutboxAutoScroll) {
display.scrollTop = display.scrollHeight;
} else {
shoutboxUnreadCount++;
var btn = document.getElementById('shoutboxNewMsgBtn');
var txt = document.getElementById('shoutboxNewMsgText');
if (btn && txt) {
txt.textContent = shoutboxUnreadCount + ' nouveau' + (shoutboxUnreadCount > 1 ? 'x' : '') + ' message' + (shoutboxUnreadCount > 1 ? 's' : '');
btn.classList.remove('hidden');
}
}
}
function shoutboxScrollToBottom() {
var d = document.getElementById('shoutboxDisplay');
d.scrollTop = d.scrollHeight;
shoutboxAutoScroll = true;
shoutboxUnreadCount = 0;
var btn = document.getElementById('shoutboxNewMsgBtn');
if (btn) btn.classList.add('hidden');
}
function updateOnlineList(users) {
var list = document.getElementById('shoutboxOnlineList');
if (!list) return;
if (!users || users.length === 0) {
list.innerHTML = '<div class="text-xs text-gray-600 italic">---</div>';
return;
}
list.innerHTML = '';
users.forEach(function(u) {
var el = document.createElement('div');
el.className = 'flex items-center gap-1.5 text-xs text-gray-300 cursor-pointer hover:text-white';
el.onclick = function() { shoutboxMention(u); };
el.innerHTML = '<span class="w-1.5 h-1.5 rounded-full bg-green-500 flex-shrink-0"></span>' + escapeHtml(u);
list.appendChild(el);
});
}
function shoutboxHeartbeat() {
fetch('{{ url_for("shoutbox_heartbeat") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}'
})
.then(function(r) { return r.json(); })
.then(function(data) { updateOnlineList(data.online_users); })
.catch(function() {});
}
function fetchShoutbox() {
var url = '{{ url_for("shoutbox_messages") }}';
if (shoutboxLastTimestamp) url += '?since=' + encodeURIComponent(shoutboxLastTimestamp);
fetch(url)
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.items && data.items.length > 0) {
data.items.forEach(addShoutboxItem);
if (shoutboxAutoScroll) {
setTimeout(function() {
var d = document.getElementById('shoutboxDisplay');
d.scrollTop = d.scrollHeight;
}, 0);
}
}
if (data.timestamp) shoutboxLastTimestamp = data.timestamp;
if (data.online_users) updateOnlineList(data.online_users);
var cnt = document.getElementById('shoutboxCount');
if (cnt) cnt.textContent = '(' + shoutboxKnownIds.size + ')';
})
.catch(function(e) { console.error('Shoutbox error:', e); });
}
function openShoutboxPopout() {
window.open('{{ url_for("shoutbox_popout") }}', 'shoutbox_popout', 'width=520,height=420,resizable=yes,scrollbars=no,menubar=no,toolbar=no,location=no,status=no');
}
function sendShoutboxMessage(event) {
event.preventDefault();
var input = document.getElementById('shoutboxInput');
var message = input.value.trim();
if (!message) return;
var btn = event.target.querySelector('button[type="submit"]');
btn.disabled = true;
fetch('{{ url_for("shoutbox_send") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: message })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
input.value = '';
fetchShoutbox();
} else {
showNotification(data.error || 'Erreur shoutbox', 'error');
}
})
.catch(function() { showNotification('Erreur réseau', 'error'); })
.finally(function() { btn.disabled = false; });
}
function clearShoutbox() {
if (!confirm('Effacer tous les messages de la shoutbox ?')) return;
fetch('{{ url_for("shoutbox_clear") }}')
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
var display = document.getElementById('shoutboxDisplay');
display.innerHTML = '<div class="text-gray-500 text-center py-4" id="shoutboxPlaceholder">Aucun message</div>';
shoutboxKnownIds.clear();
shoutboxLastTimestamp = '';
showNotification('Shoutbox effacée', 'success');
}
})
.catch(function() { showNotification('Erreur réseau', 'error'); });
}
document.getElementById('shoutboxDisplay').addEventListener('scroll', function() {
shoutboxAutoScroll = this.scrollHeight - this.scrollTop <= this.clientHeight + 30;
if (shoutboxAutoScroll && shoutboxUnreadCount > 0) {
shoutboxUnreadCount = 0;
var btn = document.getElementById('shoutboxNewMsgBtn');
if (btn) btn.classList.add('hidden');
}
});
shoutboxApplyFontSize();
shoutboxUpdateSoundUI();
setInterval(fetchShoutbox, 3000);
fetchShoutbox();
setInterval(shoutboxHeartbeat, 10000);
shoutboxHeartbeat();
// =============================
// Modal ajout de commande
// =============================
function openAddCommandModal() {
document.getElementById('addCommandModal').classList.remove('hidden');
}
function closeAddCommandModal() {
document.getElementById('addCommandModal').classList.add('hidden');
}
// =============================
// Insertion de variables dans les textareas
// =============================
function insertVariable(textareaId, variable) {
var textarea = document.getElementById(textareaId);
if (!textarea) return;
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
var text = textarea.value;
textarea.value = text.substring(0, start) + variable + text.substring(end);
textarea.selectionStart = textarea.selectionEnd = start + variable.length;
textarea.focus();
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeAddCommandModal();
});
// =============================
// Polling infos stream dynamique
// =============================
var _streamStartedAt = {{ started_at | tojson }};
var _uptimeInterval = null;
function updateUptime() {
var el = document.getElementById('streamUptime');
if (!el || !_streamStartedAt) return;
var start = new Date(_streamStartedAt);
var now = new Date();
var diff = Math.max(0, Math.floor((now - start) / 1000));
var h = Math.floor(diff / 3600);
var m = Math.floor((diff % 3600) / 60);
var s = diff % 60;
el.textContent = (h > 0 ? h + 'h ' : '') + (m < 10 && h > 0 ? '0' : '') + m + 'min ' + (s < 10 ? '0' : '') + s + 's';
}
function fetchStreamInfo() {
fetch('{{ url_for("twitch_stream_info") }}')
.then(function(r) { return r.json(); })
.then(function(data) {
var titleEl = document.getElementById('streamTitle');
var gameEl = document.getElementById('streamGame');
var viewersEl = document.getElementById('streamViewers');
var playerViewersEl = document.getElementById('playerViewerCount');
if (titleEl) titleEl.textContent = data.title || 'N/A';
if (gameEl) gameEl.textContent = data.game_name || 'N/A';
if (viewersEl) viewersEl.textContent = data.viewer_count;
if (playerViewersEl) playerViewersEl.innerHTML = '&#8226; ' + data.viewer_count + ' viewers';
if (data.started_at) {
_streamStartedAt = data.started_at;
if (!_uptimeInterval) {
_uptimeInterval = setInterval(updateUptime, 1000);
}
updateUptime();
}
})
.catch(function(e) { console.error('Erreur stream info:', e); });
}
{% if is_live %}
if (_streamStartedAt) {
_uptimeInterval = setInterval(updateUptime, 1000);
updateUptime();
}
{% endif %}
setInterval(fetchStreamInfo, 15000);
fetchStreamInfo();
</script>
{% endblock %}