1401 lines
70 KiB
HTML
1401 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; }
|
|
</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>
|
|
<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 xl:grid-cols-4 gap-4">
|
|
<!-- Colonne 1 : Live Twitch -->
|
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden flex flex-col xl:h-[600px]">
|
|
<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">• {{ 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="p-3 space-y-2 text-sm overflow-y-auto">
|
|
<div>
|
|
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">Titre</div>
|
|
<div class="font-medium text-gray-900 dark:text-white leading-snug" id="streamTitle">{{ stream_title or 'N/A' }}</div>
|
|
</div>
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">Catégorie</div>
|
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300" id="streamGame">{{ game_name or 'N/A' }}</span>
|
|
</div>
|
|
<div class="text-right">
|
|
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">Viewers</div>
|
|
<div class="text-xl font-bold text-purple-600 dark:text-purple-400" id="streamViewers">{{ viewer_count }}</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">Uptime</div>
|
|
<div class="text-sm font-mono font-medium text-gray-900 dark:text-white" id="streamUptime">--:--:--</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Colonne 2 : Chat bot Twitch -->
|
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden flex flex-col xl:h-[600px]">
|
|
|
|
<!-- === 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 • <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="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden flex flex-col xl:h-[600px]">
|
|
<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="section-card flex flex-col xl:h-[600px]">
|
|
<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-28 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">></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) + ' → ' + escapeHtml(log.target) +
|
|
(log.details && log.details !== '-' ? ' — ' + 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')) || 12;
|
|
|
|
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, "\\'") + '\')"><' + escapeHtml(item.author) + '></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 = '• ' + 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 %}
|