- Introduced a new helper function `_format_stream_uptime` to calculate and format the stream uptime based on the start time. - Updated the Twitch moderation route to include the formatted stream uptime in the rendered template. - Enhanced the Twitch moderation template layout for better responsiveness and visual clarity, including adjustments to the live status display and overall structure.
1396 lines
73 KiB
HTML
1396 lines
73 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; }
|
|
@keyframes tmOfflineGlow {
|
|
0%, 100% { opacity: 0.55; }
|
|
50% { opacity: 1; }
|
|
}
|
|
.tm-offline-icon { animation: tmOfflineGlow 3s ease-in-out infinite; }
|
|
</style>
|
|
|
|
<div id="notificationStack"></div>
|
|
|
|
<div class="space-y-4">
|
|
<div class="flex flex-col gap-2 sm:gap-3 lg:flex-row lg:items-center lg:justify-between lg:gap-4">
|
|
<div class="min-w-0 flex-shrink">
|
|
<h1 class="text-xl font-bold text-gray-900 dark:text-white leading-tight">Modération Twitch</h1>
|
|
<p class="text-sm text-gray-600 dark:text-gray-400 mt-0.5">Commandes et logs de modération pour {{ twitch_channel }}</p>
|
|
</div>
|
|
<div class="flex flex-wrap gap-1.5 sm:gap-2 lg:justify-end lg:max-w-[min(100%,40rem)] lg:flex-shrink-0">
|
|
<!-- Diffusion : live / hors ligne (compact, aligné au titre) -->
|
|
<div class="flex flex-1 min-w-[128px] max-w-full sm:flex-initial sm:max-w-[11.5rem] items-center gap-1.5 rounded-md border shadow-sm px-2 py-1.5 {{ 'border-violet-200/80 dark:border-violet-500/25 bg-gradient-to-br from-violet-50/90 to-white dark:from-violet-950/40 dark:to-gray-900/90' if is_live else 'border-indigo-200/60 dark:border-indigo-400/20 bg-gradient-to-br from-slate-100/90 to-indigo-50/50 dark:from-slate-800/80 dark:to-indigo-950/40' }}">
|
|
<div class="p-1 rounded shrink-0 {{ 'bg-violet-500/15 dark:bg-violet-500/20' if is_live else 'bg-indigo-500/10 dark:bg-indigo-400/15' }}">
|
|
{% if is_live %}
|
|
<svg class="w-3 h-3 text-violet-600 dark:text-violet-400" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"></path></svg>
|
|
{% else %}
|
|
<svg class="w-3 h-3 text-indigo-500 dark:text-indigo-300 tm-offline-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path></svg>
|
|
{% endif %}
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<h3 class="font-semibold text-gray-900 dark:text-white text-[11px] leading-tight tracking-tight">{{ 'En direct' if is_live else 'Hors ligne' }}</h3>
|
|
{% if is_live %}
|
|
<p class="text-[10px] text-gray-600 dark:text-gray-300 mt-0.5 line-clamp-1 leading-tight" title="{{ stream_title or '' }}">{{ stream_title or 'Sans titre' }}</p>
|
|
{% else %}
|
|
<p class="text-[10px] text-indigo-600/80 dark:text-indigo-300/80 mt-0.5 leading-tight">Repos — à bientôt sur Twitch</p>
|
|
{% endif %}
|
|
</div>
|
|
<a href="https://www.twitch.tv/{{ twitch_channel }}" target="_blank" rel="noopener noreferrer" class="text-[10px] text-purple-600 dark:text-purple-400 hover:underline shrink-0" title="Ouvrir la chaîne Twitch">Chaîne</a>
|
|
</div>
|
|
<div class="flex flex-1 min-w-[128px] max-w-full sm:flex-initial sm:max-w-[10rem] items-center gap-1.5 rounded-md border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-2 py-1.5 shadow-sm">
|
|
<div class="p-1 rounded shrink-0 {{ 'bg-green-100 dark:bg-green-900/30' if link_filter_enabled else 'bg-gray-100 dark:bg-gray-700' }}">
|
|
<svg class="w-3 h-3 {{ 'text-green-600 dark:text-green-400' if link_filter_enabled else 'text-gray-500' }}" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path></svg>
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<h3 class="font-semibold text-gray-900 dark:text-white text-[11px] leading-tight">Filtre liens</h3>
|
|
<p class="text-[10px] text-gray-500 dark:text-gray-400">{{ 'Actif' if link_filter_enabled else 'Inactif' }}</p>
|
|
</div>
|
|
<a href="{{ url_for('link_filter') }}" class="text-[10px] text-purple-600 dark:text-purple-400 hover:underline shrink-0">Réglages</a>
|
|
</div>
|
|
<div class="flex flex-1 min-w-[128px] max-w-full sm:flex-initial sm:max-w-[10rem] items-center gap-1.5 rounded-md border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-2 py-1.5 shadow-sm">
|
|
<div class="p-1 rounded shrink-0 {{ 'bg-orange-100 dark:bg-orange-900/30' if banned_words else 'bg-gray-100 dark:bg-gray-700' }}">
|
|
<svg class="w-3 h-3 {{ 'text-orange-600 dark:text-orange-400' if banned_words else 'text-gray-500' }}" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path></svg>
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<h3 class="font-semibold text-gray-900 dark:text-white text-[11px] leading-tight">Mots interdits</h3>
|
|
<p class="text-[10px] text-gray-500 dark:text-gray-400">{{ banned_words|length }} actif{{ 's' if banned_words|length != 1 else '' }}</p>
|
|
</div>
|
|
<a href="#section-mots-interdits" class="text-[10px] text-purple-600 dark:text-purple-400 hover:underline shrink-0">Liste</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 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 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 min-w-0">
|
|
{% if is_live %}
|
|
<span class="flex h-2 w-2 relative shrink-0" id="liveIndicator">
|
|
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
|
<span class="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
|
|
</span>
|
|
<span class="inline-flex items-center gap-1.5 rounded-full bg-red-500/10 dark:bg-red-500/15 px-2 py-0.5 border border-red-500/25 dark:border-red-400/20 shrink-0" id="liveStatusPill">
|
|
<span class="text-xs font-semibold text-red-700 dark:text-red-300 tracking-wide" id="liveStatusLabel">EN DIRECT</span>
|
|
</span>
|
|
{% else %}
|
|
<span class="inline-flex items-center gap-1.5 rounded-full bg-indigo-500/10 dark:bg-indigo-400/10 px-2 py-0.5 border border-indigo-400/20 dark:border-indigo-400/25 shrink-0" id="liveStatusPill">
|
|
<svg class="w-3.5 h-3.5 text-indigo-500 dark:text-indigo-300 tm-offline-icon shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path></svg>
|
|
<span class="text-xs font-medium text-indigo-800 dark:text-indigo-200" id="liveStatusLabel">Pas en direct</span>
|
|
</span>
|
|
{% endif %}
|
|
<span class="text-sm text-gray-500 dark:text-gray-400 truncate" 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="flex-1 p-3 min-h-[3.5rem] flex items-center">
|
|
<div class="min-w-0 text-left w-full">
|
|
{% if is_live %}
|
|
<p class="text-sm font-medium text-gray-900 dark:text-white line-clamp-2" title="{{ stream_title or '' }}">{{ stream_title or 'Sans titre' }}</p>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate">{{ game_name or 'Catégorie non renseignée' }}{% if stream_uptime %}<span class="text-gray-400 dark:text-gray-500"> · {{ stream_uptime }}</span>{% endif %}</p>
|
|
{% else %}
|
|
<p class="text-sm text-gray-600 dark:text-gray-300">En attente du prochain live</p>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Lecteur intégré — son activé automatiquement quand la chaîne est en ligne</p>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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 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 • <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 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>
|
|
|
|
</div>
|
|
|
|
<!-- Shoutbox modérateurs (pleine largeur pour lisibilité) -->
|
|
<div class="section-card flex flex-col" style="height: 340px;">
|
|
<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">></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>
|
|
|
|
<!-- 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 id="section-mots-interdits" class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden scroll-mt-4">
|
|
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
|
<h3 class="font-semibold text-gray-900 dark:text-white">Mots interdits ({{ banned_words|length }})</h3>
|
|
</div>
|
|
<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')) || 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, "\\'") + '\')"><' + 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 %}
|