Merge branch 'twitchbot' into main
Intégration de toutes les fonctionnalités développées dans la branche twitchbot : - Système de modération Twitch complet - Filtre de liens intelligent - Notifications d'événements Twitch - Système Freeloot Discord - Salons automatiques Discord - Authentification utilisateur - Gestion des utilisateurs - Interfaces d'administration étendues Conflits résolus en faveur des versions de twitchbot pour : - templates/commandes.html - templates/configurations.html - templates/humeurs.html - templates/index.html - templates/live-alert.html - templates/moderation.html - templates/protondb.html - templates/template.html Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+53
-1
@@ -1,5 +1,57 @@
|
||||
import os
|
||||
from flask import Flask
|
||||
from flask_login import LoginManager
|
||||
|
||||
webapp = Flask(__name__)
|
||||
|
||||
from webapp import commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation
|
||||
# Secret key pour les sessions (Flask-Login)
|
||||
webapp.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-change-in-production")
|
||||
|
||||
# État des bots (mis à jour par les bots, lu par le panneau)
|
||||
webapp.config["BOT_STATUS"] = {
|
||||
"discord_connected": False,
|
||||
"discord_guild_count": 0,
|
||||
"twitch_connected": False,
|
||||
"twitch_channel_name": None,
|
||||
"twitch_is_live": False,
|
||||
"twitch_viewer_count": 0,
|
||||
"twitch_chat_messages": [], # Derniers messages du chat (max 100)
|
||||
}
|
||||
|
||||
login_manager = LoginManager()
|
||||
login_manager.init_app(webapp)
|
||||
login_manager.login_view = "login"
|
||||
login_manager.login_message = "Veuillez vous connecter pour accéder à cette page."
|
||||
|
||||
from database.models import WebappUser
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
try:
|
||||
return WebappUser.query.get(int(user_id))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
from webapp import auth, commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation, youtube, announcements, twitch_moderation, link_filter, twitch_events, users, settings, freeloot
|
||||
|
||||
from flask import request, redirect, url_for
|
||||
from flask_login import current_user
|
||||
|
||||
@webapp.context_processor
|
||||
def inject_user_level():
|
||||
from flask_login import current_user
|
||||
from database.helpers import ConfigurationHelper
|
||||
reg = ConfigurationHelper().getValue("registration_enabled")
|
||||
registration_enabled = reg not in (None, "", "false", "0", "no", "off")
|
||||
return {
|
||||
"current_user_level": current_user.get_level() if current_user.is_authenticated else -1,
|
||||
"registration_enabled": registration_enabled,
|
||||
}
|
||||
|
||||
@webapp.before_request
|
||||
def require_login():
|
||||
"""Redirige vers /login si non authentifié (sauf login, register, static, callback Twitch OAuth)."""
|
||||
if request.endpoint in (None, "login", "register", "static", "twitchReceiveToken"):
|
||||
return
|
||||
if not current_user.is_authenticated:
|
||||
return redirect(url_for("login", next=request.url))
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
from flask import render_template, request, redirect, url_for
|
||||
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page, can_write_page
|
||||
from database import db
|
||||
from database.models import TwitchAnnouncement
|
||||
|
||||
|
||||
@webapp.route("/announcements")
|
||||
@require_page("announcements")
|
||||
def openAnnouncements():
|
||||
announcements = TwitchAnnouncement.query.all()
|
||||
return render_template("announcements.html", announcements=announcements)
|
||||
|
||||
|
||||
@webapp.route("/announcements/add", methods=['POST'])
|
||||
@require_page("announcements")
|
||||
def addAnnouncement():
|
||||
if not can_write_page("announcements"):
|
||||
return render_template("403.html"), 403
|
||||
announcement = TwitchAnnouncement(
|
||||
enable=True,
|
||||
name=request.form.get('name'),
|
||||
text=request.form.get('text'),
|
||||
periodicity=int(request.form.get('periodicity', 10)),
|
||||
min_chat_messages=int(request.form.get('min_chat_messages', 0))
|
||||
)
|
||||
db.session.add(announcement)
|
||||
db.session.commit()
|
||||
return redirect(url_for("openAnnouncements"))
|
||||
|
||||
|
||||
@webapp.route("/announcements/toggle/<int:id>")
|
||||
@require_page("announcements")
|
||||
def toggleAnnouncement(id):
|
||||
if not can_write_page("announcements"):
|
||||
return render_template("403.html"), 403
|
||||
announcement = TwitchAnnouncement.query.get_or_404(id)
|
||||
announcement.enable = not announcement.enable
|
||||
db.session.commit()
|
||||
return redirect(url_for("openAnnouncements"))
|
||||
|
||||
|
||||
@webapp.route("/announcements/edit/<int:id>")
|
||||
@require_page("announcements")
|
||||
def openEditAnnouncement(id):
|
||||
announcement = TwitchAnnouncement.query.get_or_404(id)
|
||||
return render_template("announcements.html", announcement=announcement)
|
||||
|
||||
|
||||
@webapp.route("/announcements/edit/<int:id>", methods=['POST'])
|
||||
@require_page("announcements")
|
||||
def submitEditAnnouncement(id):
|
||||
if not can_write_page("announcements"):
|
||||
return render_template("403.html"), 403
|
||||
announcement = TwitchAnnouncement.query.get_or_404(id)
|
||||
announcement.name = request.form.get('name')
|
||||
announcement.text = request.form.get('text')
|
||||
announcement.periodicity = int(request.form.get('periodicity', 10))
|
||||
announcement.min_chat_messages = int(request.form.get('min_chat_messages', 0))
|
||||
db.session.commit()
|
||||
return redirect(url_for("openAnnouncements"))
|
||||
|
||||
|
||||
@webapp.route("/announcements/del/<int:id>")
|
||||
@require_page("announcements")
|
||||
def delAnnouncement(id):
|
||||
if not can_write_page("announcements"):
|
||||
return render_template("403.html"), 403
|
||||
announcement = TwitchAnnouncement.query.get_or_404(id)
|
||||
db.session.delete(announcement)
|
||||
db.session.commit()
|
||||
return redirect(url_for("openAnnouncements"))
|
||||
|
||||
|
||||
@webapp.route("/announcements/reset/<int:id>")
|
||||
@require_page("announcements")
|
||||
def resetAnnouncement(id):
|
||||
if not can_write_page("announcements"):
|
||||
return render_template("403.html"), 403
|
||||
announcement = TwitchAnnouncement.query.get_or_404(id)
|
||||
announcement.last_sent = None
|
||||
db.session.commit()
|
||||
return redirect(url_for("openAnnouncements"))
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
# Authentification webapp : login, register, logout et contrôle d'accès par rôles.
|
||||
from functools import wraps
|
||||
|
||||
from flask import render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
|
||||
from database import db
|
||||
from database.models import WebappUser, ROLE_ORDER, PagePermission
|
||||
from database.helpers import ConfigurationHelper
|
||||
|
||||
from webapp import webapp
|
||||
|
||||
|
||||
def require_roles(allowed_roles: list):
|
||||
"""Décorateur : exige que l'utilisateur soit authentifié et ait l'un des rôles autorisés."""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def wrapped(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
return redirect(url_for("login", next=request.url))
|
||||
if current_user.role not in allowed_roles:
|
||||
return render_template("403.html"), 403
|
||||
return f(*args, **kwargs)
|
||||
return wrapped
|
||||
return decorator
|
||||
|
||||
|
||||
def require_role_min(min_role: str):
|
||||
"""Décorateur : exige que l'utilisateur ait au moins le rôle min_role (niveau en base)."""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def wrapped(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
return redirect(url_for("login", next=request.url))
|
||||
if not current_user.has_role_at_least(min_role):
|
||||
return render_template("403.html"), 403
|
||||
return f(*args, **kwargs)
|
||||
return wrapped
|
||||
return decorator
|
||||
|
||||
|
||||
def _page_min_level(page_key: str, for_write: bool = False) -> int:
|
||||
"""Niveau minimum requis pour la page (lecture ou écriture)."""
|
||||
perm = PagePermission.query.filter_by(page_key=page_key).first()
|
||||
if not perm:
|
||||
return 0
|
||||
if for_write and perm.write_level is not None:
|
||||
return perm.write_level
|
||||
return perm.min_level
|
||||
|
||||
|
||||
def require_page(page_key: str):
|
||||
"""Décorateur : accès en lecture selon les permissions de la page (webapp_page_permission)."""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def wrapped(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
return redirect(url_for("login", next=request.url))
|
||||
min_level = _page_min_level(page_key, for_write=False)
|
||||
if not current_user.has_level_at_least(min_level):
|
||||
return render_template("403.html"), 403
|
||||
return f(*args, **kwargs)
|
||||
return wrapped
|
||||
return decorator
|
||||
|
||||
|
||||
def require_page_write(page_key: str):
|
||||
"""Décorateur : accès en écriture selon les permissions de la page."""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def wrapped(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
return redirect(url_for("login", next=request.url))
|
||||
min_level = _page_min_level(page_key, for_write=True)
|
||||
if not current_user.has_level_at_least(min_level):
|
||||
return render_template("403.html"), 403
|
||||
return f(*args, **kwargs)
|
||||
return wrapped
|
||||
return decorator
|
||||
|
||||
|
||||
def can_write_page(page_key: str) -> bool:
|
||||
"""Retourne True si l'utilisateur connecté a le niveau pour écrire sur cette page."""
|
||||
if not current_user.is_authenticated:
|
||||
return False
|
||||
return current_user.has_level_at_least(_page_min_level(page_key, for_write=True))
|
||||
|
||||
|
||||
@webapp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("index"))
|
||||
if request.method == "POST":
|
||||
identifier = (request.form.get("identifier") or "").strip()
|
||||
password = request.form.get("password") or ""
|
||||
if not identifier or not password:
|
||||
flash("Identifiant et mot de passe requis.", "error")
|
||||
return render_template("login.html")
|
||||
user = WebappUser.query.filter(
|
||||
(WebappUser.username == identifier) | (WebappUser.email == identifier)
|
||||
).first()
|
||||
if user and check_password_hash(user.password_hash, password):
|
||||
login_user(user, remember=True)
|
||||
next_url = request.args.get("next")
|
||||
if next_url and next_url.startswith("/"):
|
||||
return redirect(next_url)
|
||||
return redirect(url_for("index"))
|
||||
flash("Identifiant ou mot de passe incorrect.", "error")
|
||||
return render_template("login.html")
|
||||
return render_template("login.html")
|
||||
|
||||
|
||||
@webapp.route("/register", methods=["GET", "POST"])
|
||||
def register():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("index"))
|
||||
# Inscriptions désactivées par le super admin
|
||||
reg_enabled = ConfigurationHelper().getValue("registration_enabled")
|
||||
if reg_enabled in (None, "", "false", "0", "no", "off"):
|
||||
flash("Les inscriptions sont désactivées.", "error")
|
||||
return redirect(url_for("login"))
|
||||
if request.method == "POST":
|
||||
username = (request.form.get("username") or "").strip()
|
||||
email = (request.form.get("email") or "").strip().lower()
|
||||
password = request.form.get("password") or ""
|
||||
password_confirm = request.form.get("password_confirm") or ""
|
||||
errors = []
|
||||
if len(username) < 3:
|
||||
errors.append("Le nom d'utilisateur doit faire au moins 3 caractères.")
|
||||
if len(email) < 5 or "@" not in email:
|
||||
errors.append("Adresse e-mail invalide.")
|
||||
if len(password) < 8:
|
||||
errors.append("Le mot de passe doit faire au moins 8 caractères.")
|
||||
if password != password_confirm:
|
||||
errors.append("Les mots de passe ne correspondent pas.")
|
||||
if WebappUser.query.filter_by(username=username).first():
|
||||
errors.append("Ce nom d'utilisateur est déjà pris.")
|
||||
if WebappUser.query.filter_by(email=email).first():
|
||||
errors.append("Cette adresse e-mail est déjà utilisée.")
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, "error")
|
||||
return render_template("register.html")
|
||||
# Premier inscrit = super administrateur
|
||||
role = "super_administrateur" if WebappUser.query.count() == 0 else "viewer_twitch"
|
||||
user = WebappUser(
|
||||
username=username,
|
||||
email=email,
|
||||
password_hash=generate_password_hash(password, method="scrypt"),
|
||||
role=role,
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
flash("Compte créé. Vous pouvez vous connecter.", "success")
|
||||
return redirect(url_for("login"))
|
||||
return render_template("register.html")
|
||||
|
||||
|
||||
@webapp.route("/logout")
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
return redirect(url_for("login"))
|
||||
+22
-2
@@ -1,19 +1,30 @@
|
||||
from flask import render_template, request, redirect, url_for, flash
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page, can_write_page
|
||||
from database import db
|
||||
from database.models import Commande
|
||||
|
||||
@webapp.route("/commandes")
|
||||
@require_page("commandes")
|
||||
def commandes():
|
||||
commandes_list = Commande.query.all()
|
||||
return render_template("commandes.html", commandes=commandes_list)
|
||||
return render_template("commandes.html", commandes=commandes_list, twitch_permissions=TWITCH_PERMISSIONS)
|
||||
|
||||
TWITCH_PERMISSIONS = {'viewer': 'Tous (viewers)', 'sub': 'Abonnés', 'vip': 'VIP', 'moderator': 'Modérateur'}
|
||||
|
||||
|
||||
@webapp.route("/commandes/add", methods=['POST'])
|
||||
@require_page("commandes")
|
||||
def add_commande():
|
||||
if not can_write_page("commandes"):
|
||||
return render_template("403.html"), 403
|
||||
trigger = request.form.get('trigger')
|
||||
response = request.form.get('response')
|
||||
discord_enable = request.form.get('discord_enable') != None
|
||||
twitch_enable = request.form.get('twitch_enable') != None
|
||||
twitch_permission = request.form.get('twitch_permission') or 'viewer'
|
||||
if twitch_permission not in TWITCH_PERMISSIONS:
|
||||
twitch_permission = 'viewer'
|
||||
|
||||
if trigger and response:
|
||||
if not trigger.startswith('!'):
|
||||
@@ -21,28 +32,37 @@ def add_commande():
|
||||
|
||||
existing = Commande.query.filter_by(trigger=trigger).first()
|
||||
if not existing:
|
||||
commande = Commande(trigger=trigger, response=response, discord_enable=discord_enable, twitch_enable=twitch_enable)
|
||||
commande = Commande(trigger=trigger, response=response, discord_enable=discord_enable, twitch_enable=twitch_enable, twitch_permission=twitch_permission)
|
||||
db.session.add(commande)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for('commandes'))
|
||||
|
||||
@webapp.route("/commandes/delete/<int:commande_id>")
|
||||
@require_page("commandes")
|
||||
def delete_commande(commande_id):
|
||||
if not can_write_page("commandes"):
|
||||
return render_template("403.html"), 403
|
||||
commande = Commande.query.get_or_404(commande_id)
|
||||
db.session.delete(commande)
|
||||
db.session.commit()
|
||||
return redirect(url_for('commandes'))
|
||||
|
||||
@webapp.route("/commandes/toggle-discord/<int:commande_id>")
|
||||
@require_page("commandes")
|
||||
def toggle_discord_commande(commande_id):
|
||||
if not can_write_page("commandes"):
|
||||
return render_template("403.html"), 403
|
||||
commande = Commande.query.get_or_404(commande_id)
|
||||
commande.discord_enable = not commande.discord_enable
|
||||
db.session.commit()
|
||||
return redirect(url_for('commandes'))
|
||||
|
||||
@webapp.route("/commandes/toggle-twitch/<int:commande_id>")
|
||||
@require_page("commandes")
|
||||
def toggle_twitch_commande(commande_id):
|
||||
if not can_write_page("commandes"):
|
||||
return render_template("403.html"), 403
|
||||
commande = Commande.query.get_or_404(commande_id)
|
||||
commande.twitch_enable = not commande.twitch_enable
|
||||
db.session.commit()
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
from flask import render_template, request, redirect, url_for
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page
|
||||
from database import db
|
||||
from database.helpers import ConfigurationHelper
|
||||
from discordbot import bot
|
||||
|
||||
@webapp.route("/configurations")
|
||||
@require_page("configurations")
|
||||
def openConfigurations():
|
||||
return render_template("configurations.html", configuration = ConfigurationHelper(), channels = bot.getAllTextChannel(), roles = bot.getAllRoles())
|
||||
return render_template("configurations.html", configuration=ConfigurationHelper(), channels=bot.getAllTextChannel(), voice_channels=bot.getAllVoiceChannels(), roles=bot.getAllRoles())
|
||||
|
||||
@webapp.route("/configurations/update", methods=['POST'])
|
||||
@webapp.route("/configurations/update", methods=['POST'])
|
||||
@require_page("configurations")
|
||||
def updateConfiguration():
|
||||
checkboxes = {
|
||||
'humble_bundle_enable': 'humble_bundle_channel',
|
||||
@@ -17,7 +20,9 @@ def updateConfiguration():
|
||||
'moderation_ban_enable': 'moderation_staff_role_ids',
|
||||
'moderation_kick_enable': 'moderation_staff_role_ids',
|
||||
'welcome_enable': 'welcome_channel_id',
|
||||
'leave_enable': 'leave_channel_id'
|
||||
'leave_enable': 'leave_channel_id',
|
||||
'auto_rooms_enable': 'auto_rooms_channel_id',
|
||||
'twitch_commands_enable': 'twitch_channel'
|
||||
}
|
||||
|
||||
staff_roles = request.form.getlist('moderation_staff_role_ids')
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# Page webapp : configuration des notifications FreeLoot (feed LootScraper)
|
||||
from flask import render_template, request, redirect, url_for
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page, can_write_page
|
||||
from database import db
|
||||
from database.helpers import ConfigurationHelper
|
||||
from discordbot import bot
|
||||
from discordbot.freeloot import send_entry_to_discord_sync
|
||||
from freeloot_feed import SOURCES, get_display_entries
|
||||
|
||||
|
||||
def _format_updated(updated: str | None) -> str:
|
||||
"""Formate la date ISO en affichage court."""
|
||||
if not updated:
|
||||
return ""
|
||||
try:
|
||||
from datetime import datetime
|
||||
dt = datetime.fromisoformat(updated.replace("Z", "+00:00"))
|
||||
return dt.strftime("%d/%m/%Y %H:%M")
|
||||
except Exception:
|
||||
return updated[:16] if len(updated or "") >= 16 else (updated or "")
|
||||
|
||||
|
||||
def _parse_mention_config(raw: str | None) -> tuple[bool, bool, list[str]]:
|
||||
"""Retourne (everyone, here, list of role_ids) depuis freeloot_mention."""
|
||||
everyone, here, role_ids = False, False, []
|
||||
if not raw or not str(raw).strip():
|
||||
return (everyone, here, role_ids)
|
||||
for part in str(raw).strip().split(","):
|
||||
part = part.strip()
|
||||
if part == "everyone":
|
||||
everyone = True
|
||||
elif part == "here":
|
||||
here = True
|
||||
elif part.isdigit():
|
||||
role_ids.append(part)
|
||||
return (everyone, here, role_ids)
|
||||
|
||||
|
||||
@webapp.route("/freeloot")
|
||||
@require_page("freeloot")
|
||||
def openFreeLoot():
|
||||
helper = ConfigurationHelper()
|
||||
channels = bot.getAllTextChannel()
|
||||
roles = bot.getAllRoles()
|
||||
raw_sources = helper.getValue("freeloot_sources")
|
||||
enabled_sources = []
|
||||
if raw_sources and str(raw_sources).strip():
|
||||
enabled_sources = [s.strip() for s in str(raw_sources).split(",") if s.strip()]
|
||||
raw_mention = helper.getValue("freeloot_mention")
|
||||
mention_everyone, mention_here, mention_role_ids = _parse_mention_config(raw_mention)
|
||||
entries = get_display_entries()
|
||||
if enabled_sources:
|
||||
entries = [e for e in entries if e.get("source_key") in enabled_sources]
|
||||
for e in entries:
|
||||
e["updated_formatted"] = _format_updated(e.get("updated"))
|
||||
return render_template(
|
||||
"freeloot.html",
|
||||
configuration=helper,
|
||||
channels=channels,
|
||||
roles=roles,
|
||||
sources=SOURCES,
|
||||
enabled_sources=enabled_sources,
|
||||
mention_everyone=mention_everyone,
|
||||
mention_here=mention_here,
|
||||
mention_role_ids=mention_role_ids,
|
||||
entries=entries,
|
||||
)
|
||||
|
||||
|
||||
@webapp.route("/freeloot/update", methods=["POST"])
|
||||
@require_page("freeloot")
|
||||
def updateFreeLoot():
|
||||
if not can_write_page("freeloot"):
|
||||
return render_template("403.html"), 403
|
||||
helper = ConfigurationHelper()
|
||||
enable = request.form.get("freeloot_enable") in ("on", "1", "true", "yes")
|
||||
channel_id = request.form.get("freeloot_channel_id")
|
||||
source_keys = request.form.getlist("freeloot_sources")
|
||||
mention_parts = []
|
||||
if request.form.get("freeloot_mention_everyone"):
|
||||
mention_parts.append("everyone")
|
||||
if request.form.get("freeloot_mention_here"):
|
||||
mention_parts.append("here")
|
||||
mention_parts.extend(request.form.getlist("freeloot_mention_roles"))
|
||||
helper.createOrUpdate("freeloot_enable", "true" if enable else "false")
|
||||
if channel_id:
|
||||
try:
|
||||
helper.createOrUpdate("freeloot_channel_id", str(int(channel_id)))
|
||||
except ValueError:
|
||||
pass
|
||||
helper.createOrUpdate("freeloot_sources", ",".join(source_keys) if source_keys else "")
|
||||
helper.createOrUpdate("freeloot_mention", ",".join(mention_parts))
|
||||
db.session.commit()
|
||||
return redirect(url_for("openFreeLoot") + "?msg=Configuration enregistrée.&type=success")
|
||||
|
||||
|
||||
@webapp.route("/freeloot/send", methods=["POST"])
|
||||
@require_page("freeloot")
|
||||
def send_free_loot_to_discord():
|
||||
if not can_write_page("freeloot"):
|
||||
return render_template("403.html"), 403
|
||||
entry_id = (request.form.get("entry_id") or "").strip()
|
||||
if not entry_id:
|
||||
return redirect(url_for("openFreeLoot") + "?" + urlencode({"msg": "Entrée manquante.", "type": "error"}))
|
||||
ok, message = send_entry_to_discord_sync(bot, entry_id)
|
||||
msg_type = "success" if ok else "error"
|
||||
return redirect(url_for("openFreeLoot") + "?" + urlencode({"msg": message, "type": msg_type}))
|
||||
+9
-1
@@ -1,22 +1,30 @@
|
||||
from flask import render_template, request, redirect, url_for
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page, can_write_page
|
||||
from database import db
|
||||
from database.models import Humeur
|
||||
|
||||
@webapp.route("/humeurs")
|
||||
@require_page("humeurs")
|
||||
def listHumeurs():
|
||||
humeurs = Humeur.query.all()
|
||||
return render_template("humeurs.html", humeurs = humeurs)
|
||||
return render_template("humeurs.html", humeurs=humeurs)
|
||||
|
||||
@webapp.route('/humeurs/add', methods=['POST'])
|
||||
@require_page("humeurs")
|
||||
def addHumeur():
|
||||
if not can_write_page("humeurs"):
|
||||
return render_template("403.html"), 403
|
||||
humeur = Humeur(text=request.form['text'])
|
||||
db.session.add(humeur)
|
||||
db.session.commit()
|
||||
return redirect(url_for('listHumeurs'))
|
||||
|
||||
@webapp.route('/humeurs/del/<id>')
|
||||
@require_page("humeurs")
|
||||
def delHumeur(id):
|
||||
if not can_write_page("humeurs"):
|
||||
return render_template("403.html"), 403
|
||||
Humeur.query.filter_by(id=id).delete()
|
||||
db.session.commit()
|
||||
return redirect(url_for('listHumeurs'))
|
||||
|
||||
+17
-1
@@ -1,6 +1,22 @@
|
||||
from flask import render_template
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page
|
||||
from database.models import ModerationEvent, TwitchAnnouncement, TwitchModerationLog
|
||||
|
||||
@webapp.route("/")
|
||||
@require_page("index")
|
||||
def index():
|
||||
return render_template("index.html")
|
||||
status = webapp.config["BOT_STATUS"]
|
||||
sanctions_count = ModerationEvent.query.count()
|
||||
twitch_announcements_count = TwitchAnnouncement.query.count()
|
||||
twitch_moderation_count = TwitchModerationLog.query.count()
|
||||
return render_template(
|
||||
"index.html",
|
||||
discord_connected=status["discord_connected"],
|
||||
discord_guild_count=status["discord_guild_count"],
|
||||
sanctions_count=sanctions_count,
|
||||
twitch_connected=status["twitch_connected"],
|
||||
twitch_channel_name=status["twitch_channel_name"],
|
||||
twitch_announcements_count=twitch_announcements_count,
|
||||
twitch_moderation_count=twitch_moderation_count,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
from flask import render_template, request, redirect, url_for
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page, can_write_page
|
||||
from database import db
|
||||
from database.models import TwitchLinkFilter, TwitchAllowedDomain, TwitchAllowedUser
|
||||
|
||||
|
||||
def _get_or_create_config():
|
||||
config = TwitchLinkFilter.query.first()
|
||||
if not config:
|
||||
config = TwitchLinkFilter(enabled=False)
|
||||
db.session.add(config)
|
||||
db.session.commit()
|
||||
return config
|
||||
|
||||
|
||||
@webapp.route("/link-filter")
|
||||
@require_page("link_filter")
|
||||
def link_filter():
|
||||
config = _get_or_create_config()
|
||||
domains = TwitchAllowedDomain.query.order_by(TwitchAllowedDomain.domain).all()
|
||||
users = TwitchAllowedUser.query.order_by(TwitchAllowedUser.username).all()
|
||||
return render_template("link-filter.html", config=config, domains=domains, users=users)
|
||||
|
||||
|
||||
@webapp.route("/link-filter/toggle")
|
||||
@require_page("link_filter")
|
||||
def toggle_link_filter():
|
||||
if not can_write_page("link_filter"):
|
||||
return render_template("403.html"), 403
|
||||
config = _get_or_create_config()
|
||||
config.enabled = not config.enabled
|
||||
db.session.commit()
|
||||
return redirect(url_for('link_filter'))
|
||||
|
||||
|
||||
@webapp.route("/link-filter/update", methods=['POST'])
|
||||
@require_page("link_filter")
|
||||
def update_link_filter():
|
||||
if not can_write_page("link_filter"):
|
||||
return render_template("403.html"), 403
|
||||
config = _get_or_create_config()
|
||||
config.allow_subscribers = request.form.get('allow_subscribers') is not None
|
||||
config.allow_vips = request.form.get('allow_vips') is not None
|
||||
config.allow_moderators = request.form.get('allow_moderators') is not None
|
||||
config.timeout_duration = int(request.form.get('timeout_duration', 60))
|
||||
config.warning_message = request.form.get('warning_message', '')
|
||||
db.session.commit()
|
||||
return redirect(url_for('link_filter'))
|
||||
|
||||
|
||||
@webapp.route("/link-filter/domain/add", methods=['POST'])
|
||||
@require_page("link_filter")
|
||||
def add_allowed_domain():
|
||||
if not can_write_page("link_filter"):
|
||||
return render_template("403.html"), 403
|
||||
domain = request.form.get('domain', '').strip().lower()
|
||||
if domain:
|
||||
domain = domain.replace('https://', '').replace('http://', '').replace('www.', '')
|
||||
domain = domain.split('/')[0]
|
||||
existing = TwitchAllowedDomain.query.filter_by(domain=domain).first()
|
||||
if not existing:
|
||||
new_domain = TwitchAllowedDomain(domain=domain)
|
||||
db.session.add(new_domain)
|
||||
db.session.commit()
|
||||
return redirect(url_for('link_filter'))
|
||||
|
||||
|
||||
@webapp.route("/link-filter/domain/delete/<int:domain_id>")
|
||||
@require_page("link_filter")
|
||||
def delete_allowed_domain(domain_id):
|
||||
if not can_write_page("link_filter"):
|
||||
return render_template("403.html"), 403
|
||||
domain = TwitchAllowedDomain.query.get_or_404(domain_id)
|
||||
db.session.delete(domain)
|
||||
db.session.commit()
|
||||
return redirect(url_for('link_filter'))
|
||||
|
||||
|
||||
@webapp.route("/link-filter/user/add", methods=['POST'])
|
||||
@require_page("link_filter")
|
||||
def add_allowed_user():
|
||||
if not can_write_page("link_filter"):
|
||||
return render_template("403.html"), 403
|
||||
username = request.form.get('username', '').strip().lower().lstrip('@')
|
||||
if username:
|
||||
existing = TwitchAllowedUser.query.filter_by(username=username).first()
|
||||
if not existing:
|
||||
new_user = TwitchAllowedUser(username=username)
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
return redirect(url_for('link_filter'))
|
||||
|
||||
|
||||
@webapp.route("/link-filter/user/delete/<int:user_id>")
|
||||
@require_page("link_filter")
|
||||
def delete_allowed_user(user_id):
|
||||
if not can_write_page("link_filter"):
|
||||
return render_template("403.html"), 403
|
||||
user = TwitchAllowedUser.query.get_or_404(user_id)
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
return redirect(url_for('link_filter'))
|
||||
+57
-3
@@ -1,12 +1,14 @@
|
||||
from flask import render_template, request, redirect, url_for
|
||||
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page, can_write_page
|
||||
from database import db
|
||||
from database.models import LiveAlert
|
||||
from discordbot import bot
|
||||
|
||||
|
||||
@webapp.route("/live-alert")
|
||||
@require_page("live_alert")
|
||||
def openLiveAlert():
|
||||
alerts : list[LiveAlert] = LiveAlert.query.all()
|
||||
channels = bot.getAllTextChannel()
|
||||
@@ -17,37 +19,89 @@ def openLiveAlert():
|
||||
return render_template("live-alert.html", alerts = alerts, channels = channels)
|
||||
|
||||
@webapp.route("/live-alert/add", methods=['POST'])
|
||||
@require_page("live_alert")
|
||||
def addLiveAlert():
|
||||
alert = LiveAlert(enable = True, login = request.form.get('login'), notify_channel = request.form.get('notify_channel'), message = request.form.get('message'))
|
||||
if not can_write_page("live_alert"):
|
||||
return render_template("403.html"), 403
|
||||
embed_color = (request.form.get('embed_color') or '9146FF').strip().lstrip('#')
|
||||
if len(embed_color) != 6:
|
||||
embed_color = '9146FF'
|
||||
alert = LiveAlert(
|
||||
enable=True,
|
||||
login=request.form.get('login'),
|
||||
notify_channel=request.form.get('notify_channel'),
|
||||
message=(request.form.get('message') or '').strip(),
|
||||
watch_activity=request.form.get('watch_activity') == '1',
|
||||
embed_title=request.form.get('embed_title') or None,
|
||||
embed_description=request.form.get('embed_description') or None,
|
||||
embed_color=embed_color,
|
||||
embed_footer=request.form.get('embed_footer') or None,
|
||||
embed_author_name=request.form.get('embed_author_name') or None,
|
||||
embed_author_icon=request.form.get('embed_author_icon') or None,
|
||||
embed_thumbnail=request.form.get('embed_thumbnail') == 'on',
|
||||
embed_image=request.form.get('embed_image') == 'on',
|
||||
)
|
||||
db.session.add(alert)
|
||||
db.session.commit()
|
||||
return redirect(url_for("openLiveAlert"))
|
||||
|
||||
@webapp.route("/live-alert/toggle/<int:id>")
|
||||
@require_page("live_alert")
|
||||
def toggleLiveAlert(id):
|
||||
if not can_write_page("live_alert"):
|
||||
return render_template("403.html"), 403
|
||||
alert : LiveAlert = LiveAlert.query.get_or_404(id)
|
||||
alert.enable = not alert.enable
|
||||
db.session.commit()
|
||||
return redirect(url_for("openLiveAlert"))
|
||||
|
||||
@webapp.route("/live-alert/edit/<int:id>")
|
||||
@require_page("live_alert")
|
||||
def openEditLiveAlert(id):
|
||||
alert = LiveAlert.query.get_or_404(id)
|
||||
channels = bot.getAllTextChannel()
|
||||
return render_template("live-alert.html", alert = alert, channels = channels)
|
||||
|
||||
@webapp.route("/live-alert/edit/<int:id>", methods=['POST'])
|
||||
@require_page("live_alert")
|
||||
def submitEditLiveAlert(id):
|
||||
alert : LiveAlert = LiveAlert.query.get_or_404(id)
|
||||
if not can_write_page("live_alert"):
|
||||
return render_template("403.html"), 403
|
||||
alert: LiveAlert = LiveAlert.query.get_or_404(id)
|
||||
embed_color = (request.form.get('embed_color') or '9146FF').strip().lstrip('#')
|
||||
if len(embed_color) != 6:
|
||||
embed_color = '9146FF'
|
||||
alert.login = request.form.get('login')
|
||||
alert.notify_channel = request.form.get('notify_channel')
|
||||
alert.message = request.form.get('message')
|
||||
alert.message = (request.form.get('message') or '').strip()
|
||||
alert.watch_activity = request.form.get('watch_activity') == '1'
|
||||
alert.embed_title = request.form.get('embed_title') or None
|
||||
alert.embed_description = request.form.get('embed_description') or None
|
||||
alert.embed_color = embed_color
|
||||
alert.embed_footer = request.form.get('embed_footer') or None
|
||||
alert.embed_author_name = request.form.get('embed_author_name') or None
|
||||
alert.embed_author_icon = request.form.get('embed_author_icon') or None
|
||||
alert.embed_thumbnail = request.form.get('embed_thumbnail') == 'on'
|
||||
alert.embed_image = request.form.get('embed_image') == 'on'
|
||||
db.session.commit()
|
||||
return redirect(url_for("openLiveAlert"))
|
||||
|
||||
@webapp.route("/live-alert/toggle-watch/<int:id>")
|
||||
@require_page("live_alert")
|
||||
def toggleWatchActivity(id):
|
||||
if not can_write_page("live_alert"):
|
||||
return render_template("403.html"), 403
|
||||
alert : LiveAlert = LiveAlert.query.get_or_404(id)
|
||||
alert.watch_activity = not alert.watch_activity
|
||||
db.session.commit()
|
||||
return redirect(url_for("openLiveAlert"))
|
||||
|
||||
|
||||
@webapp.route("/live-alert/del/<int:id>")
|
||||
@require_page("live_alert")
|
||||
def delLiveAlert(id):
|
||||
if not can_write_page("live_alert"):
|
||||
return render_template("403.html"), 403
|
||||
alert = LiveAlert.query.get_or_404(id)
|
||||
db.session.delete(alert)
|
||||
db.session.commit()
|
||||
|
||||
+53
-2
@@ -1,28 +1,79 @@
|
||||
from flask import render_template, request, redirect, url_for
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page, can_write_page
|
||||
from database import db
|
||||
from database.models import ModerationEvent
|
||||
|
||||
def _top_sanctioned():
|
||||
return (
|
||||
db.session.query(
|
||||
ModerationEvent.discord_id,
|
||||
db.func.max(ModerationEvent.username).label("username"),
|
||||
db.func.count(ModerationEvent.id).label("count"),
|
||||
)
|
||||
.group_by(ModerationEvent.discord_id)
|
||||
.order_by(db.func.count(ModerationEvent.id).desc())
|
||||
.limit(3)
|
||||
.all()
|
||||
)
|
||||
|
||||
def _top_moderators():
|
||||
return (
|
||||
db.session.query(
|
||||
ModerationEvent.staff_id,
|
||||
db.func.max(ModerationEvent.staff_name).label("staff_name"),
|
||||
db.func.count(ModerationEvent.id).label("count"),
|
||||
)
|
||||
.group_by(ModerationEvent.staff_id)
|
||||
.order_by(db.func.count(ModerationEvent.id).desc())
|
||||
.limit(3)
|
||||
.all()
|
||||
)
|
||||
|
||||
@webapp.route("/moderation")
|
||||
@require_page("moderation")
|
||||
def moderation():
|
||||
events = ModerationEvent.query.order_by(ModerationEvent.created_at.desc()).all()
|
||||
return render_template("moderation.html", events=events, event=None)
|
||||
top_sanctioned = _top_sanctioned()
|
||||
top_moderators = _top_moderators()
|
||||
return render_template(
|
||||
"moderation.html",
|
||||
events=events,
|
||||
event=None,
|
||||
top_sanctioned=top_sanctioned,
|
||||
top_moderators=top_moderators,
|
||||
)
|
||||
|
||||
@webapp.route("/moderation/edit/<int:event_id>")
|
||||
@require_page("moderation")
|
||||
def open_edit_moderation_event(event_id):
|
||||
event = ModerationEvent.query.get_or_404(event_id)
|
||||
events = ModerationEvent.query.order_by(ModerationEvent.created_at.desc()).all()
|
||||
return render_template("moderation.html", events=events, event=event)
|
||||
top_sanctioned = _top_sanctioned()
|
||||
top_moderators = _top_moderators()
|
||||
return render_template(
|
||||
"moderation.html",
|
||||
events=events,
|
||||
event=event,
|
||||
top_sanctioned=top_sanctioned,
|
||||
top_moderators=top_moderators,
|
||||
)
|
||||
|
||||
@webapp.route("/moderation/update/<int:event_id>", methods=['POST'])
|
||||
@require_page("moderation")
|
||||
def update_moderation_event(event_id):
|
||||
if not can_write_page("moderation"):
|
||||
return render_template("403.html"), 403
|
||||
event = ModerationEvent.query.get_or_404(event_id)
|
||||
event.reason = request.form.get('reason')
|
||||
db.session.commit()
|
||||
return redirect(url_for('moderation'))
|
||||
|
||||
@webapp.route("/moderation/delete/<int:event_id>")
|
||||
@require_page("moderation")
|
||||
def delete_moderation_event(event_id):
|
||||
if not can_write_page("moderation"):
|
||||
return render_template("403.html"), 403
|
||||
event = ModerationEvent.query.get_or_404(event_id)
|
||||
db.session.delete(event)
|
||||
db.session.commit()
|
||||
|
||||
+11
-3
@@ -1,23 +1,31 @@
|
||||
from flask import render_template, request, redirect, url_for
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page, can_write_page
|
||||
from database import db
|
||||
from database.models import GameAlias
|
||||
from database.helpers import ConfigurationHelper
|
||||
|
||||
@webapp.route("/protondb")
|
||||
@require_page("protondb")
|
||||
def openProtonDB():
|
||||
aliases = GameAlias.query.all()
|
||||
return render_template("protondb.html", aliases = aliases, configuration = ConfigurationHelper())
|
||||
return render_template("protondb.html", aliases=aliases, configuration=ConfigurationHelper())
|
||||
|
||||
@webapp.route("/protondb/gamealias/add", methods=['POST'])
|
||||
@require_page("protondb")
|
||||
def addGameAlias():
|
||||
game_alias = GameAlias(alias = request.form.get('alias'), name = request.form.get('name'))
|
||||
if not can_write_page("protondb"):
|
||||
return render_template("403.html"), 403
|
||||
game_alias = GameAlias(alias=request.form.get('alias'), name=request.form.get('name'))
|
||||
db.session.add(game_alias)
|
||||
db.session.commit()
|
||||
return redirect(url_for('openProtonDB'))
|
||||
|
||||
@webapp.route('/protondb/gamealias/del/<int:id>')
|
||||
def delGameAlias(id : int):
|
||||
@require_page("protondb")
|
||||
def delGameAlias(id: int):
|
||||
if not can_write_page("protondb"):
|
||||
return render_template("403.html"), 403
|
||||
GameAlias.query.filter_by(id=id).delete()
|
||||
db.session.commit()
|
||||
return redirect(url_for('openProtonDB'))
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
# Paramètres webapp : rôles, permissions par page, inscriptions (super administrateur uniquement).
|
||||
from flask import render_template, request, redirect, url_for, flash
|
||||
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page
|
||||
from database import db
|
||||
from database.models import WebappRole, PagePermission, WebappUser
|
||||
from database.helpers import ConfigurationHelper
|
||||
|
||||
# Métadonnées des pages : catégorie, label d'affichage, description
|
||||
PAGE_METADATA = {
|
||||
"index": {
|
||||
"label": "Tableau de bord",
|
||||
"category": "general",
|
||||
"description": "Page d'accueil avec aperçu du système",
|
||||
"icon": "home"
|
||||
},
|
||||
"commandes": {
|
||||
"label": "Commandes",
|
||||
"category": "content",
|
||||
"description": "Gérer les commandes Discord et Twitch",
|
||||
"icon": "terminal"
|
||||
},
|
||||
"configurations": {
|
||||
"label": "Configurations",
|
||||
"category": "config",
|
||||
"description": "Paramètres généraux du bot",
|
||||
"icon": "settings"
|
||||
},
|
||||
"humeurs": {
|
||||
"label": "Humeurs",
|
||||
"category": "content",
|
||||
"description": "Gérer les statuts du bot Discord",
|
||||
"icon": "smile"
|
||||
},
|
||||
"protondb": {
|
||||
"label": "ProtonDB",
|
||||
"category": "content",
|
||||
"description": "Recherche de compatibilité des jeux Linux",
|
||||
"icon": "gamepad"
|
||||
},
|
||||
"live_alert": {
|
||||
"label": "Alertes Live",
|
||||
"category": "content",
|
||||
"description": "Notifications Discord pour les streams Twitch",
|
||||
"icon": "bell"
|
||||
},
|
||||
"youtube": {
|
||||
"label": "YouTube",
|
||||
"category": "content",
|
||||
"description": "Notifications Discord pour les vidéos YouTube",
|
||||
"icon": "video"
|
||||
},
|
||||
"announcements": {
|
||||
"label": "Annonces Twitch",
|
||||
"category": "content",
|
||||
"description": "Messages automatiques dans le chat Twitch",
|
||||
"icon": "megaphone"
|
||||
},
|
||||
"moderation": {
|
||||
"label": "Modération Discord",
|
||||
"category": "moderation",
|
||||
"description": "Historique de modération Discord",
|
||||
"icon": "shield"
|
||||
},
|
||||
"twitch_moderation": {
|
||||
"label": "Modération Twitch",
|
||||
"category": "moderation",
|
||||
"description": "Commandes et logs de modération Twitch",
|
||||
"icon": "shield-check"
|
||||
},
|
||||
"link_filter": {
|
||||
"label": "Filtre de liens",
|
||||
"category": "moderation",
|
||||
"description": "Filtrage automatique des liens Twitch",
|
||||
"icon": "filter"
|
||||
},
|
||||
"twitch_events": {
|
||||
"label": "Événements Twitch",
|
||||
"category": "content",
|
||||
"description": "Notifications subs, follows, raids, clips",
|
||||
"icon": "star"
|
||||
},
|
||||
"freeloot": {
|
||||
"label": "Free Loot",
|
||||
"category": "content",
|
||||
"description": "Flux RSS de jeux gratuits vers Discord",
|
||||
"icon": "gift"
|
||||
},
|
||||
"users": {
|
||||
"label": "Utilisateurs",
|
||||
"category": "admin",
|
||||
"description": "Gestion des comptes et rôles webapp",
|
||||
"icon": "users"
|
||||
},
|
||||
"settings": {
|
||||
"label": "Paramètres",
|
||||
"category": "admin",
|
||||
"description": "Rôles, permissions et inscriptions",
|
||||
"icon": "cog"
|
||||
},
|
||||
}
|
||||
|
||||
# Labels des catégories
|
||||
CATEGORY_LABELS = {
|
||||
"general": {"label": "Général", "color": "#6B7280", "icon": "layout"},
|
||||
"content": {"label": "Contenu", "color": "#3B82F6", "icon": "file-text"},
|
||||
"moderation": {"label": "Modération", "color": "#EF4444", "icon": "shield"},
|
||||
"config": {"label": "Configuration", "color": "#8B5CF6", "icon": "settings"},
|
||||
"admin": {"label": "Administration", "color": "#F59E0B", "icon": "crown"},
|
||||
}
|
||||
|
||||
# Rôles par défaut avec métadonnées
|
||||
DEFAULT_ROLES = {
|
||||
"viewer_twitch": {
|
||||
"description": "Accès minimal, consultation uniquement",
|
||||
"color": "#9146FF",
|
||||
"icon": "eye"
|
||||
},
|
||||
"utilisateur_discord": {
|
||||
"description": "Peut consulter et modifier du contenu basique",
|
||||
"color": "#5865F2",
|
||||
"icon": "user"
|
||||
},
|
||||
"moderateur_discord": {
|
||||
"description": "Accès aux outils de modération Discord",
|
||||
"color": "#57F287",
|
||||
"icon": "shield"
|
||||
},
|
||||
"expert_discord": {
|
||||
"description": "Gestion avancée du contenu et des configurations",
|
||||
"color": "#FEE75C",
|
||||
"icon": "star"
|
||||
},
|
||||
"moderateur_twitch": {
|
||||
"description": "Accès aux outils de modération Twitch",
|
||||
"color": "#9146FF",
|
||||
"icon": "shield-check"
|
||||
},
|
||||
"super_administrateur": {
|
||||
"description": "Accès complet à toutes les fonctionnalités",
|
||||
"color": "#ED4245",
|
||||
"icon": "crown"
|
||||
},
|
||||
}
|
||||
|
||||
PAGE_KEYS = [
|
||||
("index", "Tableau de bord"),
|
||||
("configurations", "Configurations"),
|
||||
("commandes", "Commandes"),
|
||||
("humeurs", "Humeurs"),
|
||||
("live_alert", "Alerte live"),
|
||||
("announcements", "Annonces Twitch"),
|
||||
("twitch_moderation", "Modération Twitch"),
|
||||
("link_filter", "Filtre de liens"),
|
||||
("twitch_events", "Notifications événements Twitch"),
|
||||
("youtube", "YouTube"),
|
||||
("protondb", "ProtonDB"),
|
||||
("freeloot", "FreeLoot"),
|
||||
("moderation", "Modération Discord"),
|
||||
("users", "Utilisateurs"),
|
||||
("settings", "Paramètres"),
|
||||
]
|
||||
|
||||
|
||||
@webapp.route("/settings")
|
||||
@require_page("settings")
|
||||
def settings():
|
||||
roles = WebappRole.query.order_by(WebappRole.level).all()
|
||||
permissions = PagePermission.query.all()
|
||||
perm_by_key = {p.page_key: p for p in permissions}
|
||||
reg_enabled = ConfigurationHelper().getValue("registration_enabled") not in (None, "", "false", "0", "no", "off")
|
||||
|
||||
# Organiser les pages par catégorie
|
||||
pages_by_category = {}
|
||||
for page_key, meta in PAGE_METADATA.items():
|
||||
category = meta.get("category", "general")
|
||||
if category not in pages_by_category:
|
||||
pages_by_category[category] = []
|
||||
pages_by_category[category].append({
|
||||
"key": page_key,
|
||||
"meta": meta,
|
||||
"permission": perm_by_key.get(page_key)
|
||||
})
|
||||
|
||||
# Trier les pages dans chaque catégorie par label
|
||||
for category in pages_by_category:
|
||||
pages_by_category[category].sort(key=lambda x: x["meta"]["label"])
|
||||
|
||||
return render_template(
|
||||
"settings.html",
|
||||
roles=roles,
|
||||
pages_by_category=pages_by_category,
|
||||
category_labels=CATEGORY_LABELS,
|
||||
perm_by_key=perm_by_key,
|
||||
registration_enabled=reg_enabled,
|
||||
page_metadata=PAGE_METADATA,
|
||||
default_roles_meta=DEFAULT_ROLES,
|
||||
)
|
||||
|
||||
|
||||
@webapp.route("/settings/registration", methods=["POST"])
|
||||
@require_page("settings")
|
||||
def settings_toggle_registration():
|
||||
enabled = request.form.get("enabled") in ("1", "true", "on", "yes")
|
||||
ConfigurationHelper().createOrUpdate("registration_enabled", "true" if enabled else "false")
|
||||
db.session.commit()
|
||||
flash("Inscriptions " + ("activées" if enabled else "désactivées") + ".", "success")
|
||||
return redirect(url_for("settings"))
|
||||
|
||||
|
||||
@webapp.route("/settings/roles/add", methods=["POST"])
|
||||
@require_page("settings")
|
||||
def settings_role_add():
|
||||
name = (request.form.get("name") or "").strip()
|
||||
level_str = request.form.get("level", "0")
|
||||
description = (request.form.get("description") or "").strip()
|
||||
color = (request.form.get("color") or "#6B7280").strip()
|
||||
icon = (request.form.get("icon") or "").strip()
|
||||
|
||||
if not name:
|
||||
flash("Nom du rôle requis.", "error")
|
||||
return redirect(url_for("settings"))
|
||||
try:
|
||||
level = int(level_str)
|
||||
except ValueError:
|
||||
level = 0
|
||||
if WebappRole.query.filter_by(name=name).first():
|
||||
flash(f"Le rôle « {name} » existe déjà.", "error")
|
||||
return redirect(url_for("settings"))
|
||||
|
||||
role = WebappRole(
|
||||
name=name,
|
||||
level=level,
|
||||
description=description if description else None,
|
||||
color=color,
|
||||
icon=icon if icon else None
|
||||
)
|
||||
db.session.add(role)
|
||||
db.session.commit()
|
||||
flash(f"Rôle « {name} » créé.", "success")
|
||||
return redirect(url_for("settings"))
|
||||
|
||||
|
||||
@webapp.route("/settings/roles/<int:role_id>/edit", methods=["POST"])
|
||||
@require_page("settings")
|
||||
def settings_role_edit(role_id):
|
||||
role = WebappRole.query.get_or_404(role_id)
|
||||
level_str = request.form.get("level")
|
||||
description = request.form.get("description")
|
||||
color = request.form.get("color")
|
||||
icon = request.form.get("icon")
|
||||
|
||||
if level_str is not None:
|
||||
try:
|
||||
role.level = int(level_str)
|
||||
except ValueError:
|
||||
flash("Niveau invalide.", "error")
|
||||
return redirect(url_for("settings"))
|
||||
|
||||
if description is not None:
|
||||
role.description = description.strip() if description.strip() else None
|
||||
if color is not None:
|
||||
role.color = color.strip() if color.strip() else "#6B7280"
|
||||
if icon is not None:
|
||||
role.icon = icon.strip() if icon.strip() else None
|
||||
|
||||
db.session.commit()
|
||||
flash(f"Rôle « {role.name} » mis à jour.", "success")
|
||||
return redirect(url_for("settings"))
|
||||
|
||||
|
||||
@webapp.route("/settings/roles/<int:role_id>/delete", methods=["POST"])
|
||||
@require_page("settings")
|
||||
def settings_role_delete(role_id):
|
||||
role = WebappRole.query.get_or_404(role_id)
|
||||
if WebappUser.query.filter_by(role=role.name).count() > 0:
|
||||
flash(f"Impossible de supprimer le rôle « {role.name} » : des utilisateurs l'utilisent.", "error")
|
||||
return redirect(url_for("settings"))
|
||||
db.session.delete(role)
|
||||
db.session.commit()
|
||||
flash(f"Rôle « {role.name} » supprimé.", "success")
|
||||
return redirect(url_for("settings"))
|
||||
|
||||
|
||||
@webapp.route("/settings/permissions/update", methods=["POST"])
|
||||
@require_page("settings")
|
||||
def settings_permissions_update():
|
||||
page_key = request.form.get("page_key")
|
||||
role_name = request.form.get("role")
|
||||
if not page_key:
|
||||
return redirect(url_for("settings"))
|
||||
role = WebappRole.query.filter_by(name=role_name).first()
|
||||
level = role.level if role else 0
|
||||
perm = PagePermission.query.filter_by(page_key=page_key).first()
|
||||
if perm:
|
||||
perm.min_level = level
|
||||
perm.write_level = level
|
||||
else:
|
||||
perm = PagePermission(page_key=page_key, min_level=level, write_level=level)
|
||||
db.session.add(perm)
|
||||
db.session.commit()
|
||||
flash(f"Accès à « {page_key} » mis à jour.", "success")
|
||||
return redirect(url_for("settings"))
|
||||
|
||||
|
||||
@webapp.route("/settings/permissions/bulk", methods=["POST"])
|
||||
@require_page("settings")
|
||||
def settings_permissions_bulk():
|
||||
page_keys = request.form.getlist("page_keys")
|
||||
role_name = request.form.get("role")
|
||||
if not page_keys or not role_name:
|
||||
flash("Sélectionnez au moins une page et un rôle.", "error")
|
||||
return redirect(url_for("settings"))
|
||||
role = WebappRole.query.filter_by(name=role_name).first()
|
||||
level = role.level if role else 0
|
||||
updated = 0
|
||||
for page_key in page_keys:
|
||||
perm = PagePermission.query.filter_by(page_key=page_key).first()
|
||||
if perm:
|
||||
perm.min_level = level
|
||||
perm.write_level = level
|
||||
else:
|
||||
perm = PagePermission(page_key=page_key, min_level=level, write_level=level)
|
||||
db.session.add(perm)
|
||||
updated += 1
|
||||
db.session.commit()
|
||||
flash(f"Accès mis à jour pour {updated} page(s) avec le rôle « {role_name} ».", "success")
|
||||
return redirect(url_for("settings"))
|
||||
@@ -0,0 +1,9 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-md mx-auto py-12 text-center">
|
||||
<h1 class="text-2xl font-bold text-slate-800 dark:text-white mb-2">Accès refusé</h1>
|
||||
<p class="text-slate-600 dark:text-slate-400 mb-6">Vous n'avez pas les droits nécessaires pour accéder à cette page.</p>
|
||||
<a href="{{ url_for('index') }}" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">Retour à l'accueil</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,188 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-8">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="p-3 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||
<svg class="w-6 h-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Annonces Twitch</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">Messages automatiques périodiques dans le chat</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Configurez des messages envoyés automatiquement dans le chat Twitch.
|
||||
L'annonce est envoyée uniquement si le temps est écoulé ET si le nombre minimum de messages a été atteint.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if not announcement %}
|
||||
<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-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Annonces configurées</h2>
|
||||
</div>
|
||||
|
||||
{% if announcements %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Nom</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Message</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Temps</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Min. messages</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Dernier envoi</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for ann in announcements %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
|
||||
<td class="px-6 py-4">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ ann.name }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-gray-600 dark:text-gray-300 text-sm max-w-xs truncate block">{{ ann.text[:60] }}{% if ann.text|length > 60 %}...{% endif %}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
{{ ann.periodicity }} min
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
|
||||
{{ ann.min_chat_messages }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{% if ann.last_sent %}
|
||||
{{ ann.last_sent.strftime('%d/%m %H:%M') }}
|
||||
{% else %}
|
||||
<span class="italic">Jamais</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<a href="{{ url_for('toggleAnnouncement', id=ann.id) }}"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
title="{{ 'Désactiver' if ann.enable else 'Activer' }}">
|
||||
{% if ann.enable %}
|
||||
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</a>
|
||||
<a href="{{ url_for('resetAnnouncement', id=ann.id) }}"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
title="Remettre le compteur à zéro">
|
||||
<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="{{ url_for('openEditAnnouncement', id=ann.id) }}"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
title="Modifier">
|
||||
<svg class="w-5 h-5 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="{{ url_for('delAnnouncement', id=ann.id) }}"
|
||||
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette annonce ?')"
|
||||
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors"
|
||||
title="Supprimer">
|
||||
<svg class="w-5 h-5 text-red-500" 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>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-6 py-12 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"></path>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">Aucune annonce</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Commencez par créer votre première annonce automatique.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-6">
|
||||
{{ 'Modifier l\'annonce' if announcement else 'Ajouter une annonce' }}
|
||||
</h2>
|
||||
|
||||
<form action="{{ url_for('submitEditAnnouncement', id=announcement.id) if announcement else url_for('addAnnouncement') }}" method="POST" class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Nom de l'annonce
|
||||
</label>
|
||||
<input type="text" name="name" id="name" required maxlength="64"
|
||||
value="{{ announcement.name if announcement else '' }}"
|
||||
placeholder="Ex: Règles du chat"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="periodicity" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Temps entre les annonces (minutes)
|
||||
</label>
|
||||
<input type="number" name="periodicity" id="periodicity" required min="1" max="1440"
|
||||
value="{{ announcement.periodicity if announcement else 10 }}"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors">
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">1 min à 1440 min (24h)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="min_chat_messages" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Messages minimum entre annonces
|
||||
</label>
|
||||
<input type="number" name="min_chat_messages" id="min_chat_messages" required min="0" max="1000"
|
||||
value="{{ announcement.min_chat_messages if announcement else 0 }}"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors">
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">0 = pas de minimum</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="text" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Message
|
||||
</label>
|
||||
<textarea name="text" id="text" required maxlength="500" rows="4"
|
||||
placeholder="Le message qui sera envoyé dans le chat..."
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors resize-none">{{ announcement.text if announcement else '' }}</textarea>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Maximum 500 caractères</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<button type="submit"
|
||||
class="px-6 py-2 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2">
|
||||
{{ 'Enregistrer' if announcement else 'Ajouter' }}
|
||||
</button>
|
||||
{% if announcement %}
|
||||
<a href="{{ url_for('openAnnouncements') }}"
|
||||
class="px-6 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 font-medium rounded-lg transition-colors">
|
||||
Annuler
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
+106
-77
@@ -1,99 +1,128 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-semibold text-slate-800 dark:text-white mb-1">Commandes</h1>
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400">
|
||||
Gérez les commandes personnalisées du bot. Ces commandes peuvent être activées sur Discord et/ou Twitch.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden mb-6">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700">
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Commande</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Réponse</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Discord</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Twitch</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
{% for commande in commandes %}
|
||||
<tr class="hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors">
|
||||
<td class="px-4 py-3">
|
||||
<code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 rounded text-xs font-mono">{{ commande.trigger }}</code>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-600 dark:text-slate-400 text-sm max-w-xs">
|
||||
<div class="line-clamp-2">{{ commande.response }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<a href="{{ url_for('toggle_discord_commande', commande_id = commande.id) }}" class="inline-flex" title="{{ 'Désactiver' if commande.discord_enable else 'Activer' }}">
|
||||
{% if commande.discord_enable %}
|
||||
<span class="w-5 h-5 text-green-600 dark:text-green-500">✓</span>
|
||||
{% else %}
|
||||
<span class="w-5 h-5 text-slate-400">–</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<a href="{{ url_for('toggle_twitch_commande', commande_id = commande.id) }}" class="inline-flex" title="{{ 'Désactiver' if commande.twitch_enable else 'Activer' }}">
|
||||
{% if commande.twitch_enable %}
|
||||
<span class="w-5 h-5 text-green-600 dark:text-green-500">✓</span>
|
||||
{% else %}
|
||||
<span class="w-5 h-5 text-slate-400">–</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<a href="{{ url_for('delete_commande', commande_id = commande.id) }}" onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette commande ?')" class="text-sm text-slate-500 hover:text-red-600 dark:hover:text-red-400 transition-colors">
|
||||
Supprimer
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="px-4 py-8 text-center text-sm text-slate-500 dark:text-slate-400">
|
||||
Aucune commande configurée
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Commandes de Mamie</h1>
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Gérez les commandes personnalisées du bot. Ces commandes peuvent être activées sur Discord et/ou Twitch selon vos besoins.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-5">
|
||||
<h2 class="text-lg font-medium text-slate-800 dark:text-white mb-5">Ajouter une commande</h2>
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Liste des commandes</h2>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Commande</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Réponse</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Discord</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Twitch</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Permission Twitch</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for commande in commandes %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<code class="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-purple-600 dark:text-purple-400 font-mono">{{ commande.trigger }}</code>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-gray-600 dark:text-gray-400 max-w-md truncate">{{ commande.response }}</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<a href="{{ url_for('toggle_discord_commande', commande_id = commande.id) }}"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors inline-block"
|
||||
title="{{ 'Désactiver sur Discord' if commande.discord_enable else 'Activer sur Discord' }}">
|
||||
{{ '✅' if commande.discord_enable else '❌' }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<a href="{{ url_for('toggle_twitch_commande', commande_id = commande.id) }}"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors inline-block"
|
||||
title="{{ 'Désactiver sur Twitch' if commande.twitch_enable else 'Activer sur Twitch' }}">
|
||||
{{ '✅' if commande.twitch_enable else '❌' }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
{% if commande.twitch_enable %}
|
||||
<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">
|
||||
{{ twitch_permissions.get(commande.twitch_permission or 'viewer', 'Tous') }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-gray-500">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<a href="{{ url_for('delete_commande', commande_id = commande.id) }}"
|
||||
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette commande ?')"
|
||||
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors text-red-600 dark:text-red-400 inline-block"
|
||||
title="Supprimer">
|
||||
<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="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>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
Aucune commande configurée. Ajoutez-en une ci-dessous.
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Ajouter une commande</h2>
|
||||
|
||||
<form action="{{ url_for('add_commande') }}" method="POST" class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="trigger" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Commande</label>
|
||||
<input type="text" name="trigger" id="trigger" placeholder="!macommande" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
|
||||
<label for="trigger" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Commande</label>
|
||||
<input name="trigger" id="trigger" type="text" required
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
|
||||
placeholder="!macommande"/>
|
||||
</div>
|
||||
<div class="flex items-end gap-6">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input type="checkbox" name="discord_enable" checked class="w-4 h-4 text-slate-600 bg-slate-100 dark:bg-slate-700 border-slate-300 dark:border-slate-600 rounded focus:ring-slate-500 focus:ring-2">
|
||||
<span class="text-sm text-slate-700 dark:text-slate-300">Discord</span>
|
||||
|
||||
<div class="flex flex-wrap items-end gap-6">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input name="discord_enable" type="checkbox" checked
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Discord</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input type="checkbox" name="twitch_enable" class="w-4 h-4 text-slate-600 bg-slate-100 dark:bg-slate-700 border-slate-300 dark:border-slate-600 rounded focus:ring-slate-500 focus:ring-2">
|
||||
<span class="text-sm text-slate-700 dark:text-slate-300">Twitch</span>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input name="twitch_enable" type="checkbox" id="twitch_enable_checkbox"
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Twitch</span>
|
||||
</label>
|
||||
<div class="w-full sm:w-auto">
|
||||
<label for="twitch_permission" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Permission Twitch (qui peut utiliser la commande)</label>
|
||||
<select name="twitch_permission" id="twitch_permission"
|
||||
class="w-full sm:w-48 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
{% for value, label in twitch_permissions.items() %}
|
||||
<option value="{{ value }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="response" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Réponse</label>
|
||||
<textarea name="response" id="response" rows="4" placeholder="Le message que le bot enverra..." class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all resize-none"></textarea>
|
||||
<label for="response" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Réponse</label>
|
||||
<textarea name="response" id="response" rows="4" required
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all resize-y"
|
||||
placeholder="La réponse que le bot enverra..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600 text-white text-sm font-medium rounded-lg transition-colors">
|
||||
Ajouter
|
||||
<div>
|
||||
<button type="submit"
|
||||
class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
Ajouter la commande
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,251 +1,341 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-semibold text-slate-800 dark:text-white mb-1">Configurations</h1>
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400">
|
||||
Paramètres Discord, Twitch et Humble Bundle.
|
||||
</p>
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="mb-4 space-y-2">
|
||||
{% for category, msg in messages %}
|
||||
<div class="p-4 rounded-lg {{ 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-200' if category == 'success' else 'bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-200' }}">
|
||||
{{ msg }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Configuration de Mamie</h1>
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Configurez les tokens Discord, les notifications Humble Bundle et l'API Twitch.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 mb-6 overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<h2 class="text-lg font-medium text-slate-800 dark:text-white">Discord</h2>
|
||||
</div>
|
||||
|
||||
<form action="{{ url_for('updateConfiguration') }}" method="POST" class="p-5 space-y-6">
|
||||
<div>
|
||||
<label for="discord_token" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Token Discord</label>
|
||||
<input type="password" name="discord_token" id="discord_token" placeholder="Votre token Discord (caché)" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
|
||||
<p class="mt-1 text-xs text-amber-600 dark:text-amber-400">Nécessite un redémarrage après modification</p>
|
||||
<div class="space-y-8">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<svg class="w-6 h-6 text-indigo-500" fill="currentColor" viewBox="0 0 24 24"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Discord</h2>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 border-t border-slate-200 dark:border-slate-700">
|
||||
<h3 class="text-sm font-medium text-slate-800 dark:text-white mb-4">Messages de bienvenue</h3>
|
||||
|
||||
<label class="flex items-center gap-3 cursor-pointer mb-4">
|
||||
<input type="checkbox" name="welcome_enable" {% if configuration.getValue('welcome_enable') %}checked{% endif %} class="w-4 h-4 text-slate-600 bg-slate-100 dark:bg-slate-700 border-slate-300 dark:border-slate-600 rounded focus:ring-slate-500">
|
||||
<span class="text-sm text-slate-700 dark:text-slate-300">Activer le message de bienvenue</span>
|
||||
</label>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
|
||||
<form action="{{ url_for('updateConfiguration') }}" method="POST" class="space-y-6">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">API Discord</h3>
|
||||
<div>
|
||||
<label for="welcome_channel_id" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Canal de bienvenue</label>
|
||||
<select name="welcome_channel_id" id="welcome_channel_id" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
|
||||
{% for channel in channels %}
|
||||
<option value="{{ channel.id }}" {% if configuration.getIntValue('welcome_channel_id') == channel.id %}selected{% endif %}>{{ channel.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label for="discord_token" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Token Discord (caché)</label>
|
||||
<input name="discord_token" id="discord_token" type="password"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
|
||||
placeholder="Votre token Discord"/>
|
||||
<p class="mt-1 text-xs text-amber-600 dark:text-amber-400">Nécessite un redémarrage après modification</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="welcome_message" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Message personnalisé</label>
|
||||
<textarea name="welcome_message" id="welcome_message" rows="2" placeholder="Bienvenue {member.mention} sur le serveur !" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all resize-none">{{ configuration.getValue('welcome_message') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg p-3">
|
||||
<p class="text-xs font-medium text-slate-600 dark:text-slate-400 mb-2">Variables :</p>
|
||||
<div class="flex flex-wrap gap-2 text-xs">
|
||||
<code class="px-1.5 py-0.5 bg-slate-200 dark:bg-slate-600 rounded">{member.mention}</code>
|
||||
<code class="px-1.5 py-0.5 bg-slate-200 dark:bg-slate-600 rounded">{member.name}</code>
|
||||
<code class="px-1.5 py-0.5 bg-slate-200 dark:bg-slate-600 rounded">{server.name}</code>
|
||||
<code class="px-1.5 py-0.5 bg-slate-200 dark:bg-slate-600 rounded">{server.member_count}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 border-t border-slate-200 dark:border-slate-700">
|
||||
<h3 class="text-sm font-medium text-slate-800 dark:text-white mb-4">Messages de départ</h3>
|
||||
|
||||
<label class="flex items-center gap-3 cursor-pointer mb-4">
|
||||
<input type="checkbox" name="leave_enable" {% if configuration.getValue('leave_enable') %}checked{% endif %} class="w-4 h-4 text-slate-600 bg-slate-100 dark:bg-slate-700 border-slate-300 dark:border-slate-600 rounded focus:ring-slate-500">
|
||||
<span class="text-sm text-slate-700 dark:text-slate-300">Activer le message de départ</span>
|
||||
</label>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label for="leave_channel_id" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Canal de départ</label>
|
||||
<select name="leave_channel_id" id="leave_channel_id" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
|
||||
{% for channel in channels %}
|
||||
<option value="{{ channel.id }}" {% if configuration.getIntValue('leave_channel_id') == channel.id %}selected{% endif %}>{{ channel.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="leave_message" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Message personnalisé</label>
|
||||
<textarea name="leave_message" id="leave_message" rows="2" placeholder="{member.mention} a quitté le serveur." class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all resize-none">{{ configuration.getValue('leave_message') }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 border-t border-slate-200 dark:border-slate-700">
|
||||
<h3 class="text-sm font-medium text-slate-800 dark:text-white mb-4">Modération</h3>
|
||||
|
||||
<div class="space-y-3 mb-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input type="checkbox" name="moderation_enable" {% if configuration.getValue('moderation_enable') %}checked{% endif %} class="w-4 h-4 text-slate-600 bg-slate-100 dark:bg-slate-700 border-slate-300 dark:border-slate-600 rounded focus:ring-slate-500">
|
||||
<span class="text-sm text-slate-700 dark:text-slate-300">Activer les commandes d'avertissement</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input type="checkbox" name="moderation_ban_enable" {% if configuration.getValue('moderation_ban_enable') %}checked{% endif %} class="w-4 h-4 text-slate-600 bg-slate-100 dark:bg-slate-700 border-slate-300 dark:border-slate-600 rounded focus:ring-slate-500">
|
||||
<span class="text-sm text-slate-700 dark:text-slate-300">Activer les commandes de bannissement</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input type="checkbox" name="moderation_kick_enable" {% if configuration.getValue('moderation_kick_enable') %}checked{% endif %} class="w-4 h-4 text-slate-600 bg-slate-100 dark:bg-slate-700 border-slate-300 dark:border-slate-600 rounded focus:ring-slate-500">
|
||||
<span class="text-sm text-slate-700 dark:text-slate-300">Activer la commande d'expulsion</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label for="moderation_log_channel_id" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Canal de logs</label>
|
||||
<select name="moderation_log_channel_id" id="moderation_log_channel_id" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
|
||||
{% for channel in channels %}
|
||||
<option value="{{ channel.id }}" {% if configuration.getIntValue('moderation_log_channel_id') == channel.id %}selected{% endif %}>{{ channel.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="moderation_embed_delete_delay" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Délai suppression (sec)</label>
|
||||
<input type="number" name="moderation_embed_delete_delay" id="moderation_embed_delete_delay" value="{{ configuration.getValue('moderation_embed_delete_delay') or '0' }}" min="0" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">Rôles Staff autorisés</label>
|
||||
{% set selected_roles = (configuration.getValue('moderation_staff_role_ids') or '').split(',') %}
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">Messages de bienvenue</h3>
|
||||
|
||||
{% if roles|length > 1 %}
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
{% for guild_data in roles %}
|
||||
<button type="button" onclick="openTab(event, 'guild-{{ guild_data.guild_id }}')" class="tab-button px-3 py-1.5 text-sm rounded-lg bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors {% if loop.first %}active bg-slate-200 dark:bg-slate-600{% endif %}">
|
||||
{{ guild_data.guild_name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="welcome_enable" {% if configuration.getValue('welcome_enable') %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Activer le message de bienvenue pour les nouveaux membres</span>
|
||||
</label>
|
||||
|
||||
{% for guild_data in roles %}
|
||||
<div id="guild-{{ guild_data.guild_id }}" class="tab-content {% if not loop.first %}hidden{% endif %}">
|
||||
<div class="max-h-48 overflow-y-auto bg-slate-50 dark:bg-slate-700/50 rounded-lg p-3 space-y-1">
|
||||
{% for role in guild_data.roles %}
|
||||
<label class="flex items-center gap-3 cursor-pointer p-2 rounded hover:bg-slate-100 dark:hover:bg-slate-600/50 transition-colors">
|
||||
<input type="checkbox" name="moderation_staff_role_ids" value="{{ role.id }}" {% if role.id|string in selected_roles %}checked{% endif %} class="w-4 h-4 text-slate-600 bg-slate-100 dark:bg-slate-700 border-slate-300 dark:border-slate-600 rounded focus:ring-slate-500">
|
||||
{% if role.color.value != 0 %}
|
||||
<span style="color:#{{ '%06x' % role.color.value }}">●</span>
|
||||
{% else %}
|
||||
<span class="text-slate-400">○</span>
|
||||
{% endif %}
|
||||
<span class="text-sm text-slate-700 dark:text-slate-300">{{ role.name }}</span>
|
||||
</label>
|
||||
<div>
|
||||
<label for="welcome_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal de bienvenue</label>
|
||||
<select name="welcome_channel_id" id="welcome_channel_id"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}" {% if configuration.getIntValue('welcome_channel_id')==channel.id %}selected{% endif %}>{{channel.name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="welcome_message" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Message personnalisé</label>
|
||||
<textarea name="welcome_message" id="welcome_message" rows="3"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all resize-y"
|
||||
placeholder="Bienvenue {member.mention} sur le serveur !">{{ configuration.getValue('welcome_message') }}</textarea>
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<p><code class="px-1 bg-gray-200 dark:bg-gray-600 rounded">{member.mention}</code> Mentionne l'utilisateur</p>
|
||||
<p><code class="px-1 bg-gray-200 dark:bg-gray-600 rounded">{member.name}</code> Nom d'utilisateur</p>
|
||||
<p><code class="px-1 bg-gray-200 dark:bg-gray-600 rounded">{server.name}</code> Nom du serveur</p>
|
||||
<p><code class="px-1 bg-gray-200 dark:bg-gray-600 rounded">{server.member_count}</code> Nombre de membres</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 border-t border-slate-200 dark:border-slate-700">
|
||||
<button type="submit" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600 text-white text-sm font-medium rounded-lg transition-colors">
|
||||
Enregistrer
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">Messages de départ</h3>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="leave_enable" {% if configuration.getValue('leave_enable') %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Activer le message quand un membre quitte le serveur</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label for="leave_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal de départ</label>
|
||||
<select name="leave_channel_id" id="leave_channel_id"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}" {% if configuration.getIntValue('leave_channel_id')==channel.id %}selected{% endif %}>{{channel.name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="leave_message" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Message personnalisé</label>
|
||||
<textarea name="leave_message" id="leave_message" rows="3"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all resize-y"
|
||||
placeholder="{member.name} a quitté le serveur.">{{ configuration.getValue('leave_message') }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="auto-rooms" class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">Auto Rooms (salons vocaux temporaires)</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Quand un membre rejoint le canal vocal configuré ci-dessous, un salon vocal temporaire est créé. Le message de configuration avec les réactions apparaît dans la <strong>partie texte du vocal</strong> (onglet Discussion à droite quand on ouvre le salon). Seul le propriétaire peut réagir.
|
||||
</p>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="auto_rooms_enable" {% if configuration.getValue('auto_rooms_enable') %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Activer les Auto Rooms</span>
|
||||
</label>
|
||||
<div>
|
||||
<label for="auto_rooms_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal vocal à rejoindre pour créer un salon</label>
|
||||
<select name="auto_rooms_channel_id" id="auto_rooms_channel_id"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all">
|
||||
<option value="">— Choisir un canal vocal —</option>
|
||||
{% for channel in voice_channels %}
|
||||
<option value="{{ channel.id }}" {% if configuration.getIntValue('auto_rooms_channel_id') == channel.id %}selected{% endif %}>{{ channel.name }} (vocal)</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Ex. « + Créer votre salon » — les membres qui rejoignent ce canal obtiennent un salon vocal dédié.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">Modération</h3>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="moderation_enable" {% if configuration.getValue('moderation_enable') %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Activer les commandes d'avertissement (!warn, !unwarn, !inspect)</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="moderation_ban_enable" {% if configuration.getValue('moderation_ban_enable') %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Activer les commandes de bannissement (!ban, !unban)</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="moderation_kick_enable" {% if configuration.getValue('moderation_kick_enable') %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Activer la commande d'expulsion (!kick)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="moderation_log_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal de logs de modération</label>
|
||||
<select name="moderation_log_channel_id" id="moderation_log_channel_id"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}" {% if configuration.getIntValue('moderation_log_channel_id')==channel.id %}selected{% endif %}>{{channel.name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Toutes les actions de modération seront notifiées dans ce canal</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Rôles Staff autorisés</label>
|
||||
{% set selected_roles = (configuration.getValue('moderation_staff_role_ids') or '').split(',') %}
|
||||
|
||||
{% if roles|length > 1 %}
|
||||
<div class="flex flex-wrap gap-1 border-b border-gray-200 dark:border-gray-600 mb-3">
|
||||
{% for guild_data in roles %}
|
||||
<button type="button" class="tab-button px-4 py-2 text-sm font-medium rounded-t-lg transition-colors
|
||||
{% if loop.first %}bg-gray-200 dark:bg-gray-600 text-gray-900 dark:text-white{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600{% endif %}"
|
||||
onclick="openTab(event, 'guild-{{guild_data.guild_id}}')" {% if loop.first %}id="defaultOpen"{% endif %}>
|
||||
{{ guild_data.guild_name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for guild_data in roles %}
|
||||
<div id="guild-{{guild_data.guild_id}}" class="tab-content {% if not loop.first %}hidden{% endif %}">
|
||||
<div class="max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-600 rounded-lg p-3 space-y-2">
|
||||
{% for role in guild_data.roles %}
|
||||
<label class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 p-1 rounded">
|
||||
<input type="checkbox" name="moderation_staff_role_ids" value="{{role.id}}"
|
||||
{% if role.id|string in selected_roles %}checked{% endif %}
|
||||
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
|
||||
{% if role.color.value != 0 %}
|
||||
<span style="color:#{{ '%06x' % role.color.value }}">●</span>
|
||||
{% else %}
|
||||
<span class="text-gray-400">○</span>
|
||||
{% endif %}
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{role.name}}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">Sélectionnez les rôles qui peuvent utiliser les commandes de modération</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="moderation_embed_delete_delay" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Délai de suppression des embeds (secondes)</label>
|
||||
<input name="moderation_embed_delete_delay" id="moderation_embed_delete_delay" type="number" min="0"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
|
||||
value="{{ configuration.getValue('moderation_embed_delete_delay') or '0' }}" placeholder="0"/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Mettre 0 pour ne pas supprimer automatiquement</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
class="px-6 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
Enregistrer la configuration Discord
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 mb-6 overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<h2 class="text-lg font-medium text-slate-800 dark:text-white">API Twitch</h2>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form action="{{ url_for('updateConfiguration') }}" method="POST" class="p-5 space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="twitch_client_id" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Client ID</label>
|
||||
<input type="text" name="twitch_client_id" id="twitch_client_id" value="{{ configuration.getValue('twitch_client_id') }}" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
|
||||
</div>
|
||||
<div>
|
||||
<label for="twitch_client_secret" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Client Secret</label>
|
||||
<input type="text" name="twitch_client_secret" id="twitch_client_secret" value="{{ configuration.getValue('twitch_client_secret') }}" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<svg class="w-6 h-6 text-purple-500" fill="currentColor" viewBox="0 0 24 24"><path d="M11.571 4.714h1.715v5.143H11.57l-.002-5.143zm3.43 0H16.714v5.143H15V4.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0H6zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714v9.429z"/></svg>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">API Twitch</h2>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="twitch_channel" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Chaîne à rejoindre</label>
|
||||
<input type="text" name="twitch_channel" id="twitch_channel" value="{{ configuration.getValue('twitch_channel') }}" placeholder="#machinTruc" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
|
||||
</div>
|
||||
<form action="{{ url_for('updateConfiguration') }}" method="POST" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="twitch_client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Client ID</label>
|
||||
<input name="twitch_client_id" id="twitch_client_id" type="text"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
|
||||
value="{{ configuration.getValue('twitch_client_id') }}"/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="twitch_client_secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Client Secret</label>
|
||||
<input name="twitch_client_secret" id="twitch_client_secret" type="text"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
|
||||
value="{{ configuration.getValue('twitch_client_secret') }}"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="twitch_channel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Chaîne à rejoindre</label>
|
||||
<input name="twitch_channel" id="twitch_channel" type="text"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
|
||||
placeholder="#machinTruc"
|
||||
value="{{ configuration.getValue('twitch_channel') }}"/>
|
||||
</div>
|
||||
|
||||
{% if configuration.getValue('twitch_client_secret') and configuration.getValue('twitch_client_id') %}
|
||||
<div>
|
||||
<a href="{{ url_for('twitchRequestToken') }}" class="text-sm text-slate-600 dark:text-slate-400 hover:underline">
|
||||
Obtenir token et refresh token
|
||||
</a>
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">Fonctionnalités du bot Twitch</h3>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="twitch_commands_enable" {% if configuration.getValue('twitch_commands_enable') %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Activer les commandes personnalisées (!commande)</span>
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 ml-7">Les commandes configurées dans la page "Commandes" seront actives dans le chat Twitch</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<button type="submit"
|
||||
class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
Enregistrer la configuration Twitch
|
||||
</button>
|
||||
<a href="{{ url_for('twitchConfigurationHelp') }}" class="text-purple-600 dark:text-purple-400 hover:underline text-sm">Aide</a>
|
||||
</div>
|
||||
|
||||
{% if configuration.getValue('twitch_client_secret') and configuration.getValue('twitch_client_id') %}
|
||||
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<a href="{{ url_for('twitchRequestToken') }}"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded-lg hover:bg-purple-200 dark:hover:bg-purple-900/50 transition-colors text-sm font-medium">
|
||||
<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="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path></svg>
|
||||
Obtenir token et refresh token
|
||||
</a>
|
||||
|
||||
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Access Token</label>
|
||||
<input type="text" readonly
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-600 text-gray-700 dark:text-gray-300"
|
||||
value="{{ configuration.getValue('twitch_access_token') }}"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Refresh Token</label>
|
||||
<input type="text" readonly
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-600 text-gray-700 dark:text-gray-300"
|
||||
value="{{ configuration.getValue('twitch_refresh_token') }}"/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">Nécessite un redémarrage après l'obtention des Tokens.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<svg class="w-6 h-6 text-orange-500" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Humble Bundle</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="twitch_access_token" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Access Token</label>
|
||||
<input type="text" name="twitch_access_token" id="twitch_access_token" value="{{ configuration.getValue('twitch_access_token') }}" readonly class="w-full px-3 py-2 bg-slate-100 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-500 dark:text-slate-400 font-mono">
|
||||
<form action="{{ url_for('updateConfiguration') }}" method="POST" class="space-y-4">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Humble Bundle propose régulièrement des bundles de jeux vidéo à des prix réduits. Activez les notifications pour recevoir automatiquement les nouveaux packs disponibles sur votre serveur Discord.
|
||||
</p>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="humble_bundle_enable" {% if configuration.getValue('humble_bundle_enable') %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-orange-600 focus:ring-orange-500 dark:bg-gray-700">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Activer les notifications Humble Bundle</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="twitch_refresh_token" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Refresh Token</label>
|
||||
<input type="text" name="twitch_refresh_token" id="twitch_refresh_token" value="{{ configuration.getValue('twitch_refresh_token') }}" readonly class="w-full px-3 py-2 bg-slate-100 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-500 dark:text-slate-400 font-mono">
|
||||
<label for="humble_bundle_channel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal de notification</label>
|
||||
<select name="humble_bundle_channel" id="humble_bundle_channel"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-all">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}" {% if configuration.getIntValue('humble_bundle_channel')==channel.id %}selected{% endif %}>{{channel.name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-amber-600 dark:text-amber-400">Nécessite un redémarrage après l'obtention des tokens</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<button type="submit" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600 text-white text-sm font-medium rounded-lg transition-colors">
|
||||
Enregistrer
|
||||
<button type="submit"
|
||||
class="px-6 py-2.5 bg-orange-600 hover:bg-orange-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
Enregistrer la configuration Humble Bundle
|
||||
</button>
|
||||
<a href="{{ url_for('twitchConfigurationHelp') }}" class="text-sm text-slate-600 dark:text-slate-400 hover:underline">Besoin d'aide ?</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<h2 class="text-lg font-medium text-slate-800 dark:text-white">Humble Bundle</h2>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form action="{{ url_for('updateConfiguration') }}" method="POST" class="p-5 space-y-6">
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400">
|
||||
Activez les notifications pour recevoir automatiquement les nouveaux packs sur Discord.
|
||||
</p>
|
||||
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input type="checkbox" name="humble_bundle_enable" {% if configuration.getValue('humble_bundle_enable') %}checked{% endif %} class="w-4 h-4 text-slate-600 bg-slate-100 dark:bg-slate-700 border-slate-300 dark:border-slate-600 rounded focus:ring-slate-500">
|
||||
<span class="text-sm text-slate-700 dark:text-slate-300">Activer les notifications Humble Bundle</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label for="humble_bundle_channel" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Canal de notification</label>
|
||||
<select name="humble_bundle_channel" id="humble_bundle_channel" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
|
||||
{% for channel in channels %}
|
||||
<option value="{{ channel.id }}" {% if configuration.getIntValue('humble_bundle_channel') == channel.id %}selected{% endif %}>{{ channel.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600 text-white text-sm font-medium rounded-lg transition-colors">
|
||||
Enregistrer
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openTab(evt, tabName) {
|
||||
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
|
||||
document.querySelectorAll('.tab-button').forEach(el => {
|
||||
el.classList.remove('active', 'bg-slate-200', 'dark:bg-slate-600');
|
||||
el.classList.add('bg-slate-100', 'dark:bg-slate-700');
|
||||
el.classList.remove('bg-gray-200', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white');
|
||||
el.classList.add('bg-gray-100', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400');
|
||||
});
|
||||
document.getElementById(tabName).classList.remove('hidden');
|
||||
evt.currentTarget.classList.add('active', 'bg-slate-200', 'dark:bg-slate-600');
|
||||
evt.currentTarget.classList.remove('bg-slate-100', 'dark:bg-slate-700');
|
||||
evt.currentTarget.classList.remove('bg-gray-100', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400');
|
||||
evt.currentTarget.classList.add('bg-gray-200', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white');
|
||||
}
|
||||
document.getElementById("defaultOpen")?.click();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">FreeLoot — Jeux gratuits</h1>
|
||||
{% if request.args.get('msg') %}
|
||||
{% set msg_type = request.args.get('type') %}
|
||||
<div class="mb-4 p-4 rounded-lg {% if msg_type == 'success' %}bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-300{% elif msg_type == 'error' %}bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300{% else %}bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300{% endif %}">
|
||||
{{ request.args.get('msg') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Notifications des jeux gratuits (Epic Games, Amazon Prime, GOG, Google Play, Apple App Store) via le flux
|
||||
<a href="https://feed.eikowagenknecht.com/lootscraper.xml" target="_blank" rel="noopener" class="text-amber-700 dark:text-amber-300 hover:underline">LootScraper</a>.
|
||||
Choisissez le canal Discord et les types de loot à notifier (PC, Android, iOS selon la source). Le bot vérifie le flux environ toutes les 30 minutes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<span class="text-3xl">🎁</span>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Configuration FreeLoot</h2>
|
||||
</div>
|
||||
|
||||
<form action="{{ url_for('updateFreeLoot') }}" method="POST" class="space-y-6">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="freeloot_enable" {% if configuration.getValue('freeloot_enable') %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500 dark:bg-gray-700">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Activer les notifications FreeLoot</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="freeloot_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal Discord pour les notifications</label>
|
||||
<select name="freeloot_channel_id" id="freeloot_channel_id"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-amber-500 focus:border-transparent transition-all">
|
||||
<option value="">— Choisir un canal —</option>
|
||||
{% for channel in channels %}
|
||||
<option value="{{ channel.id }}" {% if configuration.getIntValue('freeloot_channel_id') == channel.id %}selected{% endif %}>{{ channel.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">Mentions (optionnel)</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Choisissez qui mentionner au début du message (avant l’embed).</p>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="freeloot_mention_everyone" {% if mention_everyone %}checked{% endif %}
|
||||
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">@everyone</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="freeloot_mention_here" {% if mention_here %}checked{% endif %}
|
||||
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">@here</span>
|
||||
</label>
|
||||
</div>
|
||||
{% if roles %}
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Rôles à mentionner</p>
|
||||
{% if roles|length > 1 %}
|
||||
<div class="flex flex-wrap gap-1 border-b border-gray-200 dark:border-gray-600 mb-3">
|
||||
{% for guild_data in roles %}
|
||||
<button type="button" class="freeloot-role-tab px-4 py-2 text-sm font-medium rounded-t-lg transition-colors {% if loop.first %}bg-gray-200 dark:bg-gray-600 text-gray-900 dark:text-white{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600{% endif %}"
|
||||
data-tab="freeloot-roles-{{ guild_data.guild_id }}" {% if loop.first %}data-default{% endif %}>
|
||||
{{ guild_data.guild_name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for guild_data in roles %}
|
||||
<div id="freeloot-roles-{{ guild_data.guild_id }}" class="freeloot-role-panel {% if not loop.first %}hidden{% endif %} max-h-48 overflow-y-auto border border-gray-200 dark:border-gray-600 rounded-lg p-3 space-y-2">
|
||||
{% for role in guild_data.roles %}
|
||||
<label class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600/50 p-1 rounded">
|
||||
<input type="checkbox" name="freeloot_mention_roles" value="{{ role.id }}"
|
||||
{% if role.id|string in mention_role_ids %}checked{% endif %}
|
||||
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500 dark:bg-gray-700">
|
||||
{% if role.color is defined and role.color is not none and role.color.value != 0 %}
|
||||
<span class="w-3 h-3 rounded-full flex-shrink-0" style="background-color:#{{ '%06x'|format(role.color.value) }}"></span>
|
||||
{% else %}
|
||||
<span class="w-3 h-3 rounded-full flex-shrink-0 bg-gray-400"></span>
|
||||
{% endif %}
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ role.name }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">Types de loot à notifier</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Cochez les sources et plateformes pour lesquelles vous voulez recevoir une notification.</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{% for key, label, emoji in sources %}
|
||||
<label class="flex items-center gap-2 cursor-pointer p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600/50 transition-colors">
|
||||
<input type="checkbox" name="freeloot_sources" value="{{ key }}"
|
||||
{% if not enabled_sources or key in enabled_sources %}checked{% endif %}
|
||||
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500 dark:bg-gray-700">
|
||||
<span class="text-lg" title="{{ label }}">{{ emoji }}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ label }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
class="px-6 py-2.5 bg-amber-600 hover:bg-amber-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
Enregistrer
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200 mb-3">Aperçu de l’embed Discord (style DraftBot)</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Exemple du message envoyé dans le canal.</p>
|
||||
<div class="inline-block rounded-r-lg overflow-hidden border border-gray-300 dark:border-gray-600 bg-[#2f3136] max-w-lg shadow-lg" style="border-left: 4px solid #E67E22;">
|
||||
<div class="p-4">
|
||||
<div class="flex items-start gap-2 mb-2">
|
||||
<a href="#" class="text-[#00a8fc] hover:underline font-semibold text-base flex-1">Definitely Not Fried Chicken gratuit sur l'Epic Games Store !</a>
|
||||
<img src="https://store.epicgames.com/favicon.ico" alt="" class="w-10 h-10 rounded flex-shrink-0" title="Logo boutique">
|
||||
</div>
|
||||
<p class="text-[#dcddde] text-sm leading-relaxed mb-3">Definitely Not Fried Chicken is a business management sim with a twist! Grow your drugs trade through legitimate fronts, managing both sides of the business. Acquire new "businesses", meet new clientele, develop more potent narcotics…</p>
|
||||
<div class="text-sm mb-2">
|
||||
<span class="text-[#b9bbbe]">Prix</span>
|
||||
<p class="text-[#dcddde]"><strong>Gratuit</strong> • jusqu'au 05/02/2026</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm mb-2">
|
||||
<div><span class="text-[#b9bbbe]">Prix recommandé</span><p class="text-[#dcddde]">39.99 EUR</p></div>
|
||||
<div><span class="text-[#b9bbbe]">Genres</span><p class="text-[#dcddde]">Simulation, Indie</p></div>
|
||||
<div><span class="text-[#b9bbbe]">Ratings</span><p class="text-[#dcddde]">PEGI 18, USK 18</p></div>
|
||||
</div>
|
||||
<p class="mb-3"><a href="#" class="text-[#00a8fc] hover:underline text-sm">Ouvrir dans la boutique !</a></p>
|
||||
<div class="rounded overflow-hidden bg-[#202225] aspect-video flex items-center justify-center my-2">
|
||||
<span class="text-4xl text-gray-500">🎁</span>
|
||||
</div>
|
||||
<p class="text-xs text-[#72767d] pt-1">MamieHenriette • FreeLoot</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('.freeloot-role-tab').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
var tabId = this.getAttribute('data-tab');
|
||||
document.querySelectorAll('.freeloot-role-panel').forEach(p => p.classList.add('hidden'));
|
||||
document.querySelectorAll('.freeloot-role-tab').forEach(b => {
|
||||
b.classList.remove('bg-gray-200', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white');
|
||||
b.classList.add('bg-gray-100', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400');
|
||||
});
|
||||
document.getElementById(tabId).classList.remove('hidden');
|
||||
this.classList.remove('bg-gray-100', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400');
|
||||
this.classList.add('bg-gray-200', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white');
|
||||
});
|
||||
});
|
||||
document.querySelector('.freeloot-role-tab[data-default]')?.click();
|
||||
</script>
|
||||
|
||||
{% if entries %}
|
||||
<div class="mt-8">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Jeux gratuits actuellement disponibles</h2>
|
||||
</div>
|
||||
<div class="p-6 overflow-x-auto">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{% for e in entries %}
|
||||
<article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden hover:shadow-md transition-shadow flex flex-col">
|
||||
<div class="aspect-video bg-gray-100 dark:bg-gray-700 flex items-center justify-center overflow-hidden">
|
||||
{% if e.image_url %}
|
||||
<img src="{{ e.image_url }}" alt="" class="w-full h-full object-cover" loading="lazy" />
|
||||
{% else %}
|
||||
<span class="text-4xl text-gray-400 dark:text-gray-500">🎁</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="p-4 flex flex-col flex-1 border-t border-gray-200 dark:border-gray-700">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white mb-1 line-clamp-2" title="{{ e.game_name }}">{{ e.game_name }}</h3>
|
||||
<p class="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
<span>{{ e.emoji }}</span>
|
||||
<span>{{ e.source_label }}</span>
|
||||
</p>
|
||||
{% if e.recommended_price or e.genres or e.rating %}
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 space-y-0.5 mb-2">
|
||||
{% if e.recommended_price %}<p><span class="text-gray-500 dark:text-gray-500">Prix recommandé:</span> {{ e.recommended_price }}</p>{% endif %}
|
||||
{% if e.genres %}<p><span class="text-gray-500 dark:text-gray-500">Genres:</span> {{ e.genres }}</p>{% endif %}
|
||||
{% if e.rating %}<p><span class="text-gray-500 dark:text-gray-500">Ratings:</span> {{ e.rating }}</p>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if e.updated_formatted %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-500 mb-3">{{ e.updated_formatted }}</p>
|
||||
{% endif %}
|
||||
<div class="mt-auto flex flex-col gap-2">
|
||||
<form action="{{ url_for('send_free_loot_to_discord') }}" method="POST" class="w-full">
|
||||
<input type="hidden" name="entry_id" value="{{ e.id }}">
|
||||
<button type="submit" class="w-full inline-flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white transition-colors focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/></svg>
|
||||
Envoyer sur Discord
|
||||
</button>
|
||||
</form>
|
||||
{% if e.link %}
|
||||
<a href="{{ e.link }}" target="_blank" rel="noopener noreferrer" class="inline-flex items-center justify-center gap-2 w-full px-3 py-2 text-sm font-medium rounded-lg bg-amber-600 hover:bg-amber-700 text-white transition-colors focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
Ouvrir dans la boutique
|
||||
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mt-8 p-4 rounded-lg bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm">Le flux LootScraper n’a pas pu être chargé. Réessayez plus tard.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,42 +1,67 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-semibold text-slate-800 dark:text-white mb-1">Humeurs</h1>
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400">
|
||||
Statuts Discord qui changeront automatiquement toutes les 10 minutes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden mb-6">
|
||||
{% if humeurs %}
|
||||
<ul class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
{% for humeur in humeurs %}
|
||||
<li class="flex items-center justify-between px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors group">
|
||||
<span class="text-sm text-slate-700 dark:text-slate-300">{{ humeur.text }}</span>
|
||||
<a href="{{ url_for('delHumeur', id = humeur.id) }}" onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette humeur ?')" class="p-1.5 text-slate-400 hover:text-red-500 dark:hover:text-red-400 rounded opacity-0 group-hover:opacity-100 transition-all" title="Supprimer">
|
||||
<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="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>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="px-4 py-8 text-center">
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">Aucune humeur configurée</p>
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Humeurs de Mamie</h1>
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Définissez les statuts Discord qui changeront automatiquement toutes les 10 minutes pour donner de la personnalité à votre bot.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-5">
|
||||
<h2 class="text-lg font-medium text-slate-800 dark:text-white mb-4">Ajouter une humeur</h2>
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Liste des humeurs</h2>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Texte</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-24">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for humeur in humeurs %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<td class="px-6 py-4 text-gray-700 dark:text-gray-300">{{ humeur.text }}</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<a href="{{ url_for('delHumeur', id = humeur.id) }}"
|
||||
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette humeur ?')"
|
||||
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors text-red-600 dark:text-red-400 inline-block"
|
||||
title="Supprimer">
|
||||
<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="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>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="2" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
Aucune humeur configurée. Ajoutez-en une ci-dessous.
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Ajouter une humeur</h2>
|
||||
|
||||
<form action="{{ url_for('addHumeur') }}" method="POST">
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<div class="flex-1">
|
||||
<input type="text" name="text" id="text" placeholder="Joue à un super jeu..." class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600 text-white text-sm font-medium rounded-lg transition-colors whitespace-nowrap">
|
||||
Ajouter
|
||||
<form action="{{ url_for('addHumeur') }}" method="POST" class="space-y-6">
|
||||
<div>
|
||||
<label for="text" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Texte du statut</label>
|
||||
<input name="text" id="text" type="text" required
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
|
||||
placeholder="Joue à un jeu vidéo..."/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit"
|
||||
class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
Ajouter l'humeur
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
+56
-71
@@ -10,84 +10,69 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-10">
|
||||
<a href="/live-alert" class="group bg-white dark:bg-slate-800 rounded-lg p-5 border border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 hover:shadow-md transition-all">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-slate-600 dark:text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-slate-800 dark:text-white">Alertes Live</h3>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">Notifications Twitch</p>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-slate-400 ml-auto opacity-0 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden mb-8">
|
||||
<div class="p-4 sm:p-6 border-b border-slate-200 dark:border-slate-700 flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-flex items-center justify-center w-3 h-3 rounded-full {% if discord_connected %}bg-emerald-500 ring-4 ring-emerald-500/30{% else %}bg-slate-400 ring-4 ring-slate-400/30{% endif %}" title="{% if discord_connected %}Bot Discord connecté{% else %}Bot Discord déconnecté{% endif %}"></span>
|
||||
<h2 class="text-xl font-semibold text-slate-800 dark:text-white">Discord</h2>
|
||||
</div>
|
||||
</a>
|
||||
<span class="text-sm text-slate-500 dark:text-slate-400">
|
||||
{% if discord_connected %}Connecté{% else %}Déconnecté{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-4 sm:p-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600">
|
||||
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">Serveurs connectés</p>
|
||||
<p class="text-2xl font-bold text-slate-800 dark:text-white mt-1">{{ discord_guild_count }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600">
|
||||
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">Sanctions enregistrées</p>
|
||||
<p class="text-2xl font-bold text-slate-800 dark:text-white mt-1">{{ sanctions_count }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600 sm:col-span-2 lg:col-span-2 flex items-center justify-center">
|
||||
<div class="flex flex-wrap gap-3 justify-center">
|
||||
<a href="/live-alert" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Alertes Live</a>
|
||||
<a href="/youtube" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Notification YouTube</a>
|
||||
<a href="/humeurs" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Humeurs</a>
|
||||
<a href="/protondb" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">ProtonDB</a>
|
||||
<a href="/commandes" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Commandes</a>
|
||||
<a href="/moderation" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Modération</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="/commandes" class="group bg-white dark:bg-slate-800 rounded-lg p-5 border border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 hover:shadow-md transition-all">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-slate-600 dark:text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-slate-800 dark:text-white">Commandes</h3>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">Commandes personnalisées</p>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-slate-400 ml-auto opacity-0 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden mb-8">
|
||||
<div class="p-4 sm:p-6 border-b border-slate-200 dark:border-slate-700 flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-flex items-center justify-center w-3 h-3 rounded-full {% if twitch_connected %}bg-emerald-500 ring-4 ring-emerald-500/30{% else %}bg-slate-400 ring-4 ring-slate-400/30{% endif %}" title="{% if twitch_connected %}Bot Twitch connecté{% else %}Bot Twitch déconnecté{% endif %}"></span>
|
||||
<h2 class="text-xl font-semibold text-slate-800 dark:text-white">Twitch</h2>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/humeurs" class="group bg-white dark:bg-slate-800 rounded-lg p-5 border border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 hover:shadow-md transition-all">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-slate-600 dark:text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-slate-800 dark:text-white">Humeurs</h3>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">Statuts Discord rotatifs</p>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-slate-400 ml-auto opacity-0 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
|
||||
<span class="text-sm text-slate-500 dark:text-slate-400">
|
||||
{% if twitch_connected %}Connecté{% else %}Déconnecté{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-4 sm:p-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600">
|
||||
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">Canal connecté</p>
|
||||
<p class="text-2xl font-bold text-slate-800 dark:text-white mt-1">{% if twitch_channel_name %}{{ twitch_channel_name }}{% else %}—{% endif %}</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/moderation" class="group bg-white dark:bg-slate-800 rounded-lg p-5 border border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 hover:shadow-md transition-all">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-slate-600 dark:text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-slate-800 dark:text-white">Modération</h3>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">Historique et actions</p>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-slate-400 ml-auto opacity-0 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
|
||||
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600">
|
||||
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">Annonces configurées</p>
|
||||
<p class="text-2xl font-bold text-slate-800 dark:text-white mt-1">{{ twitch_announcements_count }}</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/protondb" class="group bg-white dark:bg-slate-800 rounded-lg p-5 border border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 hover:shadow-md transition-all">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-slate-600 dark:text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z"></path></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-slate-800 dark:text-white">ProtonDB</h3>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">Compatibilité Linux</p>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-slate-400 ml-auto opacity-0 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
|
||||
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600">
|
||||
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">Actions de modération</p>
|
||||
<p class="text-2xl font-bold text-slate-800 dark:text-white mt-1">{{ twitch_moderation_count }}</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/configurations" class="group bg-white dark:bg-slate-800 rounded-lg p-5 border border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 hover:shadow-md transition-all">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-slate-600 dark:text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
|
||||
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600 flex items-center justify-center">
|
||||
<div class="flex flex-wrap gap-3 justify-center">
|
||||
<a href="/announcements" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-600 text-white text-sm font-medium hover:bg-purple-700 transition-colors">Annonces</a>
|
||||
<a href="/twitch-moderation" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Modération</a>
|
||||
<a href="/link-filter" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Filtre de liens</a>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-slate-800 dark:text-white">Configurations</h3>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">Paramètres du bot</p>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-slate-400 ml-auto opacity-0 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg p-6 border border-slate-200 dark:border-slate-700">
|
||||
@@ -98,7 +83,7 @@
|
||||
<div>
|
||||
<h3 class="font-medium text-slate-800 dark:text-white mb-2">À propos</h3>
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400 mb-4">
|
||||
Mamie Henriette est un bot open source pour Discord et Twitch, développé par la communauté.
|
||||
Mamie Henriette est un bot open source pour Discord et Twitch, développé par la communauté.
|
||||
Cette interface vous permet de configurer et gérer toutes les fonctionnalités.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">Filtre de liens</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">Bloquez les liens non autorises sur votre chat Twitch.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-3 rounded-lg {{ 'bg-green-100 dark:bg-green-900/30' if config.enabled else 'bg-gray-100 dark:bg-gray-700' }}">
|
||||
<svg class="w-6 h-6 {{ 'text-green-600 dark:text-green-400' if config.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="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>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Protection des liens</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ 'Active' if config.enabled else 'Desactive' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ url_for('toggle_link_filter') }}" class="px-4 py-2 rounded-lg font-medium transition-colors {{ 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400' if config.enabled else 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' }}">
|
||||
{{ 'Desactiver' if config.enabled else 'Activer' }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form action="{{ url_for('update_link_filter') }}" method="POST" class="space-y-6">
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-medium text-gray-900 dark:text-white">Autoriser les liens pour</h3>
|
||||
<label class="flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input type="checkbox" name="allow_moderators" {{ 'checked' if config.allow_moderators }} class="rounded border-gray-300 text-purple-600 focus:ring-purple-500">
|
||||
<div>
|
||||
<span class="text-gray-900 dark:text-white font-medium">Moderateurs</span>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Les modos peuvent toujours poster des liens</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input type="checkbox" name="allow_vips" {{ 'checked' if config.allow_vips }} class="rounded border-gray-300 text-purple-600 focus:ring-purple-500">
|
||||
<div>
|
||||
<span class="text-gray-900 dark:text-white font-medium">VIP</span>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Les VIP peuvent poster des liens</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input type="checkbox" name="allow_subscribers" {{ 'checked' if config.allow_subscribers }} class="rounded border-gray-300 text-purple-600 focus:ring-purple-500">
|
||||
<div>
|
||||
<span class="text-gray-900 dark:text-white font-medium">Abonnes</span>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Les abonnes peuvent poster des liens</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Duree du timeout (secondes)</label>
|
||||
<input type="number" name="timeout_duration" value="{{ config.timeout_duration }}" 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 focus:border-transparent">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">0 = pas de timeout, juste suppression du message</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Message d'avertissement</label>
|
||||
<textarea name="warning_message" rows="2"
|
||||
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 focus:border-transparent resize-none">{{ config.warning_message }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors font-medium">
|
||||
Enregistrer les parametres
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<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 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path>
|
||||
</svg>
|
||||
Domaines autorises ({{ domains|length }})
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<form action="{{ url_for('add_allowed_domain') }}" method="POST" class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex gap-2">
|
||||
<input type="text" name="domain" placeholder="exemple.com" required
|
||||
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 focus:border-transparent text-sm">
|
||||
<button type="submit" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors text-sm font-medium">
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if domains %}
|
||||
<div class="max-h-48 overflow-y-auto">
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for domain in domains %}
|
||||
<div class="px-4 py-2 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<code class="text-sm text-gray-700 dark:text-gray-300 font-mono">{{ domain.domain }}</code>
|
||||
<a href="{{ url_for('delete_allowed_domain', domain_id=domain.id) }}" class="text-red-600 hover:text-red-700 text-sm">Supprimer</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-4 py-6 text-center text-gray-500 dark:text-gray-400 text-sm">
|
||||
Aucun domaine autorise
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<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 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
|
||||
</svg>
|
||||
Viewers autorises ({{ users|length }})
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<form action="{{ url_for('add_allowed_user') }}" method="POST" class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex gap-2">
|
||||
<input type="text" name="username" placeholder="@pseudo" required
|
||||
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 focus:border-transparent text-sm">
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors text-sm font-medium">
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if users %}
|
||||
<div class="max-h-48 overflow-y-auto">
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for user in users %}
|
||||
<div class="px-4 py-2 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">@{{ user.username }}</span>
|
||||
<a href="{{ url_for('delete_allowed_user', user_id=user.id) }}" class="text-red-600 hover:text-red-700 text-sm">Supprimer</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-4 py-6 text-center text-gray-500 dark:text-gray-400 text-sm">
|
||||
Aucun viewer en liste blanche
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-900/20 dark:to-purple-900/20 rounded-xl p-6 border border-blue-200 dark:border-blue-800">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
Commande Permit
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm mb-4">
|
||||
Utilisez la commande <code class="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-xs">!permit</code> pour autoriser temporairement un viewer a poster un lien.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-4 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="px-2 py-1 bg-white dark:bg-gray-800 rounded text-xs font-mono text-purple-600 dark:text-purple-400">!permit @viewer</code>
|
||||
<span class="text-gray-600 dark:text-gray-400">Autorise 1 min</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="px-2 py-1 bg-white dark:bg-gray-800 rounded text-xs font-mono text-purple-600 dark:text-purple-400">!permit @viewer 5</code>
|
||||
<span class="text-gray-600 dark:text-gray-400">Autorise 5 min</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
+314
-114
@@ -1,135 +1,335 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-semibold text-slate-800 dark:text-white mb-1">Alertes Live</h1>
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400">
|
||||
Chaînes Twitch surveillées. Vérification toutes les 5 minutes, maximum 100 chaînes.
|
||||
</p>
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Alerte Live</h1>
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Liste des chaînes surveillées pour les alertes de live Twitch.
|
||||
Le bot vérifie toutes les 5 minutes qui est en live dans la liste en dessous.
|
||||
Le bot enregistre le status de stream toutes les 5 minutes, quand le status passe de "hors-ligne" à "en ligne" alors
|
||||
le bot enverra une notification (embed Discord) sur le canal choisi.
|
||||
<span class="font-medium text-blue-600 dark:text-blue-400">Ne peut surveiller qu'au maximum 100 chaînes.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not alert %}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden mb-6">
|
||||
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<h2 class="text-lg font-medium text-slate-800 dark:text-white">Alertes configurées</h2>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700">
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Chaîne</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Canal Discord</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Message</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
{% for alert in alerts %}
|
||||
<tr class="hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors">
|
||||
<td class="px-4 py-3">
|
||||
<a href="https://www.twitch.tv/{{ alert.login }}" target="_blank" class="text-sm font-medium text-slate-700 dark:text-slate-300 hover:underline">{{ alert.login }}</a>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-sm text-slate-600 dark:text-slate-400">#{{ alert.notify_channel_name }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-600 dark:text-slate-400 max-w-xs">
|
||||
<div class="line-clamp-2">{{ alert.message }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<a href="{{ url_for('toggleLiveAlert', id = alert.id) }}" class="text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 transition-colors" title="{{ 'Désactiver' if alert.enable else 'Activer' }}">
|
||||
{% if alert.enable %}
|
||||
<span class="text-green-600 dark:text-green-500">Actif</span>
|
||||
{% else %}
|
||||
<span class="text-slate-400">Inactif</span>
|
||||
{% endif %}
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Alertes configurées</h2>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Chaîne</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Canal</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Message / Embed</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider" title="Le bot affichera 'Regarde [streamer]' comme activité">Activité</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for alert in alerts %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<a href="https://www.twitch.tv/{{alert.login}}" target="_blank" class="text-purple-600 dark:text-purple-400 hover:underline font-medium">{{alert.login}}</a>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-gray-700 dark:text-gray-300">{{alert.notify_channel_name}}</td>
|
||||
<td class="px-6 py-4 text-gray-600 dark:text-gray-400 max-w-md truncate">{{alert.message or '(embed)'}}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<a href="{{ url_for('toggleWatchActivity', id = alert.id) }}"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
||||
title="{{ 'Désactiver l\'activité' if alert.watch_activity else 'Activer l\'activité' }}">
|
||||
{{ '👁️' if alert.watch_activity else '👁️🗨️' }}
|
||||
</a>
|
||||
<a href="{{ url_for('openEditLiveAlert', id = alert.id) }}" class="text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 transition-colors">
|
||||
Modifier
|
||||
</a>
|
||||
<a href="{{ url_for('delLiveAlert', id = alert.id) }}" onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette alerte ?')" class="text-sm text-slate-500 hover:text-red-600 dark:hover:text-red-400 transition-colors">
|
||||
Supprimer
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="px-4 py-8 text-center text-sm text-slate-500 dark:text-slate-400">
|
||||
Aucune alerte configurée
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<a href="{{ url_for('toggleLiveAlert', id = alert.id) }}"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
||||
title="{{ 'Désactiver' if alert.enable else 'Activer' }}">
|
||||
{{ '✅' if alert.enable else '❌' }}
|
||||
</a>
|
||||
<a href="{{ url_for('openEditLiveAlert', id = alert.id) }}"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors text-blue-600 dark:text-blue-400"
|
||||
title="Modifier">
|
||||
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
|
||||
</a>
|
||||
<a href="{{ url_for('delLiveAlert', id = alert.id) }}"
|
||||
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette alerte ?')"
|
||||
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors text-red-600 dark:text-red-400"
|
||||
title="Supprimer">
|
||||
<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="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>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
Aucune alerte configurée. Ajoutez-en une ci-dessous.
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-5">
|
||||
<h2 class="text-lg font-medium text-slate-800 dark:text-white mb-5">
|
||||
{% if alert %}Modifier l'alerte{% else %}Ajouter une alerte{% endif %}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||
{{ 'Modifier l\'alerte' if alert else 'Ajouter une alerte de Live' }}
|
||||
</h2>
|
||||
|
||||
<form action="{{ url_for('submitEditLiveAlert', id = alert.id) if alert else url_for('addLiveAlert') }}" method="POST" class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="login" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Chaîne Twitch</label>
|
||||
<input type="text" name="login" id="login" maxlength="32" required value="{{ alert.login if alert else '' }}" placeholder="chainesteve" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
|
||||
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">Le login de la chaîne, ex: chainesteve</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="notify_channel" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Canal de Notification</label>
|
||||
<select name="notify_channel" id="notify_channel" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
|
||||
{% for channel in channels %}
|
||||
<option value="{{ channel.id }}" {% if alert and alert.notify_channel == channel.id %}selected{% endif %}>{{ channel.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div>
|
||||
<label for="message" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Message de notification</label>
|
||||
<textarea name="message" id="message" rows="4" required placeholder="🔴 **{0.user_name}** est en live !" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all resize-none">{{ alert.message if alert else '' }}</textarea>
|
||||
<form id="live-alert-form" action="{{ url_for('submitEditLiveAlert', id = alert.id) if alert else url_for('addLiveAlert') }}" method="POST" class="space-y-6">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">Configuration de base</h3>
|
||||
|
||||
<div>
|
||||
<label for="login" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Chaîne Twitch</label>
|
||||
<input name="login" id="login" type="text" maxlength="32" required
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
|
||||
placeholder="chainesteve"
|
||||
value="{{alert.login if alert}}"/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="notify_channel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal de notification Discord</label>
|
||||
<select name="notify_channel" id="notify_channel"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}" {% if alert and alert.notify_channel == channel.id %}selected{% endif %}>{{channel.name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="message" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Message (optionnel, avant l'embed)</label>
|
||||
<textarea name="message" id="message" rows="2"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all resize-y"
|
||||
placeholder="Message envoyé avant l'embed">{{alert.message if alert}}</textarea>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Variables: {user_name}, {title}, [lien](https://www.twitch.tv/{user_login})</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="checkbox" name="watch_activity" id="watch_activity" value="1"
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700"
|
||||
{% if alert and alert.watch_activity %}checked{% endif %}>
|
||||
<label for="watch_activity" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Afficher "Regarde ce stream" comme activité du bot Discord
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">Personnalisation de l'embed Discord</h3>
|
||||
|
||||
<div>
|
||||
<label for="embed_title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Titre de l'embed</label>
|
||||
<input name="embed_title" id="embed_title" type="text" maxlength="256"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
|
||||
placeholder="{title}"
|
||||
value="{{alert.embed_title if alert else '{title}'}}"/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Variables: {title}, {user_name}, {game_name}, {stream_url}, {user_login}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="embed_description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description de l'embed</label>
|
||||
<textarea name="embed_description" id="embed_description" rows="2"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all resize-y"
|
||||
placeholder="Description optionnelle">{{alert.embed_description if alert}}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="embed_color" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Couleur</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input name="embed_color" id="embed_color" type="color"
|
||||
class="w-12 h-10 rounded border border-gray-300 dark:border-gray-600 cursor-pointer"
|
||||
value="#{{alert.embed_color if alert else '9146FF'}}"/>
|
||||
<input type="text" id="embed_color_text" maxlength="6"
|
||||
class="flex-1 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
|
||||
value="{{alert.embed_color if alert else '9146FF'}}" placeholder="9146FF"/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="embed_author_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Nom de l'auteur</label>
|
||||
<input name="embed_author_name" id="embed_author_name" type="text" maxlength="256"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
|
||||
placeholder="{user_name}"
|
||||
value="{{alert.embed_author_name if alert}}"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="embed_author_icon" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Icône de l'auteur (URL)</label>
|
||||
<input name="embed_author_icon" id="embed_author_icon" type="text" maxlength="512"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
|
||||
placeholder="Laissez vide pour l'avatar Twitch"
|
||||
value="{{alert.embed_author_icon if alert}}"/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="embed_footer" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Pied de page</label>
|
||||
<input name="embed_footer" id="embed_footer" type="text" maxlength="2048"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
|
||||
placeholder="Texte optionnel en bas"
|
||||
value="{{alert.embed_footer if alert}}"/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="embed_thumbnail" id="embed_thumbnail"
|
||||
{% if not alert or alert.embed_thumbnail %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Miniature (preview)</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="embed_image" id="embed_image"
|
||||
{% if not alert or alert.embed_image %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Image principale</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<button type="submit"
|
||||
class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
{{ 'Enregistrer' if alert else 'Ajouter l\'alerte' }}
|
||||
</button>
|
||||
{% if alert %}
|
||||
<a href="{{ url_for('openLiveAlert') }}"
|
||||
class="px-6 py-2.5 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 font-medium rounded-lg transition-colors">
|
||||
Annuler
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-50 dark:bg-slate-700/50 rounded-lg p-4">
|
||||
<p class="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">Variables disponibles :</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="px-2 py-0.5 bg-slate-200 dark:bg-slate-600 rounded text-xs font-mono">{0.user_login}</code>
|
||||
<span class="text-slate-600 dark:text-slate-400">Lien vers la chaîne</span>
|
||||
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200 mb-4">Prévisualisation de l'embed Discord</h3>
|
||||
<div id="embed-preview" class="bg-[#2f3136] rounded p-4 font-sans text-[#dcddde] max-w-xl border-l-4" style="border-left-color: #9146FF;">
|
||||
<div id="embed-author" class="flex items-center mb-2 text-sm">
|
||||
<img id="embed-author-icon" src="https://static-cdn.jtvnw.net/ttv-favicon/favicon-32x32.png" class="w-5 h-5 rounded-full mr-2" onerror="this.style.display='none'"/>
|
||||
<span id="embed-author-name" class="font-semibold">Nom du streamer</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="px-2 py-0.5 bg-slate-200 dark:bg-slate-600 rounded text-xs font-mono">{0.user_name}</code>
|
||||
<span class="text-slate-600 dark:text-slate-400">Nom du streamer</span>
|
||||
<a id="embed-title" href="#" class="text-[#00aff4] no-underline text-base font-semibold block mb-2">Titre du stream</a>
|
||||
<div id="embed-description" class="text-sm leading-relaxed mb-2 text-[#dcddde]"></div>
|
||||
<div id="embed-thumbnail-container" class="my-2">
|
||||
<img id="embed-thumbnail" src="" class="max-w-[80px] max-h-[80px] rounded float-right ml-4 hidden"/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="px-2 py-0.5 bg-slate-200 dark:bg-slate-600 rounded text-xs font-mono">{0.game_name}</code>
|
||||
<span class="text-slate-600 dark:text-slate-400">Jeu en cours</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="px-2 py-0.5 bg-slate-200 dark:bg-slate-600 rounded text-xs font-mono">{0.title}</code>
|
||||
<span class="text-slate-600 dark:text-slate-400">Titre du stream</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="px-2 py-0.5 bg-slate-200 dark:bg-slate-600 rounded text-xs font-mono">{0.language}</code>
|
||||
<span class="text-slate-600 dark:text-slate-400">Langue du stream</span>
|
||||
<div id="embed-image-container" class="mt-4">
|
||||
<img id="embed-image" src="https://static-cdn.jtvnw.net/previews-ttv/live_user_chaine-320x180.jpg" class="max-w-full rounded hidden"/>
|
||||
</div>
|
||||
<div id="embed-footer" class="mt-2 text-xs text-[#72767d]"></div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">Cette prévisualisation est approximative.</p>
|
||||
|
||||
<div class="mt-6 bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||
<h4 class="font-medium text-gray-800 dark:text-gray-200 mb-2">Variables disponibles (embed)</h4>
|
||||
<ul class="text-sm text-gray-600 dark:text-gray-400 space-y-1">
|
||||
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{user_login}</code> — Login Twitch</li>
|
||||
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{user_name}</code> — Nom d'affichage</li>
|
||||
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{game_name}</code> — Jeu en cours</li>
|
||||
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{title}</code> — Titre du stream</li>
|
||||
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{language}</code> — Langue</li>
|
||||
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{stream_url}</code> — Lien Twitch</li>
|
||||
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{thumbnail}</code> — URL preview</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
{% if alert %}
|
||||
<a href="{{ url_for('openLiveAlert') }}" class="px-4 py-2 text-slate-700 dark:text-slate-300 text-sm font-medium rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">
|
||||
Annuler
|
||||
</a>
|
||||
{% endif %}
|
||||
<button type="submit" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600 text-white text-sm font-medium rounded-lg transition-colors">
|
||||
{% if alert %}Enregistrer{% else %}Ajouter{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function formatText(text, vars) {
|
||||
if (!text) return '';
|
||||
return text.replace(/\{(\w+)\}/g, function(match, key) {
|
||||
return vars[key] !== undefined && vars[key] !== null ? vars[key] : match;
|
||||
});
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
const embedTitle = document.getElementById('embed_title').value || '{title}';
|
||||
const embedDescription = document.getElementById('embed_description').value || '';
|
||||
const embedColor = document.getElementById('embed_color_text').value || '9146FF';
|
||||
const embedAuthorName = document.getElementById('embed_author_name').value || '{user_name}';
|
||||
const embedAuthorIcon = document.getElementById('embed_author_icon').value || '';
|
||||
const embedFooter = document.getElementById('embed_footer').value || '';
|
||||
const embedThumbnail = document.getElementById('embed_thumbnail').checked;
|
||||
const embedImage = document.getElementById('embed_image').checked;
|
||||
|
||||
const vars = {
|
||||
user_login: 'chainesteve',
|
||||
user_name: 'ChaîneSteve',
|
||||
game_name: 'Minecraft',
|
||||
title: '🔴 Live chill avec les viewers',
|
||||
language: 'fr',
|
||||
stream_url: 'https://www.twitch.tv/chainesteve',
|
||||
thumbnail: 'https://static-cdn.jtvnw.net/previews-ttv/live_user_chainesteve-320x180.jpg'
|
||||
};
|
||||
|
||||
document.getElementById('embed-title').textContent = formatText(embedTitle, vars);
|
||||
document.getElementById('embed-title').href = vars.stream_url;
|
||||
document.getElementById('embed-description').textContent = formatText(embedDescription, vars);
|
||||
document.getElementById('embed-author-name').textContent = formatText(embedAuthorName, vars);
|
||||
document.getElementById('embed-author-icon').src = embedAuthorIcon || 'https://static-cdn.jtvnw.net/ttv-favicon/favicon-32x32.png';
|
||||
document.getElementById('embed-author-icon').style.display = embedAuthorIcon ? '' : 'none';
|
||||
document.getElementById('embed-footer').textContent = formatText(embedFooter, vars);
|
||||
|
||||
document.getElementById('embed-preview').style.borderLeftColor = '#' + embedColor;
|
||||
|
||||
if (embedThumbnail) {
|
||||
document.getElementById('embed-thumbnail').src = vars.thumbnail;
|
||||
document.getElementById('embed-thumbnail').style.display = 'block';
|
||||
} else {
|
||||
document.getElementById('embed-thumbnail').style.display = 'none';
|
||||
}
|
||||
|
||||
if (embedImage) {
|
||||
document.getElementById('embed-image').src = vars.thumbnail;
|
||||
document.getElementById('embed-image').style.display = 'block';
|
||||
} else {
|
||||
document.getElementById('embed-image').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
const colorInput = document.getElementById('embed_color');
|
||||
if (colorInput) {
|
||||
colorInput.addEventListener('input', function(e) {
|
||||
document.getElementById('embed_color_text').value = e.target.value.substring(1).toUpperCase();
|
||||
updatePreview();
|
||||
});
|
||||
}
|
||||
|
||||
const colorText = document.getElementById('embed_color_text');
|
||||
if (colorText) {
|
||||
colorText.addEventListener('input', function(e) {
|
||||
const val = e.target.value.replace(/[^0-9A-Fa-f]/g, '').substring(0, 6);
|
||||
e.target.value = val;
|
||||
if (val.length === 6) {
|
||||
document.getElementById('embed_color').value = '#' + val;
|
||||
updatePreview();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const formFields = ['embed_title', 'embed_description', 'embed_author_name', 'embed_author_icon', 'embed_footer', 'embed_thumbnail', 'embed_image'];
|
||||
formFields.forEach(function(field) {
|
||||
const el = document.getElementById(field);
|
||||
if (el) {
|
||||
el.addEventListener('input', updatePreview);
|
||||
el.addEventListener('change', updatePreview);
|
||||
}
|
||||
});
|
||||
|
||||
updatePreview();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-md mx-auto py-12">
|
||||
<h1 class="text-2xl font-bold text-slate-800 dark:text-white mb-6 text-center">Connexion</h1>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="mb-4 space-y-2">
|
||||
{% for category, msg in messages %}
|
||||
<p class="p-3 rounded-lg text-sm {% if category == 'error' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200{% else %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200{% endif %}">{{ msg }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="post" action="{{ url_for('login') }}" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6 space-y-4">
|
||||
<div>
|
||||
<label for="identifier" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Identifiant (nom d'utilisateur ou e-mail)</label>
|
||||
<input type="text" id="identifier" name="identifier" required autocomplete="username" class="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent" placeholder="nom ou email">
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Mot de passe</label>
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password" class="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent">
|
||||
</div>
|
||||
<button type="submit" class="w-full py-2.5 px-4 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">Se connecter</button>
|
||||
</form>
|
||||
|
||||
{% if registration_enabled %}
|
||||
<p class="mt-4 text-center text-sm text-slate-600 dark:text-slate-400">
|
||||
Pas encore de compte ? <a href="{{ url_for('register') }}" class="text-primary-600 dark:text-primary-400 hover:underline">Créer un compte</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -8,6 +8,53 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<h2 class="text-lg font-medium text-slate-800 dark:text-white">Top 3 sanctions</h2>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Utilisateurs les plus sanctionnés</p>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
{% for row in top_sanctioned %}
|
||||
<div class="px-5 py-3 flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span class="flex-shrink-0 w-7 h-7 rounded-full bg-slate-200 dark:bg-slate-600 flex items-center justify-center text-sm font-bold text-slate-700 dark:text-slate-300">{{ loop.index }}</span>
|
||||
<div class="min-w-0">
|
||||
<span class="block text-sm font-medium text-slate-800 dark:text-white truncate">{{ row.username or '—' }}</span>
|
||||
<span class="block text-xs text-slate-500 dark:text-slate-400 font-mono truncate">{{ row.discord_id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="flex-shrink-0 text-sm font-semibold text-slate-600 dark:text-slate-300">{{ row.count }} sanction{{ 's' if row.count > 1 else '' }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-5 py-6 text-center text-sm text-slate-500 dark:text-slate-400">Aucune sanction enregistrée</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<h2 class="text-lg font-medium text-slate-800 dark:text-white">Top 3 modérateurs</h2>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Staff ayant effectué le plus d'actions</p>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
{% for row in top_moderators %}
|
||||
<div class="px-5 py-3 flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span class="flex-shrink-0 w-7 h-7 rounded-full bg-slate-200 dark:bg-slate-600 flex items-center justify-center text-sm font-bold text-slate-700 dark:text-slate-300">{{ loop.index }}</span>
|
||||
<div class="min-w-0">
|
||||
<span class="block text-sm font-medium text-slate-800 dark:text-white truncate">{{ row.staff_name or '—' }}</span>
|
||||
<span class="block text-xs text-slate-500 dark:text-slate-400 font-mono truncate">{{ row.staff_id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="flex-shrink-0 text-sm font-semibold text-slate-600 dark:text-slate-300">{{ row.count }} action{{ 's' if row.count > 1 else '' }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-5 py-6 text-center text-sm text-slate-500 dark:text-slate-400">Aucune action enregistrée</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden mb-6">
|
||||
<details class="group">
|
||||
<summary class="flex items-center justify-between px-5 py-4 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors">
|
||||
|
||||
+124
-113
@@ -1,124 +1,135 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-semibold text-slate-800 dark:text-white mb-1">ProtonDB</h1>
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400">
|
||||
Compatibilité des jeux Windows sur Linux via Steam Play.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if configuration.getValue('proton_db_enable_enable') %}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden mb-6">
|
||||
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<h2 class="text-lg font-medium text-slate-800 dark:text-white">Alias de jeux</h2>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700">
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Alias</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Nom du jeu</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
{% for a in aliases %}
|
||||
<tr class="hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors">
|
||||
<td class="px-4 py-3">
|
||||
<code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 rounded text-xs font-mono">{{ a.alias }}</code>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-600 dark:text-slate-400">
|
||||
{{ a.name }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<a href="{{ url_for('delGameAlias', id = a.id) }}" onclick="return confirm('Êtes-vous sûr de vouloir supprimer cet alias ?')" class="text-sm text-slate-500 hover:text-red-600 dark:hover:text-red-400 transition-colors">
|
||||
Supprimer
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3" class="px-4 py-8 text-center text-sm text-slate-500 dark:text-slate-400">
|
||||
Aucun alias configuré
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-5 mb-6">
|
||||
<h2 class="text-lg font-medium text-slate-800 dark:text-white mb-5">Ajouter un alias</h2>
|
||||
|
||||
<form action="{{ url_for('addGameAlias') }}" method="POST" class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="alias" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Alias</label>
|
||||
<input type="text" name="alias" id="alias" maxlength="32" required placeholder="GTA" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
|
||||
</div>
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Nom complet</label>
|
||||
<input type="text" name="name" id="name" maxlength="256" required placeholder="Grand Theft Auto" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600 text-white text-sm font-medium rounded-lg transition-colors">
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-5 bg-slate-50 dark:bg-slate-700/50 rounded-lg p-4">
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400">
|
||||
<strong class="text-slate-800 dark:text-white">Exemple :</strong> Si vous créez un alias GTA → Grand Theft Auto, alors !protondb GTA 5 fera une recherche sur Grand Theft Auto 5.
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">ProtonDB</h1>
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
ProtonDB évalue la compatibilité des jeux Windows sur Linux via Steam Play.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<h2 class="text-lg font-medium text-slate-800 dark:text-white">Configuration</h2>
|
||||
</div>
|
||||
|
||||
<form action="{{ url_for('updateConfiguration') }}" method="POST" class="p-5 space-y-6">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input type="checkbox" name="proton_db_enable_enable" {% if configuration.getValue('proton_db_enable_enable') %}checked{% endif %} class="w-4 h-4 text-slate-600 bg-slate-100 dark:bg-slate-700 border-slate-300 dark:border-slate-600 rounded focus:ring-slate-500">
|
||||
<span class="text-sm text-slate-700 dark:text-slate-300">Activer la commande ProtonDB</span>
|
||||
</label>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="proton_db_api_id" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">API ID</label>
|
||||
<input type="text" name="proton_db_api_id" id="proton_db_api_id" value="{{ configuration.getValue('proton_db_api_id') }}" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
|
||||
</div>
|
||||
<div>
|
||||
<label for="proton_db_api_key" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Clé API</label>
|
||||
<input type="text" name="proton_db_api_key" id="proton_db_api_key" value="{{ configuration.getValue('proton_db_api_key') }}" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600 text-white text-sm font-medium rounded-lg transition-colors">
|
||||
Enregistrer
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="px-5 pb-5">
|
||||
<div class="bg-slate-50 dark:bg-slate-700/50 rounded-lg p-4">
|
||||
<p class="text-sm font-medium text-slate-800 dark:text-white mb-2">Comment trouver les clés API ?</p>
|
||||
<ol class="list-decimal list-inside space-y-1 text-sm text-slate-600 dark:text-slate-400">
|
||||
<li>Ouvrez l'outil d'inspection de votre navigateur (F12)</li>
|
||||
<li>Allez dans l'onglet Réseau/Network</li>
|
||||
<li>Faites une recherche de jeu sur ProtonDB</li>
|
||||
<li>Cherchez les clés dans les requêtes réseau</li>
|
||||
</ol>
|
||||
<a href="/static/img/algolia-key.jpg" target="_blank" class="inline-block mt-3 text-sm text-slate-600 dark:text-slate-400 hover:underline">
|
||||
Voir l'exemple en image
|
||||
</a>
|
||||
{% if configuration.getValue('proton_db_enable_enable') %}
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Alias de jeux</h2>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Alias</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Jeu</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-24">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for a in aliases %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<code class="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-purple-600 dark:text-purple-400 font-mono">{{ a.alias }}</code>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-gray-700 dark:text-gray-300">{{ a.name }}</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<a href="{{ url_for('delGameAlias', id = a.id) }}"
|
||||
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cet alias ?')"
|
||||
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors text-red-600 dark:text-red-400 inline-block"
|
||||
title="Supprimer">
|
||||
<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="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>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
Aucun alias configuré. Ajoutez-en un ci-dessous.
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Ajouter un alias</h2>
|
||||
|
||||
<form action="{{ url_for('addGameAlias') }}" method="POST" class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="alias" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Alias</label>
|
||||
<input name="alias" id="alias" type="text" maxlength="32" required
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
|
||||
placeholder="GTA"/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Nom du jeu</label>
|
||||
<input name="name" id="name" type="text" maxlength="256" required
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
|
||||
placeholder="Grand Theft Auto"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Si vous créez un alias <code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">GTA</code> → <code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">Grand Theft Auto</code>,
|
||||
alors la commande <code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">!protondb GTA 5</code> fera une recherche sur <strong class="text-gray-800 dark:text-gray-200">Grand Theft Auto 5</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit"
|
||||
class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
Ajouter l'alias
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Configuration</h2>
|
||||
|
||||
<form action="{{ url_for('updateConfiguration') }}" method="POST" class="space-y-6">
|
||||
<div class="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<input type="checkbox" name="proton_db_enable_enable" id="proton_db_enable_enable"
|
||||
{% if configuration.getValue('proton_db_enable_enable') %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700">
|
||||
<label for="proton_db_enable_enable" class="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">
|
||||
Activer la commande ProtonDB
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="proton_db_api_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">API ID</label>
|
||||
<input name="proton_db_api_id" id="proton_db_api_id" type="text"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
|
||||
value="{{ configuration.getValue('proton_db_api_id') }}"/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="proton_db_api_key" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Clé API</label>
|
||||
<input name="proton_db_api_key" id="proton_db_api_key" type="text"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
|
||||
value="{{ configuration.getValue('proton_db_api_key') }}"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
|
||||
<p class="text-sm text-amber-800 dark:text-amber-200">
|
||||
Pour trouver les clés : ouvrez l'outil d'inspection (F12) dans votre navigateur, faites une recherche de jeux sur ProtonDB,
|
||||
puis cherchez les clés dans les requêtes (onglet Réseau/Network).
|
||||
<a href="/static/img/algolia-key.jpg" target="_blank" class="underline hover:no-underline">Voir l'exemple</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit"
|
||||
class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
Enregistrer la configuration
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-md mx-auto py-12">
|
||||
<h1 class="text-2xl font-bold text-slate-800 dark:text-white mb-6 text-center">Créer un compte</h1>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="mb-4 space-y-2">
|
||||
{% for category, msg in messages %}
|
||||
<p class="p-3 rounded-lg text-sm {% if category == 'error' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200{% else %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200{% endif %}">{{ msg }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="post" action="{{ url_for('register') }}" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6 space-y-4">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Nom d'utilisateur</label>
|
||||
<input type="text" id="username" name="username" required minlength="3" autocomplete="username" class="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent" placeholder="min. 3 caractères">
|
||||
</div>
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Adresse e-mail</label>
|
||||
<input type="email" id="email" name="email" required autocomplete="email" class="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent" placeholder="vous@exemple.com">
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Mot de passe</label>
|
||||
<input type="password" id="password" name="password" required minlength="8" autocomplete="new-password" class="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent" placeholder="min. 8 caractères">
|
||||
</div>
|
||||
<div>
|
||||
<label for="password_confirm" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Confirmer le mot de passe</label>
|
||||
<input type="password" id="password_confirm" name="password_confirm" required minlength="8" autocomplete="new-password" class="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent">
|
||||
</div>
|
||||
<button type="submit" class="w-full py-2.5 px-4 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">S'inscrire</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-4 text-center text-sm text-slate-600 dark:text-slate-400">
|
||||
Déjà un compte ? <a href="{{ url_for('login') }}" class="text-primary-600 dark:text-primary-400 hover:underline">Se connecter</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,386 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">Paramètres et Permissions</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">Configuration des rôles et des accès aux pages (super administrateur uniquement)</p>
|
||||
</div>
|
||||
<button type="button" onclick="document.getElementById('help-modal').classList.remove('hidden')"
|
||||
class="px-4 py-2 rounded-lg bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 hover:bg-primary-200 dark:hover:bg-primary-900/50 transition-colors flex items-center gap-2">
|
||||
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>Aide</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="mb-6 space-y-2">
|
||||
{% for category, msg in messages %}
|
||||
<div class="p-4 rounded-lg {% if category == 'error' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800{% else %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-800{% endif %}">
|
||||
{{ msg }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Inscriptions -->
|
||||
<section class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 shadow-sm">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">Inscriptions</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Autoriser ou bloquer la création de nouveaux comptes par les visiteurs</p>
|
||||
<form action="{{ url_for('settings_toggle_registration') }}" method="post">
|
||||
<label class="inline-flex items-center gap-3 cursor-pointer group">
|
||||
<div class="relative">
|
||||
<input type="checkbox" name="enabled" value="1" {% if registration_enabled %}checked{% endif %}
|
||||
onchange="this.form.submit()"
|
||||
class="sr-only peer">
|
||||
<div class="w-14 h-7 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-green-500 dark:peer-checked:bg-green-600 transition-colors"></div>
|
||||
<div class="absolute left-1 top-1 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-7 shadow-md"></div>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-white">
|
||||
{% if registration_enabled %}✓ Inscriptions activées{% else %}✗ Inscriptions désactivées{% endif %}
|
||||
</span>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Rôles -->
|
||||
<section class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">Hiérarchie des rôles</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Les rôles définissent le niveau d'accès des utilisateurs. Plus le niveau est élevé, plus les permissions sont étendues.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="space-y-3 mb-6">
|
||||
{% for r in roles %}
|
||||
<div class="bg-gray-50 dark:bg-gray-700/30 rounded-lg p-4 border border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500 transition-colors">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center" style="background-color: {{ r.color or '#6B7280' }}20;">
|
||||
<span class="text-2xl" style="color: {{ r.color or '#6B7280' }};">●</span>
|
||||
</div>
|
||||
<div class="flex-grow min-w-0">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ r.name }}</h3>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
style="background-color: {{ r.color or '#6B7280' }}20; color: {{ r.color or '#6B7280' }};">
|
||||
Niveau {{ r.level }}
|
||||
</span>
|
||||
</div>
|
||||
{% if r.description %}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">{{ r.description }}</p>
|
||||
{% else %}
|
||||
{% set default_desc = default_roles_meta.get(r.name, {}).get('description') %}
|
||||
{% if default_desc %}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">{{ default_desc }}</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<details class="group">
|
||||
<summary class="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 cursor-pointer list-none flex items-center gap-1">
|
||||
<svg class="w-4 h-4 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span>Modifier ce rôle</span>
|
||||
</summary>
|
||||
<form action="{{ url_for('settings_role_edit', role_id=r.id) }}" method="post" class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-600 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Niveau (0-99)</label>
|
||||
<input type="number" name="level" value="{{ r.level }}" min="0" max="99"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Couleur</label>
|
||||
<input type="color" name="color" value="{{ r.color or '#6B7280' }}"
|
||||
class="w-full h-10 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 cursor-pointer">
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Description</label>
|
||||
<input type="text" name="description" value="{{ r.description or '' }}" placeholder="Description du rôle..."
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
|
||||
</div>
|
||||
<div class="sm:col-span-2 flex items-center gap-2">
|
||||
<button type="submit" class="px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white text-sm font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">
|
||||
Enregistrer
|
||||
</button>
|
||||
{% if r.name not in ['viewer_twitch','utilisateur_discord','moderateur_discord','expert_discord','moderateur_twitch','super_administrateur'] %}
|
||||
<button type="button" onclick="if(confirm('Supprimer ce rôle ?')) { this.closest('details').querySelector('form').setAttribute('action', '{{ url_for('settings_role_delete', role_id=r.id) }}'); this.closest('form').submit(); }"
|
||||
class="px-4 py-2 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm font-medium hover:bg-red-200 dark:hover:bg-red-900/50 transition-colors">
|
||||
Supprimer
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<details class="bg-primary-50 dark:bg-primary-900/20 rounded-lg border border-primary-200 dark:border-primary-800">
|
||||
<summary class="px-4 py-3 cursor-pointer list-none flex items-center gap-2 text-primary-700 dark:text-primary-300 font-medium hover:text-primary-800 dark:hover:text-primary-200">
|
||||
<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="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
<span>Créer un nouveau rôle</span>
|
||||
</summary>
|
||||
<form action="{{ url_for('settings_role_add') }}" method="post" class="p-4 pt-0 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Nom du rôle *</label>
|
||||
<input type="text" name="name" placeholder="ex: editeur_contenu" required minlength="2"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Niveau (0-99) *</label>
|
||||
<input type="number" name="level" value="0" min="0" max="99"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Couleur</label>
|
||||
<input type="color" name="color" value="#6B7280"
|
||||
class="w-full h-10 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 cursor-pointer">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Icône (optionnel)</label>
|
||||
<input type="text" name="icon" placeholder="ex: star, shield, user"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Description</label>
|
||||
<input type="text" name="description" placeholder="Description du rôle..."
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<button type="submit" class="px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">
|
||||
Créer le rôle
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Permissions par page -->
|
||||
<section class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">Accès aux pages</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Définissez le rôle minimum requis pour accéder à chaque section de l'interface
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Appliquer en masse -->
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/30">
|
||||
<details class="group">
|
||||
<summary class="cursor-pointer list-none flex items-center gap-2 text-gray-700 dark:text-gray-300 font-medium hover:text-gray-900 dark:hover:text-white">
|
||||
<svg class="w-5 h-5 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span>⚡ Modification en masse</span>
|
||||
</summary>
|
||||
<form action="{{ url_for('settings_permissions_bulk') }}" method="post" class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-600">
|
||||
<div class="flex flex-wrap items-center gap-4 mb-4">
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<span>Appliquer le rôle :</span>
|
||||
<select name="role" required class="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
|
||||
{% for r in roles %}
|
||||
<option value="{{ r.name }}" style="color: {{ r.color or '#6B7280' }};">{{ r.name }} (niveau {{ r.level }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">aux pages cochées :</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<button type="button" onclick="document.querySelectorAll('.bulk-page-cb').forEach(c => c.checked = true)"
|
||||
class="text-xs px-3 py-1.5 rounded-lg bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors">
|
||||
Tout cocher
|
||||
</button>
|
||||
<button type="button" onclick="document.querySelectorAll('.bulk-page-cb').forEach(c => c.checked = false)"
|
||||
class="text-xs px-3 py-1.5 rounded-lg bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors">
|
||||
Tout décocher
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2 mb-4">
|
||||
{% for page_key, meta in page_metadata.items() %}
|
||||
<label class="flex items-center gap-2 px-3 py-2 rounded-lg bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 hover:border-primary-300 dark:hover:border-primary-600 cursor-pointer transition-colors">
|
||||
<input type="checkbox" name="page_keys" value="{{ page_key }}" class="bulk-page-cb rounded border-gray-300 dark:border-gray-600 text-primary-600 focus:ring-primary-500">
|
||||
<span class="text-xs text-gray-700 dark:text-gray-300">{{ meta.label }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">
|
||||
Appliquer à la sélection
|
||||
</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Pages par catégorie -->
|
||||
<div class="p-6">
|
||||
{% for category_key in ['general', 'content', 'moderation', 'config', 'admin'] %}
|
||||
{% if category_key in pages_by_category %}
|
||||
{% set category_info = category_labels[category_key] %}
|
||||
<div class="mb-8 last:mb-0">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-8 h-8 rounded-lg flex items-center justify-center" style="background-color: {{ category_info.color }}20;">
|
||||
<span class="text-lg" style="color: {{ category_info.color }};">●</span>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ category_info.label }}</h3>
|
||||
<div class="flex-grow h-px bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{% for page_data in pages_by_category[category_key] %}
|
||||
{% set page_key = page_data.key %}
|
||||
{% set meta = page_data.meta %}
|
||||
{% set perm = page_data.permission %}
|
||||
{% set min_lvl = perm.min_level if perm else 0 %}
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-700/30 rounded-lg p-4 border border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500 transition-colors">
|
||||
<div class="flex items-start justify-between gap-3 mb-2">
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900 dark:text-white mb-1">{{ meta.label }}</h4>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">{{ meta.description }}</p>
|
||||
</div>
|
||||
{% for r in roles %}
|
||||
{% if r.level == min_lvl %}
|
||||
<span class="flex-shrink-0 inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium whitespace-nowrap"
|
||||
style="background-color: {{ r.color or '#6B7280' }}20; color: {{ r.color or '#6B7280' }};">
|
||||
{{ r.name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<form action="{{ url_for('settings_permissions_update') }}" method="post" class="flex items-center gap-2">
|
||||
<input type="hidden" name="page_key" value="{{ page_key }}">
|
||||
<select name="role" class="flex-grow px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-xs focus:ring-2 focus:ring-primary-500">
|
||||
{% for r in roles %}
|
||||
<option value="{{ r.name }}" {% if r.level == min_lvl %}selected{% endif %}>{{ r.name }} (niv. {{ r.level }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="px-3 py-1.5 rounded-lg bg-primary-600 dark:bg-primary-500 text-white text-xs font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">
|
||||
OK
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Modal d'aide -->
|
||||
<div id="help-modal" class="hidden fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" onclick="document.getElementById('help-modal').classList.add('hidden')"></div>
|
||||
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full">
|
||||
<div class="bg-white dark:bg-gray-800 px-6 pt-6 pb-4">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">Guide des permissions</h3>
|
||||
<button type="button" onclick="document.getElementById('help-modal').classList.add('hidden')"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||
<svg class="w-6 h-6" 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" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div>
|
||||
<h4 class="font-semibold text-gray-900 dark:text-white mb-2">🎭 Rôles</h4>
|
||||
<p>Les rôles définissent le niveau d'autorité d'un utilisateur. Plus le niveau est élevé, plus l'utilisateur a accès à des fonctionnalités.</p>
|
||||
<ul class="list-disc list-inside mt-2 space-y-1 ml-4">
|
||||
<li><strong>Niveau 0-1</strong> : Accès basique en lecture seule</li>
|
||||
<li><strong>Niveau 2-3</strong> : Modification de contenu et gestion basique</li>
|
||||
<li><strong>Niveau 4</strong> : Modération et configuration avancée</li>
|
||||
<li><strong>Niveau 5+</strong> : Administration complète du système</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-gray-900 dark:text-white mb-2">🔐 Permissions par page</h4>
|
||||
<p>Chaque page peut être restreinte à un rôle minimum. Un utilisateur doit avoir au moins le niveau requis pour y accéder.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-gray-900 dark:text-white mb-2">📋 Catégories</h4>
|
||||
<ul class="list-disc list-inside space-y-1 ml-4">
|
||||
<li><strong style="color: #6B7280;">●</strong> <strong>Général</strong> : Pages d'accueil et navigation</li>
|
||||
<li><strong style="color: #3B82F6;">●</strong> <strong>Contenu</strong> : Gestion des commandes, alertes, humeurs, etc.</li>
|
||||
<li><strong style="color: #EF4444;">●</strong> <strong>Modération</strong> : Outils de modération Discord/Twitch</li>
|
||||
<li><strong style="color: #8B5CF6;">●</strong> <strong>Configuration</strong> : Paramètres techniques des bots</li>
|
||||
<li><strong style="color: #F59E0B;">●</strong> <strong>Administration</strong> : Gestion des utilisateurs et permissions</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-gray-900 dark:text-white mb-2">💡 Bonnes pratiques</h4>
|
||||
<ul class="list-disc list-inside space-y-1 ml-4">
|
||||
<li>Attribuez le rôle le plus bas possible selon les besoins</li>
|
||||
<li>Testez les permissions avec un compte utilisateur avant de les déployer</li>
|
||||
<li>Documentez les rôles personnalisés avec des descriptions claires</li>
|
||||
<li>Vérifiez régulièrement les accès des utilisateurs</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4">
|
||||
<button type="button" onclick="document.getElementById('help-modal').classList.add('hidden')"
|
||||
class="w-full px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">
|
||||
Compris !
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Améliorer le style du toggle switch */
|
||||
input[type="checkbox"].sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
+151
-33
@@ -77,46 +77,117 @@
|
||||
|
||||
<!-- Navigation Desktop -->
|
||||
<div class="hidden md:flex items-center gap-1">
|
||||
<a href="/live-alert" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all">
|
||||
<span class="flex items-center gap-2">
|
||||
<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="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
|
||||
Alerte live
|
||||
</span>
|
||||
<!-- Discord (sous-menus) -->
|
||||
<div class="relative group">
|
||||
<button type="button" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
|
||||
Discord
|
||||
<svg class="w-4 h-4 transition-transform group-hover:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
|
||||
</button>
|
||||
<div class="absolute left-0 top-full pt-1 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 min-w-[200px]">
|
||||
<a href="/humeurs" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<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="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
Humeur
|
||||
</a>
|
||||
<a href="/live-alert" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<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="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
|
||||
Notification Twitch
|
||||
</a>
|
||||
<a href="{{ url_for('open_twitch_events') }}" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<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="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path></svg>
|
||||
Événements Twitch (sub, raid, clip)
|
||||
</a>
|
||||
<a href="/youtube" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
|
||||
Notification YouTube
|
||||
</a>
|
||||
<a href="/protondb" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<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="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z"></path></svg>
|
||||
ProtonDB
|
||||
</a>
|
||||
<a href="{{ url_for('openFreeLoot') }}" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<span class="text-lg">🎁</span>
|
||||
FreeLoot
|
||||
</a>
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 my-1"></div>
|
||||
<a href="/commandes" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<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="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
|
||||
Commandes
|
||||
</a>
|
||||
<a href="/moderation" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<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="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
|
||||
Modération
|
||||
</a>
|
||||
<a href="/configurations#auto-rooms" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<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="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v3m0 0v-3a7 7 0 017-7"></path></svg>
|
||||
Auto Rooms
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Twitch (futur bot) -->
|
||||
<div class="relative group">
|
||||
<button type="button" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M11.571 4.714h1.715v5.143H11.57l-.002-5.143zm3.43 0H16.714v5.143H15V4.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0H6zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714v9.429z"/></svg>
|
||||
Twitch
|
||||
<svg class="w-4 h-4 transition-transform group-hover:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
|
||||
</button>
|
||||
<div class="absolute left-0 top-full pt-1 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 min-w-[200px]">
|
||||
<a href="/live-alert" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<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="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
|
||||
Alerte live
|
||||
</a>
|
||||
<a href="{{ url_for('open_twitch_events') }}" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<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="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path></svg>
|
||||
Événements (sub, raid, clip)
|
||||
</a>
|
||||
<a href="/announcements" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<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="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"></path></svg>
|
||||
Annonces
|
||||
</a>
|
||||
<a href="/twitch-moderation" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<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="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
|
||||
Moderation
|
||||
</a>
|
||||
<a href="/link-filter" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<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="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>
|
||||
Filtre de liens
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuration locale -->
|
||||
<a href="/configurations" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all flex items-center gap-2">
|
||||
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
|
||||
Configuration
|
||||
</a>
|
||||
<a href="/commandes" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all">
|
||||
<span class="flex items-center gap-2">
|
||||
<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="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
|
||||
Commandes
|
||||
</span>
|
||||
{% if current_user.is_authenticated and current_user_level >= 5 %}
|
||||
<a href="{{ url_for('users_list') }}" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all flex items-center gap-2">
|
||||
<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="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
|
||||
Utilisateurs
|
||||
</a>
|
||||
<a href="/humeurs" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all">
|
||||
<span class="flex items-center gap-2">
|
||||
<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="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
Humeurs
|
||||
</span>
|
||||
</a>
|
||||
<a href="/moderation" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all">
|
||||
<span class="flex items-center gap-2">
|
||||
<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="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
|
||||
Modération
|
||||
</span>
|
||||
</a>
|
||||
<a href="/protondb" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all">
|
||||
<span class="flex items-center gap-2">
|
||||
<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="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z"></path></svg>
|
||||
ProtonDB
|
||||
</span>
|
||||
</a>
|
||||
<a href="/configurations" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all">
|
||||
<span class="flex items-center gap-2">
|
||||
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
|
||||
Configurations
|
||||
</span>
|
||||
<a href="{{ url_for('settings') }}" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all flex items-center gap-2">
|
||||
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
|
||||
Paramètres
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2">
|
||||
{% if current_user.is_authenticated %}
|
||||
<span class="hidden sm:inline text-sm text-gray-600 dark:text-gray-400" title="Rôle : {{ current_user.role }}">{{ current_user.username }}</span>
|
||||
<a href="{{ url_for('logout') }}" class="px-3 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">Déconnexion</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('login') }}" class="px-3 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">Connexion</a>
|
||||
{% if registration_enabled %}
|
||||
<a href="{{ url_for('register') }}" class="px-3 py-2 rounded-lg text-sm font-medium bg-primary-600 dark:bg-primary-500 text-white hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">Créer un compte</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<!-- Dark Mode Toggle -->
|
||||
<button onclick="toggleDarkMode()" class="p-2 rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all" title="Mode sombre">
|
||||
<svg class="w-5 h-5 hidden dark:block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
|
||||
@@ -138,6 +209,26 @@
|
||||
<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="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
|
||||
Alerte live
|
||||
</a>
|
||||
<a href="/announcements" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
||||
<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="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"></path></svg>
|
||||
Annonces Twitch
|
||||
</a>
|
||||
<a href="/twitch-moderation" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
||||
<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="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
|
||||
Moderation Twitch
|
||||
</a>
|
||||
<a href="/link-filter" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
||||
<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="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>
|
||||
Filtre de liens
|
||||
</a>
|
||||
<a href="{{ url_for('open_twitch_events') }}" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
||||
<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="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path></svg>
|
||||
Événements Twitch (sub, raid, clip)
|
||||
</a>
|
||||
<a href="/youtube" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
|
||||
YouTube
|
||||
</a>
|
||||
<a href="/commandes" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
||||
<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="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
|
||||
Commandes
|
||||
@@ -150,14 +241,41 @@
|
||||
<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="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
|
||||
Modération
|
||||
</a>
|
||||
<a href="/configurations#auto-rooms" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
||||
<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="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v3m0 0v-3a7 7 0 017-7"></path></svg>
|
||||
Auto Rooms
|
||||
</a>
|
||||
<a href="/protondb" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
||||
<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="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z"></path></svg>
|
||||
ProtonDB
|
||||
</a>
|
||||
<a href="{{ url_for('openFreeLoot') }}" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
||||
<span class="text-lg">🎁</span>
|
||||
FreeLoot
|
||||
</a>
|
||||
<a href="/configurations" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
||||
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
|
||||
Configurations
|
||||
</a>
|
||||
{% if current_user.is_authenticated and current_user_level >= 5 %}
|
||||
<a href="{{ url_for('users_list') }}" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
||||
<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="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
|
||||
Utilisateurs
|
||||
</a>
|
||||
<a href="{{ url_for('settings') }}" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
||||
Paramètres
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if current_user.is_authenticated %}
|
||||
<a href="{{ url_for('logout') }}" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all border-t border-gray-200 dark:border-gray-700 mt-2 pt-4">
|
||||
Déconnexion ({{ current_user.username }})
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('login') }}" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all border-t border-gray-200 dark:border-gray-700 mt-2 pt-4">Connexion</a>
|
||||
{% if registration_enabled %}
|
||||
<a href="{{ url_for('register') }}" class="flex items-center gap-3 px-4 py-3 rounded-lg text-primary-600 dark:text-primary-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">Créer un compte</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Notifications d'événements Twitch</h1>
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Configurez les notifications pour les abonnements, follows, raids et nouveaux clips.
|
||||
Pour chaque type d'événement vous pouvez activer l'envoi dans le <strong>chat Twitch</strong> et/ou dans un <strong>canal Discord</strong>.
|
||||
Les événements sub, follow et raid utilisent Twitch EventSub ; les clips sont détectés par vérification périodique.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form action="{{ url_for('save_twitch_events') }}" method="POST" class="space-y-8">
|
||||
{% for cfg in configs %}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between flex-wrap gap-4">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">{{ labels[cfg.event_type] }}</h2>
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="hidden" name="ev_{{ cfg.event_type }}_enable" value="0">
|
||||
<input type="checkbox" name="ev_{{ cfg.event_type }}_enable" value="1" {{ 'checked' if cfg.enable }}
|
||||
class="rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Activer</span>
|
||||
</label>
|
||||
<a href="{{ url_for('toggle_twitch_event', event_type=cfg.event_type) }}"
|
||||
class="text-sm {{ 'text-green-600 dark:text-green-400' if cfg.enable else 'text-gray-500' }}">
|
||||
{{ 'Activé' if cfg.enable else 'Désactivé' }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">Où notifier</h3>
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input type="hidden" name="ev_{{ cfg.event_type }}_notify_twitch_chat" value="0">
|
||||
<input type="checkbox" name="ev_{{ cfg.event_type }}_notify_twitch_chat" value="1" {{ 'checked' if cfg.notify_twitch_chat }}
|
||||
class="rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500">
|
||||
<span class="text-gray-700 dark:text-gray-300">Chat Twitch</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input type="hidden" name="ev_{{ cfg.event_type }}_notify_discord" value="0">
|
||||
<input type="checkbox" name="ev_{{ cfg.event_type }}_notify_discord" value="1" {{ 'checked' if cfg.notify_discord }}
|
||||
class="rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500">
|
||||
<span class="text-gray-700 dark:text-gray-300">Discord (canal de notifs)</span>
|
||||
</label>
|
||||
<div>
|
||||
<label for="ev_{{ cfg.event_type }}_discord_channel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Canal Discord</label>
|
||||
<select name="ev_{{ cfg.event_type }}_discord_channel_id" id="ev_{{ cfg.event_type }}_discord_channel"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
<option value="">— Choisir un canal —</option>
|
||||
{% for ch in channels %}
|
||||
<option value="{{ ch.id }}" {{ 'selected' if cfg.discord_channel_id == ch.id }}>{{ ch.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="ev_{{ cfg.event_type }}_message_twitch" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Message (chat Twitch)</label>
|
||||
<input type="text" name="ev_{{ cfg.event_type }}_message_twitch" id="ev_{{ cfg.event_type }}_message_twitch" maxlength="500"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
value="{{ cfg.message_twitch }}" placeholder="Merci {user} !">
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Sub/Follow: <code>{user}</code> <code>{user_name}</code> —
|
||||
Raid: <code>{from_broadcaster_name}</code> <code>{viewers}</code> —
|
||||
Clip: <code>{user}</code> <code>{title}</code> <code>{url}</code>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="ev_{{ cfg.event_type }}_message_discord" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Message Discord (optionnel, avant l'embed)</label>
|
||||
<textarea name="ev_{{ cfg.event_type }}_message_discord" id="ev_{{ cfg.event_type }}_message_discord" rows="2" maxlength="2000"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white resize-y">{{ cfg.message_discord or '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<h4 class="font-medium text-gray-800 dark:text-gray-200 mb-3">Embed Discord (optionnel)</h4>
|
||||
<div class="grid sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="ev_{{ cfg.event_type }}_embed_title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Titre</label>
|
||||
<input type="text" name="ev_{{ cfg.event_type }}_embed_title" id="ev_{{ cfg.event_type }}_embed_title" maxlength="256"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
value="{{ cfg.embed_title or '' }}" placeholder="Ex: Nouveau clip">
|
||||
</div>
|
||||
<div>
|
||||
<label for="ev_{{ cfg.event_type }}_embed_color" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Couleur (hex)</label>
|
||||
<input type="text" name="ev_{{ cfg.event_type }}_embed_color" id="ev_{{ cfg.event_type }}_embed_color" maxlength="6"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono"
|
||||
value="{{ cfg.embed_color or '9146FF' }}" placeholder="9146FF">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label for="ev_{{ cfg.event_type }}_embed_description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Description</label>
|
||||
<textarea name="ev_{{ cfg.event_type }}_embed_description" id="ev_{{ cfg.event_type }}_embed_description" rows="2" maxlength="2000"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white resize-y">{{ cfg.embed_description or '' }}</textarea>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 mt-3 cursor-pointer">
|
||||
<input type="hidden" name="ev_{{ cfg.event_type }}_embed_thumbnail" value="0">
|
||||
<input type="checkbox" name="ev_{{ cfg.event_type }}_embed_thumbnail" value="1" {{ 'checked' if cfg.embed_thumbnail }}
|
||||
class="rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Miniature dans l'embed (clips)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
Enregistrer tout
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,964 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.panel-moderation {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 56%) 1fr;
|
||||
gap: 1rem;
|
||||
min-height: calc(100vh - 8rem);
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.panel-moderation { grid-template-columns: 1fr; }
|
||||
}
|
||||
.player-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-bottom: 56.25%;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
background: #0e0e10;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,.25);
|
||||
}
|
||||
.player-wrap iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
.chat-wrap {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #18181b;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,.2);
|
||||
min-height: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.chat-wrap iframe {
|
||||
flex: 1;
|
||||
min-height: 280px;
|
||||
border: none;
|
||||
}
|
||||
.mod-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: #1f1f23;
|
||||
border-bottom: 1px solid #2d2d35;
|
||||
}
|
||||
.mod-toolbar input[type="text"] {
|
||||
width: 140px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #3d3d48;
|
||||
background: #18181b;
|
||||
color: #efeff1;
|
||||
font-size: 13px;
|
||||
}
|
||||
.mod-toolbar input::placeholder { color: #6b6b7b; }
|
||||
.mod-toolbar button {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background .15s, transform .05s;
|
||||
}
|
||||
.mod-toolbar button:active { transform: scale(0.98); }
|
||||
.btn-timeout { background: #e0a82e; color: #1f1f23; }
|
||||
.btn-timeout:hover { background: #e8b84a; }
|
||||
.btn-ban { background: #eb0400; color: #fff; }
|
||||
.btn-ban:hover { background: #ff1a15; }
|
||||
.btn-unban { background: #338847; color: #fff; }
|
||||
.btn-unban:hover { background: #3da352; }
|
||||
.btn-clean { background: #53535f; color: #efeff1; }
|
||||
.btn-clean:hover { background: #63636f; }
|
||||
.btn-copy { background: #9147ff; color: #fff; }
|
||||
.btn-copy:hover { background: #a970ff; }
|
||||
.right-col { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
|
||||
.commandes-logs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.section-card-body-logs {
|
||||
max-height: 280px;
|
||||
}
|
||||
.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; }
|
||||
.page-title-mod { font-size: 1.25rem; margin-bottom: 0.5rem; }
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h1 class="page-title-mod 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: Live status, Link filter, Banned words -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<!-- Live Status -->
|
||||
<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>
|
||||
|
||||
<!-- Link Filter -->
|
||||
<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>
|
||||
|
||||
<!-- Banned Words -->
|
||||
<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>
|
||||
|
||||
<!-- Commandes & logs Twitch (sans lecteur/iframe) -->
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<!-- Commandes de modération -->
|
||||
<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>
|
||||
|
||||
<!-- Logs de modération -->
|
||||
<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">
|
||||
{% if logs %}
|
||||
<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 '' }}">
|
||||
<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>
|
||||
{% else %}
|
||||
<div class="px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400">Aucun log</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lecteur Live + Chat Twitch (si live en cours) OU Formulaire + Chat (hors live) -->
|
||||
<div class="mt-6">
|
||||
{% if is_live %}
|
||||
<!-- Layout avec lecteur vidéo (live en cours) -->
|
||||
<div class="grid lg:grid-cols-3 gap-6">
|
||||
<!-- Lecteur Twitch (2/3) -->
|
||||
<div class="lg:col-span-2 bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="aspect-video bg-gray-900">
|
||||
<iframe
|
||||
src="https://player.twitch.tv/?channel={{ twitch_channel }}&enableExtensions=true&muted=false&parent={{ embed_parent }}&player=popout&quality=auto&volume=0.5"
|
||||
style="width: 100%; height: 100%; border: none;"
|
||||
allowfullscreen>
|
||||
</iframe>
|
||||
</div>
|
||||
<div class="p-3 bg-gray-50 dark:bg-gray-700 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex h-2 w-2 relative">
|
||||
<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="text-sm font-medium text-gray-900 dark:text-white">EN DIRECT</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">• {{ 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 sur Twitch
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Formulaire rapide (1/3) -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<h2 class="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
||||
<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="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
Nouvelle commande
|
||||
</h2>
|
||||
<form action="{{ url_for('add_twitch_commande') }}" method="POST" class="space-y-3">
|
||||
<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">
|
||||
<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>
|
||||
<textarea name="response" rows="3" placeholder="Réponse... ({user} = mention)" 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>
|
||||
<button type="submit" class="w-full px-3 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors font-medium text-sm">Ajouter</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat en dessous du lecteur -->
|
||||
<div class="mt-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden flex flex-col" style="height: 500px;">
|
||||
{% else %}
|
||||
<!-- Layout sans lecteur (hors live) -->
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<!-- Formulaire d'ajout de commande -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<h2 class="text-base font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
||||
<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="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
Ajouter une commande
|
||||
</h2>
|
||||
<form action="{{ url_for('add_twitch_commande') }}" method="POST" class="space-y-3">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<input type="text" name="trigger" placeholder="!commande" required
|
||||
class="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">
|
||||
<select name="twitch_permission"
|
||||
class="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>
|
||||
<textarea name="response" rows="2" placeholder="Réponse du bot... ({user} = mention)" 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>
|
||||
<button type="submit" class="w-full px-3 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors font-medium text-sm">Ajouter</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Chat -->
|
||||
<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" style="height: 500px;">
|
||||
{% endif %}
|
||||
<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 en direct</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>
|
||||
<a href="https://www.twitch.tv/{{ twitch_channel }}/chat" target="_blank" class="text-xs text-purple-600 dark:text-purple-400 hover:underline flex items-center gap-1">
|
||||
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
|
||||
</svg>
|
||||
Ouvrir sur Twitch
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Zone d'affichage des messages et actions -->
|
||||
<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>
|
||||
|
||||
<!-- Zone de saisie et boutons -->
|
||||
<div class="p-3 bg-gray-50 dark:bg-gray-700/50 border-t border-gray-200 dark:border-gray-600">
|
||||
<!-- Boutons d'action rapide -->
|
||||
<div class="grid grid-cols-4 gap-1.5 mb-3">
|
||||
<!-- Ligne 1 : Actions sur les users -->
|
||||
<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 75.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>
|
||||
|
||||
<!-- Ligne 2 : Modes du chat -->
|
||||
<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>
|
||||
|
||||
<!-- Input de message -->
|
||||
<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>
|
||||
</div>
|
||||
</div> <!-- Fermeture du conteneur principal -->
|
||||
|
||||
{% if custom_commands %}
|
||||
<div class="mt-6 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">Commandes personnalisées Twitch ({{ custom_commands|length }})</h3>
|
||||
</div>
|
||||
<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">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">
|
||||
{% for cmd in custom_commands %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<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">{{ cmd.trigger }}</code></td>
|
||||
<td class="px-4 py-2 text-gray-700 dark:text-gray-300 max-w-xs truncate">{{ cmd.response }}</td>
|
||||
<td class="px-4 py-2">{{ twitch_permissions.get(cmd.twitch_permission or 'viewer', 'Tous') }}</td>
|
||||
<td class="px-4 py-2"><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></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Section mots interdits -->
|
||||
<div class="mt-6 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>
|
||||
|
||||
<!-- Formulaire d'ajout -->
|
||||
<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>
|
||||
|
||||
<!-- Liste des mots interdits -->
|
||||
{% 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>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var modTarget = document.getElementById('modTarget');
|
||||
var copyCmdBtn = document.getElementById('copyCmdBtn');
|
||||
var lastCmd = '';
|
||||
|
||||
document.querySelectorAll('.mod-toolbar button[data-cmd]').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var user = (modTarget && modTarget.value.trim()) || '';
|
||||
lastCmd = this.getAttribute('data-cmd').replace(/\{\}/g, user).trim();
|
||||
if (user && (this.classList.contains('btn-clean') && lastCmd === '!clean')) {
|
||||
lastCmd = '!clean';
|
||||
}
|
||||
navigator.clipboard && navigator.clipboard.writeText(lastCmd).then(function() {
|
||||
var t = copyCmdBtn.textContent;
|
||||
copyCmdBtn.textContent = 'Copié !';
|
||||
setTimeout(function() { copyCmdBtn.textContent = t; }, 1200);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (copyCmdBtn) {
|
||||
copyCmdBtn.addEventListener('click', function() {
|
||||
if (lastCmd) {
|
||||
navigator.clipboard && navigator.clipboard.writeText(lastCmd).then(function() {
|
||||
var t = copyCmdBtn.textContent;
|
||||
copyCmdBtn.textContent = 'Copié !';
|
||||
setTimeout(function() { copyCmdBtn.textContent = t; }, 1200);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var logRows = document.querySelectorAll('.log-row');
|
||||
var logsSearchInput = document.getElementById('logsSearchInput');
|
||||
var logsCount = document.getElementById('logsCount');
|
||||
if (logsSearchInput) {
|
||||
logsSearchInput.addEventListener('input', function() {
|
||||
var q = this.value.toLowerCase();
|
||||
var n = 0;
|
||||
logRows.forEach(function(row) {
|
||||
var ok = row.dataset.search.toLowerCase().indexOf(q) >= 0;
|
||||
row.style.display = ok ? '' : 'none';
|
||||
if (ok) n++;
|
||||
});
|
||||
if (logsCount) logsCount.textContent = n;
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
// Fonctions pour le chat Twitch
|
||||
var displayedMessages = new Set();
|
||||
var isAutoScroll = true;
|
||||
|
||||
function addMessageToDisplay(username, message, timestamp, badges, userColor) {
|
||||
var chatDisplay = document.getElementById('chatDisplay');
|
||||
var messagesContainer = chatDisplay.querySelector('.space-y-2');
|
||||
|
||||
// Supprimer le message d'aide si c'est le premier message
|
||||
var helpText = messagesContainer.querySelector('.text-center');
|
||||
if (helpText) {
|
||||
helpText.remove();
|
||||
}
|
||||
|
||||
// Créer un ID unique pour ce message
|
||||
var messageId = timestamp + username + message;
|
||||
if (displayedMessages.has(messageId)) {
|
||||
return; // Message déjà affiché
|
||||
}
|
||||
displayedMessages.add(messageId);
|
||||
|
||||
// Créer l'élément de message
|
||||
var messageDiv = document.createElement('div');
|
||||
var msgDate = new Date(timestamp);
|
||||
var timeStr = msgDate.getHours().toString().padStart(2, '0') + ':' + msgDate.getMinutes().toString().padStart(2, '0');
|
||||
|
||||
// Déterminer les badges
|
||||
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>';
|
||||
}
|
||||
|
||||
messageDiv.className = 'bg-white dark:bg-gray-800 rounded-lg p-3 text-sm animate-fade-in border-l-2 border-gray-300 dark:border-gray-600';
|
||||
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'}">${escapeHtml(username)}</span>
|
||||
${badgeIcons}
|
||||
<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);
|
||||
|
||||
// Limiter à 100 messages affichés
|
||||
while (messagesContainer.children.length > 100) {
|
||||
messagesContainer.removeChild(messagesContainer.firstChild);
|
||||
}
|
||||
|
||||
// Scroll vers le bas si auto-scroll activé
|
||||
if (isAutoScroll) {
|
||||
chatDisplay.scrollTop = chatDisplay.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function addBotMessageToDisplay(message, type) {
|
||||
var chatDisplay = document.getElementById('chatDisplay');
|
||||
var messagesContainer = chatDisplay.querySelector('.space-y-2');
|
||||
|
||||
// Supprimer le message d'aide si c'est le premier message
|
||||
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 = 'bg-blue-50 dark:bg-blue-900/20';
|
||||
var borderColor = '#3b82f6';
|
||||
|
||||
if (type === 'command') {
|
||||
bgColor = 'bg-purple-50 dark:bg-purple-900/20';
|
||||
borderColor = '#9146FF';
|
||||
}
|
||||
|
||||
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>
|
||||
<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);
|
||||
|
||||
// Limiter à 100 messages
|
||||
while (messagesContainer.children.length > 100) {
|
||||
messagesContainer.removeChild(messagesContainer.firstChild);
|
||||
}
|
||||
|
||||
// Scroll vers le bas
|
||||
if (isAutoScroll) {
|
||||
chatDisplay.scrollTop = chatDisplay.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// Polling pour récupérer les messages
|
||||
function fetchChatMessages() {
|
||||
fetch('{{ url_for("get_twitch_messages") }}')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
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(error => {
|
||||
console.error('Erreur lors de la récupération des messages:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Démarrer le polling
|
||||
setInterval(fetchChatMessages, 2000); // Toutes les 2 secondes
|
||||
fetchChatMessages(); // Premier chargement immédiat
|
||||
|
||||
// Détecter le scroll manuel pour désactiver l'auto-scroll
|
||||
document.getElementById('chatDisplay').addEventListener('scroll', function() {
|
||||
var element = this;
|
||||
var isAtBottom = element.scrollHeight - element.scrollTop <= element.clientHeight + 50;
|
||||
isAutoScroll = isAtBottom;
|
||||
});
|
||||
|
||||
function escapeHtml(text) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function sendMessage(event) {
|
||||
event.preventDefault();
|
||||
var input = document.getElementById('chatMessage');
|
||||
var message = input.value.trim();
|
||||
|
||||
if (!message) return;
|
||||
|
||||
// Désactiver le bouton pendant l'envoi
|
||||
var submitBtn = event.target.querySelector('button[type="submit"]');
|
||||
var originalText = submitBtn.textContent;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Envoi...';
|
||||
|
||||
// Utiliser la fonction sendTextMessage pour les messages normaux
|
||||
fetch('{{ url_for("send_twitch_message") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ message: message })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Ajouter le message à l'affichage
|
||||
var type = message.startsWith('!') ? 'command' : 'message';
|
||||
addBotMessageToDisplay(message, type);
|
||||
input.value = '';
|
||||
showNotification('Message envoyé', 'success');
|
||||
} else {
|
||||
showNotification(data.error || 'Erreur lors de l\'envoi', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showNotification('Erreur réseau', 'error');
|
||||
})
|
||||
.finally(function() {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = originalText;
|
||||
});
|
||||
}
|
||||
|
||||
function executeQuickAction(action) {
|
||||
// Commandes simples sans paramètres
|
||||
if (['subon', 'suboff', 'emoteon', 'emoteoff'].includes(action)) {
|
||||
executeModerationAction(action, {});
|
||||
return;
|
||||
}
|
||||
|
||||
// Ban : demander utilisateur + raison
|
||||
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;
|
||||
}
|
||||
|
||||
// Timeout : demander utilisateur + durée + raison
|
||||
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, // Convertir en secondes
|
||||
reason: reason || 'Timeout'
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean : demander si utilisateur spécifique ou tout le chat
|
||||
if (action === 'clean') {
|
||||
var username = prompt('Nom d\'utilisateur (ou laissez vide pour tout le chat) :', '');
|
||||
if (username === null) return; // Annulé
|
||||
executeModerationAction('clean', {
|
||||
username: username ? username.trim().replace('@', '') : ''
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Permit : demander utilisateur + durée
|
||||
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 // Convertir en secondes
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showNotification(data.message || 'Action exécutée', 'success');
|
||||
// Ajouter un message visuel dans le chat
|
||||
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');
|
||||
} else {
|
||||
showNotification(data.error || 'Erreur lors de l\'exécution', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showNotification('Erreur réseau', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// Cette fonction est conservée pour les messages texte normaux (non-modération)
|
||||
function sendTextMessage(message) {
|
||||
fetch('{{ url_for("send_twitch_message") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ message: message })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
var type = message.startsWith('!') ? 'command' : 'message';
|
||||
addBotMessageToDisplay(message, type);
|
||||
showNotification('Message envoyé', 'success');
|
||||
} else {
|
||||
showNotification(data.error || 'Erreur lors de l\'envoi', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showNotification('Erreur réseau', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function showNotification(message, type) {
|
||||
var color = type === 'success' ? 'bg-green-500' : 'bg-red-500';
|
||||
var notification = document.createElement('div');
|
||||
notification.className = `fixed bottom-4 right-4 ${color} text-white px-4 py-2 rounded-lg shadow-lg z-50`;
|
||||
notification.textContent = message;
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(function() {
|
||||
notification.remove();
|
||||
}, 3000);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,292 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-xl bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
|
||||
<svg class="w-7 h-7 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">Gestion des utilisateurs</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">Liste des comptes webapp et attribution des rôles</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick="document.getElementById('create-user-modal').classList.remove('hidden')"
|
||||
class="flex-shrink-0 px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors flex items-center gap-2">
|
||||
<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="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
<span>Créer un utilisateur</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="mb-6 space-y-2">
|
||||
{% for category, msg in messages %}
|
||||
<div class="p-4 rounded-lg {% if category == 'error' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800{% else %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-800{% endif %}">
|
||||
{{ msg }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||
{% if users %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700">
|
||||
<tr>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||
Utilisateur
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||
E-mail
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||
Rôle actuel
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||
Inscrit le
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||
Modifier le rôle
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for u in users %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center text-white font-semibold">
|
||||
{{ u.username[0].upper() }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium text-gray-900 dark:text-white">{{ u.username }}</div>
|
||||
{% if u.id == current_user.id %}
|
||||
<span class="text-xs text-primary-600 dark:text-primary-400 font-medium">C'est vous</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{ u.email }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
{% set user_role = None %}
|
||||
{% for r in roles %}
|
||||
{% if r == u.role %}
|
||||
{% set user_role = r %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if user_role %}
|
||||
{% set role_obj = namespace(found=None) %}
|
||||
{% for role_name in roles %}
|
||||
{% if role_name == user_role %}
|
||||
{% for r_obj in roles %}
|
||||
{# Obtenir l'objet WebappRole complet #}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<span class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium whitespace-nowrap"
|
||||
style="background-color: #6B728020; color: #6B7280;">
|
||||
<span class="w-2 h-2 rounded-full" style="background-color: #6B7280;"></span>
|
||||
{{ role_labels.get(u.role, u.role) }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300">
|
||||
{{ role_labels.get(u.role, u.role) }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{% if u.created_at %}{{ u.created_at.strftime('%d/%m/%Y à %H:%M') }}{% else %}—{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<form action="{{ url_for('users_set_role', user_id=u.id) }}" method="post" class="flex items-center gap-2">
|
||||
<select name="role" class="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500 transition-colors"
|
||||
{% if u.id == current_user.id %}disabled title="Vous ne pouvez pas modifier votre propre rôle"{% endif %}>
|
||||
{% for r in roles %}
|
||||
<option value="{{ r }}" {% if u.role == r %}selected{% endif %}>{{ role_labels.get(r, r) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit"
|
||||
{% if u.id == current_user.id %}disabled{% endif %}
|
||||
class="px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white text-sm font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
Appliquer
|
||||
</button>
|
||||
</form>
|
||||
{% if u.id != current_user.id %}
|
||||
<form action="{{ url_for('users_delete', user_id=u.id) }}" method="post" class="inline" onsubmit="return confirm('Êtes-vous sûr de vouloir supprimer cet utilisateur ?');">
|
||||
<button type="submit" class="px-3 py-2 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm font-medium hover:bg-red-200 dark:hover:bg-red-900/50 transition-colors" title="Supprimer cet utilisateur">
|
||||
<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="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>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if u.id == current_user.id %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Protection: modification/suppression impossible</p>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-12 text-center">
|
||||
<svg class="w-16 h-16 mx-auto text-gray-400 dark:text-gray-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-lg">Aucun utilisateur enregistré</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Légende des rôles -->
|
||||
{% if users %}
|
||||
<div class="mt-6 bg-blue-50 dark:bg-blue-900/20 rounded-xl border border-blue-200 dark:border-blue-800 p-6">
|
||||
<h3 class="text-lg font-semibold text-blue-900 dark:text-blue-200 mb-4 flex items-center gap-2">
|
||||
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>Hiérarchie des rôles</span>
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{% for role_name in roles %}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 border border-blue-100 dark:border-blue-900">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="w-3 h-3 rounded-full" style="background-color: #6B7280;"></span>
|
||||
<span class="font-medium text-gray-900 dark:text-white text-sm">{{ role_labels.get(role_name, role_name) }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 ml-5">
|
||||
{% if role_name == 'viewer_twitch' %}
|
||||
Accès minimal, consultation uniquement
|
||||
{% elif role_name == 'utilisateur_discord' %}
|
||||
Modification de contenu basique
|
||||
{% elif role_name == 'moderateur_discord' %}
|
||||
Outils de modération Discord
|
||||
{% elif role_name == 'expert_discord' %}
|
||||
Gestion avancée du contenu
|
||||
{% elif role_name == 'moderateur_twitch' %}
|
||||
Outils de modération Twitch
|
||||
{% elif role_name == 'super_administrateur' %}
|
||||
Accès complet au système
|
||||
{% else %}
|
||||
Rôle personnalisé
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="mt-4 p-3 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
||||
<p class="text-sm text-blue-800 dark:text-blue-200">
|
||||
<strong>💡 Conseil :</strong> Vous pouvez personnaliser les rôles et leurs permissions dans la page
|
||||
<a href="{{ url_for('settings') }}" class="underline hover:text-blue-900 dark:hover:text-blue-100">Paramètres</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Modal de création d'utilisateur -->
|
||||
<div id="create-user-modal" class="hidden fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" onclick="document.getElementById('create-user-modal').classList.add('hidden')"></div>
|
||||
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||
<form action="{{ url_for('users_create') }}" method="post">
|
||||
<div class="bg-white dark:bg-gray-800 px-6 pt-6 pb-4">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">Créer un utilisateur</h3>
|
||||
</div>
|
||||
<button type="button" onclick="document.getElementById('create-user-modal').classList.add('hidden')"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||
<svg class="w-6 h-6" 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" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Nom d'utilisateur <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" id="username" name="username" required minlength="3"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="ex: john_doe">
|
||||
</div>
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Adresse e-mail <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="email" id="email" name="email" required
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="ex: john@example.com">
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Mot de passe <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="password" id="password" name="password" required minlength="8"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="Au moins 8 caractères">
|
||||
</div>
|
||||
<div>
|
||||
<label for="password_confirm" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Confirmer le mot de passe <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="password" id="password_confirm" name="password_confirm" required minlength="8"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="Retapez le mot de passe">
|
||||
</div>
|
||||
<div>
|
||||
<label for="role" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Rôle <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select id="role" name="role" required
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500">
|
||||
{% for r in roles %}
|
||||
<option value="{{ r }}">{{ role_labels.get(r, r) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 flex gap-3 justify-end">
|
||||
<button type="button" onclick="document.getElementById('create-user-modal').classList.add('hidden')"
|
||||
class="px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 font-medium hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors">
|
||||
Annuler
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">
|
||||
Créer l'utilisateur
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Animation au survol des lignes */
|
||||
tbody tr {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,342 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Notifications YouTube</h1>
|
||||
|
||||
{% if msg %}
|
||||
<div id="alert-msg" class="mb-4 p-4 rounded-lg {{ 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300' if msg_type == 'error' else 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-300' }}">
|
||||
{{ msg }}
|
||||
</div>
|
||||
<script>
|
||||
setTimeout(function() {
|
||||
var el = document.getElementById('alert-msg');
|
||||
if (el) el.style.display = 'none';
|
||||
}, 5000);
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Liste des chaînes YouTube surveillées pour les notifications de nouvelles vidéos.
|
||||
Le bot vérifie toutes les 5 minutes les nouvelles vidéos des chaînes en dessous.
|
||||
Quand une nouvelle vidéo est détectée, le bot enverra une notification sur Discord.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not notification %}
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Notifications configurées</h2>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Chaîne YouTube</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Canal Discord</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Type</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Message</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for notification in notifications %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<code class="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-red-600 dark:text-red-400 font-mono text-sm">{{ notification.channel_id }}</code>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-gray-700 dark:text-gray-300">{{ notification.notify_channel_name }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 py-1 text-xs font-medium rounded-full
|
||||
{% if notification.video_type == 'all' %}bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300
|
||||
{% elif notification.video_type == 'video' %}bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300
|
||||
{% else %}bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300{% endif %}">
|
||||
{% if notification.video_type == 'all' %}Toutes
|
||||
{% elif notification.video_type == 'video' %}Vidéos
|
||||
{% else %}Shorts{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-gray-600 dark:text-gray-400 max-w-xs truncate">{{ notification.message }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<a href="{{ url_for('toggleYouTube', id = notification.id) }}"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
||||
title="{{ 'Désactiver' if notification.enable else 'Activer' }}">
|
||||
{{ '✅' if notification.enable else '❌' }}
|
||||
</a>
|
||||
<a href="{{ url_for('openEditYouTube', id = notification.id) }}"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors text-blue-600 dark:text-blue-400"
|
||||
title="Modifier">
|
||||
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
|
||||
</a>
|
||||
<a href="{{ url_for('delYouTube', id = notification.id) }}"
|
||||
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette notification ?')"
|
||||
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors text-red-600 dark:text-red-400"
|
||||
title="Supprimer">
|
||||
<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="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>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
Aucune notification configurée. Ajoutez-en une ci-dessous.
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||
{{ 'Modifier la notification' if notification else 'Ajouter une notification YouTube' }}
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div>
|
||||
<form id="youtube-form" action="{{ url_for('submitEditYouTube', id = notification.id) if notification else url_for('addYouTube') }}" method="POST" class="space-y-6">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">Configuration de base</h3>
|
||||
|
||||
<div>
|
||||
<label for="channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Lien ou ID de la chaîne YouTube</label>
|
||||
<input name="channel_id" id="channel_id" type="text" maxlength="256" required
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all"
|
||||
placeholder="https://www.youtube.com/@513v3 ou UC..."
|
||||
value="{{notification.channel_id if notification}}"/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="notify_channel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal de notification Discord</label>
|
||||
<select name="notify_channel" id="notify_channel"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}" {% if notification and notification.notify_channel == channel.id %}selected{% endif %}>{{channel.name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="video_type" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Type de vidéo à notifier</label>
|
||||
<select name="video_type" id="video_type"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all">
|
||||
<option value="all" {% if notification and notification.video_type == 'all' %}selected{% endif %}>Toutes (vidéos + shorts)</option>
|
||||
<option value="video" {% if notification and notification.video_type == 'video' %}selected{% endif %}>Vidéos uniquement</option>
|
||||
<option value="short" {% if notification and notification.video_type == 'short' %}selected{% endif %}>Shorts uniquement</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="message" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Message (optionnel)</label>
|
||||
<textarea name="message" id="message" rows="2"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all resize-y"
|
||||
placeholder="Message envoyé avant l'embed">{{notification.message if notification}}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">Personnalisation de l'embed Discord</h3>
|
||||
|
||||
<div>
|
||||
<label for="embed_title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Titre de l'embed</label>
|
||||
<input name="embed_title" id="embed_title" type="text" maxlength="256"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all"
|
||||
placeholder="{video_title}"
|
||||
value="{{notification.embed_title if notification}}"/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Variables: {video_title}, {channel_name}, {video_url}, {video_id}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="embed_description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description de l'embed</label>
|
||||
<textarea name="embed_description" id="embed_description" rows="2"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all resize-y"
|
||||
placeholder="Description optionnelle">{{notification.embed_description if notification}}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="embed_color" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Couleur</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input name="embed_color" id="embed_color" type="color"
|
||||
class="w-12 h-10 rounded border border-gray-300 dark:border-gray-600 cursor-pointer"
|
||||
value="#{{notification.embed_color if notification else 'FF0000'}}"/>
|
||||
<input type="text" id="embed_color_text" maxlength="6"
|
||||
class="flex-1 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
|
||||
value="{{notification.embed_color if notification else 'FF0000'}}" placeholder="FF0000"/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="embed_author_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Nom de l'auteur</label>
|
||||
<input name="embed_author_name" id="embed_author_name" type="text" maxlength="256"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all"
|
||||
placeholder="{channel_name}"
|
||||
value="{{notification.embed_author_name if notification}}"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="embed_author_icon" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Icône de l'auteur (URL)</label>
|
||||
<input name="embed_author_icon" id="embed_author_icon" type="text" maxlength="512"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all"
|
||||
placeholder="https://www.youtube.com/img/desktop/yt_1200.png"
|
||||
value="{{notification.embed_author_icon if notification}}"/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="embed_footer" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Pied de page</label>
|
||||
<input name="embed_footer" id="embed_footer" type="text" maxlength="2048"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all"
|
||||
placeholder="Texte optionnel en bas"
|
||||
value="{{notification.embed_footer if notification}}"/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="embed_thumbnail" id="embed_thumbnail"
|
||||
{% if not notification or notification.embed_thumbnail %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-red-600 focus:ring-red-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Miniature</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="embed_image" id="embed_image"
|
||||
{% if not notification or notification.embed_image %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-red-600 focus:ring-red-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Image principale</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<button type="submit"
|
||||
class="px-6 py-2.5 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
{{ 'Enregistrer' if notification else 'Ajouter la notification' }}
|
||||
</button>
|
||||
{% if notification %}
|
||||
<a href="{{ url_for('youtube') }}"
|
||||
class="px-6 py-2.5 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 font-medium rounded-lg transition-colors">
|
||||
Annuler
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200 mb-4">Prévisualisation de l'embed Discord</h3>
|
||||
<div id="embed-preview" class="bg-[#2f3136] rounded p-4 font-sans text-[#dcddde] max-w-xl border-l-4" style="border-left-color: #FF0000;">
|
||||
<div id="embed-author" class="flex items-center mb-2 text-sm">
|
||||
<img id="embed-author-icon" src="https://www.youtube.com/img/desktop/yt_1200.png" class="w-5 h-5 rounded-full mr-2" onerror="this.style.display='none'"/>
|
||||
<span id="embed-author-name" class="font-semibold">Nom de la chaîne</span>
|
||||
</div>
|
||||
<a id="embed-title" href="#" class="text-[#00aff4] no-underline text-base font-semibold block mb-2">Titre de la vidéo</a>
|
||||
<div id="embed-description" class="text-sm leading-relaxed mb-2 text-[#dcddde]"></div>
|
||||
<div id="embed-thumbnail-container" class="my-2">
|
||||
<img id="embed-thumbnail" src="" class="max-w-[80px] max-h-[80px] rounded float-right ml-4 hidden"/>
|
||||
</div>
|
||||
<div id="embed-image-container" class="mt-4">
|
||||
<img id="embed-image" src="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" class="max-w-full rounded hidden"/>
|
||||
</div>
|
||||
<div id="embed-footer" class="mt-2 text-xs text-[#72767d]"></div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">Cette prévisualisation est approximative.</p>
|
||||
|
||||
<div class="mt-6 bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||
<h4 class="font-medium text-gray-800 dark:text-gray-200 mb-2">Variables disponibles</h4>
|
||||
<ul class="text-sm text-gray-600 dark:text-gray-400 space-y-1">
|
||||
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{channel_name}</code> — Nom de la chaîne</li>
|
||||
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{video_title}</code> — Titre de la vidéo</li>
|
||||
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{video_url}</code> — Lien vers la vidéo</li>
|
||||
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{video_id}</code> — ID de la vidéo</li>
|
||||
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{thumbnail}</code> — URL de la miniature</li>
|
||||
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{published_at}</code> — Date de publication</li>
|
||||
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{is_short}</code> — True si c'est un short</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function formatText(text, vars) {
|
||||
if (!text) return '';
|
||||
return text.replace(/\{(\w+)\}/g, function(match, key) {
|
||||
return vars[key] || match;
|
||||
});
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
const embedTitle = document.getElementById('embed_title').value || '{video_title}';
|
||||
const embedDescription = document.getElementById('embed_description').value || '';
|
||||
const embedColor = document.getElementById('embed_color_text').value || 'FF0000';
|
||||
const embedAuthorName = document.getElementById('embed_author_name').value || '{channel_name}';
|
||||
const embedAuthorIcon = document.getElementById('embed_author_icon').value || 'https://www.youtube.com/img/desktop/yt_1200.png';
|
||||
const embedFooter = document.getElementById('embed_footer').value || '';
|
||||
const embedThumbnail = document.getElementById('embed_thumbnail').checked;
|
||||
const embedImage = document.getElementById('embed_image').checked;
|
||||
|
||||
const vars = {
|
||||
video_title: 'Nouvelle vidéo de test',
|
||||
channel_name: 'Ma Chaîne YouTube',
|
||||
video_url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
video_id: 'dQw4w9WgXcQ',
|
||||
thumbnail: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg',
|
||||
published_at: '2026-01-25T12:00:00Z',
|
||||
is_short: false
|
||||
};
|
||||
|
||||
document.getElementById('embed-title').textContent = formatText(embedTitle, vars);
|
||||
document.getElementById('embed-title').href = vars.video_url;
|
||||
document.getElementById('embed-description').textContent = formatText(embedDescription, vars);
|
||||
document.getElementById('embed-author-name').textContent = formatText(embedAuthorName, vars);
|
||||
document.getElementById('embed-author-icon').src = embedAuthorIcon;
|
||||
document.getElementById('embed-footer').textContent = formatText(embedFooter, vars);
|
||||
|
||||
document.getElementById('embed-preview').style.borderLeftColor = '#' + embedColor;
|
||||
|
||||
if (embedThumbnail) {
|
||||
document.getElementById('embed-thumbnail').src = vars.thumbnail;
|
||||
document.getElementById('embed-thumbnail').style.display = 'block';
|
||||
} else {
|
||||
document.getElementById('embed-thumbnail').style.display = 'none';
|
||||
}
|
||||
|
||||
if (embedImage) {
|
||||
document.getElementById('embed-image').src = vars.thumbnail;
|
||||
document.getElementById('embed-image').style.display = 'block';
|
||||
} else {
|
||||
document.getElementById('embed-image').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('embed_color').addEventListener('input', function(e) {
|
||||
document.getElementById('embed_color_text').value = e.target.value.substring(1).toUpperCase();
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
document.getElementById('embed_color_text').addEventListener('input', function(e) {
|
||||
const val = e.target.value.replace(/[^0-9A-Fa-f]/g, '').substring(0, 6);
|
||||
e.target.value = val;
|
||||
if (val.length === 6) {
|
||||
document.getElementById('embed_color').value = '#' + val;
|
||||
updatePreview();
|
||||
}
|
||||
});
|
||||
|
||||
const formFields = ['embed_title', 'embed_description', 'embed_author_name', 'embed_author_icon', 'embed_footer', 'embed_thumbnail', 'embed_image'];
|
||||
formFields.forEach(field => {
|
||||
const el = document.getElementById(field);
|
||||
if (el) {
|
||||
el.addEventListener('input', updatePreview);
|
||||
el.addEventListener('change', updatePreview);
|
||||
}
|
||||
});
|
||||
|
||||
updatePreview();
|
||||
</script>
|
||||
{% endblock %}
|
||||
+38
-17
@@ -1,50 +1,71 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from flask import render_template, request, redirect, url_for
|
||||
from flask import render_template, request, redirect, url_for, flash
|
||||
from twitchAPI.twitch import Twitch
|
||||
from twitchAPI.type import TwitchAPIException
|
||||
from twitchAPI.type import TwitchAPIException
|
||||
from twitchAPI.oauth import UserAuthenticator
|
||||
|
||||
from database import db
|
||||
from database.helpers import ConfigurationHelper
|
||||
from twitchbot import USER_SCOPE
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page
|
||||
|
||||
|
||||
auth: UserAuthenticator
|
||||
|
||||
@webapp.route("/configurations/twitch/help")
|
||||
def twitchConfigurationHelp():
|
||||
return render_template("twitch-aide.html", token_redirect_url = _buildUrl())
|
||||
|
||||
@webapp.route("/configurations/twitch/request-token")
|
||||
async def twitchRequestToken():
|
||||
@webapp.route("/configurations/twitch/help")
|
||||
@require_page("configurations")
|
||||
def twitchConfigurationHelp():
|
||||
return render_template("twitch-aide.html", token_redirect_url=_buildUrl())
|
||||
|
||||
|
||||
@webapp.route("/configurations/twitch/request-token")
|
||||
@require_page("configurations")
|
||||
def twitchRequestToken():
|
||||
global auth
|
||||
helper = ConfigurationHelper()
|
||||
twitch = await Twitch(helper.getValue('twitch_client_id'), helper.getValue('twitch_client_secret'))
|
||||
twitch = asyncio.run(Twitch(helper.getValue('twitch_client_id'), helper.getValue('twitch_client_secret')))
|
||||
auth = UserAuthenticator(twitch, USER_SCOPE, url=_buildUrl())
|
||||
return redirect(auth.return_auth_url())
|
||||
|
||||
@webapp.route("/configurations/twitch/receive-token")
|
||||
async def twitchReceiveToken():
|
||||
|
||||
@webapp.route("/configurations/twitch/receive-token")
|
||||
def twitchReceiveToken():
|
||||
global auth
|
||||
state = request.args.get('state')
|
||||
code = request.args.get('code')
|
||||
if state != auth.state :
|
||||
logging('bad returned state')
|
||||
|
||||
logging.info("Callback Twitch reçu - state: %s, code: %s", state, code is not None)
|
||||
|
||||
if not hasattr(auth, 'state') or auth is None:
|
||||
logging.error('Objet auth non initialisé - veuillez réessayer')
|
||||
return redirect(url_for('openConfigurations'))
|
||||
if code == None :
|
||||
logging('no returned state')
|
||||
|
||||
if state != auth.state:
|
||||
logging.error('State invalide - attendu: %s, reçu: %s', auth.state, state)
|
||||
return redirect(url_for('openConfigurations'))
|
||||
|
||||
if code is None:
|
||||
logging.error('Pas de code retourné par Twitch')
|
||||
return redirect(url_for('openConfigurations'))
|
||||
|
||||
try:
|
||||
token, refresh = await auth.authenticate(user_token=code)
|
||||
token, refresh = asyncio.run(auth.authenticate(user_token=code))
|
||||
logging.info('Tokens Twitch obtenus avec succès')
|
||||
helper = ConfigurationHelper()
|
||||
helper.createOrUpdate('twitch_access_token', token)
|
||||
helper.createOrUpdate('twitch_refresh_token', refresh)
|
||||
db.session.commit()
|
||||
logging.info('Tokens Twitch sauvegardés en base de données')
|
||||
flash('Token Twitch enregistré. Redémarrez l\'application pour que le bot utilise le nouveau token.', 'success')
|
||||
except TwitchAPIException as e:
|
||||
logging(e)
|
||||
logging.error('Erreur API Twitch: %s', e)
|
||||
flash(f'Erreur API Twitch : {e}', 'error')
|
||||
except Exception as e:
|
||||
logging.error('Erreur inattendue: %s', e)
|
||||
flash(f'Erreur inattendue : {e}', 'error')
|
||||
return redirect(url_for('openConfigurations'))
|
||||
|
||||
# hack pas fou mais on estime qu'on sera toujours en ssl en connecté
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Notifications d'événements Twitch : sub, follow, raid, clip (chat + Discord)
|
||||
from flask import render_template, request, redirect, url_for
|
||||
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page, can_write_page
|
||||
from database import db
|
||||
from database.models import TwitchEventNotification
|
||||
from discordbot import bot
|
||||
|
||||
EVENT_LABELS = {
|
||||
"sub": "Abonnement (sub)",
|
||||
"follow": "Nouveau follow",
|
||||
"raid": "Raid reçu",
|
||||
"clip": "Nouveau clip",
|
||||
}
|
||||
|
||||
|
||||
@webapp.route("/twitch-events")
|
||||
@require_page("twitch_events")
|
||||
def open_twitch_events():
|
||||
configs = TwitchEventNotification.query.order_by(TwitchEventNotification.event_type).all()
|
||||
# S'assurer qu'il existe une config par type
|
||||
existing = {c.event_type for c in configs}
|
||||
for ev in ("sub", "follow", "raid", "clip"):
|
||||
if ev not in existing:
|
||||
cfg = TwitchEventNotification(
|
||||
event_type=ev,
|
||||
message_twitch="Merci {user} !" if ev != "raid" else "Bienvenue aux viewers de {from_broadcaster_name} !",
|
||||
)
|
||||
db.session.add(cfg)
|
||||
configs.append(cfg)
|
||||
db.session.commit()
|
||||
channels = bot.getAllTextChannel()
|
||||
# Nom du canal Discord pour l'affichage
|
||||
for c in configs:
|
||||
if c.discord_channel_id:
|
||||
c.discord_channel_name = next((ch.name for ch in channels if ch.id == c.discord_channel_id), None)
|
||||
else:
|
||||
c.discord_channel_name = None
|
||||
return render_template("twitch-events.html", configs=configs, channels=channels, labels=EVENT_LABELS)
|
||||
|
||||
|
||||
@webapp.route("/twitch-events/save", methods=["POST"])
|
||||
@require_page("twitch_events")
|
||||
def save_twitch_events():
|
||||
if not can_write_page("twitch_events"):
|
||||
return render_template("403.html"), 403
|
||||
for ev in ("sub", "follow", "raid", "clip"):
|
||||
cfg = TwitchEventNotification.query.filter_by(event_type=ev).first()
|
||||
if not cfg:
|
||||
cfg = TwitchEventNotification(event_type=ev)
|
||||
db.session.add(cfg)
|
||||
prefix = f"ev_{ev}_"
|
||||
cfg.enable = request.form.get(prefix + "enable") == "1"
|
||||
cfg.notify_twitch_chat = request.form.get(prefix + "notify_twitch_chat") == "1"
|
||||
cfg.notify_discord = request.form.get(prefix + "notify_discord") == "1"
|
||||
ch_id = request.form.get(prefix + "discord_channel_id")
|
||||
cfg.discord_channel_id = int(ch_id) if ch_id and ch_id.isdigit() else None
|
||||
cfg.message_twitch = (request.form.get(prefix + "message_twitch") or "").strip()[:500]
|
||||
cfg.message_discord = (request.form.get(prefix + "message_discord") or "").strip()[:2000] or None
|
||||
embed_color = (request.form.get(prefix + "embed_color") or "9146FF").strip().lstrip("#")[:6]
|
||||
cfg.embed_color = embed_color if len(embed_color) == 6 else "9146FF"
|
||||
cfg.embed_title = (request.form.get(prefix + "embed_title") or "").strip()[:256] or None
|
||||
cfg.embed_description = (request.form.get(prefix + "embed_description") or "").strip()[:2000] or None
|
||||
cfg.embed_thumbnail = request.form.get(prefix + "embed_thumbnail") == "1"
|
||||
db.session.commit()
|
||||
return redirect(url_for("open_twitch_events"))
|
||||
|
||||
|
||||
@webapp.route("/twitch-events/toggle/<event_type>")
|
||||
@require_page("twitch_events")
|
||||
def toggle_twitch_event(event_type):
|
||||
if not can_write_page("twitch_events"):
|
||||
return render_template("403.html"), 403
|
||||
if event_type not in ("sub", "follow", "raid", "clip"):
|
||||
return redirect(url_for("open_twitch_events"))
|
||||
cfg = TwitchEventNotification.query.filter_by(event_type=event_type).first()
|
||||
if cfg:
|
||||
cfg.enable = not cfg.enable
|
||||
db.session.commit()
|
||||
return redirect(url_for("open_twitch_events"))
|
||||
@@ -0,0 +1,403 @@
|
||||
from flask import render_template, request, redirect, url_for, jsonify
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page, can_write_page
|
||||
from database import db
|
||||
from database.models import Commande, TwitchModerationLog, TwitchLinkFilter, TwitchBannedWord
|
||||
from database.helpers import ConfigurationHelper
|
||||
from datetime import datetime, timedelta
|
||||
import asyncio
|
||||
|
||||
MODERATION_COMMANDS = [
|
||||
{
|
||||
"commands": ["!kick", "!to", "!timeout", "!tm"],
|
||||
"usage": "!timeout <viewer> [minutes] [raison]",
|
||||
"description": "Ejection temporaire d'un viewer (3 minutes par defaut) avec raison optionnelle",
|
||||
"permission": "Moderateur"
|
||||
},
|
||||
{
|
||||
"commands": ["!ban"],
|
||||
"usage": "!ban <viewer1> [viewer2] ...",
|
||||
"description": "Bannissement d'un ou plusieurs viewers (max 5)",
|
||||
"permission": "Moderateur"
|
||||
},
|
||||
{
|
||||
"commands": ["!unban"],
|
||||
"usage": "!unban <viewer1> [viewer2] ...",
|
||||
"description": "Debannissement d'un ou plusieurs viewers (max 5)",
|
||||
"permission": "Moderateur"
|
||||
},
|
||||
{
|
||||
"commands": ["!clean"],
|
||||
"usage": "!clean [viewer]",
|
||||
"description": "Nettoyage du chat ou des messages d'un viewer",
|
||||
"permission": "Moderateur"
|
||||
},
|
||||
{
|
||||
"commands": ["!shieldmode"],
|
||||
"usage": "!shieldmode <on/off>",
|
||||
"description": "Active/desactive le mode Shield de Twitch",
|
||||
"permission": "Moderateur"
|
||||
},
|
||||
{
|
||||
"commands": ["!settitle"],
|
||||
"usage": "!settitle <titre>",
|
||||
"description": "Changement du titre du live",
|
||||
"permission": "Moderateur"
|
||||
},
|
||||
{
|
||||
"commands": ["!setgame", "!setcateg"],
|
||||
"usage": "!setgame <jeu>",
|
||||
"description": "Changement du jeu/categorie du live",
|
||||
"permission": "Moderateur"
|
||||
},
|
||||
{
|
||||
"commands": ["!subon"],
|
||||
"usage": "!subon",
|
||||
"description": "Activation du mode abonnes uniquement",
|
||||
"permission": "Moderateur"
|
||||
},
|
||||
{
|
||||
"commands": ["!suboff"],
|
||||
"usage": "!suboff",
|
||||
"description": "Desactivation du mode abonnes uniquement",
|
||||
"permission": "Moderateur"
|
||||
},
|
||||
{
|
||||
"commands": ["!follon"],
|
||||
"usage": "!follon [minutes]",
|
||||
"description": "Activation du mode followers-only",
|
||||
"permission": "Moderateur"
|
||||
},
|
||||
{
|
||||
"commands": ["!folloff"],
|
||||
"usage": "!folloff",
|
||||
"description": "Desactivation du mode followers-only",
|
||||
"permission": "Moderateur"
|
||||
},
|
||||
{
|
||||
"commands": ["!emoteon"],
|
||||
"usage": "!emoteon",
|
||||
"description": "Activation du mode emote-only",
|
||||
"permission": "Moderateur"
|
||||
},
|
||||
{
|
||||
"commands": ["!emoteoff"],
|
||||
"usage": "!emoteoff",
|
||||
"description": "Desactivation du mode emote-only",
|
||||
"permission": "Moderateur"
|
||||
},
|
||||
{
|
||||
"commands": ["!ann"],
|
||||
"usage": "!ann <alias> <on/off/toggle>",
|
||||
"description": "Activer/desactiver/inverser une liste d'annonce par alias",
|
||||
"permission": "Moderateur"
|
||||
},
|
||||
{
|
||||
"commands": ["!no_game"],
|
||||
"usage": "!no_game <on/off>",
|
||||
"description": "Desactiver/activer tous les jeux de la chaine",
|
||||
"permission": "Moderateur"
|
||||
},
|
||||
{
|
||||
"commands": ["!multitwitch"],
|
||||
"usage": "!multitwitch [live1] [live2] ... | auto | reset",
|
||||
"description": "Creation d'un lien MultiTwitch. '@' = chaine actuelle, 'auto' = depuis le titre, 'reset' = reinitialiser",
|
||||
"permission": "Moderateur (creation) / Tous (affichage)"
|
||||
},
|
||||
{
|
||||
"commands": ["!permit"],
|
||||
"usage": "!permit <viewer> [minutes]",
|
||||
"description": "Autorise temporairement un viewer a poster un lien (1 minute par defaut)",
|
||||
"permission": "Moderateur"
|
||||
},
|
||||
]
|
||||
|
||||
TWITCH_PERMISSIONS = {'viewer': 'Tous (viewers)', 'sub': 'Abonnés', 'vip': 'VIP', 'moderator': 'Modérateur'}
|
||||
|
||||
|
||||
@webapp.route("/twitch-moderation")
|
||||
@require_page("twitch_moderation")
|
||||
def twitch_moderation():
|
||||
custom_commands = Commande.query.filter_by(twitch_enable=True).all()
|
||||
logs = TwitchModerationLog.query.order_by(TwitchModerationLog.created_at.desc()).limit(50).all()
|
||||
raw_channel = ConfigurationHelper().getValue("twitch_channel") or webapp.config["BOT_STATUS"].get("twitch_channel_name") or "chainesteve"
|
||||
twitch_channel = (raw_channel or "").strip().lower() or "chainesteve"
|
||||
embed_parent = request.host or "localhost"
|
||||
|
||||
# Link filter status
|
||||
link_filter_config = TwitchLinkFilter.query.first()
|
||||
link_filter_enabled = link_filter_config.enabled if link_filter_config else False
|
||||
|
||||
# Banned words
|
||||
banned_words = TwitchBannedWord.query.filter_by(enabled=True).all()
|
||||
|
||||
# Live status (from BOT_STATUS)
|
||||
bot_status = webapp.config.get("BOT_STATUS", {})
|
||||
is_live = bot_status.get("twitch_is_live", False)
|
||||
viewer_count = bot_status.get("twitch_viewer_count", 0)
|
||||
|
||||
return render_template(
|
||||
"twitch-moderation.html",
|
||||
commands=MODERATION_COMMANDS,
|
||||
custom_commands=custom_commands,
|
||||
logs=logs,
|
||||
twitch_permissions=TWITCH_PERMISSIONS,
|
||||
twitch_channel=twitch_channel,
|
||||
embed_parent=embed_parent,
|
||||
link_filter_enabled=link_filter_enabled,
|
||||
banned_words=banned_words,
|
||||
is_live=is_live,
|
||||
viewer_count=viewer_count,
|
||||
)
|
||||
|
||||
@webapp.route("/twitch-moderation/logs/clear")
|
||||
@require_page("twitch_moderation")
|
||||
def clear_twitch_logs():
|
||||
if not can_write_page("twitch_moderation"):
|
||||
return render_template("403.html"), 403
|
||||
TwitchModerationLog.query.delete()
|
||||
db.session.commit()
|
||||
return redirect(url_for('twitch_moderation'))
|
||||
|
||||
@webapp.route("/twitch-moderation/add", methods=['POST'])
|
||||
@require_page("twitch_moderation")
|
||||
def add_twitch_commande():
|
||||
if not can_write_page("twitch_moderation"):
|
||||
return render_template("403.html"), 403
|
||||
trigger = request.form.get('trigger')
|
||||
response = request.form.get('response')
|
||||
twitch_permission = request.form.get('twitch_permission') or 'viewer'
|
||||
if twitch_permission not in TWITCH_PERMISSIONS:
|
||||
twitch_permission = 'viewer'
|
||||
|
||||
if trigger and response:
|
||||
if not trigger.startswith('!'):
|
||||
trigger = '!' + trigger
|
||||
|
||||
existing = Commande.query.filter_by(trigger=trigger).first()
|
||||
if not existing:
|
||||
commande = Commande(trigger=trigger, response=response, discord_enable=False, twitch_enable=True, twitch_permission=twitch_permission)
|
||||
db.session.add(commande)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for('twitch_moderation'))
|
||||
|
||||
@webapp.route("/twitch-moderation/banned-word/add", methods=['POST'])
|
||||
@require_page("twitch_moderation")
|
||||
def add_banned_word():
|
||||
if not can_write_page("twitch_moderation"):
|
||||
return render_template("403.html"), 403
|
||||
|
||||
word = request.form.get('word', '').strip().lower()
|
||||
timeout_duration = int(request.form.get('timeout_duration', 60))
|
||||
|
||||
if word:
|
||||
existing = TwitchBannedWord.query.filter_by(word=word).first()
|
||||
if not existing:
|
||||
banned_word = TwitchBannedWord(word=word, enabled=True, timeout_duration=timeout_duration)
|
||||
db.session.add(banned_word)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for('twitch_moderation'))
|
||||
|
||||
@webapp.route("/twitch-moderation/banned-word/delete/<int:word_id>")
|
||||
@require_page("twitch_moderation")
|
||||
def delete_banned_word(word_id):
|
||||
if not can_write_page("twitch_moderation"):
|
||||
return render_template("403.html"), 403
|
||||
|
||||
banned_word = TwitchBannedWord.query.get_or_404(word_id)
|
||||
db.session.delete(banned_word)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for('twitch_moderation'))
|
||||
|
||||
@webapp.route("/twitch-moderation/send-message", methods=['POST'])
|
||||
@require_page("twitch_moderation")
|
||||
def send_twitch_message():
|
||||
if not can_write_page("twitch_moderation"):
|
||||
return jsonify({"success": False, "error": "Permission refusée"}), 403
|
||||
|
||||
data = request.get_json()
|
||||
message = data.get('message', '').strip()
|
||||
|
||||
if not message:
|
||||
return jsonify({"success": False, "error": "Message vide"}), 400
|
||||
|
||||
# Vérifier que le bot Twitch est connecté
|
||||
from twitchbot import twitchBot
|
||||
if not hasattr(twitchBot, 'chat') or not twitchBot.chat:
|
||||
return jsonify({"success": False, "error": "Bot Twitch non connecté"}), 503
|
||||
|
||||
# Récupérer le nom du channel
|
||||
channel = ConfigurationHelper().getValue('twitch_channel')
|
||||
if not channel:
|
||||
return jsonify({"success": False, "error": "Channel Twitch non configuré"}), 400
|
||||
|
||||
# Envoyer le message de manière asynchrone
|
||||
try:
|
||||
async def send_msg():
|
||||
try:
|
||||
await twitchBot.chat.send_message(channel, message)
|
||||
return True
|
||||
except Exception as e:
|
||||
return str(e)
|
||||
|
||||
# Exécuter la coroutine de manière synchrone
|
||||
loop = asyncio.new_event_loop()
|
||||
result = loop.run_until_complete(send_msg())
|
||||
loop.close()
|
||||
|
||||
if result is True:
|
||||
return jsonify({"success": True})
|
||||
else:
|
||||
return jsonify({"success": False, "error": f"Erreur: {result}"}), 500
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@webapp.route("/twitch-moderation/messages")
|
||||
@require_page("twitch_moderation")
|
||||
def get_twitch_messages():
|
||||
"""Retourne les derniers messages du chat Twitch"""
|
||||
messages = webapp.config["BOT_STATUS"].get("twitch_chat_messages", [])
|
||||
return jsonify({"messages": messages})
|
||||
|
||||
@webapp.route("/twitch-moderation/execute-action", methods=['POST'])
|
||||
@require_page("twitch_moderation")
|
||||
def execute_moderation_action():
|
||||
"""Exécute une action de modération directement"""
|
||||
if not can_write_page("twitch_moderation"):
|
||||
return jsonify({"success": False, "error": "Permission refusée"}), 403
|
||||
|
||||
data = request.get_json()
|
||||
action = data.get('action', '').strip()
|
||||
params = data.get('params', {})
|
||||
|
||||
if not action:
|
||||
return jsonify({"success": False, "error": "Action non spécifiée"}), 400
|
||||
|
||||
# Vérifier que le bot Twitch est connecté
|
||||
from twitchbot import twitchBot
|
||||
if not hasattr(twitchBot, 'chat') or not twitchBot.chat or not hasattr(twitchBot, 'twitch'):
|
||||
return jsonify({"success": False, "error": "Bot Twitch non connecté"}), 503
|
||||
|
||||
# Récupérer le nom du channel
|
||||
channel = ConfigurationHelper().getValue('twitch_channel')
|
||||
if not channel:
|
||||
return jsonify({"success": False, "error": "Channel Twitch non configuré"}), 400
|
||||
|
||||
# Créer un objet ChatMessage simulé pour les commandes qui en ont besoin
|
||||
from twitchAPI.chat import ChatMessage
|
||||
from types import SimpleNamespace
|
||||
|
||||
# Exécuter l'action de manière asynchrone
|
||||
try:
|
||||
async def execute_action():
|
||||
try:
|
||||
if action == 'timeout':
|
||||
from twitchbot.moderation import _get_broadcaster_id, _get_moderator_id, _get_user_id, _log_action
|
||||
username = params.get('username', '').strip().lstrip('@')
|
||||
duration = int(params.get('duration', 600)) # en secondes
|
||||
reason = params.get('reason', 'Timeout')
|
||||
|
||||
broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel)
|
||||
moderator_id = await _get_moderator_id(twitchBot.twitch)
|
||||
user_id = await _get_user_id(twitchBot.twitch, username)
|
||||
|
||||
if user_id:
|
||||
await twitchBot.twitch.ban_user(broadcaster_id, moderator_id, user_id, reason=reason, duration=duration)
|
||||
_log_action("timeout", "WebApp", username, f"{duration}s - {reason}")
|
||||
return {"success": True, "message": f"Timeout de {username} pour {duration}s"}
|
||||
return {"success": False, "error": f"Utilisateur {username} introuvable"}
|
||||
|
||||
elif action == 'ban':
|
||||
from twitchbot.moderation import _get_broadcaster_id, _get_moderator_id, _get_user_id, _log_action
|
||||
username = params.get('username', '').strip().lstrip('@')
|
||||
reason = params.get('reason', 'Ban')
|
||||
|
||||
broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel)
|
||||
moderator_id = await _get_moderator_id(twitchBot.twitch)
|
||||
user_id = await _get_user_id(twitchBot.twitch, username)
|
||||
|
||||
if user_id:
|
||||
await twitchBot.twitch.ban_user(broadcaster_id, moderator_id, user_id, reason=reason)
|
||||
_log_action("ban", "WebApp", username, reason)
|
||||
return {"success": True, "message": f"Ban de {username}"}
|
||||
return {"success": False, "error": f"Utilisateur {username} introuvable"}
|
||||
|
||||
elif action == 'clean':
|
||||
from twitchbot.moderation import _get_broadcaster_id, _get_moderator_id, _get_user_id, _log_action
|
||||
username = params.get('username', '').strip().lstrip('@')
|
||||
|
||||
broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel)
|
||||
moderator_id = await _get_moderator_id(twitchBot.twitch)
|
||||
|
||||
if username:
|
||||
user_id = await _get_user_id(twitchBot.twitch, username)
|
||||
if user_id:
|
||||
await twitchBot.twitch.delete_chat_messages(broadcaster_id, moderator_id, user_id=user_id)
|
||||
_log_action("clean", "WebApp", username)
|
||||
return {"success": True, "message": f"Messages de {username} supprimés"}
|
||||
return {"success": False, "error": f"Utilisateur {username} introuvable"}
|
||||
else:
|
||||
await twitchBot.twitch.delete_chat_messages(broadcaster_id, moderator_id)
|
||||
_log_action("clean", "WebApp", None, "Chat complet")
|
||||
return {"success": True, "message": "Chat nettoyé"}
|
||||
|
||||
elif action == 'permit':
|
||||
from database.models import TwitchPermit
|
||||
username = params.get('username', '').strip().lstrip('@').lower()
|
||||
duration = int(params.get('duration', 60)) # en secondes
|
||||
|
||||
expires_at = datetime.now() + timedelta(seconds=duration)
|
||||
|
||||
with webapp.app_context():
|
||||
existing = TwitchPermit.query.filter_by(username=username).first()
|
||||
if existing:
|
||||
existing.expires_at = expires_at
|
||||
else:
|
||||
permit = TwitchPermit(username=username, expires_at=expires_at)
|
||||
db.session.add(permit)
|
||||
db.session.commit()
|
||||
|
||||
return {"success": True, "message": f"Permit accordé à {username} pour {duration//60}min"}
|
||||
|
||||
elif action in ['subon', 'suboff', 'emoteon', 'emoteoff']:
|
||||
from twitchbot.moderation import _get_broadcaster_id, _get_moderator_id, _log_action
|
||||
|
||||
broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel)
|
||||
moderator_id = await _get_moderator_id(twitchBot.twitch)
|
||||
|
||||
if action == 'subon':
|
||||
await twitchBot.twitch.update_chat_settings(broadcaster_id, moderator_id, subscriber_mode=True)
|
||||
_log_action("subon", "WebApp")
|
||||
return {"success": True, "message": "Mode abonnés activé"}
|
||||
elif action == 'suboff':
|
||||
await twitchBot.twitch.update_chat_settings(broadcaster_id, moderator_id, subscriber_mode=False)
|
||||
_log_action("suboff", "WebApp")
|
||||
return {"success": True, "message": "Mode abonnés désactivé"}
|
||||
elif action == 'emoteon':
|
||||
await twitchBot.twitch.update_chat_settings(broadcaster_id, moderator_id, emote_mode=True)
|
||||
_log_action("emoteon", "WebApp")
|
||||
return {"success": True, "message": "Mode emote activé"}
|
||||
elif action == 'emoteoff':
|
||||
await twitchBot.twitch.update_chat_settings(broadcaster_id, moderator_id, emote_mode=False)
|
||||
_log_action("emoteoff", "WebApp")
|
||||
return {"success": True, "message": "Mode emote désactivé"}
|
||||
|
||||
return {"success": False, "error": f"Action '{action}' non reconnue"}
|
||||
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.error(f"Erreur lors de l'exécution de l'action {action}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
# Exécuter la coroutine de manière synchrone
|
||||
loop = asyncio.new_event_loop()
|
||||
result = loop.run_until_complete(execute_action())
|
||||
loop.close()
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
# Gestion des utilisateurs webapp (réservé super administrateur).
|
||||
from flask import render_template, request, redirect, url_for, flash
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page
|
||||
from database import db
|
||||
from database.models import WebappUser, WebappRole
|
||||
|
||||
ROLE_LABELS = {
|
||||
"viewer_twitch": "Viewer Twitch",
|
||||
"utilisateur_discord": "Utilisateur Discord",
|
||||
"moderateur_discord": "Modérateur Discord",
|
||||
"expert_discord": "Expert Discord",
|
||||
"moderateur_twitch": "Modérateur Twitch",
|
||||
"super_administrateur": "Super administrateur",
|
||||
}
|
||||
|
||||
|
||||
def _role_labels():
|
||||
roles = WebappRole.query.order_by(WebappRole.level).all()
|
||||
return {r.name: r.name.replace("_", " ").title() for r in roles}
|
||||
|
||||
|
||||
@webapp.route("/users")
|
||||
@require_page("users")
|
||||
def users_list():
|
||||
users = WebappUser.query.order_by(WebappUser.created_at.desc()).all()
|
||||
roles = WebappRole.query.order_by(WebappRole.level).all()
|
||||
labels = dict(ROLE_LABELS)
|
||||
labels.update(_role_labels())
|
||||
return render_template(
|
||||
"users.html",
|
||||
users=users,
|
||||
roles=[r.name for r in roles],
|
||||
role_labels=labels,
|
||||
)
|
||||
|
||||
|
||||
@webapp.route("/users/role/<int:user_id>", methods=["POST"])
|
||||
@require_page("users")
|
||||
def users_set_role(user_id):
|
||||
user = WebappUser.query.get_or_404(user_id)
|
||||
new_role = request.form.get("role")
|
||||
existing = WebappRole.query.filter_by(name=new_role).first()
|
||||
if new_role and existing:
|
||||
user.role = new_role
|
||||
db.session.commit()
|
||||
return redirect(url_for("users_list"))
|
||||
|
||||
|
||||
@webapp.route("/users/create", methods=["POST"])
|
||||
@require_page("users")
|
||||
def users_create():
|
||||
"""Création d'un utilisateur par un administrateur."""
|
||||
username = (request.form.get("username") or "").strip()
|
||||
email = (request.form.get("email") or "").strip().lower()
|
||||
password = request.form.get("password") or ""
|
||||
password_confirm = request.form.get("password_confirm") or ""
|
||||
role = request.form.get("role") or "viewer_twitch"
|
||||
|
||||
errors = []
|
||||
|
||||
# Validations
|
||||
if len(username) < 3:
|
||||
errors.append("Le nom d'utilisateur doit faire au moins 3 caractères.")
|
||||
if len(email) < 5 or "@" not in email:
|
||||
errors.append("Adresse e-mail invalide.")
|
||||
if len(password) < 8:
|
||||
errors.append("Le mot de passe doit faire au moins 8 caractères.")
|
||||
if password != password_confirm:
|
||||
errors.append("Les mots de passe ne correspondent pas.")
|
||||
if WebappUser.query.filter_by(username=username).first():
|
||||
errors.append("Ce nom d'utilisateur est déjà pris.")
|
||||
if WebappUser.query.filter_by(email=email).first():
|
||||
errors.append("Cette adresse e-mail est déjà utilisée.")
|
||||
|
||||
# Vérifier que le rôle existe
|
||||
if not WebappRole.query.filter_by(name=role).first():
|
||||
errors.append("Rôle invalide.")
|
||||
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, "error")
|
||||
return redirect(url_for("users_list"))
|
||||
|
||||
# Créer l'utilisateur
|
||||
user = WebappUser(
|
||||
username=username,
|
||||
email=email,
|
||||
password_hash=generate_password_hash(password, method="scrypt"),
|
||||
role=role,
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
flash(f"Utilisateur « {username} » créé avec succès.", "success")
|
||||
return redirect(url_for("users_list"))
|
||||
|
||||
|
||||
@webapp.route("/users/delete/<int:user_id>", methods=["POST"])
|
||||
@require_page("users")
|
||||
def users_delete(user_id):
|
||||
"""Suppression d'un utilisateur (sauf soi-même)."""
|
||||
from flask_login import current_user
|
||||
|
||||
user = WebappUser.query.get_or_404(user_id)
|
||||
|
||||
# Protection : impossible de se supprimer soi-même
|
||||
if user.id == current_user.id:
|
||||
flash("Vous ne pouvez pas supprimer votre propre compte.", "error")
|
||||
return redirect(url_for("users_list"))
|
||||
|
||||
username = user.username
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
|
||||
flash(f"Utilisateur « {username} » supprimé.", "success")
|
||||
return redirect(url_for("users_list"))
|
||||
@@ -0,0 +1,195 @@
|
||||
import re
|
||||
import requests
|
||||
from urllib.parse import urlencode
|
||||
from flask import render_template, request, redirect, url_for
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page, can_write_page
|
||||
from database import db
|
||||
from database.models import YouTubeNotification
|
||||
from discordbot import bot
|
||||
|
||||
|
||||
def extract_channel_id(channel_input: str) -> str:
|
||||
"""Extrait l'ID de la chaîne YouTube depuis différents formats"""
|
||||
if not channel_input:
|
||||
return None
|
||||
|
||||
channel_input = channel_input.strip()
|
||||
|
||||
if channel_input.startswith('UC') and len(channel_input) == 24:
|
||||
return channel_input
|
||||
|
||||
if '/channel/' in channel_input:
|
||||
match = re.search(r'/channel/([a-zA-Z0-9_-]{24})', channel_input)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
if '/c/' in channel_input or '/user/' in channel_input:
|
||||
parts = channel_input.split('/')
|
||||
for i, part in enumerate(parts):
|
||||
if part in ['c', 'user'] and i + 1 < len(parts):
|
||||
handle = parts[i + 1].split('?')[0].split('&')[0]
|
||||
channel_id = _get_channel_id_from_handle(handle)
|
||||
if channel_id:
|
||||
return channel_id
|
||||
|
||||
if '@' in channel_input:
|
||||
handle = re.search(r'@([a-zA-Z0-9_-]+)', channel_input)
|
||||
if handle:
|
||||
channel_id = _get_channel_id_from_handle(handle.group(1))
|
||||
if channel_id:
|
||||
return channel_id
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _get_channel_id_from_handle(handle: str) -> str:
|
||||
"""Récupère l'ID de la chaîne depuis un handle en utilisant le flux RSS"""
|
||||
try:
|
||||
url = f"https://www.youtube.com/@{handle}"
|
||||
response = requests.get(url, timeout=10, allow_redirects=True)
|
||||
|
||||
if response.status_code == 200:
|
||||
channel_id_match = re.search(r'"channelId":"([^"]{24})"', response.text)
|
||||
if channel_id_match:
|
||||
return channel_id_match.group(1)
|
||||
|
||||
canonical_match = re.search(r'<link rel="canonical" href="https://www\.youtube\.com/channel/([^"]{24})"', response.text)
|
||||
if canonical_match:
|
||||
return canonical_match.group(1)
|
||||
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@webapp.route("/youtube")
|
||||
@require_page("youtube")
|
||||
def openYouTube():
|
||||
notifications: list[YouTubeNotification] = YouTubeNotification.query.all()
|
||||
channels = bot.getAllTextChannel()
|
||||
for notification in notifications:
|
||||
for channel in channels:
|
||||
if notification.notify_channel == channel.id:
|
||||
notification.notify_channel_name = channel.name
|
||||
msg = request.args.get('msg')
|
||||
msg_type = request.args.get('type', 'info')
|
||||
return render_template("youtube.html", notifications=notifications, channels=channels, msg=msg, msg_type=msg_type)
|
||||
|
||||
|
||||
@webapp.route("/youtube/add", methods=['POST'])
|
||||
@require_page("youtube")
|
||||
def addYouTube():
|
||||
if not can_write_page("youtube"):
|
||||
return render_template("403.html"), 403
|
||||
channel_input = request.form.get('channel_id', '').strip()
|
||||
channel_id = extract_channel_id(channel_input)
|
||||
|
||||
if not channel_id:
|
||||
return redirect(url_for("openYouTube") + "?" + urlencode({'msg': f"Impossible d'extraire l'ID de la chaîne depuis : {channel_input}. Veuillez vérifier le lien.", 'type': 'error'}))
|
||||
|
||||
notify_channel_str = request.form.get('notify_channel')
|
||||
if not notify_channel_str:
|
||||
return redirect(url_for("openYouTube") + "?" + urlencode({'msg': "Veuillez sélectionner un canal Discord. Assurez-vous que le bot Discord est connecté.", 'type': 'error'}))
|
||||
|
||||
try:
|
||||
notify_channel = int(notify_channel_str)
|
||||
except ValueError:
|
||||
return redirect(url_for("openYouTube") + "?" + urlencode({'msg': "Canal Discord invalide.", 'type': 'error'}))
|
||||
|
||||
embed_color = request.form.get('embed_color', 'FF0000').strip().lstrip('#')
|
||||
if len(embed_color) != 6:
|
||||
embed_color = 'FF0000'
|
||||
|
||||
notification = YouTubeNotification(
|
||||
enable=True,
|
||||
channel_id=channel_id,
|
||||
notify_channel=notify_channel,
|
||||
message=request.form.get('message'),
|
||||
video_type=request.form.get('video_type', 'all'),
|
||||
embed_title=request.form.get('embed_title') or None,
|
||||
embed_description=request.form.get('embed_description') or None,
|
||||
embed_color=embed_color,
|
||||
embed_footer=request.form.get('embed_footer') or None,
|
||||
embed_author_name=request.form.get('embed_author_name') or None,
|
||||
embed_author_icon=request.form.get('embed_author_icon') or None,
|
||||
embed_thumbnail=request.form.get('embed_thumbnail') == 'on',
|
||||
embed_image=request.form.get('embed_image') == 'on'
|
||||
)
|
||||
db.session.add(notification)
|
||||
db.session.commit()
|
||||
return redirect(url_for("openYouTube") + "?" + urlencode({'msg': f"Notification ajoutée avec succès pour la chaîne {channel_id}", 'type': 'success'}))
|
||||
|
||||
|
||||
@webapp.route("/youtube/toggle/<int:id>")
|
||||
@require_page("youtube")
|
||||
def toggleYouTube(id):
|
||||
if not can_write_page("youtube"):
|
||||
return render_template("403.html"), 403
|
||||
notification: YouTubeNotification = YouTubeNotification.query.get_or_404(id)
|
||||
notification.enable = not notification.enable
|
||||
db.session.commit()
|
||||
return redirect(url_for("openYouTube"))
|
||||
|
||||
|
||||
@webapp.route("/youtube/edit/<int:id>")
|
||||
@require_page("youtube")
|
||||
def openEditYouTube(id):
|
||||
notification = YouTubeNotification.query.get_or_404(id)
|
||||
channels = bot.getAllTextChannel()
|
||||
msg = request.args.get('msg')
|
||||
msg_type = request.args.get('type', 'info')
|
||||
return render_template("youtube.html", notification=notification, channels=channels, notifications=YouTubeNotification.query.all(), msg=msg, msg_type=msg_type)
|
||||
|
||||
|
||||
@webapp.route("/youtube/edit/<int:id>", methods=['POST'])
|
||||
@require_page("youtube")
|
||||
def submitEditYouTube(id):
|
||||
if not can_write_page("youtube"):
|
||||
return render_template("403.html"), 403
|
||||
notification: YouTubeNotification = YouTubeNotification.query.get_or_404(id)
|
||||
|
||||
channel_input = request.form.get('channel_id', '').strip()
|
||||
channel_id = extract_channel_id(channel_input)
|
||||
|
||||
if not channel_id:
|
||||
return redirect(url_for("openEditYouTube", id=id) + "?" + urlencode({'msg': f"Impossible d'extraire l'ID de la chaîne depuis : {channel_input}. Veuillez vérifier le lien.", 'type': 'error'}))
|
||||
|
||||
notify_channel_str = request.form.get('notify_channel')
|
||||
if not notify_channel_str:
|
||||
return redirect(url_for("openEditYouTube", id=id) + "?" + urlencode({'msg': "Veuillez sélectionner un canal Discord. Assurez-vous que le bot Discord est connecté.", 'type': 'error'}))
|
||||
|
||||
try:
|
||||
notify_channel = int(notify_channel_str)
|
||||
except ValueError:
|
||||
return redirect(url_for("openEditYouTube", id=id) + "?" + urlencode({'msg': "Canal Discord invalide.", 'type': 'error'}))
|
||||
|
||||
embed_color = request.form.get('embed_color', 'FF0000').strip().lstrip('#')
|
||||
if len(embed_color) != 6:
|
||||
embed_color = 'FF0000'
|
||||
|
||||
notification.channel_id = channel_id
|
||||
notification.notify_channel = notify_channel
|
||||
notification.message = request.form.get('message')
|
||||
notification.video_type = request.form.get('video_type', 'all')
|
||||
notification.embed_title = request.form.get('embed_title') or None
|
||||
notification.embed_description = request.form.get('embed_description') or None
|
||||
notification.embed_color = embed_color
|
||||
notification.embed_footer = request.form.get('embed_footer') or None
|
||||
notification.embed_author_name = request.form.get('embed_author_name') or None
|
||||
notification.embed_author_icon = request.form.get('embed_author_icon') or None
|
||||
notification.embed_thumbnail = request.form.get('embed_thumbnail') == 'on'
|
||||
notification.embed_image = request.form.get('embed_image') == 'on'
|
||||
db.session.commit()
|
||||
return redirect(url_for("openYouTube") + "?" + urlencode({'msg': "Notification modifiée avec succès", 'type': 'success'}))
|
||||
|
||||
|
||||
@webapp.route("/youtube/del/<int:id>")
|
||||
@require_page("youtube")
|
||||
def delYouTube(id):
|
||||
if not can_write_page("youtube"):
|
||||
return render_template("403.html"), 403
|
||||
notification = YouTubeNotification.query.get_or_404(id)
|
||||
db.session.delete(notification)
|
||||
db.session.commit()
|
||||
return redirect(url_for("openYouTube"))
|
||||
Reference in New Issue
Block a user