Ajout de la gestion des publications Patreon dans la base de données. Création de la table patreon_post avec les colonnes nécessaires et mise à jour des migrations pour intégrer ces nouvelles colonnes. Intégration de la vérification des publications Patreon dans le bot Discord et ajout de liens vers Patreon dans l'interface utilisateur.

This commit is contained in:
2026-03-07 11:01:58 +01:00
parent a2ade9deeb
commit 540d23a3cf
9 changed files with 654 additions and 11 deletions
+18
View File
@@ -166,6 +166,23 @@ def _doAddColumnMigrations(cursor: Cursor):
except Exception as e:
logging.warning(f"Seed twitch_event_notification {ev}: {e}")
# Colonnes supplémentaires pour patreon_post (historique + statut notification)
if _tableExists('patreon_post', cursor):
patreon_columns = [
('title', 'VARCHAR(512)'),
('link', 'VARCHAR(1024)'),
('description', 'TEXT'),
('published_at', 'VARCHAR(64)'),
('notified', 'BOOLEAN NOT NULL DEFAULT 0'),
]
for col_name, col_type in patreon_columns:
if not _tableHaveColumn('patreon_post', col_name, cursor):
try:
cursor.execute(f'ALTER TABLE patreon_post ADD COLUMN {col_name} {col_type}')
logging.info(f"Colonne {col_name} ajoutée à patreon_post")
except Exception as e:
logging.warning(f"Colonne patreon_post.{col_name}: {e}")
# Table webapp_user (auth)
if not _tableExists('webapp_user', cursor):
try:
@@ -251,6 +268,7 @@ def _doSeedAuth(cursor: Cursor):
("youtube", 1, 2),
("protondb", 1, 2),
("freeloot", 1, 2),
("patreon", 1, 2),
("moderation", 1, 2),
("users", 5, 5),
("settings", 5, 5),
+10
View File
@@ -228,6 +228,16 @@ class TwitchBannedWord(db.Model):
created_at = db.Column(db.DateTime, default=datetime.utcnow)
class PatreonPost(db.Model):
__tablename__ = 'patreon_post'
guid = db.Column(db.String(512), primary_key=True)
title = db.Column(db.String(512))
link = db.Column(db.String(1024))
description = db.Column(db.Text)
published_at = db.Column(db.String(64))
notified = db.Column(db.Boolean, default=False)
class ModShoutboxMessage(db.Model):
__tablename__ = 'mod_shoutbox_message'
id = db.Column(db.Integer, primary_key=True)
+9
View File
@@ -199,6 +199,15 @@ CREATE TABLE IF NOT EXISTS `twitch_event_notification` (
last_clip_id VARCHAR(128) NULL
);
CREATE TABLE IF NOT EXISTS `patreon_post` (
guid VARCHAR(512) PRIMARY KEY,
title VARCHAR(512),
link VARCHAR(1024),
description TEXT,
published_at VARCHAR(64),
notified BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS `mod_shoutbox_message` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
`author` VARCHAR(64) NOT NULL,
+7
View File
@@ -26,6 +26,7 @@ from discordbot.moderation import (
transfer_message_context_menu
)
from discordbot.welcome import sendWelcomeMessage, sendLeaveMessage, updateInviteCache
from discordbot.patreon import checkPatreonPosts
from discordbot.youtube import checkYouTubeVideos
from discordbot.auto_rooms import on_voice_state_update_auto_rooms, on_raw_reaction_add_auto_rooms, on_message_auto_rooms, cleanup_orphaned_auto_rooms
from protondb import searhProtonDb
@@ -76,6 +77,7 @@ class DiscordBot(discord.Client):
self.loop.create_task(self.updateHumbleBundle())
self.loop.create_task(self.updateYouTube())
self.loop.create_task(self.updateFreeLoot())
self.loop.create_task(self.updatePatreon())
async def on_disconnect(self):
webapp.config["BOT_STATUS"]["discord_connected"] = False
@@ -105,6 +107,11 @@ class DiscordBot(discord.Client):
await checkFreeLootAndNotify(self)
await asyncio.sleep(30*60)
async def updatePatreon(self):
while not self.is_closed():
await checkPatreonPosts(self)
await asyncio.sleep(10*60)
def getAllTextChannel(self) -> list[TextChannel]:
channels = []
for channel in self.get_all_channels():
+282
View File
@@ -0,0 +1,282 @@
import asyncio
import logging
import re
import xml.etree.ElementTree as ET
import requests
from discord import Client
from database import db
from database.helpers import ConfigurationHelper
from database.models import PatreonPost
from webapp import webapp
logger = logging.getLogger('patreon-notification')
logger.setLevel(logging.INFO)
_patreon_first_check = True
def _get_mention_content() -> str:
raw = ConfigurationHelper().getValue("patreon_mention")
if not raw or not str(raw).strip():
return ""
parts = []
for s in str(raw).strip().split(","):
s = s.strip()
if s == "everyone":
parts.append("@everyone")
elif s == "here":
parts.append("@here")
elif s.isdigit():
parts.append(f"<@&{s}>")
return " ".join(parts) if parts else ""
def _strip_html(html: str, max_len: int = 300) -> str:
"""Extrait le texte brut depuis du HTML et tronque."""
if not html:
return ""
text = re.sub(r'<br\s*/?>', '\n', html)
text = re.sub(r'<[^>]+>', '', text)
text = re.sub(r'&nbsp;', ' ', text)
text = re.sub(r'&amp;', '&', text)
text = re.sub(r'&lt;', '<', text)
text = re.sub(r'&gt;', '>', text)
text = re.sub(r'&#\d+;', '', text)
text = re.sub(r'\n{3,}', '\n\n', text).strip()
if len(text) > max_len:
text = text[:max_len].rsplit(' ', 1)[0] + '...'
return text
def _extract_image(html: str) -> str | None:
"""Extrait la première URL d'image depuis le contenu HTML."""
if not html:
return None
match = re.search(r'<img[^>]+src=["\']([^"\']+)["\']', html)
if match:
url = match.group(1)
if url.startswith("http"):
return url
return None
def _parse_item(item, creator_name: str) -> dict | None:
"""Parse un <item> RSS et retourne un dict avec les métadonnées."""
guid_elem = item.find('guid')
if guid_elem is None or not guid_elem.text:
return None
title_elem = item.find('title')
link_elem = item.find('link')
desc_elem = item.find('description')
pub_elem = item.find('pubDate')
return {
'guid': guid_elem.text.strip(),
'title': title_elem.text if title_elem is not None else 'Nouveau post',
'link': link_elem.text if link_elem is not None else '',
'description': desc_elem.text if desc_elem is not None else '',
'published_at': pub_elem.text if pub_elem is not None else '',
'creator': creator_name,
}
def _fetch_rss() -> tuple[list[dict], str] | None:
"""Fetch le RSS Patreon et retourne (posts, creator_name) ou None."""
helper = ConfigurationHelper()
creator = helper.getValue("patreon_creator")
if not creator or not str(creator).strip():
return None
rss_url = f"https://www.patreon.com/rss/{str(creator).strip()}"
try:
response = requests.get(rss_url, timeout=15)
except Exception as e:
logger.error(f"Patreon: erreur réseau lors de la récupération du RSS: {e}")
return None
if response.status_code != 200:
logger.error(f"Patreon: HTTP {response.status_code} pour {rss_url}")
return None
try:
root = ET.fromstring(response.content)
except ET.ParseError as e:
logger.error(f"Patreon: erreur de parsing XML: {e}")
return None
creator_name = creator
channel_elem = root.find('.//channel/title')
if channel_elem is not None and channel_elem.text:
creator_name = channel_elem.text
items = root.findall('.//item')
posts = []
for item in items:
parsed = _parse_item(item, creator_name)
if parsed:
posts.append(parsed)
return (posts, creator_name)
def _build_embed(post: dict):
import discord
title = post.get('title') or 'Nouveau post Patreon'
link = post.get('link') or ''
description = _strip_html(post.get('description') or '', max_len=350)
creator = post.get('creator') or 'Patreon'
image_url = _extract_image(post.get('description') or '')
helper = ConfigurationHelper()
try:
color = int(helper.getValue('patreon_embed_color') or 'F96854', 16)
except (ValueError, TypeError):
color = 0xF96854
embed = discord.Embed(
title=title,
url=link if link.startswith("http") else None,
color=color,
)
if description:
embed.description = description
embed.set_author(
name=creator,
icon_url="https://c5.patreon.com/external/favicon/favicon-32x32.png",
)
if image_url:
embed.set_image(url=image_url)
embed.set_footer(text="MamieHenriette \u2022 Patreon")
return embed
async def checkPatreonPosts(bot: Client):
global _patreon_first_check
with webapp.app_context():
helper = ConfigurationHelper()
if not helper.getValue("patreon_enable"):
return
channel_id = helper.getIntValue("patreon_channel_id")
if not channel_id:
return
channel = bot.get_channel(channel_id)
if not channel:
logger.warning("Patreon: canal Discord introuvable")
return
result = await asyncio.to_thread(_fetch_rss)
if not result:
return
posts, creator_name = result
if not posts:
logger.info("Patreon: aucun post trouvé dans le flux RSS")
return
if _patreon_first_check:
logger.info("Patreon: première vérification, synchronisation sans notification")
for post_data in posts:
guid = post_data['guid']
if not PatreonPost.query.get(guid):
try:
db.session.add(PatreonPost(
guid=guid,
title=post_data['title'],
link=post_data['link'],
description=post_data['description'],
published_at=post_data['published_at'],
notified=False,
))
db.session.commit()
except Exception as e:
logger.error(f"Patreon: erreur de synchronisation pour {guid}: {e}")
db.session.rollback()
_patreon_first_check = False
return
for post_data in posts:
guid = post_data['guid']
if PatreonPost.query.get(guid):
continue
try:
embed = _build_embed(post_data)
content = _get_mention_content()
await channel.send(content=content or None, embed=embed)
db.session.add(PatreonPost(
guid=guid,
title=post_data['title'],
link=post_data['link'],
description=post_data['description'],
published_at=post_data['published_at'],
notified=True,
))
db.session.commit()
logger.info(f"Patreon: notification envoyée pour '{post_data['title']}'")
except Exception as e:
logger.error(f"Patreon: envoi Discord échoué pour {guid}: {e}")
db.session.rollback()
async def _send_post_to_discord_async(bot: Client, guid: str) -> tuple[bool, str]:
"""Envoie un post Patreon sur Discord (appel manuel). Retourne (succès, message)."""
helper = ConfigurationHelper()
channel_id = helper.getIntValue("patreon_channel_id")
if not channel_id:
return (False, "Aucun canal Discord configuré pour Patreon.")
channel = bot.get_channel(channel_id)
if not channel:
return (False, "Canal Discord introuvable.")
post_db = PatreonPost.query.get(guid)
if not post_db:
return (False, "Post introuvable en base de données.")
creator = helper.getValue("patreon_creator") or "Patreon"
# Tenter de récupérer le nom du créateur depuis le RSS
result = _fetch_rss()
creator_name = result[1] if result else creator
post_data = {
'title': post_db.title or 'Nouveau post',
'link': post_db.link or '',
'description': post_db.description or '',
'creator': creator_name,
}
try:
embed = _build_embed(post_data)
content = _get_mention_content()
await channel.send(content=content or None, embed=embed)
post_db.notified = True
db.session.commit()
return (True, "Notification envoyée sur Discord.")
except Exception as e:
logger.error(f"Patreon: envoi manuel échoué pour {guid}: {e}")
db.session.rollback()
return (False, str(e))
def send_post_to_discord_sync(bot: Client, guid: str) -> tuple[bool, str]:
"""Appel synchrone pour envoyer un post sur Discord (depuis la webapp)."""
try:
future = asyncio.run_coroutine_threadsafe(
_send_post_to_discord_async(bot, guid),
bot.loop,
)
return future.result(timeout=15)
except Exception as e:
logger.error(f"Patreon: send_post_to_discord_sync: {e}")
return (False, str(e))
+1 -1
View File
@@ -36,7 +36,7 @@ def load_user(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 webapp import auth, commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation, youtube, announcements, twitch_moderation, link_filter, twitch_events, users, settings, freeloot, patreon
from flask import request, redirect, url_for
from flask_login import current_user
+103
View File
@@ -0,0 +1,103 @@
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 database.models import PatreonPost
from discordbot import bot
from discordbot.patreon import send_post_to_discord_sync
def _parse_mention_config(raw: str | None) -> tuple[bool, bool, list[str]]:
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)
def _format_pub_date(raw: str | None) -> str:
if not raw or not str(raw).strip():
return ""
try:
from email.utils import parsedate_to_datetime
dt = parsedate_to_datetime(raw)
return dt.strftime("%d/%m/%Y %H:%M")
except Exception:
return raw[:16] if len(raw or "") >= 16 else (raw or "")
@webapp.route("/patreon")
@require_page("patreon")
def openPatreon():
helper = ConfigurationHelper()
channels = bot.getAllTextChannel()
roles = bot.getAllRoles()
raw_mention = helper.getValue("patreon_mention")
mention_everyone, mention_here, mention_role_ids = _parse_mention_config(raw_mention)
posts = PatreonPost.query.order_by(PatreonPost.published_at.desc()).all()
for p in posts:
p.published_formatted = _format_pub_date(p.published_at)
return render_template(
"patreon.html",
configuration=helper,
channels=channels,
roles=roles,
mention_everyone=mention_everyone,
mention_here=mention_here,
mention_role_ids=mention_role_ids,
posts=posts,
)
@webapp.route("/patreon/update", methods=["POST"])
@require_page("patreon")
def updatePatreon():
if not can_write_page("patreon"):
return render_template("403.html"), 403
helper = ConfigurationHelper()
enable = request.form.get("patreon_enable") in ("on", "1", "true", "yes")
creator = (request.form.get("patreon_creator") or "").strip()
channel_id = request.form.get("patreon_channel_id")
mention_parts = []
if request.form.get("patreon_mention_everyone"):
mention_parts.append("everyone")
if request.form.get("patreon_mention_here"):
mention_parts.append("here")
mention_parts.extend(request.form.getlist("patreon_mention_roles"))
helper.createOrUpdate("patreon_enable", "true" if enable else "false")
helper.createOrUpdate("patreon_creator", creator)
if channel_id:
try:
helper.createOrUpdate("patreon_channel_id", str(int(channel_id)))
except ValueError:
pass
helper.createOrUpdate("patreon_mention", ",".join(mention_parts))
db.session.commit()
return redirect(url_for("openPatreon") + "?msg=Configuration enregistrée.&type=success")
@webapp.route("/patreon/send", methods=["POST"])
@require_page("patreon")
def sendPatreonToDiscord():
if not can_write_page("patreon"):
return render_template("403.html"), 403
guid = (request.form.get("guid") or "").strip()
if not guid:
return redirect(url_for("openPatreon") + "?" + urlencode({"msg": "Post manquant.", "type": "error"}))
ok, message = send_post_to_discord_sync(bot, guid)
msg_type = "success" if ok else "error"
return redirect(url_for("openPatreon") + "?" + urlencode({"msg": message, "type": msg_type}))
+206
View File
@@ -0,0 +1,206 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Patreon — Notifications de posts</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-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
<p class="text-gray-700 dark:text-gray-300">
Notifications des nouveaux posts Patreon via le flux RSS public. Renseignez le nom du créateur Patreon
et choisissez le canal Discord de destination. Le bot vérifie le flux environ toutes les 10 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">
<svg class="w-8 h-8 text-orange-500" fill="currentColor" viewBox="0 0 24 24"><path d="M14.82 2.41c3.96 0 7.18 3.24 7.18 7.21 0 3.96-3.22 7.18-7.18 7.18-2.56 0-4.81-1.34-6.09-3.36L2 20.6h3.12l2.6-2.63 1.38 2.63h3.36l-2.75-5.23c.27-.39.51-.8.71-1.24l4.82 6.47h3.76l-6.47-8.67c.19-.76.29-1.55.29-2.37 0-1.32-.28-2.58-.78-3.71h3.63L14.82 2.41zM14.82 4.92c2.57 0 4.67 2.09 4.67 4.7 0 2.57-2.1 4.67-4.67 4.67-2.58 0-4.68-2.1-4.68-4.67 0-2.61 2.1-4.7 4.68-4.7z"/></svg>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Configuration Patreon</h2>
</div>
<form action="{{ url_for('updatePatreon') }}" 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="patreon_enable" {% if configuration.getValue('patreon_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 Patreon</span>
</label>
</div>
<div>
<label for="patreon_creator" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Nom du créateur Patreon</label>
<input type="text" name="patreon_creator" id="patreon_creator"
value="{{ configuration.getValue('patreon_creator') or '' }}"
placeholder="ex: nom_du_createur"
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">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Le nom tel qu'il apparaît dans l'URL : patreon.com/<strong>nom_du_createur</strong></p>
</div>
<div>
<label for="patreon_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="patreon_channel_id" id="patreon_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-orange-500 focus:border-transparent transition-all">
<option value="">— Choisir un canal —</option>
{% for channel in channels %}
<option value="{{ channel.id }}" {% if configuration.getIntValue('patreon_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="patreon_mention_everyone" {% if mention_everyone %}checked{% endif %}
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-orange-600 focus:ring-orange-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="patreon_mention_here" {% if mention_here %}checked{% endif %}
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-orange-600 focus:ring-orange-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="patreon-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="patreon-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="patreon-roles-{{ guild_data.guild_id }}" class="patreon-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="patreon_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-orange-600 focus:ring-orange-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>
<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
</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</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Exemple du message envoyé dans le canal lors d'un nouveau post Patreon.</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 #F96854;">
<div class="p-4">
<div class="flex items-center gap-2 mb-2">
<img src="https://c5.patreon.com/external/favicon/favicon-32x32.png" alt="" class="w-6 h-6 rounded-full">
<span class="text-[#dcddde] text-sm font-medium">Nom du créateur</span>
</div>
<a href="#" class="text-[#00a8fc] hover:underline font-semibold text-base block mb-2">Titre du post Patreon</a>
<p class="text-[#dcddde] text-sm leading-relaxed mb-3">Ceci est un aperçu de la description du post Patreon. Le contenu HTML est automatiquement nettoyé et tronqué pour l'embed Discord...</p>
<div class="rounded overflow-hidden bg-[#202225] aspect-video flex items-center justify-center my-2">
<svg class="w-12 h-12 text-gray-500" fill="currentColor" viewBox="0 0 24 24"><path d="M14.82 2.41c3.96 0 7.18 3.24 7.18 7.21 0 3.96-3.22 7.18-7.18 7.18-2.56 0-4.81-1.34-6.09-3.36L2 20.6h3.12l2.6-2.63 1.38 2.63h3.36l-2.75-5.23c.27-.39.51-.8.71-1.24l4.82 6.47h3.76l-6.47-8.67c.19-.76.29-1.55.29-2.37 0-1.32-.28-2.58-.78-3.71h3.63L14.82 2.41zM14.82 4.92c2.57 0 4.67 2.09 4.67 4.7 0 2.57-2.1 4.67-4.67 4.67-2.58 0-4.68-2.1-4.68-4.67 0-2.61 2.1-4.7 4.68-4.7z"/></svg>
</div>
<p class="text-xs text-[#72767d] pt-1">MamieHenriette &bull; Patreon</p>
</div>
</div>
</div>
</div>
{% if posts %}
<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">Historique des posts Patreon</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ posts|length }} post{{ 's' if posts|length > 1 else '' }} enregistré{{ 's' if posts|length > 1 else '' }}</p>
</div>
<div class="p-6">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{% for post in posts %}
<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="p-4 flex flex-col flex-1">
<div class="flex items-start justify-between gap-2 mb-2">
<h3 class="font-semibold text-gray-900 dark:text-white line-clamp-2 flex-1" title="{{ post.title or 'Sans titre' }}">
{% if post.link %}
<a href="{{ post.link }}" target="_blank" rel="noopener noreferrer" class="hover:text-orange-600 dark:hover:text-orange-400 transition-colors">{{ post.title or 'Sans titre' }}</a>
{% else %}
{{ post.title or 'Sans titre' }}
{% endif %}
</h3>
{% if post.notified %}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 flex-shrink-0">
<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="M5 13l4 4L19 7"></path></svg>
Notifié
</span>
{% else %}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 flex-shrink-0">
<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="M12 8v4m0 4h.01"></path></svg>
Non notifié
</span>
{% endif %}
</div>
{% if post.description %}
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-3">{{ post.description|striptags|truncate(150) }}</p>
{% endif %}
{% if post.published_formatted %}
<p class="text-xs text-gray-500 dark:text-gray-500 mb-3">{{ post.published_formatted }}</p>
{% endif %}
<div class="mt-auto">
<form action="{{ url_for('sendPatreonToDiscord') }}" method="POST" class="w-full">
<input type="hidden" name="guid" value="{{ post.guid }}">
<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>
{% if post.notified %}Re-notifier{% else %}Envoyer sur Discord{% endif %}
</button>
</form>
</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">Aucun post Patreon enregistré. Les posts apparaîtront ici après la première vérification du flux RSS.</p>
</div>
{% endif %}
<script>
document.querySelectorAll('.patreon-role-tab').forEach(btn => {
btn.addEventListener('click', function() {
var tabId = this.getAttribute('data-tab');
document.querySelectorAll('.patreon-role-panel').forEach(p => p.classList.add('hidden'));
document.querySelectorAll('.patreon-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('.patreon-role-tab[data-default]')?.click();
</script>
{% endblock %}
+18 -10
View File
@@ -106,11 +106,15 @@
<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="{{ 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>
<a href="{{ url_for('openPatreon') }}" 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="M14.82 2.41c3.96 0 7.18 3.24 7.18 7.21 0 3.96-3.22 7.18-7.18 7.18-2.56 0-4.81-1.34-6.09-3.36L2 20.6h3.12l2.6-2.63 1.38 2.63h3.36l-2.75-5.23c.27-.39.51-.8.71-1.24l4.82 6.47h3.76l-6.47-8.67c.19-.76.29-1.55.29-2.37 0-1.32-.28-2.58-.78-3.71h3.63L14.82 2.41zM14.82 4.92c2.57 0 4.67 2.09 4.67 4.7 0 2.57-2.1 4.67-4.67 4.67-2.58 0-4.68-2.1-4.68-4.67 0-2.61 2.1-4.7 4.68-4.7z"/></svg>
Patreon
</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
@@ -249,11 +253,15 @@
<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">
<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="{{ url_for('openPatreon') }}" 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="M14.82 2.41c3.96 0 7.18 3.24 7.18 7.21 0 3.96-3.22 7.18-7.18 7.18-2.56 0-4.81-1.34-6.09-3.36L2 20.6h3.12l2.6-2.63 1.38 2.63h3.36l-2.75-5.23c.27-.39.51-.8.71-1.24l4.82 6.47h3.76l-6.47-8.67c.19-.76.29-1.55.29-2.37 0-1.32-.28-2.58-.78-3.71h3.63L14.82 2.41zM14.82 4.92c2.57 0 4.67 2.09 4.67 4.7 0 2.57-2.1 4.67-4.67 4.67-2.58 0-4.68-2.1-4.68-4.67 0-2.61 2.1-4.7 4.68-4.7z"/></svg>
Patreon
</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>