From 42db84f010cd2fd5f1e88abcdd7b75ff692189ce Mon Sep 17 00:00:00 2001 From: Mow Date: Sat, 6 Dec 2025 11:25:45 +0100 Subject: [PATCH] =?UTF-8?q?Ajout=20de=20la=20gestion=20des=20jeux=20gratui?= =?UTF-8?q?ts=20et=20des=20invitations=20Discord.=20Cr=C3=A9ation=20des=20?= =?UTF-8?q?mod=C3=A8les=20`FreeGame`=20et=20`DiscordInvite`,=20mise=20?= =?UTF-8?q?=C3=A0=20jour=20de=20la=20base=20de=20donn=C3=A9es=20et=20des?= =?UTF-8?q?=20fichiers=20de=20configuration.=20Int=C3=A9gration=20de=20nou?= =?UTF-8?q?velles=20fonctionnalit=C3=A9s=20pour=20synchroniser=20et=20r?= =?UTF-8?q?=C3=A9voquer=20les=20invitations,=20ainsi=20que=20l'ajout=20d'u?= =?UTF-8?q?n=20flux=20RSS=20pour=20les=20jeux=20gratuits.=20Am=C3=A9liorat?= =?UTF-8?q?ion=20de=20l'interface=20d'administration=20avec=20des=20statis?= =?UTF-8?q?tiques=20et=20des=20options=20de=20gestion=20des=20serveurs.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- database/models.py | 32 + database/schema.sql | 32 + discordbot/__init__.py | 232 ++++- discordbot/autorole.py | 33 + discordbot/freegames.py | 270 +++++ discordbot/moderation.py | 9 +- discordbot/welcome.py | 6 +- requirements.txt | 3 + shared_stats.py | 241 +++++ twitchbot/__init__.py | 5 +- webapp/__init__.py | 4 +- webapp/configurations.py | 32 +- webapp/freegames.py | 133 +++ webapp/index.py | 42 +- webapp/moderation.py | 183 +++- webapp/static/css/mvp.css | 603 ----------- webapp/static/css/style.css | 1394 +++++++++++++++++++++++++- webapp/templates/configurations.html | 130 +++ webapp/templates/freegames.html | 381 +++++++ webapp/templates/humeurs.html | 12 + webapp/templates/index.html | 162 ++- webapp/templates/moderation.html | 371 +++++-- webapp/templates/template.html | 58 +- 23 files changed, 3671 insertions(+), 697 deletions(-) create mode 100644 discordbot/autorole.py create mode 100644 discordbot/freegames.py create mode 100644 shared_stats.py create mode 100644 webapp/freegames.py delete mode 100644 webapp/static/css/mvp.css create mode 100644 webapp/templates/freegames.html diff --git a/database/models.py b/database/models.py index 9f103d1..69d9ebc 100644 --- a/database/models.py +++ b/database/models.py @@ -61,3 +61,35 @@ class AntiCheatCache(db.Model): notes = db.Column(db.String(1024)) updated_at = db.Column(db.DateTime) +class FreeGame(db.Model): + __tablename__ = 'free_game' + id = db.Column(db.Integer, primary_key=True) + entry_id = db.Column(db.String(512), unique=True) + title = db.Column(db.String(512)) + source = db.Column(db.String(64)) + url = db.Column(db.String(2048)) + image_url = db.Column(db.String(2048)) + description = db.Column(db.Text) + valid_from = db.Column(db.DateTime) + valid_to = db.Column(db.DateTime) + notified = db.Column(db.Boolean, default=False) + notified_at = db.Column(db.DateTime) + created_at = db.Column(db.DateTime) + +class DiscordInvite(db.Model): + __tablename__ = 'discord_invite' + code = db.Column(db.String(32), primary_key=True) + guild_id = db.Column(db.String(64), nullable=False) + channel_id = db.Column(db.String(64), nullable=False) + channel_name = db.Column(db.String(256)) + inviter_id = db.Column(db.String(64)) + inviter_name = db.Column(db.String(256)) + uses = db.Column(db.Integer, default=0) + max_uses = db.Column(db.Integer, default=0) + max_age = db.Column(db.Integer, default=0) + temporary = db.Column(db.Boolean, default=False) + created_at = db.Column(db.DateTime) + expires_at = db.Column(db.DateTime) + revoked = db.Column(db.Boolean, default=False) + last_sync = db.Column(db.DateTime) + diff --git a/database/schema.sql b/database/schema.sql index 04cef86..070c277 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -76,3 +76,35 @@ CREATE TABLE IF NOT EXISTS `member_invites` ( `inviter_name` VARCHAR(256), `join_date` DATETIME NOT NULL ); + +CREATE TABLE IF NOT EXISTS `free_game` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + `entry_id` VARCHAR(512) UNIQUE NOT NULL, + `title` VARCHAR(512) NOT NULL, + `source` VARCHAR(64) NOT NULL, + `url` VARCHAR(2048) NOT NULL, + `image_url` VARCHAR(2048), + `description` TEXT, + `valid_from` DATETIME, + `valid_to` DATETIME, + `notified` BOOLEAN NOT NULL DEFAULT FALSE, + `notified_at` DATETIME, + `created_at` DATETIME NOT NULL +); + +CREATE TABLE IF NOT EXISTS `discord_invite` ( + `code` VARCHAR(32) PRIMARY KEY, + `guild_id` VARCHAR(64) NOT NULL, + `channel_id` VARCHAR(64) NOT NULL, + `channel_name` VARCHAR(256), + `inviter_id` VARCHAR(64), + `inviter_name` VARCHAR(256), + `uses` INTEGER NOT NULL DEFAULT 0, + `max_uses` INTEGER NOT NULL DEFAULT 0, + `max_age` INTEGER NOT NULL DEFAULT 0, + `temporary` BOOLEAN NOT NULL DEFAULT FALSE, + `created_at` DATETIME, + `expires_at` DATETIME, + `revoked` BOOLEAN NOT NULL DEFAULT FALSE, + `last_sync` DATETIME NOT NULL +); diff --git a/discordbot/__init__.py b/discordbot/__init__.py index 2de81dd..6a6daa4 100644 --- a/discordbot/__init__.py +++ b/discordbot/__init__.py @@ -5,9 +5,11 @@ import random from database import db from database.helpers import ConfigurationHelper -from database.models import Configuration, Humeur, Commande +from database.models import Configuration, Humeur, Commande, DiscordInvite +from datetime import datetime, timezone, timedelta from discord import Message, TextChannel, Member from discordbot.humblebundle import checkHumbleBundleAndNotify +from discordbot.freegames import checkFreeGamesAndNotify from discordbot.moderation import ( handle_warning_command, handle_remove_warning_command, @@ -22,7 +24,9 @@ from discordbot.moderation import ( handle_say_command ) from discordbot.welcome import sendWelcomeMessage, sendLeaveMessage, updateInviteCache +from discordbot.autorole import assignAutoRole from protondb import searhProtonDb +from shared_stats import stats_manager, discord_bridge class DiscordBot(discord.Client): async def on_ready(self): @@ -33,8 +37,53 @@ class DiscordBot(discord.Client): for guild in self.guilds: await updateInviteCache(guild) + # Enregistrer le bot dans le bridge pour communication avec Flask + discord_bridge.register_bot(self, self.loop) + + # Mise à jour des stats partagées + self._update_shared_stats() + + # Synchronisation initiale des invitations + await self.syncInvites() + self.loop.create_task(self.updateStatus()) self.loop.create_task(self.updateHumbleBundle()) + self.loop.create_task(self.updateFreeGames()) + self.loop.create_task(self._periodic_stats_update()) + + def _update_shared_stats(self): + """Met à jour les statistiques partagées""" + total_members = sum(g.member_count or 0 for g in self.guilds) + total_channels = len(list(self.get_all_channels())) + + stats_manager.update_discord_stats( + connected=True, + guilds=len(self.guilds), + members=total_members, + channels=total_channels, + bot_name=str(self.user), + bot_id=self.user.id + ) + + # Mise à jour des cogs/fonctionnalités activées + helper = ConfigurationHelper() + cogs = { + 'Modération': helper.getValue('moderation_enable') or False, + 'Ban': helper.getValue('moderation_ban_enable') or False, + 'Kick': helper.getValue('moderation_kick_enable') or False, + 'ProtonDB': helper.getValue('proton_db_enable_enable') or False, + 'Humeurs': True, # Toujours actif si le bot tourne + 'Jeux Gratuits': helper.getValue('freegames_enable') or False, + 'Messages de bienvenue': helper.getValue('welcome_enable') or False, + 'Auto-Role': helper.getValue('autorole_enable') or False, + } + stats_manager.update_cogs(cogs) + + async def _periodic_stats_update(self): + """Met à jour les stats périodiquement""" + while not self.is_closed(): + await asyncio.sleep(60) # Toutes les minutes + self._update_shared_stats() async def updateStatus(self): while not self.is_closed(): @@ -42,8 +91,18 @@ class DiscordBot(discord.Client): if len(humeurs)>0 : humeur = random.choice(humeurs) if humeur != None: - logging.info(f'Changement de statut : {humeur.text}') - await self.change_presence(status = discord.Status.online, activity = discord.CustomActivity(humeur.text)) + # Récupérer les stats pour les variables + total_members = sum(g.member_count or 0 for g in self.guilds) + total_channels = len(list(self.get_all_channels())) + + # Remplacer les variables dans le texte + status_text = humeur.text + status_text = status_text.replace('{servers}', str(len(self.guilds))) + status_text = status_text.replace('{members}', str(total_members)) + status_text = status_text.replace('{channels}', str(total_channels)) + + logging.info(f'Changement de statut : {status_text}') + await self.change_presence(status = discord.Status.online, activity = discord.CustomActivity(status_text)) await asyncio.sleep(10*60) async def updateHumbleBundle(self): @@ -51,6 +110,11 @@ class DiscordBot(discord.Client): await checkHumbleBundleAndNotify(self) await asyncio.sleep(30*60) + async def updateFreeGames(self): + while not self.is_closed(): + await checkFreeGamesAndNotify(self) + await asyncio.sleep(60*60) # Vérification toutes les heures + def getAllTextChannel(self) -> list[TextChannel]: channels = [] for channel in self.get_all_channels(): @@ -72,7 +136,156 @@ class DiscordBot(discord.Client): 'roles': roles }) return guilds_roles + + def getAllGuilds(self): + """Retourne la liste de tous les serveurs Discord""" + guilds_list = [] + for guild in self.guilds: + guilds_list.append({ + 'id': guild.id, + 'name': guild.name, + 'member_count': guild.member_count, + 'icon_url': str(guild.icon.url) if guild.icon else None, + 'owner_id': guild.owner_id + }) + return guilds_list + + async def leaveGuild(self, guild_id: int) -> bool: + """Quitte un serveur Discord par son ID""" + guild = self.get_guild(guild_id) + if guild: + await guild.leave() + logging.info(f'Le bot a quitté le serveur : {guild.name} (ID: {guild_id})') + return True + return False + async def syncInvites(self, guild_id: int = None) -> dict: + """Synchronise les invitations Discord avec la base de données""" + result = {'synced': 0, 'errors': [], 'guilds': []} + + guilds_to_sync = [self.get_guild(guild_id)] if guild_id else self.guilds + + for guild in guilds_to_sync: + if not guild: + continue + + try: + invites = await guild.invites() + now = datetime.now(timezone.utc) + + # Marquer les invitations existantes comme révoquées (on les réactivera si elles existent encore) + existing_invites = DiscordInvite.query.filter_by(guild_id=str(guild.id), revoked=False).all() + existing_codes = {inv.code for inv in existing_invites} + current_codes = {inv.code for inv in invites} + + # Marquer comme révoquées celles qui n'existent plus + for inv in existing_invites: + if inv.code not in current_codes: + inv.revoked = True + inv.last_sync = now + + for invite in invites: + # Calculer la date d'expiration + expires_at = None + if invite.max_age and invite.max_age > 0 and invite.created_at: + expires_at = invite.created_at + timedelta(seconds=invite.max_age) + + # Chercher si l'invitation existe déjà + db_invite = DiscordInvite.query.filter_by(code=invite.code).first() + + if db_invite: + # Mettre à jour l'invitation existante + db_invite.uses = invite.uses or 0 + db_invite.revoked = False + db_invite.last_sync = now + db_invite.channel_name = invite.channel.name if invite.channel else None + db_invite.inviter_name = invite.inviter.name if invite.inviter else None + else: + # Créer une nouvelle invitation + db_invite = DiscordInvite( + code=invite.code, + guild_id=str(guild.id), + channel_id=str(invite.channel.id) if invite.channel else '', + channel_name=invite.channel.name if invite.channel else None, + inviter_id=str(invite.inviter.id) if invite.inviter else None, + inviter_name=invite.inviter.name if invite.inviter else None, + uses=invite.uses or 0, + max_uses=invite.max_uses or 0, + max_age=invite.max_age or 0, + temporary=invite.temporary or False, + created_at=invite.created_at, + expires_at=expires_at, + revoked=False, + last_sync=now + ) + db.session.add(db_invite) + + result['synced'] += 1 + + db.session.commit() + result['guilds'].append({'id': guild.id, 'name': guild.name, 'invites': len(invites)}) + logging.info(f'Invitations synchronisées pour {guild.name}: {len(invites)} invitations') + + except Exception as e: + logging.error(f'Erreur lors de la synchronisation des invitations pour {guild.name}: {e}') + result['errors'].append(f'{guild.name}: {str(e)}') + + return result + + async def revokeInvite(self, invite_code: str) -> dict: + """Révoque une invitation Discord""" + result = {'success': False, 'message': '', 'invite_code': invite_code} + + try: + # Chercher l'invitation dans la BDD + db_invite = DiscordInvite.query.filter_by(code=invite_code).first() + if not db_invite: + result['message'] = 'Invitation non trouvée dans la base de données' + return result + + # Récupérer le guild + guild = self.get_guild(int(db_invite.guild_id)) + if not guild: + result['message'] = 'Serveur Discord non trouvé' + return result + + # Chercher l'invitation sur Discord + try: + invites = await guild.invites() + discord_invite = next((inv for inv in invites if inv.code == invite_code), None) + + if discord_invite: + await discord_invite.delete(reason='Révoquée via interface web') + logging.info(f'Invitation {invite_code} révoquée sur Discord') + + except Exception as e: + logging.warning(f'Impossible de révoquer l\'invitation sur Discord: {e}') + + # Marquer comme révoquée dans la BDD + db_invite.revoked = True + db_invite.last_sync = datetime.now(timezone.utc) + db.session.commit() + + result['success'] = True + result['message'] = f'Invitation {invite_code} révoquée avec succès' + + except Exception as e: + logging.error(f'Erreur lors de la révocation de l\'invitation {invite_code}: {e}') + result['message'] = f'Erreur: {str(e)}' + + return result + + def getInvites(self, guild_id: int = None, include_revoked: bool = False) -> list: + """Récupère les invitations depuis la base de données""" + query = DiscordInvite.query + + if guild_id: + query = query.filter_by(guild_id=str(guild_id)) + + if not include_revoked: + query = query.filter_by(revoked=False) + + return query.order_by(DiscordInvite.created_at.desc()).all() def begin(self) : token = Configuration.query.filter_by(key='discord_token').first() @@ -251,6 +464,7 @@ async def on_message(message: Message): @bot.event async def on_member_join(member: Member): await sendWelcomeMessage(bot, member) + await assignAutoRole(bot, member) @bot.event async def on_member_remove(member: Member): @@ -259,8 +473,20 @@ async def on_member_remove(member: Member): @bot.event async def on_invite_create(invite): await updateInviteCache(invite.guild) + # Synchroniser la nouvelle invitation avec la BDD + await bot.syncInvites(invite.guild.id) @bot.event async def on_invite_delete(invite): await updateInviteCache(invite.guild) + # Marquer l'invitation comme révoquée dans la BDD + try: + db_invite = DiscordInvite.query.filter_by(code=invite.code).first() + if db_invite: + db_invite.revoked = True + db_invite.last_sync = datetime.now(timezone.utc) + db.session.commit() + logging.info(f'Invitation {invite.code} marquée comme révoquée') + except Exception as e: + logging.error(f'Erreur lors de la révocation de l\'invitation {invite.code}: {e}') diff --git a/discordbot/autorole.py b/discordbot/autorole.py new file mode 100644 index 0000000..652901e --- /dev/null +++ b/discordbot/autorole.py @@ -0,0 +1,33 @@ +import discord +import logging +from database.helpers import ConfigurationHelper +from discord import Member + +async def assignAutoRole(bot: discord.Client, member: Member): + """Attribue automatiquement un rôle aux nouveaux membres""" + config = ConfigurationHelper() + + if not config.getValue('autorole_enable'): + return + + role_id = config.getIntValue('autorole_role_id') + if not role_id: + logging.warning('Auto-role activé mais aucun rôle configuré') + return + + # Chercher le rôle dans le serveur du membre + role = member.guild.get_role(role_id) + if not role: + logging.error(f'Rôle auto-role {role_id} introuvable dans le serveur {member.guild.name}') + return + + try: + await member.add_roles(role, reason='Auto-role: Attribution automatique à l\'arrivée') + logging.info(f'Auto-role: Rôle "{role.name}" attribué à {member.name} sur {member.guild.name}') + except discord.Forbidden: + logging.error(f'Auto-role: Permission refusée pour attribuer le rôle "{role.name}" à {member.name}') + except discord.HTTPException as e: + logging.error(f'Auto-role: Erreur HTTP lors de l\'attribution du rôle : {e}') + except Exception as e: + logging.error(f'Auto-role: Erreur inattendue : {e}') + diff --git a/discordbot/freegames.py b/discordbot/freegames.py new file mode 100644 index 0000000..9d8121c --- /dev/null +++ b/discordbot/freegames.py @@ -0,0 +1,270 @@ +import discord +import feedparser +import logging +import re +from datetime import datetime, timezone +from html import unescape + +from database import db +from database.helpers import ConfigurationHelper +from database.models import FreeGame +from discord import Client + +RSS_URL = "https://feed.eikowagenknecht.com/lootscraper.xml" + +KNOWN_SOURCES = { + 'epic': 'Epic Games', + 'steam': 'Steam', + 'gog': 'GOG', + 'amazon': 'Amazon Prime', + 'humble': 'Humble Bundle', + 'apple': 'Apple App Store', + 'google': 'Google Play', + 'itch': 'Itch.io', + 'ubisoft': 'Ubisoft', + 'indiegala': 'IndieGala' +} + +def _isEnabled(): + helper = ConfigurationHelper() + return helper.getValue('freegames_enable') and helper.getIntValue('freegames_channel') != 0 + +def _parseSource(title: str) -> str: + title_lower = title.lower() + for key, name in KNOWN_SOURCES.items(): + if key in title_lower: + return name + match = re.search(r'\(([^)]+)\)', title) + if match: + return match.group(1) + return "Autre" + +def _parseEntryContent(entry) -> dict: + content = entry.get('content', [{}])[0].get('value', '') if entry.get('content') else '' + summary = entry.get('summary', '') + + image_url = None + img_match = re.search(r']+src=["\']([^"\']+)["\']', content or summary) + if img_match: + image_url = img_match.group(1) + + valid_from = None + valid_to = None + + from_match = re.search(r'Offer valid from:\s*([^<]+)', content) + if from_match: + try: + date_str = from_match.group(1).strip() + valid_from = datetime.strptime(date_str, '%Y-%m-%d %H:%M') + except: + pass + + to_match = re.search(r'Offer valid to:\s*([^<]+)', content) + if to_match: + try: + date_str = to_match.group(1).strip() + valid_to = datetime.strptime(date_str, '%Y-%m-%d %H:%M') + except: + pass + + description = None + desc_match = re.search(r'Description:\s*([^<]+)', content) + if desc_match: + description = unescape(desc_match.group(1).strip()) + + price = None + price_match = re.search(r'Recommended price[^:]*:\s*([^<]+)', content) + if price_match: + price = price_match.group(1).strip() + + genres = [] + for category in entry.get('tags', []): + term = category.get('term', '') + if term.startswith('Genre:'): + genres.append(term.replace('Genre:', '').strip()) + + return { + 'image_url': image_url, + 'valid_from': valid_from, + 'valid_to': valid_to, + 'description': description, + 'price': price, + 'genres': genres + } + +def _fetchRSS() -> list: + try: + feed = feedparser.parse(RSS_URL) + if feed.bozo: + logging.warning(f"Erreur de parsing RSS: {feed.bozo_exception}") + return feed.entries + except Exception as e: + logging.error(f"Échec de la récupération du flux RSS: {e}") + return [] + +def _isSourceEnabled(source: str) -> bool: + helper = ConfigurationHelper() + enabled_sources = helper.getValue('freegames_sources') + if not enabled_sources: + return True + enabled_list = [s.strip().lower() for s in enabled_sources.split(',')] + return source.lower() in enabled_list or any(k in source.lower() for k in enabled_list) + +def _getMentionText() -> str: + helper = ConfigurationHelper() + mention_type = helper.getValue('freegames_mention_type') + + if mention_type == 'everyone': + return '@everyone ' + elif mention_type == 'here': + return '@here ' + elif mention_type == 'role': + role_id = helper.getIntValue('freegames_mention_role') + if role_id: + return f'<@&{role_id}> ' + return '' + +def _formatEmbed(game: FreeGame, parsed_data: dict) -> discord.Embed: + clean_title = game.title + if ' - ' in clean_title: + clean_title = clean_title.split(' - ', 1)[1] + + embed = discord.Embed( + title=f"{clean_title} gratuit sur {game.source} !", + url=game.url, + color=discord.Color.green() + ) + + if game.description: + desc = game.description[:300] + '...' if len(game.description) > 300 else game.description + embed.description = desc + + price_line = "" + if parsed_data.get('price'): + price_line = f"~~{parsed_data['price']}~~ **Gratuit**" + else: + price_line = "**Gratuit**" + + if game.valid_to: + price_line += f" jusqu'au {game.valid_to.strftime('%d/%m/%Y')}" + + embed.add_field(name="💰 Prix", value=price_line, inline=False) + embed.add_field(name="🔗 Récupérer le jeu", value=f"[Ouvrir dans la boutique !]({game.url})", inline=False) + + if game.image_url: + embed.set_image(url=game.image_url) + + embed.set_footer(text="🎁 Jeu à looter - Mamie Henriette") + + return embed + +def fetchAndStoreGames() -> list[FreeGame]: + entries = _fetchRSS() + new_games = [] + + for entry in entries: + entry_id = entry.get('id', entry.get('link', '')) + + existing = FreeGame.query.filter_by(entry_id=entry_id).first() + if existing: + continue + + title = entry.get('title', 'Jeu inconnu') + source = _parseSource(title) + url = entry.get('link', '') + + parsed = _parseEntryContent(entry) + + game = FreeGame( + entry_id=entry_id, + title=title, + source=source, + url=url, + image_url=parsed.get('image_url'), + description=parsed.get('description'), + valid_from=parsed.get('valid_from'), + valid_to=parsed.get('valid_to'), + notified=False, + created_at=datetime.now(timezone.utc) + ) + + db.session.add(game) + new_games.append(game) + + if new_games: + db.session.commit() + logging.info(f"{len(new_games)} nouveaux jeux gratuits ajoutés") + + return new_games + +def getPendingGames() -> list[FreeGame]: + return FreeGame.query.filter_by(notified=False).order_by(FreeGame.created_at.desc()).all() + +def getAllGames() -> list[FreeGame]: + return FreeGame.query.order_by(FreeGame.created_at.desc()).all() + +async def notifyGame(bot: Client, game_id: int) -> bool: + if not _isEnabled(): + return False + + game = FreeGame.query.get(game_id) + if not game or game.notified: + return False + + helper = ConfigurationHelper() + channel_id = helper.getIntValue('freegames_channel') + channel = bot.get_channel(channel_id) + + if not channel: + logging.error(f"Canal Free Games {channel_id} introuvable") + return False + + try: + parsed = _parseEntryContent({'content': [{'value': game.description or ''}]}) + embed = _formatEmbed(game, parsed) + mention = _getMentionText() + + await channel.send(content=mention if mention else None, embed=embed) + + game.notified = True + game.notified_at = datetime.now(timezone.utc) + db.session.commit() + + logging.info(f"Notification envoyée pour: {game.title}") + return True + except Exception as e: + logging.error(f"Échec de l'envoi de la notification Free Games: {e}") + return False + +async def checkFreeGamesAndNotify(bot: Client): + if not _isEnabled(): + logging.debug('Free Games est désactivé') + return + + helper = ConfigurationHelper() + auto_notify = helper.getValue('freegames_auto_notify') + + new_games = fetchAndStoreGames() + + if auto_notify: + for game in new_games: + if _isSourceEnabled(game.source): + await notifyGame(bot, game.id) + +def markAsNotified(game_id: int) -> bool: + game = FreeGame.query.get(game_id) + if game: + game.notified = True + game.notified_at = datetime.now(timezone.utc) + db.session.commit() + return True + return False + +def resetNotification(game_id: int) -> bool: + game = FreeGame.query.get(game_id) + if game: + game.notified = False + game.notified_at = None + db.session.commit() + return True + return False diff --git a/discordbot/moderation.py b/discordbot/moderation.py index 366aeb9..8dfe4f0 100644 --- a/discordbot/moderation.py +++ b/discordbot/moderation.py @@ -1220,7 +1220,7 @@ async def parse_target_user(message: Message, bot, parts: list): except (ValueError, discord.NotFound): return None -def create_inspect_embed(user, member, join_date, days_on_server, account_age, warnings, kicks, bans, invite_info): +def create_inspect_embed(user, member, join_date, days_on_server, account_age, warnings, kicks, bans, invite_info, requester): embed = discord.Embed( title=f"🔍 Inspection de {user.name}", color=discord.Color.blue(), @@ -1228,7 +1228,7 @@ def create_inspect_embed(user, member, join_date, days_on_server, account_age, w ) embed.set_thumbnail(url=user.display_avatar.url) - embed.add_field(name="👤 Utilisateur", value=f"**{user.name}**\n`{user.id}`", inline=True) + embed.add_field(name="👤 Utilisateur", value=f"**{user.name}**\n```{user.id}```", inline=True) if account_age is not None: embed.add_field( @@ -1281,7 +1281,7 @@ def create_inspect_embed(user, member, join_date, days_on_server, account_age, w else: embed.add_field(name="✅ Historique de modération", value="Aucun incident", inline=False) - embed.set_footer(text="Mamie Henriette") + embed.set_footer(text=f"Demandé par {requester.name}") return embed async def get_invite_info_for_user(bot, guild, user_id: int): @@ -1339,7 +1339,8 @@ async def handle_inspect_command(message: Message, bot): warnings, kicks, bans, - invite_info + invite_info, + message.author ) await message.channel.send(embed=embed) diff --git a/discordbot/welcome.py b/discordbot/welcome.py index f5e9ffe..e640bf0 100644 --- a/discordbot/welcome.py +++ b/discordbot/welcome.py @@ -87,6 +87,8 @@ async def sendWelcomeMessage(bot: discord.Client, member: Member): except Exception as e: logging.error(f'Échec de la sauvegarde de l\'invitation : {e}') + account_age_days = (datetime.now(timezone.utc) - member.created_at).days + embed = discord.Embed( title='🎉 Nouveau membre !', description=welcome_message, @@ -100,8 +102,10 @@ async def sendWelcomeMessage(bot: discord.Client, member: Member): embed.set_footer(text=f'ID: {member.id}') try: - await channel.send(embed=embed) + message = await channel.send(embed=embed) logging.info(f'Message de bienvenue envoyé pour {member.name}') + if account_age_days < 7: + await message.add_reaction('⚠️') except Exception as e: logging.error(f'Échec de l\'envoi du message de bienvenue : {e}') diff --git a/requirements.txt b/requirements.txt index 0fe17e7..c431e7d 100755 --- a/requirements.txt +++ b/requirements.txt @@ -21,3 +21,6 @@ requests>=2.32.4 # Nécessaire pour l'appel à ProtonDB algoliasearch>=4,<5 +# Nécessaire pour le flux RSS des jeux gratuits +feedparser>=6.0.0 + diff --git a/shared_stats.py b/shared_stats.py new file mode 100644 index 0000000..6fe4285 --- /dev/null +++ b/shared_stats.py @@ -0,0 +1,241 @@ + +import threading +import asyncio +import logging +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Callable +from datetime import datetime + +@dataclass +class BotStats: + """Statistiques des bots""" + # Discord + discord_connected: bool = False + discord_guilds: int = 0 + discord_members: int = 0 + discord_channels: int = 0 + discord_bot_name: str = "" + discord_bot_id: int = 0 + + # Twitch + twitch_connected: bool = False + twitch_channel: str = "" + + # Cogs/Fonctionnalités activées + cogs_enabled: Dict[str, bool] = field(default_factory=dict) + + # Dernière mise à jour + last_update: datetime = None + +class StatsManager: + """Gestionnaire thread-safe des statistiques""" + _instance = None + _lock = threading.Lock() + + def __new__(cls): + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._stats = BotStats() + cls._instance._stats_lock = threading.Lock() + return cls._instance + + def update_discord_stats(self, connected: bool = None, guilds: int = None, + members: int = None, channels: int = None, + bot_name: str = None, bot_id: int = None): + """Met à jour les stats Discord""" + with self._stats_lock: + if connected is not None: + self._stats.discord_connected = connected + if guilds is not None: + self._stats.discord_guilds = guilds + if members is not None: + self._stats.discord_members = members + if channels is not None: + self._stats.discord_channels = channels + if bot_name is not None: + self._stats.discord_bot_name = bot_name + if bot_id is not None: + self._stats.discord_bot_id = bot_id + self._stats.last_update = datetime.now() + + def update_twitch_stats(self, connected: bool = None, channel: str = None): + """Met à jour les stats Twitch""" + with self._stats_lock: + if connected is not None: + self._stats.twitch_connected = connected + if channel is not None: + self._stats.twitch_channel = channel + self._stats.last_update = datetime.now() + + def update_cogs(self, cogs: Dict[str, bool]): + """Met à jour les cogs activés""" + with self._stats_lock: + self._stats.cogs_enabled = cogs.copy() + self._stats.last_update = datetime.now() + + def get_stats(self) -> BotStats: + """Retourne une copie des stats""" + with self._stats_lock: + return BotStats( + discord_connected=self._stats.discord_connected, + discord_guilds=self._stats.discord_guilds, + discord_members=self._stats.discord_members, + discord_channels=self._stats.discord_channels, + discord_bot_name=self._stats.discord_bot_name, + discord_bot_id=self._stats.discord_bot_id, + twitch_connected=self._stats.twitch_connected, + twitch_channel=self._stats.twitch_channel, + cogs_enabled=self._stats.cogs_enabled.copy(), + last_update=self._stats.last_update + ) + + +class DiscordBridge: + """ + Pont de communication entre Flask et le bot Discord. + Permet d'exécuter des actions Discord depuis Flask de manière thread-safe. + """ + _instance = None + _lock = threading.Lock() + + def __new__(cls): + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._bot = None + cls._instance._loop = None + return cls._instance + + def register_bot(self, bot, loop): + """Enregistre le bot Discord et sa boucle événementielle""" + with self._lock: + self._bot = bot + self._loop = loop + logging.info("Bot Discord enregistré dans le bridge") + + def is_ready(self) -> bool: + """Vérifie si le bot est prêt""" + return self._bot is not None and self._loop is not None and not self._loop.is_closed() + + def get_text_channels(self) -> List[Dict]: + """Retourne la liste des canaux texte disponibles""" + if not self.is_ready(): + return [] + + channels = [] + try: + for guild in self._bot.guilds: + for channel in guild.text_channels: + channels.append({ + 'id': channel.id, + 'name': channel.name, + 'guild_name': guild.name, + 'guild_id': guild.id + }) + except Exception as e: + logging.error(f"Erreur lors de la récupération des canaux: {e}") + + return channels + + def send_message(self, channel_id: int, message: str) -> tuple[bool, str]: + """ + Envoie un message dans un canal Discord. + Retourne (succès, message d'erreur ou de confirmation) + """ + if not self.is_ready(): + return False, "Le bot Discord n'est pas connecté" + + if not message or not message.strip(): + return False, "Le message ne peut pas être vide" + + try: + future = asyncio.run_coroutine_threadsafe( + self._send_message_async(channel_id, message), + self._loop + ) + # Attendre le résultat avec timeout de 10 secondes + return future.result(timeout=10) + except asyncio.TimeoutError: + return False, "Timeout lors de l'envoi du message" + except Exception as e: + logging.error(f"Erreur lors de l'envoi du message: {e}") + return False, f"Erreur: {str(e)}" + + async def _send_message_async(self, channel_id: int, message: str) -> tuple[bool, str]: + """Coroutine interne pour envoyer le message""" + try: + channel = self._bot.get_channel(channel_id) + if not channel: + return False, "Canal introuvable" + + await channel.send(message) + return True, f"Message envoyé dans #{channel.name}" + except Exception as e: + return False, f"Erreur: {str(e)}" + + def sync_invites(self, guild_id: int = None) -> dict: + """Synchronise les invitations Discord avec la base de données""" + if not self.is_ready(): + return {'success': False, 'message': "Le bot Discord n'est pas connecté", 'synced': 0} + + try: + future = asyncio.run_coroutine_threadsafe( + self._bot.syncInvites(guild_id), + self._loop + ) + result = future.result(timeout=30) + result['success'] = True + return result + except asyncio.TimeoutError: + return {'success': False, 'message': "Timeout lors de la synchronisation", 'synced': 0} + except Exception as e: + logging.error(f"Erreur lors de la synchronisation des invitations: {e}") + return {'success': False, 'message': f"Erreur: {str(e)}", 'synced': 0} + + def revoke_invite(self, invite_code: str) -> dict: + """Révoque une invitation Discord""" + if not self.is_ready(): + return {'success': False, 'message': "Le bot Discord n'est pas connecté"} + + try: + future = asyncio.run_coroutine_threadsafe( + self._bot.revokeInvite(invite_code), + self._loop + ) + return future.result(timeout=10) + except asyncio.TimeoutError: + return {'success': False, 'message': "Timeout lors de la révocation"} + except Exception as e: + logging.error(f"Erreur lors de la révocation de l'invitation: {e}") + return {'success': False, 'message': f"Erreur: {str(e)}"} + + def get_invites(self, guild_id: int = None, include_revoked: bool = False) -> list: + """Récupère les invitations depuis la base de données""" + if not self.is_ready(): + return [] + + try: + return self._bot.getInvites(guild_id, include_revoked) + except Exception as e: + logging.error(f"Erreur lors de la récupération des invitations: {e}") + return [] + + def get_guilds(self) -> list: + """Retourne la liste des guilds""" + if not self.is_ready(): + return [] + + try: + return self._bot.getAllGuilds() + except Exception as e: + logging.error(f"Erreur lors de la récupération des guilds: {e}") + return [] + + +# Instances globales +stats_manager = StatsManager() +discord_bridge = DiscordBridge() + diff --git a/twitchbot/__init__.py b/twitchbot/__init__.py index 529bf0d..e7850fd 100644 --- a/twitchbot/__init__.py +++ b/twitchbot/__init__.py @@ -9,13 +9,16 @@ from twitchAPI.chat import Chat, ChatEvent, ChatMessage, EventData from database.helpers import ConfigurationHelper from twitchbot.live_alert import checkOnlineStreamer from webapp import webapp +from shared_stats import stats_manager USER_SCOPE = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT] async def _onReady(ready_event: EventData): logging.info('Bot Twitch prêt') with webapp.app_context(): - await ready_event.chat.join_room(ConfigurationHelper().getValue('twitch_channel')) + channel = ConfigurationHelper().getValue('twitch_channel') + await ready_event.chat.join_room(channel) + stats_manager.update_twitch_stats(connected=True, channel=channel) asyncio.get_event_loop().create_task(twitchBot._checkOnlineStreamers()) diff --git a/webapp/__init__.py b/webapp/__init__.py index 28f6c3c..173be9b 100644 --- a/webapp/__init__.py +++ b/webapp/__init__.py @@ -1,5 +1,7 @@ from flask import Flask +import os webapp = Flask(__name__) +webapp.secret_key = os.environ.get('FLASK_SECRET_KEY', 'mamie-henriette-secret-key-change-me') -from webapp import commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation +from webapp import commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation, freegames diff --git a/webapp/configurations.py b/webapp/configurations.py index a247570..a02fb97 100644 --- a/webapp/configurations.py +++ b/webapp/configurations.py @@ -1,12 +1,13 @@ -from flask import render_template, request, redirect, url_for +from flask import render_template, request, redirect, url_for, flash from webapp import webapp from database import db from database.helpers import ConfigurationHelper from discordbot import bot +import asyncio @webapp.route("/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(), roles = bot.getAllRoles(), guilds = bot.getAllGuilds()) @webapp.route("/configurations/update", methods=['POST']) def updateConfiguration(): @@ -17,7 +18,8 @@ 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', + 'autorole_enable': 'autorole_role_id' } staff_roles = request.form.getlist('moderation_staff_role_ids') @@ -40,3 +42,27 @@ def updateConfiguration(): db.session.commit() return redirect(request.referrer) +@webapp.route("/configurations/leave-guild", methods=['POST']) +def leaveGuild(): + guild_id = request.form.get('guild_id') + confirm = request.form.get('confirm_leave') + + if not guild_id or not confirm: + flash('Veuillez sélectionner un serveur et confirmer l\'action.', 'error') + return redirect(url_for('openConfigurations')) + + try: + guild_id_int = int(guild_id) + # Exécuter la coroutine dans le loop du bot + future = asyncio.run_coroutine_threadsafe(bot.leaveGuild(guild_id_int), bot.loop) + result = future.result(timeout=10) + + if result: + flash('Le bot a quitté le serveur avec succès.', 'success') + else: + flash('Serveur non trouvé.', 'error') + except Exception as e: + flash(f'Erreur lors de la tentative de quitter le serveur : {str(e)}', 'error') + + return redirect(url_for('openConfigurations')) + diff --git a/webapp/freegames.py b/webapp/freegames.py new file mode 100644 index 0000000..2a7b121 --- /dev/null +++ b/webapp/freegames.py @@ -0,0 +1,133 @@ +from flask import render_template, request, redirect, url_for, jsonify +from webapp import webapp +from database import db +from database.helpers import ConfigurationHelper +from database.models import FreeGame +from discordbot import bot +from discordbot.freegames import ( + fetchAndStoreGames, + getPendingGames, + getAllGames, + markAsNotified, + resetNotification, + KNOWN_SOURCES +) +import asyncio + + +@webapp.route("/freegames") +def openFreeGames(): + """Page principale de gestion des jeux gratuits""" + # Rafraîchir les jeux depuis le flux RSS + fetchAndStoreGames() + + games = getAllGames() + pending_games = getPendingGames() + + return render_template( + "freegames.html", + configuration=ConfigurationHelper(), + channels=bot.getAllTextChannel(), + roles=bot.getAllRoles(), + games=games, + pending_count=len(pending_games), + sources=KNOWN_SOURCES + ) + + +@webapp.route("/freegames/config", methods=['POST']) +def updateFreeGamesConfig(): + """Met à jour la configuration du module Free Games""" + helper = ConfigurationHelper() + + # Gestion de la checkbox enable + if request.form.get('freegames_channel') is not None: + if request.form.get('freegames_enable') is None: + helper.createOrUpdate('freegames_enable', False) + else: + helper.createOrUpdate('freegames_enable', 'on') + + # Gestion de la checkbox auto_notify + if request.form.get('freegames_auto_notify') is None: + helper.createOrUpdate('freegames_auto_notify', False) + else: + helper.createOrUpdate('freegames_auto_notify', 'on') + + # Canal de notification + if request.form.get('freegames_channel'): + helper.createOrUpdate('freegames_channel', request.form.get('freegames_channel')) + + # Type de mention + if request.form.get('freegames_mention_type'): + helper.createOrUpdate('freegames_mention_type', request.form.get('freegames_mention_type')) + + # Rôle à mentionner + if request.form.get('freegames_mention_role'): + helper.createOrUpdate('freegames_mention_role', request.form.get('freegames_mention_role')) + + # Sources à suivre + sources = request.form.getlist('freegames_sources') + helper.createOrUpdate('freegames_sources', ','.join(sources) if sources else '') + + db.session.commit() + return redirect(url_for('openFreeGames')) + + +@webapp.route("/freegames/notify/", methods=['POST']) +def notifyFreeGame(game_id): + """Envoie une notification pour un jeu spécifique""" + from discordbot.freegames import notifyGame + + try: + # Utiliser le loop du bot Discord pour exécuter la coroutine + if bot.loop and bot.loop.is_running(): + future = asyncio.run_coroutine_threadsafe(notifyGame(bot, game_id), bot.loop) + result = future.result(timeout=30) # Attendre max 30 secondes + else: + return jsonify({'success': False, 'message': 'Le bot Discord n\'est pas connecté'}), 400 + + if result: + return jsonify({'success': True, 'message': 'Notification envoyée'}) + else: + return jsonify({'success': False, 'message': 'Échec de l\'envoi'}), 400 + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + + +@webapp.route("/freegames/mark-notified/", methods=['POST']) +def markGameNotified(game_id): + """Marque un jeu comme notifié sans envoyer de message""" + if markAsNotified(game_id): + return jsonify({'success': True}) + return jsonify({'success': False}), 400 + + +@webapp.route("/freegames/reset/", methods=['POST']) +def resetGameNotification(game_id): + """Réinitialise le statut de notification d'un jeu""" + if resetNotification(game_id): + return jsonify({'success': True}) + return jsonify({'success': False}), 400 + + +@webapp.route("/freegames/refresh", methods=['POST']) +def refreshFreeGames(): + """Force le rafraîchissement du flux RSS""" + new_games = fetchAndStoreGames() + return jsonify({ + 'success': True, + 'new_games': len(new_games), + 'message': f'{len(new_games)} nouveau(x) jeu(x) trouvé(s)' + }) + + +@webapp.route("/freegames/delete/", methods=['POST']) +def deleteFreeGame(game_id): + """Supprime un jeu de la liste""" + game = FreeGame.query.get(game_id) + if game: + db.session.delete(game) + db.session.commit() + return jsonify({'success': True}) + return jsonify({'success': False}), 404 + diff --git a/webapp/index.py b/webapp/index.py index 10f735b..984e49e 100644 --- a/webapp/index.py +++ b/webapp/index.py @@ -1,6 +1,44 @@ -from flask import render_template +from flask import render_template, request, jsonify from webapp import webapp +from shared_stats import stats_manager, discord_bridge @webapp.route("/") def index(): - return render_template("index.html") + stats = stats_manager.get_stats() + channels = discord_bridge.get_text_channels() + return render_template("index.html", stats=stats, channels=channels) + +@webapp.route("/api/channels") +def api_channels(): + """API pour récupérer la liste des canaux Discord""" + channels = discord_bridge.get_text_channels() + return jsonify(channels) + +@webapp.route("/api/send-message", methods=["POST"]) +def api_send_message(): + """API pour envoyer un message dans un canal Discord""" + data = request.get_json() + + if not data: + return jsonify({"success": False, "error": "Données manquantes"}), 400 + + channel_id = data.get("channel_id") + message = data.get("message") + + if not channel_id: + return jsonify({"success": False, "error": "Canal non spécifié"}), 400 + + if not message or not message.strip(): + return jsonify({"success": False, "error": "Message vide"}), 400 + + try: + channel_id = int(channel_id) + except ValueError: + return jsonify({"success": False, "error": "ID de canal invalide"}), 400 + + success, result_message = discord_bridge.send_message(channel_id, message) + + if success: + return jsonify({"success": True, "message": result_message}) + else: + return jsonify({"success": False, "error": result_message}), 400 diff --git a/webapp/moderation.py b/webapp/moderation.py index 5571487..d42ba32 100644 --- a/webapp/moderation.py +++ b/webapp/moderation.py @@ -1,18 +1,67 @@ -from flask import render_template, request, redirect, url_for +from flask import render_template, request, redirect, url_for, flash, jsonify from webapp import webapp from database import db -from database.models import ModerationEvent +from database.models import ModerationEvent, DiscordInvite +from datetime import datetime, timedelta, timezone +from collections import Counter +from shared_stats import discord_bridge + +def get_moderation_stats(): + """Calcule les statistiques de modération""" + events = ModerationEvent.query.all() + + if not events: + return { + 'total': 0, + 'top_moderators': [], + 'type_counts': {}, + 'recent_24h': 0, + 'recent_7d': 0, + 'recent_30d': 0, + 'top_sanctioned': [] + } + + now = datetime.now() + + # Comptages par modérateur + mod_counts = Counter(e.staff_name for e in events if e.staff_name) + top_moderators = mod_counts.most_common(5) + + # Comptages par type + type_counts = Counter(e.type for e in events if e.type) + + # Événements récents + recent_24h = sum(1 for e in events if e.created_at and (now - e.created_at) < timedelta(hours=24)) + recent_7d = sum(1 for e in events if e.created_at and (now - e.created_at) < timedelta(days=7)) + recent_30d = sum(1 for e in events if e.created_at and (now - e.created_at) < timedelta(days=30)) + + # Top des utilisateurs les plus sanctionnés + user_counts = Counter(e.username for e in events if e.username) + top_sanctioned = user_counts.most_common(5) + + return { + 'total': len(events), + 'top_moderators': top_moderators, + 'type_counts': dict(type_counts), + 'recent_24h': recent_24h, + 'recent_7d': recent_7d, + 'recent_30d': recent_30d, + 'top_sanctioned': top_sanctioned + } @webapp.route("/moderation") def moderation(): events = ModerationEvent.query.order_by(ModerationEvent.created_at.desc()).all() - return render_template("moderation.html", events=events, event=None) + stats = get_moderation_stats() + return render_template("moderation.html", events=events, event=None, stats=stats, + invites=[], invite_stats=None, guilds=[], show_invites=False, show_revoked=False) @webapp.route("/moderation/edit/") 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) + stats = get_moderation_stats() + return render_template("moderation.html", events=events, event=event, stats=stats) @webapp.route("/moderation/update/", methods=['POST']) def update_moderation_event(event_id): @@ -28,3 +77,129 @@ def delete_moderation_event(event_id): db.session.commit() return redirect(url_for('moderation')) +def _make_aware(dt): + """Convertit un datetime naive en aware (UTC)""" + if dt is None: + return None + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt + +def get_invite_stats(): + """Calcule les statistiques des invitations""" + invites = DiscordInvite.query.filter_by(revoked=False).all() + + if not invites: + return { + 'total': 0, + 'total_uses': 0, + 'top_inviters': [], + 'permanent': 0, + 'temporary': 0, + 'expired': 0 + } + + now = datetime.now(timezone.utc) + + # Comptages par inviteur + inviter_uses = Counter() + for inv in invites: + if inv.inviter_name: + inviter_uses[inv.inviter_name] += inv.uses or 0 + top_inviters = inviter_uses.most_common(5) + + # Total des utilisations + total_uses = sum(inv.uses or 0 for inv in invites) + + # Invitations permanentes (max_age = 0) vs temporaires + permanent = sum(1 for inv in invites if inv.max_age == 0) + temporary = sum(1 for inv in invites if inv.max_age > 0) + + # Invitations expirées (mais pas encore révoquées) + expired = sum(1 for inv in invites if inv.expires_at and _make_aware(inv.expires_at) < now) + + return { + 'total': len(invites), + 'total_uses': total_uses, + 'top_inviters': top_inviters, + 'permanent': permanent, + 'temporary': temporary, + 'expired': expired + } + +def is_invite_expired(invite, now): + """Vérifie si une invitation est expirée""" + if not invite.expires_at: + return False + expires_at = _make_aware(invite.expires_at) + return expires_at < now + +@webapp.route("/moderation/invitations") +def moderation_invitations(): + """Affiche la liste des invitations Discord""" + show_revoked = request.args.get('show_revoked', 'false') == 'true' + + query = DiscordInvite.query + if not show_revoked: + query = query.filter_by(revoked=False) + + invites = query.order_by(DiscordInvite.created_at.desc()).all() + invite_stats = get_invite_stats() + guilds = discord_bridge.get_guilds() + now = datetime.now(timezone.utc) + + # Pré-calculer le statut expiré pour chaque invitation + for inv in invites: + inv.is_expired = is_invite_expired(inv, now) + + return render_template("moderation.html", + events=[], + event=None, + stats=get_moderation_stats(), + invites=invites, + invite_stats=invite_stats, + guilds=guilds, + show_invites=True, + show_revoked=show_revoked, + now=now + ) + +@webapp.route("/moderation/invitations/sync") +def sync_invitations(): + """Synchronise les invitations depuis Discord""" + guild_id = request.args.get('guild_id', type=int) + + result = discord_bridge.sync_invites(guild_id) + + if result.get('success'): + flash(f"✅ Synchronisation réussie : {result.get('synced', 0)} invitation(s) synchronisée(s)", 'success') + else: + flash(f"❌ Erreur : {result.get('message', 'Erreur inconnue')}", 'error') + + return redirect(url_for('moderation_invitations')) + +@webapp.route("/moderation/invitations/revoke/") +def revoke_invitation(invite_code): + """Révoque une invitation Discord""" + result = discord_bridge.revoke_invite(invite_code) + + if result.get('success'): + flash(f"✅ Invitation {invite_code} révoquée avec succès", 'success') + else: + flash(f"❌ Erreur : {result.get('message', 'Erreur inconnue')}", 'error') + + return redirect(url_for('moderation_invitations')) + +@webapp.route("/api/invitations/sync", methods=['POST']) +def api_sync_invitations(): + """API pour synchroniser les invitations""" + guild_id = request.json.get('guild_id') if request.is_json else None + result = discord_bridge.sync_invites(guild_id) + return jsonify(result) + +@webapp.route("/api/invitations/revoke/", methods=['POST']) +def api_revoke_invitation(invite_code): + """API pour révoquer une invitation""" + result = discord_bridge.revoke_invite(invite_code) + return jsonify(result) + diff --git a/webapp/static/css/mvp.css b/webapp/static/css/mvp.css deleted file mode 100644 index 3c3d2b6..0000000 --- a/webapp/static/css/mvp.css +++ /dev/null @@ -1,603 +0,0 @@ -/* MVP.css v1.17.2 - https://github.com/andybrewer/mvp */ - -:root { - --active-brightness: 0.85; - --border-radius: 5px; - --box-shadow: 2px 2px 10px; - --color-accent: #118bee15; - --color-bg: #fff; - --color-bg-secondary: #e9e9e9; - --color-link: #118bee; - --color-secondary: #920de9; - --color-secondary-accent: #920de90b; - --color-shadow: #f4f4f4; - --color-table: #118bee; - --color-text: #000; - --color-text-secondary: #999; - --color-scrollbar: #cacae8; - --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; - --hover-brightness: 1.2; - --justify-important: center; - --justify-normal: left; - --line-height: 1.5; - --width-card: 285px; - --width-card-medium: 460px; - --width-card-wide: 800px; - --width-content: 1080px; -} - -@media (prefers-color-scheme: dark) { - :root[color-mode="user"] { - --color-accent: #0097fc4f; - --color-bg: #333; - --color-bg-secondary: #555; - --color-link: #0097fc; - --color-secondary: #e20de9; - --color-secondary-accent: #e20de94f; - --color-shadow: #bbbbbb20; - --color-table: #0097fc; - --color-text: #f7f7f7; - --color-text-secondary: #aaa; - } -} - -html { - scroll-behavior: smooth; -} - -@media (prefers-reduced-motion: reduce) { - html { - scroll-behavior: auto; - } -} - -/* Layout */ -article aside { - background: var(--color-secondary-accent); - border-left: 4px solid var(--color-secondary); - padding: 0.01rem 0.8rem; -} - -body { - background: var(--color-bg); - color: var(--color-text); - font-family: var(--font-family); - line-height: var(--line-height); - margin: 0; - overflow-x: hidden; - padding: 0; -} - -footer, -header, -main { - margin: 0 auto; - max-width: var(--width-content); - /* padding: 3rem 1rem; */ - padding: 1rem 1rem; -} - -hr { - background-color: var(--color-bg-secondary); - border: none; - height: 1px; - margin: 4rem 0; - width: 100%; -} - -section { - display: flex; - flex-wrap: wrap; - justify-content: var(--justify-important); -} - -section img, -article img { - max-width: 100%; -} - -section pre { - overflow: auto; -} - -section aside { - border: 1px solid var(--color-bg-secondary); - border-radius: var(--border-radius); - box-shadow: var(--box-shadow) var(--color-shadow); - margin: 1rem; - padding: 1.25rem; - width: var(--width-card); -} - -section aside:hover { - box-shadow: var(--box-shadow) var(--color-bg-secondary); -} - -[hidden] { - display: none; -} - -/* Headers */ -article header, -div header, -main header { - padding-top: 0; -} - -header { - text-align: var(--justify-important); -} - -header a b, -header a em, -header a i, -header a strong { - margin-left: 0.5rem; - margin-right: 0.5rem; -} - -/* header nav img { - margin: 1rem 0; -} */ - -section header { - padding-top: 0; - width: 100%; -} - -/* Nav */ -nav { - align-items: center; - display: flex; - font-weight: bold; - justify-content: space-between; - /* margin-bottom: 7rem; */ -} - -nav ul { - list-style: none; - padding: 0; -} - -nav ul li { - display: inline-block; - margin: 0 0.5rem; - position: relative; - text-align: left; -} - -/* Nav Dropdown */ -nav ul li:hover ul { - display: block; -} - -nav ul li ul { - background: var(--color-bg); - border: 1px solid var(--color-bg-secondary); - border-radius: var(--border-radius); - box-shadow: var(--box-shadow) var(--color-shadow); - display: none; - height: auto; - left: -2px; - padding: 0.5rem 1rem; - position: absolute; - top: 1.7rem; - white-space: nowrap; - width: auto; - z-index: 1; -} - -nav ul li ul::before { - /* fill gap above to make mousing over them easier */ - content: ""; - position: absolute; - left: 0; - right: 0; - top: -0.5rem; - height: 0.5rem; -} - -nav ul li ul li, -nav ul li ul li a { - display: block; -} - -/* Nav for Mobile */ -@media (max-width: 768px) { - nav { - flex-wrap: wrap; - } - - nav ul li { - width: calc(100% - 1em); - } - - nav ul li ul { - border: none; - box-shadow: none; - display: block; - position: static; - } -} - -/* Typography */ -code, -samp { - background-color: var(--color-accent); - border-radius: var(--border-radius); - color: var(--color-text); - display: inline-block; - margin: 0 0.1rem; - padding: 0 0.5rem; -} - -details { - margin: 1.3rem 0; -} - -details summary { - font-weight: bold; - cursor: pointer; -} - -h1, -h2, -h3, -h4, -h5, -h6 { - line-height: var(--line-height); - text-wrap: balance; -} - -mark { - padding: 0.1rem; -} - -ol li, -ul li { - padding: 0.2rem 0; -} - -p { - margin: 0.75rem 0; - padding: 0; - width: 100%; -} - -pre { - margin: 1rem 0; - max-width: var(--width-card-wide); - padding: 1rem 0; -} - -pre code, -pre samp { - display: block; - max-width: var(--width-card-wide); - padding: 0.5rem 2rem; - white-space: pre-wrap; -} - -small { - color: var(--color-text-secondary); -} - -sup { - background-color: var(--color-secondary); - border-radius: var(--border-radius); - color: var(--color-bg); - font-size: xx-small; - font-weight: bold; - margin: 0.2rem; - padding: 0.2rem 0.3rem; - position: relative; - top: -2px; -} - -/* Links */ -a { - color: var(--color-link); - display: inline-block; - font-weight: bold; - text-decoration: underline; -} - -a:hover { - filter: brightness(var(--hover-brightness)); -} - -a:active { - filter: brightness(var(--active-brightness)); -} - -a b, -a em, -a i, -a strong, -button, -input[type="submit"] { - border-radius: var(--border-radius); - display: inline-block; - font-size: medium; - font-weight: bold; - line-height: var(--line-height); - margin: 0.5rem 0; - padding: 1rem 2rem; -} - -button, -input[type="submit"] { - font-family: var(--font-family); -} - -button:hover, -input[type="submit"]:hover { - cursor: pointer; - filter: brightness(var(--hover-brightness)); -} - -button:active, -input[type="submit"]:active { - filter: brightness(var(--active-brightness)); -} - -a b, -a strong, -button, -input[type="submit"] { - background-color: var(--color-link); - border: 2px solid var(--color-link); - color: var(--color-bg); -} - -a em, -a i { - border: 2px solid var(--color-link); - border-radius: var(--border-radius); - color: var(--color-link); - display: inline-block; - padding: 1rem 2rem; -} - -article aside a { - color: var(--color-secondary); -} - -/* Images */ -figure { - margin: 0; - padding: 0; -} - -figure img { - max-width: 100%; -} - -figure figcaption { - color: var(--color-text-secondary); -} - -/* Forms */ -button:disabled, -input:disabled { - background: var(--color-bg-secondary); - border-color: var(--color-bg-secondary); - color: var(--color-text-secondary); - cursor: not-allowed; -} - -button[disabled]:hover, -input[type="submit"][disabled]:hover { - filter: none; -} - -form { - border: 1px solid var(--color-bg-secondary); - border-radius: var(--border-radius); - box-shadow: var(--box-shadow) var(--color-shadow); - display: block; - max-width: var(--width-card-wide); - min-width: var(--width-card); - padding: 1.5rem; - text-align: var(--justify-normal); -} - -form header { - margin: 1.5rem 0; - padding: 1.5rem 0; -} - -input, -label, -select, -textarea { - display: block; - font-size: inherit; - max-width: var(--width-card-wide); -} - -input[type="checkbox"], -input[type="radio"] { - display: inline-block; -} - -input[type="checkbox"]+label, -input[type="radio"]+label { - display: inline-block; - font-weight: normal; - position: relative; - top: 1px; -} - -input[type="range"] { - padding: 0.4rem 0; -} - -input, -select, -textarea { - border: 1px solid var(--color-bg-secondary); - border-radius: var(--border-radius); - margin-bottom: 1rem; - padding: 0.4rem 0.8rem; -} - -input[type="text"], -input[type="password"], -input[type="email"], -textarea { - width: calc(100% - 1.6rem); -} - -input[readonly], -textarea[readonly] { - background-color: var(--color-bg-secondary); -} - -label { - font-weight: bold; - margin-bottom: 0.2rem; -} - -/* Popups */ -dialog { - max-width: 90%; - max-height: 85dvh; - margin: auto; - padding-block: 0; - padding-inline: 20px; - border: 1px solid var(--color-bg-secondary); - border-radius: 0.5rem; - overscroll-behavior: contain; - scroll-behavior: smooth; - scrollbar-width: none; - /* Hide scrollbar for Firefox */ - -ms-overflow-style: none; - /* Hide scrollbar for IE and Edge */ - scrollbar-color: transparent transparent; - animation: bottom-to-top 0.25s ease-in-out forwards; -} - -dialog::-webkit-scrollbar { - width: 0; - display: none; -} - -dialog::-webkit-scrollbar-track { - background: transparent; -} - -dialog::-webkit-scrollbar-thumb { - background-color: transparent; -} - -@media (min-width: 650px) { - dialog { - max-width: 39rem; - } -} - -dialog::backdrop { - background-color: rgba(0, 0, 0, 0.5); -} - -@keyframes bottom-to-top { - 0% { - opacity: 0; - transform: translateY(10%); - } - - 100% { - opacity: 1; - transform: translateY(0); - } -} - -dialog hr { - margin-block: 1rem; -} - -/* Tables */ -table { - border: 1px solid var(--color-bg-secondary); - border-radius: var(--border-radius); - border-spacing: 0; - display: inline-block; - max-width: 100%; - overflow-x: auto; - padding: 0; - white-space: nowrap; -} - -table td, -table th, -table tr { - padding: 0.4rem 0.8rem; - text-align: var(--justify-important); -} - -table thead { - background-color: var(--color-table); - border-collapse: collapse; - border-radius: var(--border-radius); - color: var(--color-bg); - margin: 0; - padding: 0; -} - -table thead tr:first-child th:first-child { - border-top-left-radius: var(--border-radius); -} - -table thead tr:first-child th:last-child { - border-top-right-radius: var(--border-radius); -} - -table thead th:first-child, -table tr td:first-child { - text-align: var(--justify-normal); -} - -table tr:nth-child(even) { - background-color: var(--color-accent); -} - -/* Quotes */ -blockquote { - display: block; - font-size: x-large; - line-height: var(--line-height); - margin: 1rem auto; - max-width: var(--width-card-medium); - padding: 1.5rem 1rem; - text-align: var(--justify-important); -} - -blockquote footer { - color: var(--color-text-secondary); - display: block; - font-size: small; - line-height: var(--line-height); - padding: 1.5rem 0; -} - -/* Scrollbars */ -* { - scrollbar-width: thin; - scrollbar-color: var(--color-scrollbar) transparent; -} - -*::-webkit-scrollbar { - width: 5px; - height: 5px; -} - -*::-webkit-scrollbar-track { - background: transparent; -} - -*::-webkit-scrollbar-thumb { - background-color: var(--color-scrollbar); - border-radius: 10px; -} \ No newline at end of file diff --git a/webapp/static/css/style.css b/webapp/static/css/style.css index 25a3d21..37f6d21 100644 --- a/webapp/static/css/style.css +++ b/webapp/static/css/style.css @@ -1,19 +1,1401 @@ -header nav img { - border-radius: 50%; +/* === Variables & Thèmes === */ +:root { + /* Couleurs - Mode Clair */ + --bg-primary: #fafafa; + --bg-secondary: #ffffff; + --bg-tertiary: #f0f0f0; + --text-primary: #1a1a2e; + --text-secondary: #4a4a5a; + --text-muted: #8a8a9a; + --accent: #2563eb; + --accent-hover: #1d4ed8; + --accent-soft: #2563eb15; + --border: #e2e2e8; + --shadow: rgba(0, 0, 0, 0.04); + --table-header: #1e293b; + --success: #059669; + --danger: #dc2626; + + /* Espacements */ + --space-xs: 0.25rem; + --space-sm: 0.5rem; + --space-md: 1rem; + --space-lg: 1.5rem; + --space-xl: 2rem; + --space-2xl: 3rem; + + /* Typographie */ + --font-sans: "DM Sans", -apple-system, BlinkMacSystemFont, sans-serif; + --font-mono: "JetBrains Mono", "Fira Code", monospace; + --text-sm: 0.875rem; + --text-base: 1rem; + --text-lg: 1.125rem; + --text-xl: 1.25rem; + --text-2xl: 1.5rem; + --line-height: 1.6; + + /* Bordures */ + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 16px; + --radius-full: 9999px; + + /* Largeurs */ + --max-width: 1100px; +} + +/* Mode Sombre automatique */ +@media (prefers-color-scheme: dark) { + :root { + --bg-primary: #0f0f14; + --bg-secondary: #18181f; + --bg-tertiary: #22222b; + --text-primary: #e8e8ed; + --text-secondary: #a8a8b8; + --text-muted: #68687a; + --accent: #3b82f6; + --accent-hover: #60a5fa; + --accent-soft: #3b82f620; + --border: #2a2a38; + --shadow: rgba(0, 0, 0, 0.3); + --table-header: #1e293b; + } +} + +/* === Reset & Base === */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + scroll-behavior: smooth; + font-size: 16px; +} + +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + *, *::before, *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +} + +body { + font-family: var(--font-sans); + font-size: var(--text-base); + line-height: var(--line-height); + color: var(--text-primary); + background: var(--bg-primary); + min-height: 100vh; +} + +/* === Layout === */ +header { + width: 100%; + padding: var(--space-sm) var(--space-lg); +} + +main, footer { + width: 100%; + max-width: var(--max-width); + margin: 0 auto; + padding: var(--space-md) var(--space-lg); +} + +main { + padding-top: var(--space-xl); + padding-bottom: var(--space-2xl); +} + +/* === Navigation === */ +header { + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 100; +} + +header nav { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-lg); + padding: var(--space-sm) 0; + max-width: var(--max-width); + margin: 0 auto; +} + +/* Logo */ +.nav-logo { + display: flex; + align-items: center; + gap: var(--space-sm); + text-decoration: none; + flex-shrink: 0; +} + +.nav-logo img { + width: 48px; + height: 48px; + border-radius: var(--radius-full); + transition: transform 0.2s ease; +} + +.nav-logo:hover img { + transform: scale(1.05); +} + +.nav-logo span { + font-weight: 700; + font-size: var(--text-lg); + color: var(--text-primary); + letter-spacing: -0.02em; +} + +/* Menu Toggle (hamburger) - TOUJOURS caché, sauf mobile */ +.nav-toggle { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.nav-toggle-label { + display: none; + visibility: hidden; +} + +.nav-toggle-label span { + display: block; + width: 100%; + height: 2px; + background: var(--text-primary); + border-radius: 2px; + transition: all 0.3s ease; +} + +/* Menu principal */ +.nav-menu { + display: flex; + align-items: center; + gap: var(--space-xs); + list-style: none; + margin: 0; + padding: 0; +} + +.nav-menu > li { + position: relative; +} + +.nav-menu > li > a { + display: flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-sm) var(--space-md); + color: var(--text-secondary); + text-decoration: none; + font-weight: 500; + font-size: var(--text-sm); + border-radius: var(--radius-sm); + transition: all 0.15s ease; + white-space: nowrap; +} + +.nav-menu > li > a:hover { + color: var(--accent); + background: var(--accent-soft); +} + +/* Indicateur de sous-menu */ +.nav-menu > li.has-submenu > a::after { + content: ""; + width: 0; + height: 0; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 5px solid currentColor; + margin-left: var(--space-xs); + transition: transform 0.2s ease; +} + +.nav-menu > li.has-submenu:hover > a::after { + transform: rotate(180deg); +} + +/* Sous-menu */ +.submenu { + position: absolute; + top: 100%; + left: 0; + min-width: 200px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: var(--space-sm); + list-style: none; + opacity: 0; + visibility: hidden; + transform: translateY(-8px); + transition: all 0.2s ease; + box-shadow: 0 10px 40px -10px var(--shadow); + z-index: 200; +} + +.nav-menu > li.has-submenu:hover .submenu { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.submenu li a { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + color: var(--text-secondary); + text-decoration: none; + font-size: var(--text-sm); + border-radius: var(--radius-sm); + transition: all 0.15s ease; +} + +.submenu li a:hover { + color: var(--accent); + background: var(--accent-soft); +} + +/* Nav Mobile */ +@media (max-width: 768px) { + .nav-toggle-label { + display: flex; + visibility: visible; + flex-direction: column; + justify-content: center; + gap: 5px; + width: 32px; + height: 32px; + cursor: pointer; + padding: 4px; + } + + .nav-logo span { + display: none; + } + + .nav-menu { + position: absolute; + top: 100%; + left: 0; + right: 0; + flex-direction: column; + align-items: stretch; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + padding: var(--space-md); + gap: var(--space-xs); + opacity: 0; + visibility: hidden; + transform: translateY(-10px); + transition: all 0.2s ease; + } + + .nav-toggle:checked ~ .nav-menu { + opacity: 1; + visibility: visible; + transform: translateY(0); + } + + /* Animation hamburger -> X */ + .nav-toggle:checked ~ .nav-toggle-label span:nth-child(1) { + transform: rotate(45deg) translate(5px, 5px); + } + + .nav-toggle:checked ~ .nav-toggle-label span:nth-child(2) { + opacity: 0; + } + + .nav-toggle:checked ~ .nav-toggle-label span:nth-child(3) { + transform: rotate(-45deg) translate(5px, -5px); + } + + .nav-menu > li > a { + justify-content: space-between; + } + + /* Sous-menu mobile */ + .submenu { + position: static; + opacity: 1; + visibility: visible; + transform: none; + box-shadow: none; + border: none; + background: var(--bg-tertiary); + margin-top: var(--space-xs); + padding: var(--space-xs); + display: none; + } + + .nav-menu > li.has-submenu:hover .submenu, + .nav-menu > li.has-submenu:focus-within .submenu { + display: block; + } +} + +/* === Typographie === */ +h1, h2, h3, h4, h5, h6 { + font-weight: 600; + line-height: 1.3; + color: var(--text-primary); +} + +h1 { font-size: var(--text-2xl); margin-bottom: var(--space-md); } +h2 { font-size: var(--text-xl); margin-bottom: var(--space-md); } +h3 { font-size: var(--text-lg); margin-bottom: var(--space-sm); } + +p { + color: var(--text-secondary); + margin-bottom: var(--space-md); +} + +small { + font-size: var(--text-sm); + color: var(--text-muted); +} + +/* === Liens === */ +a { + color: var(--accent); + text-decoration: none; + transition: color 0.15s ease; +} + +a:hover { + color: var(--accent-hover); +} + +/* === Boutons === */ +button, +input[type="submit"], +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-lg); + font-family: var(--font-sans); + font-size: var(--text-sm); + font-weight: 500; + color: #fff; + background: var(--accent); + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + transition: all 0.15s ease; +} + +button:hover, +input[type="submit"]:hover, +.btn:hover { + background: var(--accent-hover); + transform: translateY(-1px); +} + +button:active, +input[type="submit"]:active { + transform: translateY(0); +} + +button:disabled, +input[type="submit"]:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.btn-secondary { + color: var(--text-primary); + background: var(--bg-tertiary); + border: 1px solid var(--border); +} + +.btn-secondary:hover { + background: var(--border); +} + +.btn-danger { + background: var(--danger); +} + +.btn-danger:hover { + background: #b91c1c; +} + +/* === Formulaires === */ +form { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: var(--space-lg); + max-width: 600px; +} + +label { + display: block; + font-size: var(--text-sm); + font-weight: 500; + color: var(--text-primary); + margin-bottom: var(--space-xs); +} + +input[type="text"], +input[type="password"], +input[type="email"], +input[type="url"], +input[type="number"], +select, +textarea { + width: 100%; + padding: var(--space-sm) var(--space-md); + font-family: var(--font-sans); + font-size: var(--text-base); + color: var(--text-primary); + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + margin-bottom: var(--space-md); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +input:focus, +select:focus, +textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +input[type="checkbox"], +input[type="radio"] { + width: var(--space-md); + height: var(--space-md); + accent-color: var(--accent); + margin-right: var(--space-sm); +} + +input[type="checkbox"] + label, +input[type="radio"] + label { + display: inline; + font-weight: normal; + cursor: pointer; +} + +textarea { + min-height: 120px; + resize: vertical; +} + +input[readonly], +textarea[readonly] { + background: var(--bg-primary); + color: var(--text-muted); +} + +/* === Tableaux === */ +table { + width: 100%; + border-collapse: collapse; + background: var(--bg-secondary); + border-radius: var(--radius-md); + overflow: hidden; + border: 1px solid var(--border); +} + +table thead { + background: var(--table-header); +} + +table th { + padding: var(--space-md); + font-size: var(--text-sm); + font-weight: 600; + color: #fff; + text-align: left; } -table th, table td { + padding: var(--space-md); + font-size: var(--text-sm); + color: var(--text-secondary); + border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; - overflow: hidden; - white-space: normal; +} + +table tbody tr:last-child td { + border-bottom: none; +} + +table tbody tr:hover { + background: var(--accent-soft); } table.live-alert tr td:last-child { white-space: nowrap; } +/* === Code === */ +code, pre { + font-family: var(--font-mono); + font-size: var(--text-sm); +} + +code { + padding: var(--space-xs) var(--space-sm); + background: var(--accent-soft); + color: var(--accent); + border-radius: var(--radius-sm); +} + +pre { + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: var(--space-md); + overflow-x: auto; +} + +pre code { + background: none; + padding: 0; + color: var(--text-primary); +} + +/* === Cards & Sections === */ +.card, +section aside { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: var(--space-lg); +} + +article aside { + background: var(--accent-soft); + border-left: 3px solid var(--accent); + padding: var(--space-md); + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + margin: var(--space-md) 0; +} + +/* Info Box */ +.info-box { + background: var(--accent-soft); + border: 1px solid var(--accent); + border-radius: var(--radius-md); + padding: var(--space-lg); + margin-bottom: var(--space-lg); +} + +.info-box h3 { + color: var(--accent); + margin-bottom: var(--space-sm); +} + +.info-box p { + margin-bottom: var(--space-sm); +} + +.info-box p:last-child { + margin-bottom: 0; +} + +.info-box ul { + margin: var(--space-sm) 0; + padding-left: var(--space-lg); +} + +.info-box li { + margin-bottom: var(--space-xs); + color: var(--text-secondary); +} + +.info-box em { + color: var(--text-muted); + font-style: italic; +} + +/* === HR === */ +hr { + border: none; + height: 1px; + background: var(--border); + margin: var(--space-xl) 0; +} + +/* === Footer === */ +footer { + padding-top: 0; +} + +footer p { + font-size: var(--text-sm); + color: var(--text-muted); + text-align: center; +} + +footer a { + font-weight: 500; +} + +/* === Icônes === */ a.icon { text-decoration: none; -} \ No newline at end of file + display: inline-flex; + align-items: center; + gap: var(--space-xs); +} + +/* === Dialog/Modal === */ +dialog { + max-width: min(90vw, 500px); + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: var(--space-lg); + box-shadow: 0 25px 50px -12px var(--shadow); +} + +dialog::backdrop { + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); +} + +/* === Scrollbar === */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: var(--radius-full); +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + +* { + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +/* === Utilitaires === */ +.text-center { text-align: center; } +.text-muted { color: var(--text-muted); } +.mt-md { margin-top: var(--space-md); } +.mb-md { margin-bottom: var(--space-md); } +.flex { display: flex; } +.gap-sm { gap: var(--space-sm); } +.gap-md { gap: var(--space-md); } + +/* === Dashboard === */ +.dashboard-section { + margin-bottom: var(--space-2xl); +} + +.dashboard-section h2 { + margin-bottom: var(--space-lg); + font-size: var(--text-lg); + color: var(--text-primary); +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--space-md); +} + +.stat-card { + display: flex; + align-items: center; + gap: var(--space-md); + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: var(--space-lg); + position: relative; + transition: all 0.2s ease; +} + +.stat-card:hover { + border-color: var(--accent); + transform: translateY(-2px); +} + +.stat-card.online { + border-left: 3px solid var(--success); +} + +.stat-card.offline { + border-left: 3px solid var(--danger); +} + +.stat-icon { + font-size: 1.75rem; + line-height: 1; +} + +.stat-content { + display: flex; + flex-direction: column; + gap: var(--space-xs); + flex: 1; +} + +.stat-label { + font-size: var(--text-sm); + color: var(--text-muted); + font-weight: 500; +} + +.stat-value { + font-size: var(--text-base); + font-weight: 600; + color: var(--text-primary); +} + +.stat-value.big { + font-size: var(--text-2xl); +} + +.stat-sub { + font-size: var(--text-sm); + color: var(--text-secondary); +} + +.stat-status { + width: 10px; + height: 10px; + border-radius: var(--radius-full); + flex-shrink: 0; +} + +.stat-status.online { + background: var(--success); + box-shadow: 0 0 8px var(--success); +} + +.stat-status.offline { + background: var(--danger); +} + +/* Cogs Grid */ +.cogs-grid { + display: flex; + flex-wrap: wrap; + gap: var(--space-sm); +} + +.cog-item { + display: inline-flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-full); + font-size: var(--text-sm); + transition: all 0.15s ease; +} + +.cog-item.enabled { + border-color: var(--success); + background: rgba(5, 150, 105, 0.1); +} + +.cog-item.disabled { + opacity: 0.6; +} + +.cog-status { + font-size: var(--text-sm); +} + +.cog-name { + color: var(--text-primary); + font-weight: 500; +} + +.last-update { + font-size: var(--text-sm); + color: var(--text-muted); + margin-top: var(--space-xl); + text-align: right; +} + +/* Announce Form Inline */ +.announce-form-inline { + max-width: 100%; +} + +.announce-row { + display: flex; + gap: var(--space-sm); + align-items: stretch; +} + +.announce-row input[type="text"] { + flex: 1; + min-width: 200px; + padding: var(--space-sm) var(--space-md); + font-family: var(--font-sans); + font-size: var(--text-base); + color: var(--text-primary); + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + margin-bottom: 0; +} + +.announce-row input[type="text"]:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +.announce-row select { + width: auto; + min-width: 180px; + padding: var(--space-sm) var(--space-md); + font-family: var(--font-sans); + font-size: var(--text-sm); + color: var(--text-primary); + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + cursor: pointer; +} + +.announce-row select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +.announce-row button { + flex-shrink: 0; + white-space: nowrap; +} + +.announce-result { + margin-top: var(--space-md); + padding: var(--space-sm) var(--space-md); + border-radius: var(--radius-sm); + font-size: var(--text-sm); + font-weight: 500; +} + +.announce-result.success { + background: rgba(5, 150, 105, 0.1); + border: 1px solid var(--success); + color: var(--success); +} + +.announce-result.error { + background: rgba(220, 38, 38, 0.1); + border: 1px solid var(--danger); + color: var(--danger); +} + +@media (max-width: 640px) { + .announce-row { + flex-direction: column; + } + + .announce-row input[type="text"], + .announce-row select { + width: 100%; + min-width: unset; + } +} + +/* === Moderation Insights === */ +.moderation-insights { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: var(--space-lg); + margin-bottom: var(--space-2xl); +} + +.insight-card { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: var(--space-lg); +} + +.insight-card h3 { + font-size: var(--text-base); + margin-bottom: var(--space-md); + color: var(--text-primary); +} + +/* Leaderboard */ +.leaderboard { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.leaderboard-item { + display: flex; + align-items: center; + gap: var(--space-md); + padding: var(--space-sm) var(--space-md); + background: var(--bg-tertiary); + border-radius: var(--radius-sm); + transition: all 0.15s ease; +} + +.leaderboard-item:hover { + background: var(--accent-soft); +} + +.leaderboard-item.warning:hover { + background: rgba(220, 38, 38, 0.1); +} + +.leaderboard-item .rank { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: var(--accent); + color: #fff; + border-radius: var(--radius-full); + font-size: var(--text-sm); + font-weight: 600; + flex-shrink: 0; +} + +.leaderboard-item:first-child .rank { + background: linear-gradient(135deg, #fbbf24, #f59e0b); +} + +.leaderboard-item:nth-child(2) .rank { + background: linear-gradient(135deg, #94a3b8, #64748b); +} + +.leaderboard-item:nth-child(3) .rank { + background: linear-gradient(135deg, #d97706, #b45309); +} + +.leaderboard-item.warning .rank { + background: var(--danger); +} + +.leaderboard-item .name { + flex: 1; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.leaderboard-item .count { + font-size: var(--text-sm); + color: var(--text-muted); + flex-shrink: 0; +} + +/* Type Badges */ +.type-badges { + display: flex; + flex-wrap: wrap; + gap: var(--space-sm); +} + +.type-badge { + display: inline-flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + background: var(--bg-tertiary); + border-radius: var(--radius-full); + border: 1px solid var(--border); +} + +.type-badge .type-name { + font-size: var(--text-sm); + font-weight: 500; + color: var(--text-primary); +} + +.type-badge .type-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + height: 24px; + padding: 0 var(--space-sm); + background: var(--accent); + color: #fff; + border-radius: var(--radius-full); + font-size: var(--text-sm); + font-weight: 600; +} + +.type-badge.warn .type-count, +.type-badge.warning .type-count, +.type-badge.avertissement .type-count { + background: #f59e0b; +} + +.type-badge.kick .type-count, +.type-badge.expulsion .type-count { + background: #f97316; +} + +.type-badge.ban .type-count, +.type-badge.bannissement .type-count { + background: #dc2626; +} + +.type-badge.unban .type-count { + background: #059669; +} + +/* Commands Section (Collapsible) */ +.commands-section { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-md); + margin-bottom: var(--space-2xl); + overflow: hidden; +} + +.commands-section summary { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-lg); + font-weight: 600; + color: var(--text-primary); + cursor: pointer; + user-select: none; + transition: background 0.15s ease; +} + +.commands-section summary:hover { + background: var(--accent-soft); +} + +.commands-section summary::-webkit-details-marker { + display: none; +} + +.commands-section summary::after { + content: ""; + margin-left: auto; + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 6px solid var(--text-muted); + transition: transform 0.2s ease; +} + +.commands-section[open] summary::after { + transform: rotate(180deg); +} + +.summary-icon { + font-size: var(--text-lg); +} + +.commands-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: var(--space-md); + padding: 0 var(--space-lg) var(--space-lg); +} + +.command-card { + background: var(--bg-tertiary); + border-radius: var(--radius-sm); + padding: var(--space-md); + transition: all 0.15s ease; +} + +.command-card:hover { + background: var(--accent-soft); + transform: translateY(-2px); +} + +.command-card .command-header { + margin-bottom: var(--space-sm); +} + +.command-card .command-header code { + font-size: var(--text-sm); + font-weight: 600; +} + +.command-card p { + font-size: var(--text-sm); + color: var(--text-secondary); + margin-bottom: var(--space-xs); +} + +.command-card small { + font-size: 0.75rem; + color: var(--text-muted); +} + +/* Event Type Badge in Table */ +.event-type { + display: inline-block; + padding: var(--space-xs) var(--space-sm); + border-radius: var(--radius-sm); + font-size: var(--text-sm); + font-weight: 500; + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.event-type.warn, +.event-type.warning, +.event-type.avertissement { + background: rgba(245, 158, 11, 0.15); + color: #f59e0b; +} + +.event-type.kick, +.event-type.expulsion { + background: rgba(249, 115, 22, 0.15); + color: #f97316; +} + +.event-type.ban, +.event-type.bannissement { + background: rgba(220, 38, 38, 0.15); + color: #dc2626; +} + +.event-type.unban { + background: rgba(5, 150, 105, 0.15); + color: #059669; +} + +/* Empty State */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-2xl); + background: var(--bg-secondary); + border: 1px dashed var(--border); + border-radius: var(--radius-md); + text-align: center; +} + +.empty-state .empty-icon { + font-size: 3rem; + margin-bottom: var(--space-md); +} + +.empty-state p { + font-size: var(--text-lg); + font-weight: 500; + color: var(--text-primary); + margin-bottom: var(--space-xs); +} + +.empty-state small { + color: var(--text-muted); +} + +/* === Moderation Navigation === */ +.moderation-nav { + display: flex; + gap: var(--space-sm); + margin-bottom: var(--space-lg); + padding: var(--space-sm); + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-md); +} + +.moderation-nav .nav-btn { + display: inline-flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-lg); + font-size: var(--text-sm); + font-weight: 500; + color: var(--text-secondary); + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius-sm); + text-decoration: none; + transition: all 0.15s ease; +} + +.moderation-nav .nav-btn:hover { + color: var(--accent); + background: var(--accent-soft); +} + +.moderation-nav .nav-btn.active { + color: var(--accent); + background: var(--accent-soft); + border-color: var(--accent); +} + +/* === Invitations Section === */ +.invites-actions { + display: flex; + align-items: center; + gap: var(--space-lg); + margin-bottom: var(--space-lg); + flex-wrap: wrap; +} + +.checkbox-label { + display: inline-flex; + align-items: center; + gap: var(--space-sm); + cursor: pointer; + font-size: var(--text-sm); + color: var(--text-secondary); +} + +.checkbox-label input[type="checkbox"] { + margin: 0; +} + +/* Invitation Badges */ +.badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--space-xs) var(--space-sm); + font-size: 0.75rem; + font-weight: 600; + border-radius: var(--radius-sm); + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.badge.active { + background: rgba(5, 150, 105, 0.15); + color: #059669; +} + +.badge.permanent { + background: rgba(59, 130, 246, 0.15); + color: #3b82f6; +} + +.badge.revoked { + background: rgba(220, 38, 38, 0.15); + color: #dc2626; +} + +.badge.expired { + background: rgba(107, 114, 128, 0.15); + color: #6b7280; +} + +.badge.used { + background: rgba(245, 158, 11, 0.15); + color: #f59e0b; +} + +/* Invites Table Styles */ +.invites-table tr.revoked td { + opacity: 0.6; +} + +.invites-table tr.expired td { + opacity: 0.8; +} + +/* Flash Messages */ +.flash-messages { + margin-bottom: var(--space-lg); +} + +.flash-message { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-md); + border-radius: var(--radius-sm); + margin-bottom: var(--space-sm); + font-size: var(--text-sm); + font-weight: 500; +} + +.flash-message.success { + background: rgba(5, 150, 105, 0.15); + border: 1px solid var(--success); + color: var(--success); +} + +.flash-message.error { + background: rgba(220, 38, 38, 0.15); + border: 1px solid var(--danger); + color: var(--danger); +} + +/* === Responsive === */ +@media (max-width: 640px) { + :root { + --text-2xl: 1.375rem; + --text-xl: 1.125rem; + } + + header, main, footer { + padding-left: var(--space-md); + padding-right: var(--space-md); + } + + table { + display: block; + overflow-x: auto; + } + + .stats-grid { + grid-template-columns: 1fr; + } + + .moderation-insights { + grid-template-columns: 1fr; + } + + .commands-grid { + grid-template-columns: 1fr; + } + + .moderation-nav { + flex-direction: column; + } + + .invites-actions { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/webapp/templates/configurations.html b/webapp/templates/configurations.html index 40cf0bf..cbc3d78 100644 --- a/webapp/templates/configurations.html +++ b/webapp/templates/configurations.html @@ -5,6 +5,17 @@

Configurez les tokens Discord, les notifications Humble Bundle et l'API Twitch.

Discord

+ +{% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} +{% endwith %} +
API Discord @@ -43,6 +54,87 @@
+
+ Auto-Role + + + + {% if roles|length > 1 %} +
+ {% for guild_data in roles %} + + {% endfor %} +
+ {% endif %} + + {% for guild_data in roles %} +
+ +
+ {% endfor %} + + + Note : Le bot doit avoir la permission "Gérer les rôles" et son rôle doit être positionné plus haut que le rôle à attribuer dans la hiérarchie des rôles. + + + + + +
+
Messages de départ