kuro223 commited on
Commit
91073d4
·
1 Parent(s): 4722c16
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. app.py +106 -177
  2. attached_assets/Pasted--Cahier-des-Charges-Forum-Communautaire-Version-1-0-Date-26-Mai-2023-Auteur--1745353936750.txt +227 -0
  3. forms.py +91 -0
  4. instance/forum.db +0 -0
  5. instance/je.txt +0 -0
  6. main.py +8 -0
  7. models.py +188 -0
  8. pyproject.toml +21 -0
  9. routes/__init__.py +1 -0
  10. routes/admin.py +298 -0
  11. routes/auth.py +79 -0
  12. routes/cadmin.py +40 -0
  13. routes/forum.py +348 -0
  14. routes/user.py +121 -0
  15. static/css/styles.css +130 -0
  16. static/icons/icon-192x192.png +0 -0
  17. static/icons/icon-192x192.svg +10 -0
  18. static/icons/icon-512x512.png +0 -0
  19. static/icons/icon-512x512.svg +10 -0
  20. static/js/editor.js +165 -0
  21. static/js/forum.js +277 -0
  22. static/js/pwa-installer.js +51 -0
  23. static/js/service-worker.js +74 -0
  24. static/jsjz.txt +0 -0
  25. static/manifest.json +27 -0
  26. static/styles.css +0 -73
  27. static/uploads/avatars/20d41bee_generated-icon.png +0 -0
  28. templates/admin/create_admin.html +112 -0
  29. templates/admin/create_category.html +75 -0
  30. templates/admin/create_tag.html +47 -0
  31. templates/admin/dashboard.html +147 -0
  32. templates/admin/edit_category.html +75 -0
  33. templates/admin/edit_user.html +136 -0
  34. templates/admin/manage_categories.html +113 -0
  35. templates/admin/manage_reports.html +208 -0
  36. templates/admin/manage_tags.html +149 -0
  37. templates/admin/manage_users.html +184 -0
  38. templates/auth/login.html +68 -0
  39. templates/auth/register.html +89 -0
  40. templates/base.html +0 -65
  41. templates/errors/403.html +22 -0
  42. templates/errors/404.html +22 -0
  43. templates/errors/500.html +22 -0
  44. templates/forum/category_list.html +65 -0
  45. templates/forum/create_post.html +76 -0
  46. templates/forum/create_topic.html +94 -0
  47. templates/forum/topic_list.html +163 -0
  48. templates/forum/topic_view.html +334 -0
  49. templates/home.html +127 -0
  50. templates/hshz.txt +0 -0
app.py CHANGED
@@ -1,180 +1,109 @@
1
- from flask import Flask, request, render_template, redirect, url_for, flash, jsonify, abort
 
 
 
 
2
  from flask_sqlalchemy import SQLAlchemy
3
- from datetime import datetime, timedelta, timezone
4
- from apscheduler.schedulers.background import BackgroundScheduler
5
- import markdown
 
 
 
 
 
 
 
 
 
6
 
 
 
 
 
 
 
7
  app = Flask(__name__)
8
- app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///forum.db'
9
- app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
10
- app.config['SECRET_KEY'] = 'change_this_secret_key' # À modifier pour la production
11
- db = SQLAlchemy(app)
12
-
13
- # -------------------------------
14
- # Ajout d'un filtre escapejs pour Jinja2
15
- # -------------------------------
16
- def escapejs_filter(s):
17
- if s is None:
18
- return ""
19
- return (s.replace('\\', '\\\\')
20
- .replace("'", "\\'")
21
- .replace('"', '\\"')
22
- .replace('\n', '\\n')
23
- .replace('\r', '\\r'))
24
- app.jinja_env.filters['escapejs'] = escapejs_filter
25
-
26
- # -------------------------------
27
- # Modèles de données
28
- # -------------------------------
29
- class Thread(db.Model):
30
- id = db.Column(db.Integer, primary_key=True)
31
- title = db.Column(db.String(200), nullable=False)
32
- timestamp = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
33
- messages = db.relationship('Message', backref='thread', lazy=True, cascade="all, delete-orphan")
34
-
35
- class Message(db.Model):
36
- id = db.Column(db.Integer, primary_key=True)
37
- thread_id = db.Column(db.Integer, db.ForeignKey('thread.id'), nullable=False)
38
- content = db.Column(db.Text, nullable=False)
39
- timestamp = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
40
- vote_count = db.Column(db.Integer, default=0)
41
- reports = db.Column(db.Integer, default=0)
42
- removed = db.Column(db.Boolean, default=False)
43
-
44
- # -------------------------------
45
- # Routes de base et fonctionnalités
46
- # -------------------------------
47
-
48
- # Accueil : liste des fils de discussion
49
- @app.route('/')
50
- def index():
51
- threads = Thread.query.order_by(Thread.timestamp.desc()).all()
52
- return render_template('index.html', threads=threads)
53
-
54
- # Création d'un nouveau fil de discussion
55
- @app.route('/new_thread', methods=['GET', 'POST'])
56
- def new_thread():
57
- if request.method == 'POST':
58
- title = request.form.get('title', '').strip()
59
- content = request.form.get('content', '').strip()
60
- if not title or not content:
61
- flash("Le titre et le contenu initial sont obligatoires.", "error")
62
- return redirect(url_for('new_thread'))
63
- # Création du thread
64
- thread = Thread(title=title)
65
- db.session.add(thread)
66
- db.session.commit()
67
- # Message initial
68
- message = Message(thread_id=thread.id, content=content)
69
- db.session.add(message)
70
- db.session.commit()
71
- flash("Fil de discussion créé.", "success")
72
- return redirect(url_for('thread', thread_id=thread.id))
73
- return render_template('new_thread.html')
74
-
75
- # Visualisation d'un fil et réponse
76
- @app.route('/thread/<int:thread_id>', methods=['GET', 'POST'])
77
- def thread(thread_id):
78
- thread_obj = Thread.query.get_or_404(thread_id)
79
- if request.method == 'POST':
80
- content = request.form.get('content', '').strip()
81
- if not content:
82
- flash("Le contenu du message ne peut pas être vide.", "error")
83
- return redirect(url_for('thread', thread_id=thread_id))
84
- message = Message(thread_id=thread_id, content=content)
85
- db.session.add(message)
86
- db.session.commit()
87
- flash("Réponse postée.", "success")
88
- return redirect(url_for('thread', thread_id=thread_id))
89
- # Afficher uniquement les messages de moins de 72h et non supprimés
90
- expiration_threshold = datetime.now(timezone.utc) - timedelta(hours=72)
91
- messages = Message.query.filter(
92
- Message.thread_id == thread_id,
93
- Message.timestamp >= expiration_threshold,
94
- Message.removed == False
95
- ).order_by(Message.timestamp.asc()).all()
96
- return render_template('thread.html', thread=thread_obj, messages=messages)
97
-
98
- # Système de vote (upvote/downvote)
99
- @app.route('/vote/<int:message_id>/<action>', methods=['POST'])
100
- def vote(message_id, action):
101
- message = Message.query.get_or_404(message_id)
102
- if action == 'up':
103
- message.vote_count += 1
104
- elif action == 'down':
105
- message.vote_count -= 1
106
- else:
107
- abort(400)
108
- db.session.commit()
109
- flash("Vote enregistré.", "success")
110
- return redirect(request.referrer or url_for('index'))
111
-
112
- # Signalement d'un message
113
- @app.route('/report/<int:message_id>', methods=['POST'])
114
- def report(message_id):
115
- message = Message.query.get_or_404(message_id)
116
- message.reports += 1
117
- db.session.commit()
118
- flash("Message signalé.", "success")
119
- return redirect(request.referrer or url_for('index'))
120
-
121
- # Prévisualisation d'un message en Markdown
122
- @app.route('/preview', methods=['POST'])
123
- def preview():
124
- content = request.form.get('content', '')
125
- rendered = markdown.markdown(content)
126
- return jsonify({'preview': rendered})
127
-
128
- # Recherche dans les threads et messages
129
- @app.route('/search')
130
- def search():
131
- query = request.args.get('q', '').strip()
132
- if not query:
133
- flash("Veuillez entrer un terme de recherche.", "error")
134
- return redirect(url_for('index'))
135
- threads = Thread.query.filter(Thread.title.ilike(f'%{query}%')).all()
136
- messages = Message.query.filter(Message.content.ilike(f'%{query}%')).all()
137
- return render_template('search.html', query=query, threads=threads, messages=messages)
138
-
139
- # Page de modération : affichage des messages signalés
140
- @app.route('/moderate')
141
- def moderate():
142
- reported_messages = Message.query.filter(
143
- Message.reports >= 3, Message.removed == False
144
- ).order_by(Message.reports.desc()).all()
145
- return render_template('moderate.html', messages=reported_messages)
146
-
147
- # Action de modération : retirer un message
148
- @app.route('/remove/<int:message_id>', methods=['POST'])
149
- def remove_message(message_id):
150
- message = Message.query.get_or_404(message_id)
151
- message.removed = True
152
- db.session.commit()
153
- flash("Message retiré.", "success")
154
- return redirect(url_for('moderate'))
155
-
156
- # -------------------------------
157
- # Suppression automatique des messages de plus de 72 heures
158
- # -------------------------------
159
- def delete_old_messages():
160
- with app.app_context():
161
- expiration_threshold = datetime.now(timezone.utc) - timedelta(hours=72)
162
- old_messages = Message.query.filter(Message.timestamp < expiration_threshold).all()
163
- count = len(old_messages)
164
- for msg in old_messages:
165
- db.session.delete(msg)
166
- db.session.commit()
167
- if count:
168
- print(f"{count} messages supprimés définitivement.")
169
-
170
- scheduler = BackgroundScheduler(daemon=True)
171
- scheduler.add_job(func=delete_old_messages, trigger="interval", hours=1)
172
- scheduler.start()
173
-
174
- # -------------------------------
175
- # Lancement de l'application
176
- # -------------------------------
177
- if __name__ == '__main__':
178
- with app.app_context():
179
- db.create_all()
180
- app.run(debug=True)
 
1
+ import os
2
+ import logging
3
+ from datetime import datetime
4
+
5
+ from flask import Flask, render_template
6
  from flask_sqlalchemy import SQLAlchemy
7
+ from sqlalchemy.orm import DeclarativeBase
8
+ from werkzeug.middleware.proxy_fix import ProxyFix
9
+ from flask_login import LoginManager
10
+ from flask_wtf.csrf import CSRFProtect
11
+
12
+ # Set up logging
13
+ logging.basicConfig(level=logging.DEBUG)
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # Define base class for SQLAlchemy models
17
+ class Base(DeclarativeBase):
18
+ pass
19
 
20
+ # Initialize extensions
21
+ db = SQLAlchemy(model_class=Base)
22
+ csrf = CSRFProtect()
23
+ login_manager = LoginManager()
24
+
25
+ # Create Flask application
26
  app = Flask(__name__)
27
+
28
+ # Configure application
29
+ app.secret_key = os.environ.get("SESSION_SECRET", "dev-secret-key")
30
+ app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1)
31
+
32
+ # Configure database
33
+ app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get("DATABASE_URL", "sqlite:///forum.db")
34
+ app.config["SQLALCHEMY_ENGINE_OPTIONS"] = {
35
+ "pool_recycle": 300,
36
+ "pool_pre_ping": True,
37
+ }
38
+
39
+ # Initialize extensions with app
40
+ db.init_app(app)
41
+ csrf.init_app(app)
42
+ login_manager.init_app(app)
43
+
44
+ # Configure login manager
45
+ login_manager.login_view = "auth.login"
46
+ login_manager.login_message = "Please log in to access this page."
47
+ login_manager.login_message_category = "info"
48
+
49
+ # Import models (must be after db initialization but before create_all)
50
+ from models import User
51
+
52
+ # Register blueprints
53
+ with app.app_context():
54
+ from routes.auth import auth_bp
55
+ from routes.forum import forum_bp
56
+ from routes.user import user_bp
57
+ from routes.admin import admin_bp
58
+ from routes.cadmin import cadmin_bp
59
+
60
+ app.register_blueprint(auth_bp)
61
+ app.register_blueprint(forum_bp)
62
+ app.register_blueprint(user_bp)
63
+ app.register_blueprint(admin_bp)
64
+ app.register_blueprint(cadmin_bp)
65
+
66
+ # Create database tables
67
+ db.create_all()
68
+
69
+ # Import utility functions
70
+ from utils import format_datetime
71
+
72
+ # Add template filters
73
+ app.jinja_env.filters['format_datetime'] = format_datetime
74
+
75
+ # Context processor for template variables
76
+ @app.context_processor
77
+ def utility_processor():
78
+ return {
79
+ 'now': datetime.utcnow
80
+ }
81
+
82
+ # User loader for Flask-Login
83
+ @login_manager.user_loader
84
+ def load_user(user_id):
85
+ return User.query.get(int(user_id))
86
+
87
+ # Error handlers
88
+ @app.errorhandler(404)
89
+ def page_not_found(e):
90
+ return render_template('errors/404.html'), 404
91
+
92
+ @app.errorhandler(403)
93
+ def forbidden(e):
94
+ return render_template('errors/403.html'), 403
95
+
96
+ @app.errorhandler(500)
97
+ def internal_server_error(e):
98
+ return render_template('errors/500.html'), 500
99
+
100
+ # Ensure required directories exist
101
+ upload_dir = os.path.join(app.static_folder, 'uploads')
102
+ avatar_dir = os.path.join(upload_dir, 'avatars')
103
+
104
+ if not os.path.exists(upload_dir):
105
+ os.makedirs(upload_dir)
106
+ if not os.path.exists(avatar_dir):
107
+ os.makedirs(avatar_dir)
108
+
109
+ logger.debug("Application initialized successfully")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
attached_assets/Pasted--Cahier-des-Charges-Forum-Communautaire-Version-1-0-Date-26-Mai-2023-Auteur--1745353936750.txt ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+
3
+ **Cahier des Charges - Forum Communautaire**
4
+
5
+ **Version :** 1.0
6
+ **Date :** 26 Mai 2023
7
+ **Auteur :** [Votre Nom/Organisation]
8
+
9
+ **Table des Matières**
10
+
11
+ 1. Introduction et Objectifs
12
+ 2. Portée du Projet
13
+ 3. Public Cible et Rôles Utilisateurs
14
+ 4. Exigences Fonctionnelles
15
+ 4.1. Gestion des Utilisateurs
16
+ 4.2. Structure et Organisation du Contenu
17
+ 4.3. Interaction et Contribution
18
+ 4.4. Modération
19
+ 4.5. Administration
20
+ 5. Exigences Non Fonctionnelles
21
+ 5.1. Performance
22
+ 5.2. Sécurité
23
+ 5.3. Utilisabilité (UX/UI)
24
+ 5.4. Maintenabilité
25
+ 5.5. Scalabilité
26
+ 6. Stack Technologique Imposée
27
+ 7. Architecture Applicative (Suggestion)
28
+ 8. Modèle de Données Conceptuel
29
+ 9. Interface Utilisateur (UI) et Expérience Utilisateur (UX)
30
+ 10. Déploiement
31
+ 11. Maintenance et Évolution
32
+ 12. Livrables Attendus
33
+
34
+ ---
35
+
36
+ **1. Introduction et Objectifs**
37
+
38
+ Le présent document définit les spécifications pour la création d'une plateforme de forum communautaire moderne, réactive et robuste. L'objectif principal est de fournir un espace de discussion structuré où les utilisateurs peuvent s'inscrire, créer des sujets, y répondre, interagir avec le contenu et où les administrateurs/modérateurs disposent des outils nécessaires pour gérer la communauté et le contenu.
39
+
40
+ **2. Portée du Projet**
41
+
42
+ * **Inclus :** Toutes les fonctionnalités décrites dans la section "Exigences Fonctionnelles". Le développement du backend avec Flask, du frontend avec HTML/Tailwind CSS/JavaScript minimal, et la mise en place de la base de données associée.
43
+ * **Exclus (pour cette version) :** Messagerie privée temps réel (chat), système de gamification avancé (badges, points complexes), intégration poussée avec des services tiers (sauf authentification si spécifiée ultérieurement), applications mobiles natives.
44
+
45
+ **3. Public Cible et Rôles Utilisateurs**
46
+
47
+ * **Utilisateur Anonyme (Visiteur) :** Peut consulter les catégories et les sujets publics. Ne peut pas poster, réagir ou accéder aux profils complets.
48
+ * **Utilisateur Enregistré (Membre) :** Peut créer des sujets, poster des réponses, éditer (potentiellement) ses propres messages, utiliser les citations et réactions, signaler du contenu, consulter et modifier son propre profil.
49
+ * **Modérateur :** Possède les droits d'un Membre, plus : verrouiller/déverrouiller des sujets, supprimer/éditer n'importe quel message/sujet, gérer les signalements, voir les informations de base des utilisateurs (IP, email pour investigation), attribuer/retirer des tags. Accès limité au panneau de modération.
50
+ * **Administrateur :** Possède les droits d'un Modérateur, plus : gérer les catégories, gérer les utilisateurs (bannir, modifier rôles), accéder à toutes les fonctionnalités du panneau d'administration, configurer les paramètres généraux du forum.
51
+
52
+ **4. Exigences Fonctionnelles**
53
+
54
+ **4.1. Gestion des Utilisateurs**
55
+
56
+ * **FEAT-USER-01 : Inscription**
57
+ * Formulaire : Nom d'utilisateur (unique), Email (unique, valide), Mot de passe (avec confirmation).
58
+ * Validation côté serveur et client (basique).
59
+ * Protection anti-spam (ex: Captcha simple ou Honeypot).
60
+ * Hashage sécurisé des mots de passe (ex: Argon2, bcrypt).
61
+ * (Optionnel) Email de confirmation pour activation du compte.
62
+ * **FEAT-USER-02 : Connexion / Déconnexion**
63
+ * Formulaire : Nom d'utilisateur ou Email, Mot de passe.
64
+ * Gestion de session sécurisée (cookies HTTPOnly, Secure).
65
+ * Fonction "Se souvenir de moi" (optionnel, avec token persistant sécurisé).
66
+ * Lien de déconnexion clair.
67
+ * **FEAT-USER-03 : Profil Utilisateur**
68
+ * Page de profil publique affichant : Nom d'utilisateur, Avatar, Date d'inscription, (Optionnel) Signature, (Optionnel) Informations personnelles définies (localisation, site web, bio), Liste des sujets créés / réponses récentes.
69
+ * Possibilité pour l'utilisateur connecté de modifier son profil :
70
+ * Changer l'avatar (upload d'image avec redimensionnement/validation).
71
+ * Modifier les informations personnelles optionnelles.
72
+ * Modifier le mot de passe (nécessite l'ancien mot de passe).
73
+ * Modifier l'adresse email (peut nécessiter re-validation).
74
+ * **FEAT-USER-04 : Gestion Avancée (Admin/Modo)**
75
+ * (Admin) Interface pour lister, rechercher, voir les détails des utilisateurs.
76
+ * (Admin) Interface pour modifier les informations de base d'un utilisateur (ex: email, rôle).
77
+ * (Admin) Fonctionnalité pour bannir/débannir un utilisateur (empêche la connexion/participation).
78
+ * (Admin) Attribution des rôles (Membre, Modérateur, Administrateur).
79
+
80
+ **4.2. Structure et Organisation du Contenu**
81
+
82
+ * **FEAT-CONTENT-01 : Catégories / Sections**
83
+ * (Admin) Interface pour créer, éditer, supprimer, réordonner les catégories.
84
+ * Chaque catégorie a un nom et une description.
85
+ * Affichage de la liste des catégories sur la page d'accueil ou une page dédiée, avec statistiques (nombre de sujets, messages).
86
+ * **FEAT-CONTENT-02 : Sujets / Discussions (Threads)**
87
+ * Affichage de la liste des sujets dans une catégorie : Titre, Auteur, Nombre de réponses, Nombre de vues (optionnel), Date du dernier message (avec auteur), Tags associés, Indicateur Épinglé/Verrouillé.
88
+ * Tri possible de la liste (par défaut : dernière activité).
89
+ * **Pagination** robuste pour les listes de sujets (numéros de page, première/dernière page).
90
+ * **FEAT-CONTENT-03 : Messages / Réponses (Posts)**
91
+ * Affichage séquentiel des messages dans un sujet.
92
+ * Chaque message affiche : Avatar de l'auteur, Nom d'utilisateur (lien vers profil), Date/Heure de publication, Contenu du message, (Optionnel) Signature de l'auteur.
93
+ * **Pagination** robuste pour les sujets longs (numéros de page, première/dernière page).
94
+ * **FEAT-CONTENT-04 : Tags / Étiquettes**
95
+ * (Membre/Modo) Possibilité d'ajouter des tags lors de la création d'un sujet (depuis une liste prédéfinie par l'admin ou création libre avec modération).
96
+ * (Modo/Admin) Possibilité d'ajouter/modifier/supprimer les tags d'un sujet existant.
97
+ * Affichage des tags sur la liste des sujets et sur la page du sujet.
98
+ * (Optionnel) Page dédiée listant tous les tags et les sujets associés.
99
+
100
+ **4.3. Interaction et Contribution**
101
+
102
+ * **FEAT-INTERACT-01 : Création de Sujet**
103
+ * Accessible aux Membres depuis une catégorie.
104
+ * Formulaire : Choix de la catégorie (si applicable), Titre du sujet, Contenu du premier message (éditeur simple), Ajout de Tags.
105
+ * Validation et prévisualisation (optionnel).
106
+ * **FEAT-INTERACT-02 : Réponse à un Sujet**
107
+ * Accessible aux Membres sur un sujet non verrouillé.
108
+ * Formulaire (souvent en bas de page) : Éditeur simple pour le contenu de la réponse.
109
+ * Validation et prévisualisation (optionnel).
110
+ * **FEAT-INTERACT-03 : Citation (Quote)**
111
+ * Bouton "Citer" sur chaque message.
112
+ * Au clic, pré-remplit le formulaire de réponse avec le contenu du message cité, formaté distinctement (ex: `<blockquote><p>Contenu cité</p><footer>Auteur original</footer></blockquote>`).
113
+ * Permet la citation partielle (sélection de texte avant de cliquer - plus avancé).
114
+ * **FEAT-INTERACT-04 : Réactions / Likes / Votes**
115
+ * Bouton(s) de réaction (ex: "J'aime 👍") sur chaque message (sauf les siens propres ? à définir).
116
+ * Stockage de quelle réaction a été donnée par quel utilisateur pour quel message.
117
+ * Affichage du nombre total de chaque type de réaction sur le message.
118
+ * Possibilité d'annuler sa réaction.
119
+
120
+ **4.4. Modération**
121
+
122
+ * **FEAT-MOD-01 : Signalement (Report)**
123
+ * Bouton/Lien "Signaler" sur chaque message/sujet (sauf pour les modérateurs/admins).
124
+ * Au clic, ouvre un formulaire simple demandant la raison du signalement.
125
+ * Les signalements sont enregistrés et visibles dans le panneau de modération.
126
+ * **FEAT-MOD-02 : Verrouillage de Sujets (Lock)**
127
+ * (Modo/Admin) Bouton/Option sur un sujet pour le verrouiller/déverrouiller.
128
+ * Un sujet verrouillé ne peut plus recevoir de nouvelles réponses (le formulaire de réponse est caché ou désactivé).
129
+ * Un indicateur visuel (ex: icône cadenas) montre qu'un sujet est verrouillé.
130
+ * **FEAT-MOD-03 : Suppression de Contenu**
131
+ * (Modo/Admin) Possibilité de supprimer des messages individuels ou des sujets entiers.
132
+ * (Optionnel) Suppression "douce" (soft delete) : le contenu est masqué mais conservé en base pour historique/restauration.
133
+ * **FEAT-MOD-04 : Édition de Contenu (Modo/Admin)**
134
+ * (Modo/Admin) Possibilité d'éditer le contenu de n'importe quel message ou le titre d'un sujet.
135
+ * Un indicateur "Modifié par [Modo/Admin] le [Date]" devrait être visible.
136
+
137
+ **4.5. Administration**
138
+
139
+ * **FEAT-ADMIN-01 : Panneau d'Administration / Modération**
140
+ * Section distincte du site, accessible uniquement aux rôles Modérateur et Administrateur (avec permissions différentiées).
141
+ * Tableau de bord centralisant les actions de gestion.
142
+ * **FEAT-ADMIN-02 : Gestion des Signalements**
143
+ * Vue listant les contenus signalés (sujet/message, auteur, rapporteur, raison, date).
144
+ * Actions possibles : Marquer comme traité, Voir le contenu, Supprimer le contenu, Bannir l'auteur, Ignorer le signalement.
145
+ * **FEAT-ADMIN-03 : Gestion des Catégories (Admin)**
146
+ * CRUD (Create, Read, Update, Delete) pour les catégories.
147
+ * Possibilité de réordonner les catégories.
148
+ * **FEAT-ADMIN-04 : Gestion des Tags (Admin)**
149
+ * (Optionnel) Interface pour voir, fusionner, supprimer des tags (si la création libre est autorisée).
150
+ * **FEAT-ADMIN-05 : Configuration Générale (Admin)**
151
+ * (Optionnel) Paramètres de base du forum : nom du forum, description, nombre d'éléments par page pour la pagination, etc.
152
+
153
+ **5. Exigences Non Fonctionnelles**
154
+
155
+ * **5.1. Performance**
156
+ * Temps de chargement des pages : Objectif < 2 secondes pour les pages courantes (liste de sujets, vue d'un sujet) en conditions normales.
157
+ * Requêtes base de données optimisées (utilisation d'index, éviter N+1 queries).
158
+ * Utilisation efficace des ressources serveur.
159
+ * **5.2. Sécurité**
160
+ * Protection contre les injections SQL (utilisation d'ORM comme SQLAlchemy fortement recommandée).
161
+ * Protection contre le Cross-Site Scripting (XSS) (échappement systématique des données utilisateur affichées).
162
+ * Protection contre le Cross-Site Request Forgery (CSRF) (utilisation de tokens CSRF, ex: via Flask-WTF).
163
+ * Validation des données en entrée (côté serveur indispensable).
164
+ * Contrôle d'accès basé sur les rôles rigoureux pour toutes les actions sensibles.
165
+ * Hashage sécurisé des mots de passe.
166
+ * Utilisation de HTTPS obligatoire en production.
167
+ * Protection contre le spam (Captcha, limitation de taux, etc.).
168
+ * **5.3. Utilisabilité (UX/UI)**
169
+ * Interface claire, intuitive et cohérente.
170
+ * Navigation facile entre les catégories, sujets et profils.
171
+ * **Design Réactif (Responsive) :** Affichage et fonctionnalité optimisés pour ordinateurs de bureau, tablettes et smartphones. Approche Mobile-First encouragée avec Tailwind CSS.
172
+ * Respect des standards d'accessibilité web (WCAG) de base (structure sémantique HTML, contrastes suffisants, navigation clavier basique).
173
+ * **5.4. Maintenabilité**
174
+ * Code source clair, commenté et respectant les conventions (PEP 8 pour Python).
175
+ * Architecture modulaire (utilisation de Flask Blueprints fortement recommandée).
176
+ * Utilisation d'un système de gestion de versions (Git).
177
+ * Tests unitaires et d'intégration (au moins pour les parties critiques).
178
+ * Documentation technique (configuration, déploiement).
179
+ * **5.5. Scalabilité**
180
+ * L'architecture doit permettre une montée en charge future (augmentation du trafic et du volume de données).
181
+ * Conception de la base de données pensée pour l'évolution.
182
+ * Application "Stateless" autant que possible pour faciliter la mise à l'échelle horizontale.
183
+
184
+ **6. Stack Technologique Imposée**
185
+
186
+ * **Backend :** Python (version 3.8+), Framework Flask.
187
+ * **Frontend :** HTML5, CSS3 (via Tailwind CSS v3+), JavaScript (Vanilla JS ou micro-framework type Alpine.js si besoin pour interactivité légère, pas de gros framework type React/Vue/Angular imposé).
188
+ * **Base de Données :** Au choix du développeur, mais doit être une base de données relationnelle robuste (ex: PostgreSQL (préféré pour la scalabilité), MySQL, SQLite pour développement/tests). ORM SQLAlchemy recommandé.
189
+ * **Serveur WSGI (Production) :** Gunicorn ou uWSGI.
190
+ * **Serveur Web (Production) :** Nginx ou Apache (en tant que reverse proxy).
191
+ * **Gestion des dépendances Python :** `pip` avec `requirements.txt` ou `Pipenv` / `Poetry`.
192
+ * **Gestion des assets frontend :** Utilisation de l'outillage Tailwind CLI ou intégration via un bundler (ex: Webpack/Vite si jugé nécessaire pour JS plus complexe).
193
+
194
+ **7. Architecture Applicative (Suggestion)**
195
+
196
+ * Utilisation du pattern MVT (Model-View-Template) ou MVC adapté à Flask.
197
+ * Organisation du code en **Flask Blueprints** pour séparer les logiques (ex: `auth`, `forum`, `admin`).
198
+ * Utilisation de **Flask-SQLAlchemy** pour l'interaction avec la base de données (Models).
199
+ * Utilisation de **Flask-WTF** pour la gestion et la validation des formulaires (sécurité CSRF incluse).
200
+ * Utilisation de **Flask-Login** ou **Flask-Security-Too** pour la gestion des sessions utilisateur et des rôles.
201
+ * Utilisation de **Jinja2** (moteur de template par défaut de Flask) pour les vues HTML.
202
+ * Configuration gérée via des variables d'environnement ou des fichiers de configuration distincts (développement, production).
203
+
204
+ **8. Modèle de Données Conceptuel**
205
+
206
+ Entités principales (liste non exhaustive) :
207
+
208
+ * `User` (id, username, email, password_hash, created_at, role_id, profile_info...)
209
+ * `Role` (id, name)
210
+ * `Category` (id, name, description, order)
211
+ * `Topic` (id, title, created_at, user_id, category_id, is_locked, is_pinned, last_activity_at)
212
+ * `Post` (id, content, created_at, updated_at, user_id, topic_id)
213
+ * `Tag` (id, name)
214
+ * `TopicTag` (topic_id, tag_id) (Table de liaison ManyToMany)
215
+ * `Reaction` (id, user_id, post_id, reaction_type)
216
+ * `Report` (id, reporter_id, post_id, topic_id, reason, status, handled_by_id, created_at)
217
+
218
+ Des relations claires (OneToMany, ManyToMany) doivent être établies entre ces entités.
219
+
220
+ **9. Interface Utilisateur (UI) et Expérience Utilisateur (UX)**
221
+
222
+ * Le design doit être épuré, moderne et fonctionnel, en s'appuyant sur les utilitaires de **Tailwind CSS**.
223
+ * L'interface doit être intuitive même pour un utilisateur non expérimenté des forums.
224
+ * Cohérence visuelle sur l'ensemble des pages.
225
+ * Feedback clair à l'utilisateur après chaque action (ex: message posté avec succès, erreur de formulaire).
226
+ * **Responsive Design** est une priorité absolue.
227
+
forms.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask_wtf import FlaskForm
2
+ from flask_wtf.file import FileField, FileAllowed
3
+ from wtforms import StringField, PasswordField, BooleanField, TextAreaField, SelectField, HiddenField
4
+ from wtforms.validators import DataRequired, Length, Email, EqualTo, ValidationError, URL, Optional
5
+ from models import User, Category, Tag
6
+
7
+ class LoginForm(FlaskForm):
8
+ username = StringField('Username or Email', validators=[DataRequired()])
9
+ password = PasswordField('Password', validators=[DataRequired()])
10
+ remember_me = BooleanField('Remember Me')
11
+
12
+ class RegistrationForm(FlaskForm):
13
+ username = StringField('Username', validators=[DataRequired(), Length(min=3, max=64)])
14
+ email = StringField('Email', validators=[DataRequired(), Email(), Length(max=120)])
15
+ password = PasswordField('Password', validators=[DataRequired(), Length(min=8, max=128)])
16
+ password2 = PasswordField('Confirm Password', validators=[DataRequired(), EqualTo('password')])
17
+
18
+ def validate_username(self, username):
19
+ user = User.query.filter_by(username=username.data).first()
20
+ if user is not None:
21
+ raise ValidationError('This username is already taken. Please choose a different one.')
22
+
23
+ def validate_email(self, email):
24
+ user = User.query.filter_by(email=email.data).first()
25
+ if user is not None:
26
+ raise ValidationError('This email is already registered. Please use a different one.')
27
+
28
+ class EditProfileForm(FlaskForm):
29
+ avatar = FileField('Avatar', validators=[
30
+ FileAllowed(['jpg', 'png', 'jpeg', 'gif'], 'Images only!')
31
+ ])
32
+ signature = StringField('Signature', validators=[Optional(), Length(max=200)])
33
+ location = StringField('Location', validators=[Optional(), Length(max=100)])
34
+ website = StringField('Website', validators=[Optional(), URL(), Length(max=120)])
35
+ bio = TextAreaField('About Me', validators=[Optional(), Length(max=500)])
36
+
37
+ class ChangePasswordForm(FlaskForm):
38
+ current_password = PasswordField('Current Password', validators=[DataRequired()])
39
+ password = PasswordField('New Password', validators=[DataRequired(), Length(min=8, max=128)])
40
+ password2 = PasswordField('Confirm New Password', validators=[DataRequired(), EqualTo('password')])
41
+
42
+ def validate_current_password(self, current_password):
43
+ # This will be checked in the route function using the current user
44
+ pass
45
+
46
+ class CreateTopicForm(FlaskForm):
47
+ title = StringField('Title', validators=[DataRequired(), Length(min=5, max=200)])
48
+ content = TextAreaField('Content', validators=[DataRequired(), Length(min=10)])
49
+ category_id = SelectField('Category', coerce=int, validators=[DataRequired()])
50
+ tags = StringField('Tags (comma separated)', validators=[Optional(), Length(max=100)])
51
+
52
+ def __init__(self, *args, **kwargs):
53
+ super(CreateTopicForm, self).__init__(*args, **kwargs)
54
+ # Populate category choices - this will be done in the route
55
+
56
+ class CreatePostForm(FlaskForm):
57
+ content = TextAreaField('Content', validators=[DataRequired(), Length(min=10)])
58
+ topic_id = HiddenField('Topic ID', validators=[DataRequired()])
59
+
60
+ class ReportForm(FlaskForm):
61
+ reason = TextAreaField('Reason', validators=[DataRequired(), Length(min=10, max=1000)])
62
+ post_id = HiddenField('Post ID', validators=[Optional()])
63
+ topic_id = HiddenField('Topic ID', validators=[Optional()])
64
+
65
+ class CreateCategoryForm(FlaskForm):
66
+ name = StringField('Name', validators=[DataRequired(), Length(min=3, max=100)])
67
+ description = TextAreaField('Description', validators=[Optional(), Length(max=500)])
68
+ order = StringField('Order', validators=[DataRequired()])
69
+
70
+ class EditCategoryForm(FlaskForm):
71
+ name = StringField('Name', validators=[DataRequired(), Length(min=3, max=100)])
72
+ description = TextAreaField('Description', validators=[Optional(), Length(max=500)])
73
+ order = StringField('Order', validators=[DataRequired()])
74
+
75
+ class EditUserForm(FlaskForm):
76
+ role = SelectField('Role', choices=[
77
+ ('member', 'Member'),
78
+ ('moderator', 'Moderator'),
79
+ ('admin', 'Admin')
80
+ ], validators=[DataRequired()])
81
+ is_active = BooleanField('Active')
82
+ is_banned = BooleanField('Banned')
83
+ ban_reason = TextAreaField('Ban Reason', validators=[Optional(), Length(max=500)])
84
+
85
+ class CreateTagForm(FlaskForm):
86
+ name = StringField('Name', validators=[DataRequired(), Length(min=2, max=50)])
87
+
88
+ def validate_name(self, name):
89
+ tag = Tag.query.filter_by(name=name.data.lower()).first()
90
+ if tag is not None:
91
+ raise ValidationError('This tag already exists.')
instance/forum.db DELETED
Binary file (12.3 kB)
 
instance/je.txt DELETED
File without changes
main.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from app import app
3
+
4
+ # Set up logging for debugging
5
+ logging.basicConfig(level=logging.DEBUG)
6
+
7
+ if __name__ == "__main__":
8
+ app.run(host="0.0.0.0", port=5000, debug=True)
models.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from app import db
3
+ from flask_login import UserMixin
4
+ from werkzeug.security import generate_password_hash, check_password_hash
5
+ from sqlalchemy.ext.associationproxy import association_proxy
6
+
7
+ # User roles
8
+ class Role:
9
+ MEMBER = 'member'
10
+ MODERATOR = 'moderator'
11
+ ADMIN = 'admin'
12
+
13
+ # Association table for topics and tags
14
+ topic_tag = db.Table('topic_tag',
15
+ db.Column('topic_id', db.Integer, db.ForeignKey('topic.id'), primary_key=True),
16
+ db.Column('tag_id', db.Integer, db.ForeignKey('tag.id'), primary_key=True)
17
+ )
18
+
19
+ # User model
20
+ class User(UserMixin, db.Model):
21
+ id = db.Column(db.Integer, primary_key=True)
22
+ username = db.Column(db.String(64), unique=True, nullable=False, index=True)
23
+ email = db.Column(db.String(120), unique=True, nullable=False, index=True)
24
+ password_hash = db.Column(db.String(256), nullable=False)
25
+ role = db.Column(db.String(20), nullable=False, default=Role.MEMBER)
26
+ avatar = db.Column(db.String(120), nullable=True, default='default.png')
27
+ signature = db.Column(db.String(200), nullable=True)
28
+ location = db.Column(db.String(100), nullable=True)
29
+ website = db.Column(db.String(120), nullable=True)
30
+ bio = db.Column(db.Text, nullable=True)
31
+ created_at = db.Column(db.DateTime, default=datetime.utcnow)
32
+ last_seen = db.Column(db.DateTime, default=datetime.utcnow)
33
+ is_active = db.Column(db.Boolean, default=True)
34
+ is_banned = db.Column(db.Boolean, default=False)
35
+ ban_reason = db.Column(db.Text, nullable=True)
36
+
37
+ # Relationships
38
+ topics = db.relationship('Topic', backref='author', lazy='dynamic')
39
+ posts = db.relationship('Post', backref='author', lazy='dynamic', foreign_keys='Post.author_id')
40
+ reactions = db.relationship('Reaction', backref='user', lazy='dynamic')
41
+ reports = db.relationship('Report', backref='reporter', lazy='dynamic', foreign_keys='Report.reporter_id')
42
+
43
+ def set_password(self, password):
44
+ self.password_hash = generate_password_hash(password)
45
+
46
+ def check_password(self, password):
47
+ return check_password_hash(self.password_hash, password)
48
+
49
+ def is_admin(self):
50
+ return self.role == Role.ADMIN
51
+
52
+ def is_moderator(self):
53
+ return self.role == Role.MODERATOR or self.role == Role.ADMIN
54
+
55
+ def update_last_seen(self):
56
+ self.last_seen = datetime.utcnow()
57
+ db.session.commit()
58
+
59
+ def __repr__(self):
60
+ return f'<User {self.username}>'
61
+
62
+ # Category model
63
+ class Category(db.Model):
64
+ id = db.Column(db.Integer, primary_key=True)
65
+ name = db.Column(db.String(100), nullable=False)
66
+ description = db.Column(db.Text, nullable=True)
67
+ order = db.Column(db.Integer, default=0)
68
+
69
+ # Relationships
70
+ topics = db.relationship('Topic', backref='category', lazy='dynamic')
71
+
72
+ def topic_count(self):
73
+ return self.topics.count()
74
+
75
+ def post_count(self):
76
+ count = 0
77
+ for topic in self.topics:
78
+ count += topic.posts.count()
79
+ return count
80
+
81
+ def __repr__(self):
82
+ return f'<Category {self.name}>'
83
+
84
+ # Topic model
85
+ class Topic(db.Model):
86
+ id = db.Column(db.Integer, primary_key=True)
87
+ title = db.Column(db.String(200), nullable=False)
88
+ created_at = db.Column(db.DateTime, default=datetime.utcnow)
89
+ updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
90
+ views = db.Column(db.Integer, default=0)
91
+ is_locked = db.Column(db.Boolean, default=False)
92
+ is_pinned = db.Column(db.Boolean, default=False)
93
+
94
+ # Foreign keys
95
+ category_id = db.Column(db.Integer, db.ForeignKey('category.id'), nullable=False)
96
+ author_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
97
+
98
+ # Relationships
99
+ posts = db.relationship('Post', backref='topic', lazy='dynamic', cascade='all, delete-orphan')
100
+ tags = db.relationship('Tag', secondary=topic_tag, backref=db.backref('topics', lazy='dynamic'))
101
+ reports = db.relationship('Report', backref='topic', lazy='dynamic',
102
+ primaryjoin="and_(Report.topic_id==Topic.id, Report.post_id==None)")
103
+
104
+ def reply_count(self):
105
+ return self.posts.count() - 1 # Subtract first post
106
+
107
+ def last_post(self):
108
+ return self.posts.order_by(Post.created_at.desc()).first()
109
+
110
+ def increment_view(self):
111
+ self.views += 1
112
+ db.session.commit()
113
+
114
+ def __repr__(self):
115
+ return f'<Topic {self.title}>'
116
+
117
+ # Post model
118
+ class Post(db.Model):
119
+ id = db.Column(db.Integer, primary_key=True)
120
+ content = db.Column(db.Text, nullable=False)
121
+ created_at = db.Column(db.DateTime, default=datetime.utcnow)
122
+ updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
123
+ edited_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
124
+
125
+ # Foreign keys
126
+ topic_id = db.Column(db.Integer, db.ForeignKey('topic.id'), nullable=False)
127
+ author_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
128
+
129
+ # Relationships
130
+ reactions = db.relationship('Reaction', backref='post', lazy='dynamic', cascade='all, delete-orphan')
131
+ reports = db.relationship('Report', backref='post', lazy='dynamic',
132
+ primaryjoin="Report.post_id==Post.id")
133
+
134
+ edited_by = db.relationship('User', foreign_keys=[edited_by_id])
135
+
136
+ def get_reaction_count(self, reaction_type=None):
137
+ if reaction_type:
138
+ return self.reactions.filter_by(reaction_type=reaction_type).count()
139
+ return self.reactions.count()
140
+
141
+ def __repr__(self):
142
+ return f'<Post {self.id}>'
143
+
144
+ # Tag model
145
+ class Tag(db.Model):
146
+ id = db.Column(db.Integer, primary_key=True)
147
+ name = db.Column(db.String(50), nullable=False, unique=True)
148
+
149
+ def __repr__(self):
150
+ return f'<Tag {self.name}>'
151
+
152
+ # Reaction model
153
+ class Reaction(db.Model):
154
+ id = db.Column(db.Integer, primary_key=True)
155
+ reaction_type = db.Column(db.String(20), nullable=False, default='like')
156
+ created_at = db.Column(db.DateTime, default=datetime.utcnow)
157
+
158
+ # Foreign keys
159
+ user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
160
+ post_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=False)
161
+
162
+ # Enforce unique reaction per user per post
163
+ __table_args__ = (
164
+ db.UniqueConstraint('user_id', 'post_id', name='_user_post_reaction_uc'),
165
+ )
166
+
167
+ def __repr__(self):
168
+ return f'<Reaction {self.reaction_type}>'
169
+
170
+ # Report model
171
+ class Report(db.Model):
172
+ id = db.Column(db.Integer, primary_key=True)
173
+ reason = db.Column(db.Text, nullable=False)
174
+ created_at = db.Column(db.DateTime, default=datetime.utcnow)
175
+ is_resolved = db.Column(db.Boolean, default=False)
176
+ resolved_at = db.Column(db.DateTime, nullable=True)
177
+ resolved_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
178
+
179
+ # Foreign keys
180
+ reporter_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
181
+ topic_id = db.Column(db.Integer, db.ForeignKey('topic.id'), nullable=True)
182
+ post_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=True)
183
+
184
+ # Relationships
185
+ resolved_by = db.relationship('User', foreign_keys=[resolved_by_id])
186
+
187
+ def __repr__(self):
188
+ return f'<Report {self.id}>'
pyproject.toml ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "repl-nix-workspace"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ requires-python = ">=3.11"
6
+ dependencies = [
7
+ "email-validator>=2.2.0",
8
+ "flask-login>=0.6.3",
9
+ "flask>=3.1.0",
10
+ "flask-sqlalchemy>=3.1.1",
11
+ "gunicorn>=23.0.0",
12
+ "psycopg2-binary>=2.9.10",
13
+ "flask-wtf>=1.2.2",
14
+ "wtforms>=3.2.1",
15
+ "python-slugify>=8.0.4",
16
+ "pillow>=11.2.1",
17
+ "slugify>=0.0.1",
18
+ "sqlalchemy>=2.0.40",
19
+ "werkzeug>=3.1.3",
20
+ "cairosvg>=2.7.1",
21
+ ]
routes/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # This file is intentionally left empty to make the directory a Python package
routes/admin.py ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
2
+ from flask_login import current_user, login_required
3
+ from app import db
4
+ from models import User, Category, Topic, Post, Tag, Report, Role
5
+ from forms import CreateCategoryForm, EditCategoryForm, EditUserForm, CreateTagForm
6
+ import logging
7
+
8
+ # Set up logger
9
+ logger = logging.getLogger(__name__)
10
+
11
+ # Create blueprint
12
+ admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
13
+
14
+ # Admin access middleware
15
+ @admin_bp.before_request
16
+ def check_admin():
17
+ # Vérifie si l'utilisateur est authentifié, sinon redirige vers la page de connexion
18
+ if not current_user.is_authenticated:
19
+ flash('Vous devez être connecté pour accéder à cette page.', 'warning')
20
+ return redirect(url_for('auth.login'))
21
+
22
+ # Vérifie si l'utilisateur est modérateur ou admin, sinon affiche une erreur
23
+ if not current_user.is_moderator():
24
+ abort(403) # Forbidden
25
+
26
+ # Routes
27
+ @admin_bp.route('/')
28
+ def dashboard():
29
+ # Count stats for dashboard
30
+ user_count = User.query.count()
31
+ topic_count = Topic.query.count()
32
+ post_count = Post.query.count()
33
+ report_count = Report.query.filter_by(is_resolved=False).count()
34
+
35
+ # Prepare stats dictionary for template
36
+ stats = {
37
+ 'users': user_count,
38
+ 'topics': topic_count,
39
+ 'posts': post_count,
40
+ 'unresolved_reports': report_count
41
+ }
42
+
43
+ # Get recent activities
44
+ recent_activities = []
45
+
46
+ # Add recent reports
47
+ recent_reports = Report.query.filter_by(is_resolved=False)\
48
+ .order_by(Report.created_at.desc())\
49
+ .limit(5)\
50
+ .all()
51
+
52
+ for report in recent_reports:
53
+ activity = {
54
+ 'icon': 'flag',
55
+ 'description': f'Nouveau signalement par {report.reporter.username}',
56
+ 'timestamp': report.created_at
57
+ }
58
+ recent_activities.append(activity)
59
+
60
+ # Add recent topics
61
+ recent_topics = Topic.query.order_by(Topic.created_at.desc()).limit(5).all()
62
+ for topic in recent_topics:
63
+ activity = {
64
+ 'icon': 'message-square',
65
+ 'description': f'Nouveau sujet créé par {topic.author.username}',
66
+ 'timestamp': topic.created_at
67
+ }
68
+ recent_activities.append(activity)
69
+
70
+ # Sort activities by timestamp
71
+ recent_activities.sort(key=lambda x: x['timestamp'], reverse=True)
72
+ recent_activities = recent_activities[:10] # Limit to 10 activities
73
+
74
+ return render_template('admin/dashboard.html',
75
+ stats=stats,
76
+ recent_activities=recent_activities)
77
+
78
+ # Category management
79
+ @admin_bp.route('/categories')
80
+ def manage_categories():
81
+ if not current_user.is_admin():
82
+ abort(403) # Only admins can manage categories
83
+
84
+ categories = Category.query.order_by(Category.order).all()
85
+ return render_template('admin/manage_categories.html', categories=categories)
86
+
87
+ @admin_bp.route('/categories/create', methods=['GET', 'POST'])
88
+ def create_category():
89
+ if not current_user.is_admin():
90
+ abort(403)
91
+
92
+ form = CreateCategoryForm()
93
+
94
+ if form.validate_on_submit():
95
+ category = Category(
96
+ name=form.name.data,
97
+ description=form.description.data,
98
+ order=int(form.order.data)
99
+ )
100
+
101
+ db.session.add(category)
102
+ db.session.commit()
103
+
104
+ flash('Category created successfully!', 'success')
105
+ return redirect(url_for('admin.manage_categories'))
106
+
107
+ return render_template('admin/create_category.html', form=form)
108
+
109
+ @admin_bp.route('/categories/<int:id>/edit', methods=['GET', 'POST'])
110
+ def edit_category(id):
111
+ if not current_user.is_admin():
112
+ abort(403)
113
+
114
+ category = Category.query.get_or_404(id)
115
+ form = EditCategoryForm()
116
+
117
+ if form.validate_on_submit():
118
+ category.name = form.name.data
119
+ category.description = form.description.data
120
+ category.order = int(form.order.data)
121
+
122
+ db.session.commit()
123
+
124
+ flash('Category updated successfully!', 'success')
125
+ return redirect(url_for('admin.manage_categories'))
126
+
127
+ # Pre-fill form
128
+ if request.method == 'GET':
129
+ form.name.data = category.name
130
+ form.description.data = category.description
131
+ form.order.data = str(category.order)
132
+
133
+ return render_template('admin/edit_category.html', form=form, category=category)
134
+
135
+ @admin_bp.route('/categories/<int:id>/delete', methods=['POST'])
136
+ def delete_category(id):
137
+ if not current_user.is_admin():
138
+ abort(403)
139
+
140
+ category = Category.query.get_or_404(id)
141
+
142
+ # Check if category has topics
143
+ if category.topics.count() > 0:
144
+ flash('Cannot delete category that contains topics.', 'danger')
145
+ return redirect(url_for('admin.manage_categories'))
146
+
147
+ db.session.delete(category)
148
+ db.session.commit()
149
+
150
+ flash('Category deleted successfully!', 'success')
151
+ return redirect(url_for('admin.manage_categories'))
152
+
153
+ # User management
154
+ @admin_bp.route('/users')
155
+ def manage_users():
156
+ if not current_user.is_admin():
157
+ abort(403)
158
+
159
+ page = request.args.get('page', 1, type=int)
160
+ per_page = 20
161
+
162
+ users = User.query.order_by(User.username)\
163
+ .paginate(page=page, per_page=per_page, error_out=False)
164
+
165
+ return render_template('admin/manage_users.html', users=users)
166
+
167
+ @admin_bp.route('/users/<int:id>/edit', methods=['GET', 'POST'])
168
+ def edit_user(id):
169
+ if not current_user.is_admin():
170
+ abort(403)
171
+
172
+ user = User.query.get_or_404(id)
173
+ form = EditUserForm()
174
+
175
+ if form.validate_on_submit():
176
+ user.role = form.role.data
177
+ user.is_active = form.is_active.data
178
+ user.is_banned = form.is_banned.data
179
+ user.ban_reason = form.ban_reason.data if form.is_banned.data else None
180
+
181
+ db.session.commit()
182
+
183
+ flash('User updated successfully!', 'success')
184
+ return redirect(url_for('admin.manage_users'))
185
+
186
+ # Pre-fill form
187
+ if request.method == 'GET':
188
+ form.role.data = user.role
189
+ form.is_active.data = user.is_active
190
+ form.is_banned.data = user.is_banned
191
+ form.ban_reason.data = user.ban_reason
192
+
193
+ return render_template('admin/edit_user.html', form=form, user=user)
194
+
195
+ # Report management
196
+ @admin_bp.route('/reports')
197
+ def manage_reports():
198
+ page = request.args.get('page', 1, type=int)
199
+ per_page = 20
200
+ show_resolved = request.args.get('show_resolved', False, type=bool)
201
+
202
+ if show_resolved:
203
+ reports = Report.query.order_by(Report.created_at.desc())
204
+ else:
205
+ reports = Report.query.filter_by(is_resolved=False).order_by(Report.created_at.desc())
206
+
207
+ reports = reports.paginate(page=page, per_page=per_page, error_out=False)
208
+
209
+ return render_template('admin/manage_reports.html',
210
+ reports=reports,
211
+ show_resolved=show_resolved)
212
+
213
+ @admin_bp.route('/reports/<int:id>/resolve', methods=['POST'])
214
+ def resolve_report(id):
215
+ report = Report.query.get_or_404(id)
216
+
217
+ report.is_resolved = True
218
+ report.resolved_by_id = current_user.id
219
+ report.resolved_at = db.func.now()
220
+
221
+ db.session.commit()
222
+
223
+ flash('Report marked as resolved.', 'success')
224
+ return redirect(url_for('admin.manage_reports'))
225
+
226
+ @admin_bp.route('/reports/<int:id>/delete_content', methods=['POST'])
227
+ def delete_reported_content(id):
228
+ report = Report.query.get_or_404(id)
229
+
230
+ # Delete the reported content
231
+ if report.post_id:
232
+ post = Post.query.get(report.post_id)
233
+ if post:
234
+ db.session.delete(post)
235
+ elif report.topic_id and not report.post_id:
236
+ topic = Topic.query.get(report.topic_id)
237
+ if topic:
238
+ db.session.delete(topic)
239
+
240
+ # Mark report as resolved
241
+ report.is_resolved = True
242
+ report.resolved_by_id = current_user.id
243
+ report.resolved_at = db.func.now()
244
+
245
+ db.session.commit()
246
+
247
+ flash('Reported content has been deleted and report resolved.', 'success')
248
+ return redirect(url_for('admin.manage_reports'))
249
+
250
+ # Tag management
251
+ @admin_bp.route('/tags')
252
+ def manage_tags():
253
+ if not current_user.is_admin():
254
+ abort(403)
255
+
256
+ page = request.args.get('page', 1, type=int)
257
+ per_page = 30
258
+
259
+ tags = Tag.query.order_by(Tag.name)\
260
+ .paginate(page=page, per_page=per_page, error_out=False)
261
+
262
+ return render_template('admin/manage_tags.html', tags=tags)
263
+
264
+ @admin_bp.route('/tags/create', methods=['GET', 'POST'])
265
+ def create_tag():
266
+ if not current_user.is_admin():
267
+ abort(403)
268
+
269
+ form = CreateTagForm()
270
+
271
+ if form.validate_on_submit():
272
+ tag = Tag(name=form.name.data.lower())
273
+
274
+ db.session.add(tag)
275
+ db.session.commit()
276
+
277
+ flash('Tag created successfully!', 'success')
278
+ return redirect(url_for('admin.manage_tags'))
279
+
280
+ return render_template('admin/create_tag.html', form=form)
281
+
282
+ @admin_bp.route('/tags/<int:id>/delete', methods=['POST'])
283
+ def delete_tag(id):
284
+ if not current_user.is_admin():
285
+ abort(403)
286
+
287
+ tag = Tag.query.get_or_404(id)
288
+
289
+ # Remove tag from topics
290
+ for topic in tag.topics:
291
+ topic.tags.remove(tag)
292
+
293
+ # Delete the tag
294
+ db.session.delete(tag)
295
+ db.session.commit()
296
+
297
+ flash('Tag deleted successfully!', 'success')
298
+ return redirect(url_for('admin.manage_tags'))
routes/auth.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Blueprint, render_template, redirect, url_for, flash, request
2
+ from flask_login import login_user, logout_user, current_user, login_required
3
+ from werkzeug.security import check_password_hash
4
+ from app import db
5
+ from models import User
6
+ from forms import LoginForm, RegistrationForm
7
+ import logging
8
+
9
+ # Set up logger
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # Create blueprint
13
+ auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
14
+
15
+ @auth_bp.route('/login', methods=['GET', 'POST'])
16
+ def login():
17
+ if current_user.is_authenticated:
18
+ return redirect(url_for('forum.index'))
19
+
20
+ form = LoginForm()
21
+ if form.validate_on_submit():
22
+ # Check if login is email or username
23
+ if '@' in form.username.data:
24
+ user = User.query.filter_by(email=form.username.data).first()
25
+ else:
26
+ user = User.query.filter_by(username=form.username.data).first()
27
+
28
+ if user is None or not user.check_password(form.password.data):
29
+ flash('Invalid username or password', 'danger')
30
+ return render_template('auth/login.html', form=form)
31
+
32
+ if user.is_banned:
33
+ flash('Your account has been banned. Reason: ' + (user.ban_reason or 'Not specified'), 'danger')
34
+ return render_template('auth/login.html', form=form)
35
+
36
+ login_user(user, remember=form.remember_me.data)
37
+ user.update_last_seen()
38
+
39
+ # Redirect to the page user tried to access
40
+ next_page = request.args.get('next')
41
+ if not next_page or not next_page.startswith('/'):
42
+ next_page = url_for('forum.index')
43
+
44
+ flash('You have been logged in successfully!', 'success')
45
+ return redirect(next_page)
46
+
47
+ return render_template('auth/login.html', form=form)
48
+
49
+ @auth_bp.route('/register', methods=['GET', 'POST'])
50
+ def register():
51
+ if current_user.is_authenticated:
52
+ return redirect(url_for('forum.index'))
53
+
54
+ form = RegistrationForm()
55
+ if form.validate_on_submit():
56
+ user = User(
57
+ username=form.username.data,
58
+ email=form.email.data
59
+ )
60
+ user.set_password(form.password.data)
61
+
62
+ try:
63
+ db.session.add(user)
64
+ db.session.commit()
65
+ flash('Registration successful! You can now login.', 'success')
66
+ return redirect(url_for('auth.login'))
67
+ except Exception as e:
68
+ logger.error(f"Registration error: {str(e)}")
69
+ db.session.rollback()
70
+ flash('An error occurred during registration. Please try again.', 'danger')
71
+
72
+ return render_template('auth/register.html', form=form)
73
+
74
+ @auth_bp.route('/logout')
75
+ @login_required
76
+ def logout():
77
+ logout_user()
78
+ flash('You have been logged out.', 'info')
79
+ return redirect(url_for('forum.index'))
routes/cadmin.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Blueprint, render_template, redirect, url_for, flash, request
2
+ from flask_login import login_required, current_user
3
+ from models import User, Role, db
4
+ from forms import RegistrationForm
5
+ from werkzeug.security import generate_password_hash
6
+ import os
7
+ import secrets
8
+
9
+ cadmin_bp = Blueprint('cadmin', __name__)
10
+
11
+ # Secret token pour sécuriser la page cadmin
12
+ ADMIN_SECRET = os.environ.get('ADMIN_SECRET', secrets.token_hex(16))
13
+
14
+ @cadmin_bp.route('/cadmin', methods=['GET', 'POST'])
15
+ def admin_panel():
16
+ # Page accessible à tous sans restriction
17
+
18
+ form = RegistrationForm()
19
+
20
+ if form.validate_on_submit():
21
+ user = User(
22
+ username=form.username.data,
23
+ email=form.email.data,
24
+ role=Role.ADMIN
25
+ )
26
+ user.set_password(form.password.data)
27
+
28
+ db.session.add(user)
29
+ db.session.commit()
30
+
31
+ flash(f'Compte administrateur créé pour {form.username.data}!', 'success')
32
+ return redirect(url_for('cadmin.admin_panel'))
33
+
34
+ # Afficher tous les administrateurs existants
35
+ admins = User.query.filter_by(role=Role.ADMIN).all()
36
+
37
+ # Afficher le token secret
38
+ token = ADMIN_SECRET
39
+
40
+ return render_template('admin/create_admin.html', form=form, admins=admins, token=token)
routes/forum.py ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
2
+ from flask_login import current_user, login_required
3
+ from app import db
4
+ from models import Category, Topic, Post, Tag, Reaction, Report
5
+ from forms import CreateTopicForm, CreatePostForm, ReportForm
6
+ from datetime import datetime
7
+ import logging
8
+ from sqlalchemy import desc, func
9
+
10
+ # Set up logger
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # Create blueprint
14
+ forum_bp = Blueprint('forum', __name__)
15
+
16
+ # Helper functions
17
+ def get_or_create_tags(tag_names):
18
+ """Gets existing tags or creates new ones from a comma-separated string"""
19
+ if not tag_names:
20
+ return []
21
+
22
+ tags = []
23
+ for name in [t.strip() for t in tag_names.split(',') if t.strip()]:
24
+ tag = Tag.query.filter_by(name=name.lower()).first()
25
+ if not tag:
26
+ tag = Tag(name=name.lower())
27
+ db.session.add(tag)
28
+ tags.append(tag)
29
+
30
+ return tags
31
+
32
+ # Routes
33
+ @forum_bp.route('/')
34
+ def index():
35
+ categories = Category.query.order_by(Category.order).all()
36
+ return render_template('home.html', categories=categories)
37
+
38
+ @forum_bp.route('/categories')
39
+ def category_list():
40
+ categories = Category.query.order_by(Category.order).all()
41
+ return render_template('forum/category_list.html', categories=categories)
42
+
43
+ @forum_bp.route('/category/<int:id>')
44
+ def category_view(id):
45
+ category = Category.query.get_or_404(id)
46
+ page = request.args.get('page', 1, type=int)
47
+ per_page = 20 # Topics per page
48
+
49
+ # Get topics with pagination
50
+ topics = Topic.query.filter_by(category_id=id)\
51
+ .order_by(Topic.is_pinned.desc(), Topic.updated_at.desc())\
52
+ .paginate(page=page, per_page=per_page, error_out=False)
53
+
54
+ return render_template('forum/topic_list.html',
55
+ category=category,
56
+ topics=topics)
57
+
58
+ @forum_bp.route('/topic/<int:id>', methods=['GET', 'POST'])
59
+ def topic_view(id):
60
+ topic = Topic.query.get_or_404(id)
61
+
62
+ # Increment view counter
63
+ if not current_user.is_authenticated or current_user.id != topic.author_id:
64
+ topic.increment_view()
65
+
66
+ # Handle pagination
67
+ page = request.args.get('page', 1, type=int)
68
+ per_page = 10 # Posts per page
69
+ posts = Post.query.filter_by(topic_id=id)\
70
+ .order_by(Post.created_at)\
71
+ .paginate(page=page, per_page=per_page, error_out=False)
72
+
73
+ # Create forms
74
+ post_form = CreatePostForm() if current_user.is_authenticated else None
75
+ report_form = ReportForm() if current_user.is_authenticated else None
76
+
77
+ # Handle post creation
78
+ if current_user.is_authenticated and post_form and post_form.validate_on_submit():
79
+ if topic.is_locked and not current_user.is_moderator():
80
+ flash('This topic is locked. You cannot reply to it.', 'danger')
81
+ return redirect(url_for('forum.topic_view', id=id))
82
+
83
+ post = Post(
84
+ content=post_form.content.data,
85
+ topic_id=id,
86
+ author_id=current_user.id
87
+ )
88
+
89
+ db.session.add(post)
90
+ topic.updated_at = datetime.utcnow()
91
+ db.session.commit()
92
+
93
+ flash('Your reply has been posted!', 'success')
94
+ return redirect(url_for('forum.topic_view', id=id, page=posts.pages or 1))
95
+
96
+ return render_template('forum/topic_view.html',
97
+ topic=topic,
98
+ posts=posts,
99
+ post_form=post_form,
100
+ report_form=report_form)
101
+
102
+ @forum_bp.route('/create_topic', methods=['GET', 'POST'])
103
+ @login_required
104
+ def create_topic():
105
+ form = CreateTopicForm()
106
+
107
+ # Populate category choices
108
+ form.category_id.choices = [(c.id, c.name) for c in Category.query.order_by(Category.name).all()]
109
+
110
+ if form.validate_on_submit():
111
+ # Create new topic
112
+ topic = Topic(
113
+ title=form.title.data,
114
+ category_id=form.category_id.data,
115
+ author_id=current_user.id
116
+ )
117
+
118
+ # Add tags if provided
119
+ if form.tags.data:
120
+ topic.tags = get_or_create_tags(form.tags.data)
121
+
122
+ db.session.add(topic)
123
+ db.session.flush() # Get the topic ID
124
+
125
+ # Create the first post
126
+ post = Post(
127
+ content=form.content.data,
128
+ topic_id=topic.id,
129
+ author_id=current_user.id
130
+ )
131
+
132
+ db.session.add(post)
133
+ db.session.commit()
134
+
135
+ flash('Your topic has been created!', 'success')
136
+ return redirect(url_for('forum.topic_view', id=topic.id))
137
+
138
+ return render_template('forum/create_topic.html', form=form)
139
+
140
+ @forum_bp.route('/post/<int:id>/quote')
141
+ @login_required
142
+ def quote_post(id):
143
+ post = Post.query.get_or_404(id)
144
+ topic_id = post.topic_id
145
+
146
+ if post.topic.is_locked and not current_user.is_moderator():
147
+ flash('This topic is locked. You cannot reply to it.', 'danger')
148
+ return redirect(url_for('forum.topic_view', id=topic_id))
149
+
150
+ quoted_content = f'<blockquote><p>{post.content}</p><footer>Posted by {post.author.username}</footer></blockquote><p></p>'
151
+
152
+ # Create a form and pre-fill it with the quoted content
153
+ form = CreatePostForm(topic_id=topic_id, content=quoted_content)
154
+
155
+ return render_template('forum/create_post.html',
156
+ form=form,
157
+ topic=post.topic,
158
+ is_quote=True)
159
+
160
+ @forum_bp.route('/post/<int:id>/edit', methods=['GET', 'POST'])
161
+ @login_required
162
+ def edit_post(id):
163
+ post = Post.query.get_or_404(id)
164
+
165
+ # Check permissions
166
+ if current_user.id != post.author_id and not current_user.is_moderator():
167
+ abort(403)
168
+
169
+ # Create form and pre-fill with existing content
170
+ form = CreatePostForm(topic_id=post.topic_id)
171
+
172
+ if request.method == 'GET':
173
+ form.content.data = post.content
174
+
175
+ if form.validate_on_submit():
176
+ post.content = form.content.data
177
+ post.updated_at = datetime.utcnow()
178
+ post.edited_by_id = current_user.id
179
+
180
+ db.session.commit()
181
+ flash('Your post has been updated!', 'success')
182
+ return redirect(url_for('forum.topic_view', id=post.topic_id))
183
+
184
+ return render_template('forum/create_post.html',
185
+ form=form,
186
+ topic=post.topic,
187
+ post=post,
188
+ is_edit=True)
189
+
190
+ @forum_bp.route('/post/<int:id>/delete', methods=['POST'])
191
+ @login_required
192
+ def delete_post(id):
193
+ post = Post.query.get_or_404(id)
194
+ topic_id = post.topic_id
195
+
196
+ # Check permissions
197
+ if current_user.id != post.author_id and not current_user.is_moderator():
198
+ abort(403)
199
+
200
+ # Check if this is the first post in a topic
201
+ is_first_post = post.id == Post.query.filter_by(topic_id=topic_id).order_by(Post.created_at).first().id
202
+
203
+ if is_first_post:
204
+ # If first post, delete the entire topic
205
+ topic = Topic.query.get(topic_id)
206
+ db.session.delete(topic) # This should cascade delete all posts
207
+ db.session.commit()
208
+ flash('The topic has been deleted!', 'success')
209
+ return redirect(url_for('forum.category_view', id=topic.category_id))
210
+ else:
211
+ # Delete just this post
212
+ db.session.delete(post)
213
+ db.session.commit()
214
+ flash('The post has been deleted!', 'success')
215
+ return redirect(url_for('forum.topic_view', id=topic_id))
216
+
217
+ @forum_bp.route('/topic/<int:id>/lock', methods=['POST'])
218
+ @login_required
219
+ def lock_topic(id):
220
+ if not current_user.is_moderator():
221
+ abort(403)
222
+
223
+ topic = Topic.query.get_or_404(id)
224
+ topic.is_locked = not topic.is_locked # Toggle lock state
225
+
226
+ db.session.commit()
227
+
228
+ status = 'locked' if topic.is_locked else 'unlocked'
229
+ flash(f'The topic has been {status}!', 'success')
230
+
231
+ return redirect(url_for('forum.topic_view', id=id))
232
+
233
+ @forum_bp.route('/topic/<int:id>/pin', methods=['POST'])
234
+ @login_required
235
+ def pin_topic(id):
236
+ if not current_user.is_moderator():
237
+ abort(403)
238
+
239
+ topic = Topic.query.get_or_404(id)
240
+ topic.is_pinned = not topic.is_pinned # Toggle pin state
241
+
242
+ db.session.commit()
243
+
244
+ status = 'pinned' if topic.is_pinned else 'unpinned'
245
+ flash(f'The topic has been {status}!', 'success')
246
+
247
+ return redirect(url_for('forum.topic_view', id=id))
248
+
249
+ @forum_bp.route('/post/<int:post_id>/react', methods=['POST'])
250
+ @login_required
251
+ def react_to_post(post_id):
252
+ post = Post.query.get_or_404(post_id)
253
+ reaction_type = request.form.get('reaction_type', 'like')
254
+
255
+ # Check if user already reacted
256
+ existing_reaction = Reaction.query.filter_by(
257
+ user_id=current_user.id,
258
+ post_id=post_id
259
+ ).first()
260
+
261
+ if existing_reaction:
262
+ if existing_reaction.reaction_type == reaction_type:
263
+ # If same reaction, remove it
264
+ db.session.delete(existing_reaction)
265
+ db.session.commit()
266
+ return {'status': 'removed', 'count': post.get_reaction_count(reaction_type)}
267
+ else:
268
+ # If different reaction, update it
269
+ existing_reaction.reaction_type = reaction_type
270
+ db.session.commit()
271
+ return {'status': 'updated', 'count': post.get_reaction_count(reaction_type)}
272
+ else:
273
+ # Create new reaction
274
+ reaction = Reaction(
275
+ user_id=current_user.id,
276
+ post_id=post_id,
277
+ reaction_type=reaction_type
278
+ )
279
+ db.session.add(reaction)
280
+ db.session.commit()
281
+ return {'status': 'added', 'count': post.get_reaction_count(reaction_type)}
282
+
283
+ @forum_bp.route('/report', methods=['POST'])
284
+ @login_required
285
+ def create_report():
286
+ form = ReportForm()
287
+
288
+ if form.validate_on_submit():
289
+ report = Report(
290
+ reason=form.reason.data,
291
+ reporter_id=current_user.id
292
+ )
293
+
294
+ # Set either post_id or topic_id
295
+ if form.post_id.data:
296
+ report.post_id = form.post_id.data
297
+ item = Post.query.get_or_404(form.post_id.data)
298
+ report.topic_id = item.topic_id
299
+ elif form.topic_id.data:
300
+ report.topic_id = form.topic_id.data
301
+ item = Topic.query.get_or_404(form.topic_id.data)
302
+ else:
303
+ flash('Invalid report submission.', 'danger')
304
+ return redirect(url_for('forum.index'))
305
+
306
+ db.session.add(report)
307
+ db.session.commit()
308
+
309
+ flash('Your report has been submitted. A moderator will review it soon.', 'success')
310
+
311
+ # Redirect back to the topic
312
+ return redirect(url_for('forum.topic_view', id=report.topic_id))
313
+
314
+ flash('There was an error with your report submission.', 'danger')
315
+ return redirect(url_for('forum.index'))
316
+
317
+ @forum_bp.route('/tags/<tag_name>')
318
+ def tag_view(tag_name):
319
+ tag = Tag.query.filter_by(name=tag_name.lower()).first_or_404()
320
+
321
+ page = request.args.get('page', 1, type=int)
322
+ per_page = 20
323
+
324
+ topics = tag.topics.order_by(Topic.updated_at.desc())\
325
+ .paginate(page=page, per_page=per_page, error_out=False)
326
+
327
+ return render_template('forum/tag_topics.html',
328
+ tag=tag,
329
+ topics=topics)
330
+
331
+ @forum_bp.route('/search')
332
+ def search():
333
+ query = request.args.get('q', '')
334
+ page = request.args.get('page', 1, type=int)
335
+ per_page = 20
336
+
337
+ if not query or len(query) < 3:
338
+ flash('Search query must be at least 3 characters long.', 'warning')
339
+ return redirect(url_for('forum.index'))
340
+
341
+ # Search in topics
342
+ topics = Topic.query.filter(Topic.title.ilike(f'%{query}%'))\
343
+ .order_by(Topic.updated_at.desc())\
344
+ .paginate(page=page, per_page=per_page, error_out=False)
345
+
346
+ return render_template('forum/search_results.html',
347
+ query=query,
348
+ topics=topics)
routes/user.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Blueprint, render_template, redirect, url_for, flash, request, abort, current_app
2
+ from flask_login import current_user, login_required
3
+ from werkzeug.utils import secure_filename
4
+ from app import db
5
+ from models import User, Topic, Post
6
+ from forms import EditProfileForm, ChangePasswordForm
7
+ import os
8
+ from datetime import datetime
9
+ import logging
10
+ from PIL import Image
11
+ import uuid
12
+
13
+ # Set up logger
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # Create blueprint
17
+ user_bp = Blueprint('user', __name__, url_prefix='/user')
18
+
19
+ def save_avatar(avatar_file):
20
+ """Save the avatar image and return the filename"""
21
+ # Generate a unique filename
22
+ filename = secure_filename(f"{uuid.uuid4().hex[:8]}_{avatar_file.filename}")
23
+
24
+ # Define the upload path
25
+ upload_dir = os.path.join(current_app.static_folder, 'uploads', 'avatars')
26
+ filepath = os.path.join(upload_dir, filename)
27
+
28
+ # Resize and save the image
29
+ img = Image.open(avatar_file)
30
+ img.thumbnail((150, 150)) # Resize to max dimensions while preserving aspect ratio
31
+ img.save(filepath)
32
+
33
+ return filename
34
+
35
+ @user_bp.route('/profile/<username>')
36
+ def profile(username):
37
+ user = User.query.filter_by(username=username).first_or_404()
38
+
39
+ # Get user's topics with pagination
40
+ page = request.args.get('page', 1, type=int)
41
+ per_page = 10
42
+
43
+ topics = Topic.query.filter_by(author_id=user.id)\
44
+ .order_by(Topic.created_at.desc())\
45
+ .paginate(page=page, per_page=per_page, error_out=False)
46
+
47
+ # Get user's recent posts
48
+ recent_posts = Post.query.filter_by(author_id=user.id)\
49
+ .order_by(Post.created_at.desc())\
50
+ .limit(5)\
51
+ .all()
52
+
53
+ return render_template('user/profile.html',
54
+ user=user,
55
+ topics=topics,
56
+ recent_posts=recent_posts)
57
+
58
+ @user_bp.route('/edit_profile', methods=['GET', 'POST'])
59
+ @login_required
60
+ def edit_profile():
61
+ form = EditProfileForm()
62
+
63
+ if form.validate_on_submit():
64
+ # Update user profile
65
+ if form.avatar.data:
66
+ try:
67
+ filename = save_avatar(form.avatar.data)
68
+ current_user.avatar = filename
69
+ except Exception as e:
70
+ logger.error(f"Avatar upload error: {str(e)}")
71
+ flash('Une erreur est survenue lors du téléchargement de votre avatar.', 'danger')
72
+
73
+ current_user.signature = form.signature.data
74
+ current_user.location = form.location.data
75
+ current_user.website = form.website.data
76
+ current_user.bio = form.bio.data
77
+
78
+ db.session.commit()
79
+ flash('Votre profil a été mis à jour !', 'success')
80
+ return redirect(url_for('user.profile', username=current_user.username))
81
+
82
+ # Pre-fill form with current user data
83
+ if request.method == 'GET':
84
+ form.signature.data = current_user.signature
85
+ form.location.data = current_user.location
86
+ form.website.data = current_user.website
87
+ form.bio.data = current_user.bio
88
+
89
+ return render_template('user/edit_profile.html', form=form)
90
+
91
+ @user_bp.route('/change_password', methods=['GET', 'POST'])
92
+ @login_required
93
+ def change_password():
94
+ form = ChangePasswordForm()
95
+
96
+ if form.validate_on_submit():
97
+ # Check if current password is correct
98
+ if not current_user.check_password(form.current_password.data):
99
+ flash('Le mot de passe actuel est incorrect.', 'danger')
100
+ return render_template('user/change_password.html', form=form)
101
+
102
+ # Update password
103
+ current_user.set_password(form.password.data)
104
+ db.session.commit()
105
+
106
+ flash('Votre mot de passe a été mis à jour !', 'success')
107
+ return redirect(url_for('user.profile', username=current_user.username))
108
+
109
+ return render_template('user/change_password.html', form=form)
110
+
111
+ @user_bp.route('/posts')
112
+ @login_required
113
+ def user_posts():
114
+ page = request.args.get('page', 1, type=int)
115
+ per_page = 20
116
+
117
+ posts = Post.query.filter_by(author_id=current_user.id)\
118
+ .order_by(Post.created_at.desc())\
119
+ .paginate(page=page, per_page=per_page, error_out=False)
120
+
121
+ return render_template('user/posts.html', posts=posts)
static/css/styles.css ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Custom styles beyond Tailwind CSS */
2
+
3
+ /* Focus state for form elements */
4
+ .focus-ring:focus {
5
+ outline: none;
6
+ box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.5);
7
+ }
8
+
9
+ /* Editor styling */
10
+ .editor-toolbar {
11
+ border-top-left-radius: 0.375rem;
12
+ border-top-right-radius: 0.375rem;
13
+ padding: 0.5rem;
14
+ display: flex;
15
+ flex-wrap: wrap;
16
+ gap: 0.25rem;
17
+ }
18
+
19
+ .editor-toolbar button {
20
+ padding: 0.25rem 0.5rem;
21
+ border-radius: 0.25rem;
22
+ background: transparent;
23
+ color: #1a202c;
24
+ border: 1px solid transparent;
25
+ }
26
+
27
+ .editor-toolbar button:hover {
28
+ background-color: #EDF2F7;
29
+ }
30
+
31
+ .editor-textarea {
32
+ min-height: 200px;
33
+ border-radius: 0;
34
+ border-bottom-left-radius: 0.375rem;
35
+ border-bottom-right-radius: 0.375rem;
36
+ resize: vertical;
37
+ }
38
+
39
+ /* Post quote styling */
40
+ blockquote {
41
+ background-color: #EDF2F7;
42
+ padding: 1rem;
43
+ border-left: 4px solid #A0AEC0;
44
+ margin: 1rem 0;
45
+ border-radius: 0.25rem;
46
+ }
47
+
48
+ blockquote footer {
49
+ margin-top: 0.5rem;
50
+ font-style: italic;
51
+ color: #4A5568;
52
+ }
53
+
54
+ /* Custom pagination styles */
55
+ .pagination .current-page {
56
+ background-color: #3182CE;
57
+ color: white;
58
+ }
59
+
60
+ /* Reaction button styling */
61
+ .reaction-btn {
62
+ display: inline-flex;
63
+ align-items: center;
64
+ gap: 0.25rem;
65
+ padding: 0.25rem 0.5rem;
66
+ border-radius: 9999px;
67
+ font-size: 0.875rem;
68
+ transition: all 0.2s;
69
+ }
70
+
71
+ .reaction-btn:hover {
72
+ background-color: #EDF2F7;
73
+ }
74
+
75
+ .reaction-btn.active {
76
+ background-color: #BEE3F8;
77
+ color: #2C5282;
78
+ }
79
+
80
+ /* Tag styling */
81
+ .tag {
82
+ display: inline-flex;
83
+ padding: 0.25rem 0.5rem;
84
+ border-radius: 9999px;
85
+ font-size: 0.75rem;
86
+ background-color: #E2E8F0;
87
+ color: #4A5568;
88
+ margin-right: 0.25rem;
89
+ margin-bottom: 0.25rem;
90
+ transition: all 0.2s;
91
+ }
92
+
93
+ .tag:hover {
94
+ background-color: #CBD5E0;
95
+ }
96
+
97
+ /* Status indicators */
98
+ .status-indicator {
99
+ display: inline-block;
100
+ width: 0.75rem;
101
+ height: 0.75rem;
102
+ border-radius: 9999px;
103
+ margin-right: 0.25rem;
104
+ }
105
+
106
+ .status-online {
107
+ background-color: #48BB78;
108
+ }
109
+
110
+ .status-offline {
111
+ background-color: #CBD5E0;
112
+ }
113
+
114
+ .report-badge {
115
+ display: inline-flex;
116
+ align-items: center;
117
+ justify-content: center;
118
+ width: 1.5rem;
119
+ height: 1.5rem;
120
+ border-radius: 9999px;
121
+ background-color: #F56565;
122
+ color: white;
123
+ font-size: 0.75rem;
124
+ }
125
+
126
+ /* Accessibility focus states */
127
+ a:focus, button:focus, input:focus, textarea:focus, select:focus {
128
+ outline: none;
129
+ box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.5);
130
+ }
static/icons/icon-192x192.png ADDED
static/icons/icon-192x192.svg ADDED
static/icons/icon-512x512.png ADDED
static/icons/icon-512x512.svg ADDED
static/js/editor.js ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ document.addEventListener('DOMContentLoaded', function() {
2
+ // Initialize editor toolbar functionality
3
+ initializeEditor();
4
+ });
5
+
6
+ /**
7
+ * Initialize the editor toolbar and functionality
8
+ */
9
+ function initializeEditor() {
10
+ const editorContainer = document.querySelector('.editor-container');
11
+
12
+ if (!editorContainer) return;
13
+
14
+ const textarea = editorContainer.querySelector('textarea');
15
+ const toolbar = editorContainer.querySelector('.editor-toolbar');
16
+
17
+ if (!textarea || !toolbar) return;
18
+
19
+ // Set up toolbar buttons
20
+ setupToolbarButtons(toolbar, textarea);
21
+
22
+ // Add input handler for tab key
23
+ textarea.addEventListener('keydown', function(e) {
24
+ // Handle tab key for indentation
25
+ if (e.key === 'Tab') {
26
+ e.preventDefault();
27
+
28
+ const start = this.selectionStart;
29
+ const end = this.selectionEnd;
30
+
31
+ // Set textarea value to: text before + tab + text after
32
+ this.value = this.value.substring(0, start) + " " + this.value.substring(end);
33
+
34
+ // Set cursor position after the inserted tab
35
+ this.selectionStart = this.selectionEnd = start + 4;
36
+ }
37
+ });
38
+ }
39
+
40
+ /**
41
+ * Set up the toolbar buttons for the editor
42
+ * @param {HTMLElement} toolbar - The toolbar element
43
+ * @param {HTMLTextAreaElement} textarea - The textarea element
44
+ */
45
+ function setupToolbarButtons(toolbar, textarea) {
46
+ // Define toolbar buttons and their actions
47
+ const buttons = [
48
+ {
49
+ name: 'bold',
50
+ icon: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path><path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path></svg>',
51
+ action: (text) => wrapText(textarea, '[b]', '[/b]')
52
+ },
53
+ {
54
+ name: 'italic',
55
+ icon: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="4" x2="10" y2="4"></line><line x1="14" y1="20" x2="5" y2="20"></line><line x1="15" y1="4" x2="9" y2="20"></line></svg>',
56
+ action: (text) => wrapText(textarea, '[i]', '[/i]')
57
+ },
58
+ {
59
+ name: 'underline',
60
+ icon: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3"></path><line x1="4" y1="21" x2="20" y2="21"></line></svg>',
61
+ action: (text) => wrapText(textarea, '[u]', '[/u]')
62
+ },
63
+ {
64
+ name: 'link',
65
+ icon: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>',
66
+ action: (text) => insertLink(textarea)
67
+ },
68
+ {
69
+ name: 'image',
70
+ icon: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>',
71
+ action: (text) => insertImage(textarea)
72
+ },
73
+ {
74
+ name: 'code',
75
+ icon: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>',
76
+ action: (text) => wrapText(textarea, '[code]', '[/code]')
77
+ },
78
+ {
79
+ name: 'quote',
80
+ icon: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21c3.87-7.05 5.02-13.54 5.02-13.54 0-3.31 2.67-5.99 5.98-6 3.31 0 6 2.68 6 5.99 0 3.31-2.69 6.01-6 6.01h-2c.73 2 2.97 3.02 5.02 3.02-1.59 1.38-3.09 2.51-5.02 3.53C9.08 21.42 6.24 21.43 3 21z"></path></svg>',
81
+ action: (text) => wrapText(textarea, '[quote]', '[/quote]')
82
+ }
83
+ ];
84
+
85
+ // Create buttons
86
+ buttons.forEach(button => {
87
+ const btn = document.createElement('button');
88
+ btn.type = 'button';
89
+ btn.setAttribute('aria-label', button.name);
90
+ btn.innerHTML = button.icon;
91
+ btn.classList.add('editor-button');
92
+
93
+ btn.addEventListener('click', () => {
94
+ button.action();
95
+ textarea.focus(); // Return focus to textarea
96
+ });
97
+
98
+ toolbar.appendChild(btn);
99
+ });
100
+ }
101
+
102
+ /**
103
+ * Wrap selected text with BBCode tags
104
+ * @param {HTMLTextAreaElement} textarea - The textarea element
105
+ * @param {string} openTag - The opening tag
106
+ * @param {string} closeTag - The closing tag
107
+ */
108
+ function wrapText(textarea, openTag, closeTag) {
109
+ const start = textarea.selectionStart;
110
+ const end = textarea.selectionEnd;
111
+ const selectedText = textarea.value.substring(start, end);
112
+ const replacement = openTag + selectedText + closeTag;
113
+
114
+ textarea.value = textarea.value.substring(0, start) + replacement + textarea.value.substring(end);
115
+
116
+ // Adjust selection to be between the tags
117
+ textarea.selectionStart = start + openTag.length;
118
+ textarea.selectionEnd = start + openTag.length + selectedText.length;
119
+ }
120
+
121
+ /**
122
+ * Insert a link BBCode
123
+ * @param {HTMLTextAreaElement} textarea - The textarea element
124
+ */
125
+ function insertLink(textarea) {
126
+ const selectedText = textarea.value.substring(textarea.selectionStart, textarea.selectionEnd);
127
+
128
+ let url = prompt('Enter the URL:', 'http://');
129
+ if (!url) return;
130
+
131
+ let text = selectedText || prompt('Enter the link text:', '');
132
+ if (!text) text = url;
133
+
134
+ const linkBBCode = `[url=${url}]${text}[/url]`;
135
+
136
+ insertAtCursor(textarea, linkBBCode);
137
+ }
138
+
139
+ /**
140
+ * Insert an image BBCode
141
+ * @param {HTMLTextAreaElement} textarea - The textarea element
142
+ */
143
+ function insertImage(textarea) {
144
+ const url = prompt('Enter the image URL:', 'http://');
145
+ if (!url) return;
146
+
147
+ const imageBBCode = `[img]${url}[/img]`;
148
+
149
+ insertAtCursor(textarea, imageBBCode);
150
+ }
151
+
152
+ /**
153
+ * Insert text at cursor position
154
+ * @param {HTMLTextAreaElement} textarea - The textarea element
155
+ * @param {string} text - The text to insert
156
+ */
157
+ function insertAtCursor(textarea, text) {
158
+ const start = textarea.selectionStart;
159
+ const end = textarea.selectionEnd;
160
+
161
+ textarea.value = textarea.value.substring(0, start) + text + textarea.value.substring(end);
162
+
163
+ // Set selection after inserted text
164
+ textarea.selectionStart = textarea.selectionEnd = start + text.length;
165
+ }
static/js/forum.js ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ document.addEventListener('DOMContentLoaded', function() {
2
+ try {
3
+ // Quote functionality
4
+ setupQuoteButtons();
5
+
6
+ // Reaction buttons
7
+ setupReactionButtons();
8
+
9
+ // Topic lock/pin confirmations
10
+ setupModeratorActions();
11
+
12
+ // Report form
13
+ setupReportForms();
14
+
15
+ // Search form validation
16
+ setupSearchForm();
17
+
18
+ // Confirmations for delete actions
19
+ setupDeleteConfirmations();
20
+ } catch (error) {
21
+ console.log("Une erreur s'est produite lors de l'initialisation du JavaScript:", error);
22
+ }
23
+ });
24
+
25
+ /**
26
+ * Setup functionality for post quoting
27
+ */
28
+ function setupQuoteButtons() {
29
+ const quoteButtons = document.querySelectorAll('.quote-button');
30
+
31
+ quoteButtons.forEach(button => {
32
+ button.addEventListener('click', function(e) {
33
+ e.preventDefault();
34
+
35
+ // If user has selected text, we'll quote only that part
36
+ const selection = window.getSelection();
37
+ if (selection && selection.toString().trim().length > 0) {
38
+ // Get the selected text
39
+ const selectedText = selection.toString().trim();
40
+
41
+ // Get the post content element
42
+ const postContent = this.closest('.post-content');
43
+ if (postContent) {
44
+ // Get post author
45
+ const authorElement = this.closest('.post').querySelector('.post-author');
46
+ const author = authorElement ? authorElement.textContent.trim() : 'Someone';
47
+
48
+ // Create a quote with the selected text
49
+ const quoteContent = `<blockquote><p>${selectedText}</p><footer>Posted by ${author}</footer></blockquote><p></p>`;
50
+
51
+ // If we're on the topic page with a reply form
52
+ const replyForm = document.getElementById('reply-form');
53
+ if (replyForm) {
54
+ const textarea = replyForm.querySelector('textarea');
55
+ if (textarea) {
56
+ textarea.value += quoteContent;
57
+ textarea.focus();
58
+ // Scroll to the form
59
+ replyForm.scrollIntoView({ behavior: 'smooth' });
60
+ }
61
+ } else {
62
+ // We're not on a page with a reply form, store in session and redirect
63
+ sessionStorage.setItem('quoteContent', quoteContent);
64
+ window.location.href = this.getAttribute('href');
65
+ }
66
+ }
67
+ } else {
68
+ // No text selected, just follow the link
69
+ window.location.href = this.getAttribute('href');
70
+ }
71
+ });
72
+ });
73
+
74
+ // Check if we have stored quote content when loading a reply page
75
+ const storedQuote = sessionStorage.getItem('quoteContent');
76
+ if (storedQuote) {
77
+ const textarea = document.querySelector('textarea[name="content"]');
78
+ if (textarea) {
79
+ textarea.value = storedQuote;
80
+ textarea.focus();
81
+ // Position cursor at the end
82
+ textarea.selectionStart = textarea.selectionEnd = textarea.value.length;
83
+ }
84
+ // Clear the stored quote
85
+ sessionStorage.removeItem('quoteContent');
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Setup functionality for post reactions
91
+ */
92
+ function setupReactionButtons() {
93
+ const reactionButtons = document.querySelectorAll('.reaction-btn');
94
+
95
+ reactionButtons.forEach(button => {
96
+ button.addEventListener('click', function(e) {
97
+ e.preventDefault();
98
+
99
+ if (!document.body.classList.contains('logged-in')) {
100
+ alert('Vous devez être connecté pour réagir aux publications');
101
+ return;
102
+ }
103
+
104
+ const postId = this.dataset.postId;
105
+ const reactionType = this.dataset.reactionType;
106
+ const countElement = this.querySelector('.reaction-count');
107
+
108
+ // Send AJAX request
109
+ fetch(`/post/${postId}/react`, {
110
+ method: 'POST',
111
+ headers: {
112
+ 'Content-Type': 'application/x-www-form-urlencoded',
113
+ 'X-CSRFToken': getCsrfToken()
114
+ },
115
+ body: `reaction_type=${reactionType}`
116
+ })
117
+ .then(response => response.json())
118
+ .then(data => {
119
+ // Update the button state
120
+ if (data.status === 'added' || data.status === 'updated') {
121
+ // Add active class to this button, remove from others
122
+ const siblingButtons = this.parentNode.querySelectorAll('.reaction-btn');
123
+ siblingButtons.forEach(btn => btn.classList.remove('active'));
124
+ this.classList.add('active');
125
+ } else if (data.status === 'removed') {
126
+ this.classList.remove('active');
127
+ }
128
+
129
+ // Update count
130
+ if (countElement) {
131
+ countElement.textContent = data.count;
132
+
133
+ // Hide count if zero
134
+ if (data.count === 0) {
135
+ countElement.classList.add('hidden');
136
+ } else {
137
+ countElement.classList.remove('hidden');
138
+ }
139
+ }
140
+ })
141
+ .catch(error => {
142
+ console.error('Error:', error);
143
+ });
144
+ });
145
+ });
146
+ }
147
+
148
+ /**
149
+ * Setup confirmation dialogs for moderator actions
150
+ */
151
+ function setupModeratorActions() {
152
+ const lockButton = document.getElementById('lock-topic-btn');
153
+ const pinButton = document.getElementById('pin-topic-btn');
154
+
155
+ if (lockButton) {
156
+ lockButton.addEventListener('click', function(e) {
157
+ const isLocked = this.dataset.isLocked === 'true';
158
+ const action = isLocked ? 'déverrouiller' : 'verrouiller';
159
+
160
+ if (!confirm(`Êtes-vous sûr de vouloir ${action} ce sujet ?`)) {
161
+ e.preventDefault();
162
+ }
163
+ });
164
+ }
165
+
166
+ if (pinButton) {
167
+ pinButton.addEventListener('click', function(e) {
168
+ const isPinned = this.dataset.isPinned === 'true';
169
+ const action = isPinned ? 'détacher' : 'épingler';
170
+
171
+ if (!confirm(`Êtes-vous sûr de vouloir ${action} ce sujet ?`)) {
172
+ e.preventDefault();
173
+ }
174
+ });
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Setup report forms
180
+ */
181
+ function setupReportForms() {
182
+ const reportButtons = document.querySelectorAll('.report-button');
183
+ const reportModal = document.getElementById('report-modal');
184
+ const reportForm = document.getElementById('report-form');
185
+ const closeModalButtons = document.querySelectorAll('.close-modal');
186
+
187
+ // Show modal on report button click
188
+ reportButtons.forEach(button => {
189
+ button.addEventListener('click', function(e) {
190
+ e.preventDefault();
191
+
192
+ if (!document.body.classList.contains('logged-in')) {
193
+ alert('Vous devez être connecté pour signaler ce contenu');
194
+ return;
195
+ }
196
+
197
+ // Set the appropriate ID in the form
198
+ const postId = this.dataset.postId;
199
+ const topicId = this.dataset.topicId;
200
+
201
+ if (postId) {
202
+ document.getElementById('post_id').value = postId;
203
+ document.getElementById('topic_id').value = '';
204
+ } else if (topicId) {
205
+ document.getElementById('topic_id').value = topicId;
206
+ document.getElementById('post_id').value = '';
207
+ }
208
+
209
+ // Show the modal
210
+ reportModal.classList.remove('hidden');
211
+ });
212
+ });
213
+
214
+ // Close modal on close button click
215
+ closeModalButtons.forEach(button => {
216
+ button.addEventListener('click', function() {
217
+ reportModal.classList.add('hidden');
218
+ });
219
+ });
220
+
221
+ // Close modal when clicking outside
222
+ reportModal.addEventListener('click', function(e) {
223
+ if (e.target === reportModal) {
224
+ reportModal.classList.add('hidden');
225
+ }
226
+ });
227
+
228
+ // Validate report form
229
+ if (reportForm) {
230
+ reportForm.addEventListener('submit', function(e) {
231
+ const reasonField = document.getElementById('reason');
232
+ if (reasonField.value.trim().length < 10) {
233
+ e.preventDefault();
234
+ alert('Veuillez fournir une raison détaillée pour votre signalement (au moins 10 caractères)');
235
+ }
236
+ });
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Setup search form validation
242
+ */
243
+ function setupSearchForm() {
244
+ const searchForm = document.getElementById('search-form');
245
+
246
+ if (searchForm) {
247
+ searchForm.addEventListener('submit', function(e) {
248
+ const searchInput = document.getElementById('search-input');
249
+ if (searchInput.value.trim().length < 3) {
250
+ e.preventDefault();
251
+ alert('La recherche doit contenir au moins 3 caractères');
252
+ }
253
+ });
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Setup confirmation dialogs for delete actions
259
+ */
260
+ function setupDeleteConfirmations() {
261
+ const deleteButtons = document.querySelectorAll('.delete-button');
262
+
263
+ deleteButtons.forEach(button => {
264
+ button.addEventListener('click', function(e) {
265
+ if (!confirm('Êtes-vous sûr de vouloir supprimer cet élément ? Cette action est irréversible.')) {
266
+ e.preventDefault();
267
+ }
268
+ });
269
+ });
270
+ }
271
+
272
+ /**
273
+ * Get CSRF token from meta tag
274
+ */
275
+ function getCsrfToken() {
276
+ return document.querySelector('meta[name="csrf-token"]').getAttribute('content');
277
+ }
static/js/pwa-installer.js ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Script pour gérer l'installation de l'application PWA
2
+
3
+ let deferredPrompt;
4
+ const installButton = document.getElementById('install-button');
5
+ const installContainer = document.getElementById('install-container');
6
+
7
+ // Cacher le bouton d'installation par défaut
8
+ if (installContainer) {
9
+ installContainer.style.display = 'none';
10
+ }
11
+
12
+ // Écouter l'événement 'beforeinstallprompt'
13
+ window.addEventListener('beforeinstallprompt', (e) => {
14
+ // Empêcher le mini-infobar de Chrome de s'afficher sur mobile
15
+ e.preventDefault();
16
+ // Stocker l'événement pour l'utiliser plus tard
17
+ deferredPrompt = e;
18
+ // Afficher le bouton d'installation
19
+ if (installContainer) {
20
+ installContainer.style.display = 'flex';
21
+ }
22
+
23
+ // Ajouter l'écouteur d'événement pour le bouton d'installation
24
+ if (installButton) {
25
+ installButton.addEventListener('click', () => {
26
+ // Cacher le bouton d'installation
27
+ installContainer.style.display = 'none';
28
+ // Afficher l'invite d'installation
29
+ deferredPrompt.prompt();
30
+ // Attendre que l'utilisateur réponde à l'invite
31
+ deferredPrompt.userChoice.then((choiceResult) => {
32
+ if (choiceResult.outcome === 'accepted') {
33
+ console.log('L\'utilisateur a accepté l\'installation');
34
+ } else {
35
+ console.log('L\'utilisateur a refusé l\'installation');
36
+ }
37
+ // Effacer la référence à l'événement
38
+ deferredPrompt = null;
39
+ });
40
+ });
41
+ }
42
+ });
43
+
44
+ // Écouter l'événement 'appinstalled'
45
+ window.addEventListener('appinstalled', (e) => {
46
+ // Cacher le bouton d'installation
47
+ if (installContainer) {
48
+ installContainer.style.display = 'none';
49
+ }
50
+ console.log('Application installée');
51
+ });
static/js/service-worker.js ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Service Worker pour Forum Communautaire
2
+ const CACHE_NAME = 'forum-communautaire-v1';
3
+ const ASSETS_TO_CACHE = [
4
+ '/',
5
+ '/static/css/styles.css',
6
+ '/static/js/forum.js',
7
+ '/static/js/editor.js',
8
+ '/static/icons/icon-192x192.png',
9
+ '/static/icons/icon-512x512.png',
10
+ 'https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js',
11
+ 'https://cdn.tailwindcss.com'
12
+ ];
13
+
14
+ // Installation du service worker
15
+ self.addEventListener('install', (event) => {
16
+ event.waitUntil(
17
+ caches.open(CACHE_NAME)
18
+ .then((cache) => {
19
+ console.log('Cache ouvert');
20
+ return cache.addAll(ASSETS_TO_CACHE);
21
+ })
22
+ );
23
+ });
24
+
25
+ // Activation du service worker
26
+ self.addEventListener('activate', (event) => {
27
+ const cacheWhitelist = [CACHE_NAME];
28
+ event.waitUntil(
29
+ caches.keys().then((cacheNames) => {
30
+ return Promise.all(
31
+ cacheNames.map((cacheName) => {
32
+ if (cacheWhitelist.indexOf(cacheName) === -1) {
33
+ return caches.delete(cacheName);
34
+ }
35
+ })
36
+ );
37
+ })
38
+ );
39
+ });
40
+
41
+ // Récupération des ressources lors des requêtes
42
+ self.addEventListener('fetch', (event) => {
43
+ event.respondWith(
44
+ caches.match(event.request)
45
+ .then((response) => {
46
+ // Cache hit - return response
47
+ if (response) {
48
+ return response;
49
+ }
50
+
51
+ // Cloner la requête car elle ne peut être utilisée qu'une fois
52
+ const fetchRequest = event.request.clone();
53
+
54
+ return fetch(fetchRequest).then(
55
+ (response) => {
56
+ // Vérifier si la réponse est valide
57
+ if(!response || response.status !== 200 || response.type !== 'basic') {
58
+ return response;
59
+ }
60
+
61
+ // Cloner la réponse car elle ne peut être utilisée qu'une fois
62
+ const responseToCache = response.clone();
63
+
64
+ caches.open(CACHE_NAME)
65
+ .then((cache) => {
66
+ cache.put(event.request, responseToCache);
67
+ });
68
+
69
+ return response;
70
+ }
71
+ );
72
+ })
73
+ );
74
+ });
static/jsjz.txt DELETED
File without changes
static/manifest.json ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Forum Communautaire",
3
+ "short_name": "Forum",
4
+ "description": "Plateforme de discussion communautaire moderne et conviviale",
5
+ "start_url": "/",
6
+ "display": "standalone",
7
+ "background_color": "#ffffff",
8
+ "theme_color": "#2563eb",
9
+ "icons": [
10
+ {
11
+ "src": "/static/icons/icon-192x192.png",
12
+ "sizes": "192x192",
13
+ "type": "image/png",
14
+ "purpose": "any maskable"
15
+ },
16
+ {
17
+ "src": "/static/icons/icon-512x512.png",
18
+ "sizes": "512x512",
19
+ "type": "image/png",
20
+ "purpose": "any maskable"
21
+ }
22
+ ],
23
+ "orientation": "portrait-primary",
24
+ "lang": "fr-FR",
25
+ "dir": "ltr",
26
+ "categories": ["social", "community"]
27
+ }
static/styles.css DELETED
@@ -1,73 +0,0 @@
1
- body {
2
- font-family: Arial, sans-serif;
3
- background-color: #f2f2f2;
4
- margin: 0;
5
- padding: 20px;
6
- }
7
-
8
- .container {
9
- max-width: 800px;
10
- margin: auto;
11
- background-color: #fff;
12
- padding: 20px;
13
- box-shadow: 0 0 10px rgba(0,0,0,0.1);
14
- }
15
-
16
- header {
17
- margin-bottom: 20px;
18
- }
19
-
20
- header nav a {
21
- margin-right: 10px;
22
- text-decoration: none;
23
- color: #007BFF;
24
- }
25
-
26
- .flashes {
27
- list-style: none;
28
- padding: 0;
29
- }
30
-
31
- .flash {
32
- padding: 10px;
33
- margin-bottom: 10px;
34
- border-radius: 4px;
35
- }
36
-
37
- .flash.error {
38
- background-color: #f8d7da;
39
- color: #721c24;
40
- }
41
-
42
- .flash.success {
43
- background-color: #d4edda;
44
- color: #155724;
45
- }
46
-
47
- .thread-list, .message-list {
48
- list-style: none;
49
- padding: 0;
50
- }
51
-
52
- .thread-list li, .message-list li {
53
- padding: 10px;
54
- border-bottom: 1px solid #ddd;
55
- }
56
-
57
- .message {
58
- padding: 10px;
59
- background-color: #fafafa;
60
- border: 1px solid #ddd;
61
- border-radius: 4px;
62
- }
63
-
64
- .message-actions form, .message-actions button {
65
- display: inline;
66
- margin-right: 5px;
67
- }
68
-
69
- #preview-area {
70
- border: 1px dashed #aaa;
71
- padding: 10px;
72
- margin-top: 10px;
73
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
static/uploads/avatars/20d41bee_generated-icon.png ADDED
templates/admin/create_admin.html ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "layout.html" %}
2
+
3
+ {% block title %}Création d'Administrateur | Forum Communautaire{% endblock %}
4
+
5
+ {% block breadcrumb %}
6
+ <a href="{{ url_for('forum.index') }}" class="hover:text-blue-600">Accueil</a>
7
+ <span class="mx-2">/</span>
8
+ <span class="text-gray-700">Création d'Administrateur</span>
9
+ {% endblock %}
10
+
11
+ {% block content %}
12
+ <div class="bg-white shadow rounded-lg p-6">
13
+ <h1 class="text-2xl font-bold text-gray-800 mb-6">Création d'un Compte Administrateur</h1>
14
+
15
+ <div class="mb-8 p-4 border border-blue-200 bg-blue-50 rounded-md">
16
+ <h2 class="text-lg font-semibold text-blue-800 mb-2">Information de Sécurité</h2>
17
+ <p class="text-blue-700">
18
+ Cette page est protégée par un token secret. Partagez ce lien uniquement avec les personnes qui doivent créer des comptes administrateur :
19
+ </p>
20
+ <div class="mt-2 p-3 bg-white border border-blue-300 rounded-md overflow-x-auto">
21
+ <code class="text-sm">{{ url_for('cadmin.admin_panel', secret=token, _external=True) }}</code>
22
+ </div>
23
+ </div>
24
+
25
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-8">
26
+ <div>
27
+ <form method="POST" class="space-y-4">
28
+ {{ form.hidden_tag() }}
29
+ <div>
30
+ <label for="username" class="block text-sm font-medium text-gray-700">Nom d'utilisateur</label>
31
+ {{ form.username(class="mt-1 p-2 w-full border border-gray-300 rounded-md focus:ring focus:ring-blue-200 focus:border-blue-500") }}
32
+ {% if form.username.errors %}
33
+ <div class="text-red-600 text-sm mt-1">
34
+ {% for error in form.username.errors %}
35
+ <span>{{ error }}</span>
36
+ {% endfor %}
37
+ </div>
38
+ {% endif %}
39
+ </div>
40
+ <div>
41
+ <label for="email" class="block text-sm font-medium text-gray-700">Email</label>
42
+ {{ form.email(class="mt-1 p-2 w-full border border-gray-300 rounded-md focus:ring focus:ring-blue-200 focus:border-blue-500") }}
43
+ {% if form.email.errors %}
44
+ <div class="text-red-600 text-sm mt-1">
45
+ {% for error in form.email.errors %}
46
+ <span>{{ error }}</span>
47
+ {% endfor %}
48
+ </div>
49
+ {% endif %}
50
+ </div>
51
+ <div>
52
+ <label for="password" class="block text-sm font-medium text-gray-700">Mot de passe</label>
53
+ {{ form.password(class="mt-1 p-2 w-full border border-gray-300 rounded-md focus:ring focus:ring-blue-200 focus:border-blue-500") }}
54
+ {% if form.password.errors %}
55
+ <div class="text-red-600 text-sm mt-1">
56
+ {% for error in form.password.errors %}
57
+ <span>{{ error }}</span>
58
+ {% endfor %}
59
+ </div>
60
+ {% endif %}
61
+ </div>
62
+ <div>
63
+ <label for="password2" class="block text-sm font-medium text-gray-700">Confirmer le mot de passe</label>
64
+ {{ form.password2(class="mt-1 p-2 w-full border border-gray-300 rounded-md focus:ring focus:ring-blue-200 focus:border-blue-500") }}
65
+ {% if form.password2.errors %}
66
+ <div class="text-red-600 text-sm mt-1">
67
+ {% for error in form.password2.errors %}
68
+ <span>{{ error }}</span>
69
+ {% endfor %}
70
+ </div>
71
+ {% endif %}
72
+ </div>
73
+ <div>
74
+ <button type="submit" class="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
75
+ Créer Administrateur
76
+ </button>
77
+ </div>
78
+ </form>
79
+ </div>
80
+
81
+ <div>
82
+ <h2 class="text-xl font-semibold text-gray-800 mb-4">Administrateurs Existants</h2>
83
+ {% if admins %}
84
+ <div class="border border-gray-200 rounded-md overflow-hidden">
85
+ <table class="min-w-full divide-y divide-gray-200">
86
+ <thead class="bg-gray-50">
87
+ <tr>
88
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Nom d'utilisateur</th>
89
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
90
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date de création</th>
91
+ </tr>
92
+ </thead>
93
+ <tbody class="bg-white divide-y divide-gray-200">
94
+ {% for admin in admins %}
95
+ <tr>
96
+ <td class="px-6 py-4 whitespace-nowrap">{{ admin.username }}</td>
97
+ <td class="px-6 py-4 whitespace-nowrap">{{ admin.email }}</td>
98
+ <td class="px-6 py-4 whitespace-nowrap">{{ admin.created_at.strftime('%d/%m/%Y') }}</td>
99
+ </tr>
100
+ {% endfor %}
101
+ </tbody>
102
+ </table>
103
+ </div>
104
+ {% else %}
105
+ <div class="p-4 border border-gray-200 rounded-md bg-gray-50">
106
+ <p class="text-gray-600">Aucun administrateur n'existe encore. Utilisez le formulaire pour en créer un.</p>
107
+ </div>
108
+ {% endif %}
109
+ </div>
110
+ </div>
111
+ </div>
112
+ {% endblock %}
templates/admin/create_category.html ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'layout.html' %}
2
+
3
+ {% block title %}Créer une Catégorie | Forum Communautaire{% endblock %}
4
+
5
+ {% block breadcrumb %}
6
+ <a href="{{ url_for('forum.index') }}" class="hover:text-blue-600">Accueil</a>
7
+ <span class="mx-2">/</span>
8
+ <a href="{{ url_for('admin.dashboard') }}" class="hover:text-blue-600">Administration</a>
9
+ <span class="mx-2">/</span>
10
+ <a href="{{ url_for('admin.manage_categories') }}" class="hover:text-blue-600">Gérer les Catégories</a>
11
+ <span class="mx-2">/</span>
12
+ <span class="text-gray-700">Créer une Catégorie</span>
13
+ {% endblock %}
14
+
15
+ {% block content %}
16
+ <div class="bg-white rounded-lg shadow-sm p-6">
17
+ <h1 class="text-2xl font-bold mb-6">Créer une Nouvelle Catégorie</h1>
18
+
19
+ <form method="post" action="{{ url_for('admin.create_category') }}" class="space-y-4">
20
+ {{ form.hidden_tag() }}
21
+
22
+ <div class="space-y-2">
23
+ <label for="{{ form.name.id }}" class="block text-sm font-medium text-gray-700">
24
+ Nom <span class="text-red-600">*</span>
25
+ </label>
26
+ {{ form.name(class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring focus:ring-blue-200 focus:border-blue-500") }}
27
+ {% if form.name.errors %}
28
+ <div class="text-red-600 text-sm mt-1">
29
+ {% for error in form.name.errors %}
30
+ <p>{{ error }}</p>
31
+ {% endfor %}
32
+ </div>
33
+ {% endif %}
34
+ </div>
35
+
36
+ <div class="space-y-2">
37
+ <label for="{{ form.description.id }}" class="block text-sm font-medium text-gray-700">
38
+ Description
39
+ </label>
40
+ {{ form.description(class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring focus:ring-blue-200 focus:border-blue-500", rows=4) }}
41
+ {% if form.description.errors %}
42
+ <div class="text-red-600 text-sm mt-1">
43
+ {% for error in form.description.errors %}
44
+ <p>{{ error }}</p>
45
+ {% endfor %}
46
+ </div>
47
+ {% endif %}
48
+ </div>
49
+
50
+ <div class="space-y-2">
51
+ <label for="{{ form.order.id }}" class="block text-sm font-medium text-gray-700">
52
+ Ordre d'Affichage <span class="text-red-600">*</span>
53
+ </label>
54
+ {{ form.order(class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring focus:ring-blue-200 focus:border-blue-500", type="number") }}
55
+ {% if form.order.errors %}
56
+ <div class="text-red-600 text-sm mt-1">
57
+ {% for error in form.order.errors %}
58
+ <p>{{ error }}</p>
59
+ {% endfor %}
60
+ </div>
61
+ {% endif %}
62
+ <p class="text-sm text-gray-500">Entrez un nombre pour définir l'ordre d'affichage (les plus petits nombres apparaissent en premier)</p>
63
+ </div>
64
+
65
+ <div class="flex justify-between pt-4">
66
+ <a href="{{ url_for('admin.manage_categories') }}" class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 focus:outline-none focus:ring">
67
+ Annuler
68
+ </a>
69
+ <button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring">
70
+ Créer la Catégorie
71
+ </button>
72
+ </div>
73
+ </form>
74
+ </div>
75
+ {% endblock %}
templates/admin/create_tag.html ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'layout.html' %}
2
+
3
+ {% block title %}Créer un Tag | Forum Communautaire{% endblock %}
4
+
5
+ {% block breadcrumb %}
6
+ <a href="{{ url_for('forum.index') }}" class="hover:text-blue-600">Accueil</a>
7
+ <span class="mx-2">/</span>
8
+ <a href="{{ url_for('admin.dashboard') }}" class="hover:text-blue-600">Administration</a>
9
+ <span class="mx-2">/</span>
10
+ <a href="{{ url_for('admin.manage_tags') }}" class="hover:text-blue-600">Gérer les Tags</a>
11
+ <span class="mx-2">/</span>
12
+ <span class="text-gray-700">Créer un Tag</span>
13
+ {% endblock %}
14
+
15
+ {% block content %}
16
+ <div class="bg-white rounded-lg shadow-sm p-6">
17
+ <h1 class="text-2xl font-bold mb-6">Créer un Nouveau Tag</h1>
18
+
19
+ <form method="post" action="{{ url_for('admin.create_tag') }}" class="space-y-4">
20
+ {{ form.hidden_tag() }}
21
+
22
+ <div class="space-y-2">
23
+ <label for="{{ form.name.id }}" class="block text-sm font-medium text-gray-700">
24
+ Nom du Tag <span class="text-red-600">*</span>
25
+ </label>
26
+ {{ form.name(class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring focus:ring-blue-200 focus:border-blue-500") }}
27
+ {% if form.name.errors %}
28
+ <div class="text-red-600 text-sm mt-1">
29
+ {% for error in form.name.errors %}
30
+ <p>{{ error }}</p>
31
+ {% endfor %}
32
+ </div>
33
+ {% endif %}
34
+ <p class="text-sm text-gray-500">Entrez un nom simple et descriptif pour le tag. Par exemple : "actualités", "aide", "question".</p>
35
+ </div>
36
+
37
+ <div class="flex justify-between pt-4">
38
+ <a href="{{ url_for('admin.manage_tags') }}" class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 focus:outline-none focus:ring">
39
+ Annuler
40
+ </a>
41
+ <button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring">
42
+ Créer le Tag
43
+ </button>
44
+ </div>
45
+ </form>
46
+ </div>
47
+ {% endblock %}
templates/admin/dashboard.html ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'layout.html' %}
2
+
3
+ {% block title %}Panneau d'Administration | Forum Communautaire{% endblock %}
4
+
5
+ {% block breadcrumb %}
6
+ <a href="{{ url_for('forum.index') }}" class="hover:text-blue-600">Accueil</a>
7
+ <span class="mx-2">/</span>
8
+ <span class="text-gray-700">Administration</span>
9
+ {% endblock %}
10
+
11
+ {% block content %}
12
+ <div class="space-y-6">
13
+ <div class="bg-white rounded-lg shadow-sm p-6">
14
+ <h1 class="text-2xl font-bold mb-4">Panneau d'Administration</h1>
15
+ <p class="text-gray-600 mb-6">
16
+ Bienvenue dans le panneau d'administration du forum. Ici, vous pouvez gérer les catégories, les utilisateurs, les signalements et d'autres aspects du forum.
17
+ </p>
18
+
19
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
20
+ <div class="bg-blue-50 p-4 rounded-lg border border-blue-100">
21
+ <div class="flex items-start">
22
+ <div class="bg-blue-100 rounded-full p-3 mr-4">
23
+ <i data-feather="folder" class="w-6 h-6 text-blue-600"></i>
24
+ </div>
25
+ <div>
26
+ <h3 class="font-semibold text-lg mb-1">Catégories</h3>
27
+ <p class="text-gray-600 text-sm mb-3">
28
+ Gérez les catégories du forum pour organiser les discussions.
29
+ </p>
30
+ <a href="{{ url_for('admin.manage_categories') }}" class="text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center">
31
+ Gérer les catégories
32
+ <i data-feather="chevron-right" class="w-4 h-4 ml-1"></i>
33
+ </a>
34
+ </div>
35
+ </div>
36
+ </div>
37
+
38
+ <div class="bg-green-50 p-4 rounded-lg border border-green-100">
39
+ <div class="flex items-start">
40
+ <div class="bg-green-100 rounded-full p-3 mr-4">
41
+ <i data-feather="users" class="w-6 h-6 text-green-600"></i>
42
+ </div>
43
+ <div>
44
+ <h3 class="font-semibold text-lg mb-1">Utilisateurs</h3>
45
+ <p class="text-gray-600 text-sm mb-3">
46
+ Gérez les comptes utilisateurs, les rôles et les permissions.
47
+ </p>
48
+ <a href="{{ url_for('admin.manage_users') }}" class="text-green-600 hover:text-green-800 text-sm font-medium flex items-center">
49
+ Gérer les utilisateurs
50
+ <i data-feather="chevron-right" class="w-4 h-4 ml-1"></i>
51
+ </a>
52
+ </div>
53
+ </div>
54
+ </div>
55
+
56
+ <div class="bg-red-50 p-4 rounded-lg border border-red-100">
57
+ <div class="flex items-start">
58
+ <div class="bg-red-100 rounded-full p-3 mr-4">
59
+ <i data-feather="flag" class="w-6 h-6 text-red-600"></i>
60
+ </div>
61
+ <div>
62
+ <h3 class="font-semibold text-lg mb-1">Signalements</h3>
63
+ <p class="text-gray-600 text-sm mb-3">
64
+ Examinez et traitez les contenus signalés par les utilisateurs.
65
+ </p>
66
+ <a href="{{ url_for('admin.manage_reports') }}" class="text-red-600 hover:text-red-800 text-sm font-medium flex items-center">
67
+ Gérer les signalements
68
+ <i data-feather="chevron-right" class="w-4 h-4 ml-1"></i>
69
+ </a>
70
+ </div>
71
+ </div>
72
+ </div>
73
+
74
+ <div class="bg-purple-50 p-4 rounded-lg border border-purple-100">
75
+ <div class="flex items-start">
76
+ <div class="bg-purple-100 rounded-full p-3 mr-4">
77
+ <i data-feather="tag" class="w-6 h-6 text-purple-600"></i>
78
+ </div>
79
+ <div>
80
+ <h3 class="font-semibold text-lg mb-1">Tags</h3>
81
+ <p class="text-gray-600 text-sm mb-3">
82
+ Gérez les tags utilisés pour organiser les sujets.
83
+ </p>
84
+ <a href="{{ url_for('admin.manage_tags') }}" class="text-purple-600 hover:text-purple-800 text-sm font-medium flex items-center">
85
+ Gérer les tags
86
+ <i data-feather="chevron-right" class="w-4 h-4 ml-1"></i>
87
+ </a>
88
+ </div>
89
+ </div>
90
+ </div>
91
+ </div>
92
+ </div>
93
+
94
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
95
+ <div class="bg-white rounded-lg shadow-sm p-6">
96
+ <h2 class="text-xl font-bold mb-4 flex items-center">
97
+ <i data-feather="pie-chart" class="w-5 h-5 mr-2 text-blue-600"></i>
98
+ Statistiques du Forum
99
+ </h2>
100
+ <div class="space-y-4">
101
+ <div class="flex justify-between items-center border-b border-gray-100 pb-3">
102
+ <span class="text-gray-600">Total des sujets :</span>
103
+ <span class="font-medium">{{ stats.topics }}</span>
104
+ </div>
105
+ <div class="flex justify-between items-center border-b border-gray-100 pb-3">
106
+ <span class="text-gray-600">Total des messages :</span>
107
+ <span class="font-medium">{{ stats.posts }}</span>
108
+ </div>
109
+ <div class="flex justify-between items-center border-b border-gray-100 pb-3">
110
+ <span class="text-gray-600">Utilisateurs inscrits :</span>
111
+ <span class="font-medium">{{ stats.users }}</span>
112
+ </div>
113
+ <div class="flex justify-between items-center">
114
+ <span class="text-gray-600">Signalements non résolus :</span>
115
+ <span class="font-medium {% if stats.unresolved_reports > 0 %}text-red-600{% endif %}">
116
+ {{ stats.unresolved_reports }}
117
+ </span>
118
+ </div>
119
+ </div>
120
+ </div>
121
+
122
+ <div class="bg-white rounded-lg shadow-sm p-6">
123
+ <h2 class="text-xl font-bold mb-4 flex items-center">
124
+ <i data-feather="activity" class="w-5 h-5 mr-2 text-blue-600"></i>
125
+ Activité Récente
126
+ </h2>
127
+ {% if recent_activities %}
128
+ <div class="space-y-4">
129
+ {% for activity in recent_activities %}
130
+ <div class="flex items-start space-x-3 pb-3 border-b border-gray-100 last:border-0">
131
+ <div class="bg-gray-100 rounded-full p-2 flex-shrink-0">
132
+ <i data-feather="{{ activity.icon }}" class="w-4 h-4 text-gray-600"></i>
133
+ </div>
134
+ <div class="space-y-1">
135
+ <p class="text-sm text-gray-700">{{ activity.description }}</p>
136
+ <p class="text-xs text-gray-500">{{ activity.timestamp | format_datetime }}</p>
137
+ </div>
138
+ </div>
139
+ {% endfor %}
140
+ </div>
141
+ {% else %}
142
+ <p class="text-gray-600 text-sm">Aucune activité récente à afficher.</p>
143
+ {% endif %}
144
+ </div>
145
+ </div>
146
+ </div>
147
+ {% endblock %}
templates/admin/edit_category.html ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'layout.html' %}
2
+
3
+ {% block title %}Modifier une Catégorie | Forum Communautaire{% endblock %}
4
+
5
+ {% block breadcrumb %}
6
+ <a href="{{ url_for('forum.index') }}" class="hover:text-blue-600">Accueil</a>
7
+ <span class="mx-2">/</span>
8
+ <a href="{{ url_for('admin.dashboard') }}" class="hover:text-blue-600">Administration</a>
9
+ <span class="mx-2">/</span>
10
+ <a href="{{ url_for('admin.manage_categories') }}" class="hover:text-blue-600">Gérer les Catégories</a>
11
+ <span class="mx-2">/</span>
12
+ <span class="text-gray-700">Modifier une Catégorie</span>
13
+ {% endblock %}
14
+
15
+ {% block content %}
16
+ <div class="bg-white rounded-lg shadow-sm p-6">
17
+ <h1 class="text-2xl font-bold mb-6">Modifier la Catégorie "{{ category.name }}"</h1>
18
+
19
+ <form method="post" action="{{ url_for('admin.edit_category', id=category.id) }}" class="space-y-4">
20
+ {{ form.hidden_tag() }}
21
+
22
+ <div class="space-y-2">
23
+ <label for="{{ form.name.id }}" class="block text-sm font-medium text-gray-700">
24
+ Nom <span class="text-red-600">*</span>
25
+ </label>
26
+ {{ form.name(class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring focus:ring-blue-200 focus:border-blue-500") }}
27
+ {% if form.name.errors %}
28
+ <div class="text-red-600 text-sm mt-1">
29
+ {% for error in form.name.errors %}
30
+ <p>{{ error }}</p>
31
+ {% endfor %}
32
+ </div>
33
+ {% endif %}
34
+ </div>
35
+
36
+ <div class="space-y-2">
37
+ <label for="{{ form.description.id }}" class="block text-sm font-medium text-gray-700">
38
+ Description
39
+ </label>
40
+ {{ form.description(class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring focus:ring-blue-200 focus:border-blue-500", rows=4) }}
41
+ {% if form.description.errors %}
42
+ <div class="text-red-600 text-sm mt-1">
43
+ {% for error in form.description.errors %}
44
+ <p>{{ error }}</p>
45
+ {% endfor %}
46
+ </div>
47
+ {% endif %}
48
+ </div>
49
+
50
+ <div class="space-y-2">
51
+ <label for="{{ form.order.id }}" class="block text-sm font-medium text-gray-700">
52
+ Ordre d'Affichage <span class="text-red-600">*</span>
53
+ </label>
54
+ {{ form.order(class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring focus:ring-blue-200 focus:border-blue-500", type="number") }}
55
+ {% if form.order.errors %}
56
+ <div class="text-red-600 text-sm mt-1">
57
+ {% for error in form.order.errors %}
58
+ <p>{{ error }}</p>
59
+ {% endfor %}
60
+ </div>
61
+ {% endif %}
62
+ <p class="text-sm text-gray-500">Entrez un nombre pour définir l'ordre d'affichage (les plus petits nombres apparaissent en premier)</p>
63
+ </div>
64
+
65
+ <div class="flex justify-between pt-4">
66
+ <a href="{{ url_for('admin.manage_categories') }}" class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 focus:outline-none focus:ring">
67
+ Annuler
68
+ </a>
69
+ <button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring">
70
+ Enregistrer les Modifications
71
+ </button>
72
+ </div>
73
+ </form>
74
+ </div>
75
+ {% endblock %}
templates/admin/edit_user.html ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'layout.html' %}
2
+
3
+ {% block title %}Modifier l'Utilisateur | Forum Communautaire{% endblock %}
4
+
5
+ {% block breadcrumb %}
6
+ <a href="{{ url_for('forum.index') }}" class="hover:text-blue-600">Accueil</a>
7
+ <span class="mx-2">/</span>
8
+ <a href="{{ url_for('admin.dashboard') }}" class="hover:text-blue-600">Administration</a>
9
+ <span class="mx-2">/</span>
10
+ <a href="{{ url_for('admin.manage_users') }}" class="hover:text-blue-600">Gérer les Utilisateurs</a>
11
+ <span class="mx-2">/</span>
12
+ <span class="text-gray-700">Modifier l'Utilisateur</span>
13
+ {% endblock %}
14
+
15
+ {% block content %}
16
+ <div class="bg-white rounded-lg shadow-sm p-6">
17
+ <div class="flex items-center mb-6">
18
+ <img src="{{ url_for('static', filename='uploads/avatars/' + user.avatar) if user.avatar else url_for('static', filename='uploads/avatars/default.png') }}"
19
+ alt="{{ user.username }}"
20
+ class="w-16 h-16 rounded-full mr-4 object-cover border border-gray-200">
21
+ <div>
22
+ <h1 class="text-2xl font-bold">Modifier l'Utilisateur : {{ user.username }}</h1>
23
+ <p class="text-gray-600">{{ user.email }}</p>
24
+ </div>
25
+ </div>
26
+
27
+ <form method="post" action="{{ url_for('admin.edit_user', id=user.id) }}" class="space-y-4">
28
+ {{ form.hidden_tag() }}
29
+
30
+ <div class="space-y-2">
31
+ <label for="{{ form.role.id }}" class="block text-sm font-medium text-gray-700">
32
+ Rôle <span class="text-red-600">*</span>
33
+ </label>
34
+ {{ form.role(class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring focus:ring-blue-200 focus:border-blue-500") }}
35
+ {% if form.role.errors %}
36
+ <div class="text-red-600 text-sm mt-1">
37
+ {% for error in form.role.errors %}
38
+ <p>{{ error }}</p>
39
+ {% endfor %}
40
+ </div>
41
+ {% endif %}
42
+ </div>
43
+
44
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
45
+ <div class="space-y-2">
46
+ <div class="flex items-center">
47
+ {{ form.is_active(class="h-4 w-4 text-blue-600 border-gray-300 rounded mr-2") }}
48
+ <label for="{{ form.is_active.id }}" class="block text-sm font-medium text-gray-700">
49
+ Compte Actif
50
+ </label>
51
+ </div>
52
+ {% if form.is_active.errors %}
53
+ <div class="text-red-600 text-sm mt-1">
54
+ {% for error in form.is_active.errors %}
55
+ <p>{{ error }}</p>
56
+ {% endfor %}
57
+ </div>
58
+ {% endif %}
59
+ </div>
60
+
61
+ <div class="space-y-2">
62
+ <div class="flex items-center">
63
+ {{ form.is_banned(class="h-4 w-4 text-red-600 border-gray-300 rounded mr-2") }}
64
+ <label for="{{ form.is_banned.id }}" class="block text-sm font-medium text-gray-700">
65
+ Compte Banni
66
+ </label>
67
+ </div>
68
+ {% if form.is_banned.errors %}
69
+ <div class="text-red-600 text-sm mt-1">
70
+ {% for error in form.is_banned.errors %}
71
+ <p>{{ error }}</p>
72
+ {% endfor %}
73
+ </div>
74
+ {% endif %}
75
+ </div>
76
+ </div>
77
+
78
+ <div class="space-y-2">
79
+ <label for="{{ form.ban_reason.id }}" class="block text-sm font-medium text-gray-700">
80
+ Raison du Bannissement
81
+ </label>
82
+ {{ form.ban_reason(class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring focus:ring-blue-200 focus:border-blue-500", rows=3) }}
83
+ {% if form.ban_reason.errors %}
84
+ <div class="text-red-600 text-sm mt-1">
85
+ {% for error in form.ban_reason.errors %}
86
+ <p>{{ error }}</p>
87
+ {% endfor %}
88
+ </div>
89
+ {% endif %}
90
+ <p class="text-sm text-gray-500">Indiquez la raison du bannissement si l'utilisateur est banni. Cette raison sera visible par l'utilisateur.</p>
91
+ </div>
92
+
93
+ <div class="flex justify-between pt-4">
94
+ <a href="{{ url_for('admin.manage_users') }}" class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 focus:outline-none focus:ring">
95
+ Annuler
96
+ </a>
97
+ <button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring">
98
+ Enregistrer les Modifications
99
+ </button>
100
+ </div>
101
+ </form>
102
+ </div>
103
+
104
+ <div class="bg-white rounded-lg shadow-sm p-6 mt-6">
105
+ <h2 class="text-xl font-bold mb-4">Informations Utilisateur</h2>
106
+
107
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
108
+ <div>
109
+ <h3 class="text-sm font-medium text-gray-500">Date d'inscription</h3>
110
+ <p class="text-gray-900">{{ user.created_at | format_datetime }}</p>
111
+ </div>
112
+
113
+ <div>
114
+ <h3 class="text-sm font-medium text-gray-500">Dernière connexion</h3>
115
+ <p class="text-gray-900">{{ user.last_seen | format_datetime }}</p>
116
+ </div>
117
+
118
+ <div>
119
+ <h3 class="text-sm font-medium text-gray-500">Sujets créés</h3>
120
+ <p class="text-gray-900">{{ user.topics.count() }}</p>
121
+ </div>
122
+
123
+ <div>
124
+ <h3 class="text-sm font-medium text-gray-500">Messages postés</h3>
125
+ <p class="text-gray-900">{{ user.posts.count() }}</p>
126
+ </div>
127
+ </div>
128
+
129
+ <div class="mt-4">
130
+ <a href="{{ url_for('user.profile', username=user.username) }}" class="text-blue-600 hover:text-blue-800 flex items-center">
131
+ <i data-feather="external-link" class="w-4 h-4 mr-1"></i>
132
+ Voir le profil public
133
+ </a>
134
+ </div>
135
+ </div>
136
+ {% endblock %}
templates/admin/manage_categories.html ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'layout.html' %}
2
+
3
+ {% block title %}Gérer les Catégories | Forum Communautaire{% endblock %}
4
+
5
+ {% block breadcrumb %}
6
+ <a href="{{ url_for('forum.index') }}" class="hover:text-blue-600">Accueil</a>
7
+ <span class="mx-2">/</span>
8
+ <a href="{{ url_for('admin.dashboard') }}" class="hover:text-blue-600">Administration</a>
9
+ <span class="mx-2">/</span>
10
+ <span class="text-gray-700">Gérer les Catégories</span>
11
+ {% endblock %}
12
+
13
+ {% block content %}
14
+ <div class="bg-white rounded-lg shadow-sm p-6">
15
+ <div class="flex justify-between items-center mb-6">
16
+ <h1 class="text-2xl font-bold">Gérer les Catégories</h1>
17
+ <a href="{{ url_for('admin.create_category') }}" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring flex items-center">
18
+ <i data-feather="plus" class="w-4 h-4 mr-2"></i>
19
+ Nouvelle Catégorie
20
+ </a>
21
+ </div>
22
+
23
+ {% if categories %}
24
+ <div class="overflow-x-auto">
25
+ <table class="min-w-full divide-y divide-gray-200">
26
+ <thead class="bg-gray-50">
27
+ <tr>
28
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
29
+ Ordre
30
+ </th>
31
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
32
+ Nom
33
+ </th>
34
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
35
+ Description
36
+ </th>
37
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
38
+ Sujets
39
+ </th>
40
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
41
+ Actions
42
+ </th>
43
+ </tr>
44
+ </thead>
45
+ <tbody class="bg-white divide-y divide-gray-200">
46
+ {% for category in categories %}
47
+ <tr>
48
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
49
+ {{ category.order }}
50
+ </td>
51
+ <td class="px-6 py-4 whitespace-nowrap">
52
+ <div class="text-sm font-medium text-gray-900">
53
+ <a href="{{ url_for('forum.category_view', id=category.id) }}" class="hover:text-blue-600">
54
+ {{ category.name }}
55
+ </a>
56
+ </div>
57
+ </td>
58
+ <td class="px-6 py-4">
59
+ <div class="text-sm text-gray-500 line-clamp-2">
60
+ {{ category.description or "Aucune description" }}
61
+ </div>
62
+ </td>
63
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
64
+ {{ category.topic_count() }}
65
+ </td>
66
+ <td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
67
+ <div class="flex space-x-2">
68
+ <a href="{{ url_for('admin.edit_category', id=category.id) }}" class="text-blue-600 hover:text-blue-900">
69
+ <i data-feather="edit" class="w-4 h-4"></i>
70
+ <span class="sr-only">Modifier</span>
71
+ </a>
72
+ <form action="{{ url_for('admin.delete_category', id=category.id) }}" method="post" class="delete-form inline">
73
+ <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
74
+ <button type="submit" class="text-red-600 hover:text-red-900 bg-transparent border-none p-0 cursor-pointer"
75
+ data-confirm="Êtes-vous sûr de vouloir supprimer cette catégorie ? Cette action est irréversible et supprimera également tous les sujets associés.">
76
+ <i data-feather="trash-2" class="w-4 h-4"></i>
77
+ <span class="sr-only">Supprimer</span>
78
+ </button>
79
+ </form>
80
+ </div>
81
+ </td>
82
+ </tr>
83
+ {% endfor %}
84
+ </tbody>
85
+ </table>
86
+ </div>
87
+ {% else %}
88
+ <div class="bg-gray-50 p-6 text-center rounded-lg">
89
+ <p class="text-gray-600">Aucune catégorie n'a été créée.</p>
90
+ <a href="{{ url_for('admin.create_category') }}" class="mt-2 inline-block px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring">
91
+ Créer votre première catégorie
92
+ </a>
93
+ </div>
94
+ {% endif %}
95
+ </div>
96
+ {% endblock %}
97
+
98
+ {% block extra_js %}
99
+ <script>
100
+ document.addEventListener('DOMContentLoaded', function() {
101
+ // Configuration des confirmations de suppression
102
+ document.querySelectorAll('.delete-form').forEach(form => {
103
+ form.addEventListener('submit', function(e) {
104
+ e.preventDefault();
105
+ const confirmMessage = this.querySelector('button').getAttribute('data-confirm');
106
+ if (confirm(confirmMessage)) {
107
+ this.submit();
108
+ }
109
+ });
110
+ });
111
+ });
112
+ </script>
113
+ {% endblock %}
templates/admin/manage_reports.html ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'layout.html' %}
2
+
3
+ {% block title %}Gérer les Signalements | Forum Communautaire{% endblock %}
4
+
5
+ {% block breadcrumb %}
6
+ <a href="{{ url_for('forum.index') }}" class="hover:text-blue-600">Accueil</a>
7
+ <span class="mx-2">/</span>
8
+ <a href="{{ url_for('admin.dashboard') }}" class="hover:text-blue-600">Administration</a>
9
+ <span class="mx-2">/</span>
10
+ <span class="text-gray-700">Gérer les Signalements</span>
11
+ {% endblock %}
12
+
13
+ {% block content %}
14
+ <div class="bg-white rounded-lg shadow-sm p-6">
15
+ <div class="flex justify-between items-center mb-6">
16
+ <h1 class="text-2xl font-bold">Gérer les Signalements</h1>
17
+
18
+ <div class="flex space-x-2">
19
+ {% if show_resolved %}
20
+ <a href="{{ url_for('admin.manage_reports', show_resolved=0) }}" class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 focus:outline-none focus:ring">
21
+ Masquer les signalements résolus
22
+ </a>
23
+ {% else %}
24
+ <a href="{{ url_for('admin.manage_reports', show_resolved=1) }}" class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 focus:outline-none focus:ring">
25
+ Afficher tous les signalements
26
+ </a>
27
+ {% endif %}
28
+ </div>
29
+ </div>
30
+
31
+ {% if reports.items %}
32
+ <div class="space-y-6">
33
+ {% for report in reports.items %}
34
+ <div class="border border-gray-200 rounded-lg p-4 {% if not report.is_resolved %}bg-red-50 border-red-100{% endif %}">
35
+ <div class="flex justify-between items-start mb-3">
36
+ <div class="flex items-center">
37
+ <div class="bg-red-100 p-2 rounded-full mr-3">
38
+ <i data-feather="flag" class="w-5 h-5 text-red-600"></i>
39
+ </div>
40
+ <div>
41
+ <div class="font-medium">
42
+ {% if report.post_id %}
43
+ <span>Message signalé</span>
44
+ {% else %}
45
+ <span>Sujet signalé</span>
46
+ {% endif %}
47
+ <span class="text-gray-500">par</span>
48
+ <a href="{{ url_for('user.profile', username=report.reporter.username) }}" class="text-blue-600 hover:underline">
49
+ {{ report.reporter.username }}
50
+ </a>
51
+ </div>
52
+ <div class="text-sm text-gray-500">
53
+ {{ report.created_at | format_datetime }}
54
+ </div>
55
+ </div>
56
+ </div>
57
+ <div>
58
+ {% if report.is_resolved %}
59
+ <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
60
+ <i data-feather="check" class="w-3 h-3 mr-1"></i>
61
+ Résolu par {{ report.resolved_by.username }}
62
+ </span>
63
+ {% else %}
64
+ <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
65
+ <i data-feather="alert-circle" class="w-3 h-3 mr-1"></i>
66
+ En attente
67
+ </span>
68
+ {% endif %}
69
+ </div>
70
+ </div>
71
+
72
+ <div class="mb-4">
73
+ <h3 class="text-sm font-medium text-gray-700 mb-1">Raison du signalement:</h3>
74
+ <div class="bg-white p-3 rounded border border-gray-200 text-sm">
75
+ {{ report.reason }}
76
+ </div>
77
+ </div>
78
+
79
+ <div class="mb-4">
80
+ <h3 class="text-sm font-medium text-gray-700 mb-1">Contenu signalé:</h3>
81
+ <div class="bg-white p-3 rounded border border-gray-200 text-sm">
82
+ {% if report.post_id %}
83
+ <div class="flex items-start mb-2">
84
+ <img src="{{ url_for('static', filename='uploads/avatars/' + report.post.author.avatar) if report.post.author.avatar else url_for('static', filename='uploads/avatars/default.png') }}"
85
+ alt="{{ report.post.author.username }}"
86
+ class="w-8 h-8 rounded-full mr-2 object-cover">
87
+ <div>
88
+ <div class="font-medium">{{ report.post.author.username }}</div>
89
+ <div class="text-xs text-gray-500">{{ report.post.created_at | format_datetime }}</div>
90
+ </div>
91
+ </div>
92
+ <div>{{ report.post.content | safe }}</div>
93
+ {% else %}
94
+ <div class="flex items-start mb-2">
95
+ <img src="{{ url_for('static', filename='uploads/avatars/' + report.topic.author.avatar) if report.topic.author.avatar else url_for('static', filename='uploads/avatars/default.png') }}"
96
+ alt="{{ report.topic.author.username }}"
97
+ class="w-8 h-8 rounded-full mr-2 object-cover">
98
+ <div>
99
+ <div class="font-medium">{{ report.topic.author.username }}</div>
100
+ <div class="text-xs text-gray-500">{{ report.topic.created_at | format_datetime }}</div>
101
+ </div>
102
+ </div>
103
+ <div class="font-medium text-blue-600">{{ report.topic.title }}</div>
104
+ <div>{{ report.topic.posts[0].content | safe }}</div>
105
+ {% endif %}
106
+ </div>
107
+ </div>
108
+
109
+ {% if not report.is_resolved %}
110
+ <div class="flex space-x-2">
111
+ <form action="{{ url_for('admin.resolve_report', id=report.id) }}" method="post">
112
+ <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
113
+ <button type="submit" class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700 focus:outline-none focus:ring">
114
+ <i data-feather="check" class="w-4 h-4 mr-1 inline"></i>
115
+ Marquer comme résolu
116
+ </button>
117
+ </form>
118
+ <form action="{{ url_for('admin.delete_reported_content', id=report.id) }}" method="post" class="delete-content-form">
119
+ <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
120
+ <button type="submit" class="px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700 focus:outline-none focus:ring">
121
+ <i data-feather="trash-2" class="w-4 h-4 mr-1 inline"></i>
122
+ Supprimer le contenu
123
+ </button>
124
+ </form>
125
+ </div>
126
+ {% endif %}
127
+ </div>
128
+ {% endfor %}
129
+ </div>
130
+
131
+ <!-- Pagination -->
132
+ {% if reports.pages > 1 %}
133
+ <div class="mt-4 flex justify-center">
134
+ <nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
135
+ {% if reports.has_prev %}
136
+ <a href="{{ url_for('admin.manage_reports', page=reports.prev_num, show_resolved=show_resolved) }}" class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
137
+ <span class="sr-only">Précédent</span>
138
+ <i data-feather="chevron-left" class="w-5 h-5"></i>
139
+ </a>
140
+ {% else %}
141
+ <span class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-gray-100 text-sm font-medium text-gray-400 cursor-not-allowed">
142
+ <span class="sr-only">Précédent</span>
143
+ <i data-feather="chevron-left" class="w-5 h-5"></i>
144
+ </span>
145
+ {% endif %}
146
+
147
+ {% for page_num in reports.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
148
+ {% if page_num %}
149
+ {% if page_num == reports.page %}
150
+ <span class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-blue-50 text-sm font-medium text-blue-600">
151
+ {{ page_num }}
152
+ </span>
153
+ {% else %}
154
+ <a href="{{ url_for('admin.manage_reports', page=page_num, show_resolved=show_resolved) }}" class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50">
155
+ {{ page_num }}
156
+ </a>
157
+ {% endif %}
158
+ {% else %}
159
+ <span class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700">
160
+ ...
161
+ </span>
162
+ {% endif %}
163
+ {% endfor %}
164
+
165
+ {% if reports.has_next %}
166
+ <a href="{{ url_for('admin.manage_reports', page=reports.next_num, show_resolved=show_resolved) }}" class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
167
+ <span class="sr-only">Suivant</span>
168
+ <i data-feather="chevron-right" class="w-5 h-5"></i>
169
+ </a>
170
+ {% else %}
171
+ <span class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-gray-100 text-sm font-medium text-gray-400 cursor-not-allowed">
172
+ <span class="sr-only">Suivant</span>
173
+ <i data-feather="chevron-right" class="w-5 h-5"></i>
174
+ </span>
175
+ {% endif %}
176
+ </nav>
177
+ </div>
178
+ {% endif %}
179
+
180
+ {% else %}
181
+ <div class="bg-gray-50 p-6 text-center rounded-lg">
182
+ <p class="text-gray-600">
183
+ {% if show_resolved %}
184
+ Aucun signalement trouvé.
185
+ {% else %}
186
+ Aucun signalement en attente. Tout est résolu!
187
+ {% endif %}
188
+ </p>
189
+ </div>
190
+ {% endif %}
191
+ </div>
192
+ {% endblock %}
193
+
194
+ {% block extra_js %}
195
+ <script>
196
+ document.addEventListener('DOMContentLoaded', function() {
197
+ // Confirmation pour supprimer le contenu signalé
198
+ document.querySelectorAll('.delete-content-form').forEach(form => {
199
+ form.addEventListener('submit', function(e) {
200
+ e.preventDefault();
201
+ if (confirm('Êtes-vous sûr de vouloir supprimer ce contenu? Cette action est irréversible.')) {
202
+ this.submit();
203
+ }
204
+ });
205
+ });
206
+ });
207
+ </script>
208
+ {% endblock %}
templates/admin/manage_tags.html ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'layout.html' %}
2
+
3
+ {% block title %}Gérer les Tags | Forum Communautaire{% endblock %}
4
+
5
+ {% block breadcrumb %}
6
+ <a href="{{ url_for('forum.index') }}" class="hover:text-blue-600">Accueil</a>
7
+ <span class="mx-2">/</span>
8
+ <a href="{{ url_for('admin.dashboard') }}" class="hover:text-blue-600">Administration</a>
9
+ <span class="mx-2">/</span>
10
+ <span class="text-gray-700">Gérer les Tags</span>
11
+ {% endblock %}
12
+
13
+ {% block content %}
14
+ <div class="bg-white rounded-lg shadow-sm p-6">
15
+ <div class="flex justify-between items-center mb-6">
16
+ <h1 class="text-2xl font-bold">Gérer les Tags</h1>
17
+ <a href="{{ url_for('admin.create_tag') }}" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring flex items-center">
18
+ <i data-feather="plus" class="w-4 h-4 mr-2"></i>
19
+ Nouveau Tag
20
+ </a>
21
+ </div>
22
+
23
+ {% if tags.items %}
24
+ <div class="overflow-x-auto">
25
+ <table class="min-w-full divide-y divide-gray-200">
26
+ <thead class="bg-gray-50">
27
+ <tr>
28
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
29
+ Nom
30
+ </th>
31
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
32
+ Sujets
33
+ </th>
34
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
35
+ Actions
36
+ </th>
37
+ </tr>
38
+ </thead>
39
+ <tbody class="bg-white divide-y divide-gray-200">
40
+ {% for tag in tags.items %}
41
+ <tr>
42
+ <td class="px-6 py-4">
43
+ <div class="flex items-center">
44
+ <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
45
+ {{ tag.name }}
46
+ </span>
47
+ </div>
48
+ </td>
49
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
50
+ {{ tag.topics.count() }}
51
+ </td>
52
+ <td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
53
+ <div class="flex space-x-2">
54
+ <a href="{{ url_for('forum.tag_view', tag_name=tag.name) }}" class="text-blue-600 hover:text-blue-900">
55
+ <i data-feather="eye" class="w-4 h-4"></i>
56
+ <span class="sr-only">Voir</span>
57
+ </a>
58
+ <form action="{{ url_for('admin.delete_tag', id=tag.id) }}" method="post" class="delete-form inline">
59
+ <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
60
+ <button type="submit" class="text-red-600 hover:text-red-900 bg-transparent border-none p-0 cursor-pointer"
61
+ data-confirm="Êtes-vous sûr de vouloir supprimer ce tag ? Les sujets associés ne seront pas supprimés, mais ne seront plus taggés.">
62
+ <i data-feather="trash-2" class="w-4 h-4"></i>
63
+ <span class="sr-only">Supprimer</span>
64
+ </button>
65
+ </form>
66
+ </div>
67
+ </td>
68
+ </tr>
69
+ {% endfor %}
70
+ </tbody>
71
+ </table>
72
+ </div>
73
+
74
+ <!-- Pagination -->
75
+ {% if tags.pages > 1 %}
76
+ <div class="mt-4 flex justify-center">
77
+ <nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
78
+ {% if tags.has_prev %}
79
+ <a href="{{ url_for('admin.manage_tags', page=tags.prev_num) }}" class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
80
+ <span class="sr-only">Précédent</span>
81
+ <i data-feather="chevron-left" class="w-5 h-5"></i>
82
+ </a>
83
+ {% else %}
84
+ <span class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-gray-100 text-sm font-medium text-gray-400 cursor-not-allowed">
85
+ <span class="sr-only">Précédent</span>
86
+ <i data-feather="chevron-left" class="w-5 h-5"></i>
87
+ </span>
88
+ {% endif %}
89
+
90
+ {% for page_num in tags.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
91
+ {% if page_num %}
92
+ {% if page_num == tags.page %}
93
+ <span class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-blue-50 text-sm font-medium text-blue-600">
94
+ {{ page_num }}
95
+ </span>
96
+ {% else %}
97
+ <a href="{{ url_for('admin.manage_tags', page=page_num) }}" class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50">
98
+ {{ page_num }}
99
+ </a>
100
+ {% endif %}
101
+ {% else %}
102
+ <span class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700">
103
+ ...
104
+ </span>
105
+ {% endif %}
106
+ {% endfor %}
107
+
108
+ {% if tags.has_next %}
109
+ <a href="{{ url_for('admin.manage_tags', page=tags.next_num) }}" class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
110
+ <span class="sr-only">Suivant</span>
111
+ <i data-feather="chevron-right" class="w-5 h-5"></i>
112
+ </a>
113
+ {% else %}
114
+ <span class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-gray-100 text-sm font-medium text-gray-400 cursor-not-allowed">
115
+ <span class="sr-only">Suivant</span>
116
+ <i data-feather="chevron-right" class="w-5 h-5"></i>
117
+ </span>
118
+ {% endif %}
119
+ </nav>
120
+ </div>
121
+ {% endif %}
122
+
123
+ {% else %}
124
+ <div class="bg-gray-50 p-6 text-center rounded-lg">
125
+ <p class="text-gray-600">Aucun tag n'a été créé.</p>
126
+ <a href="{{ url_for('admin.create_tag') }}" class="mt-2 inline-block px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring">
127
+ Créer votre premier tag
128
+ </a>
129
+ </div>
130
+ {% endif %}
131
+ </div>
132
+ {% endblock %}
133
+
134
+ {% block extra_js %}
135
+ <script>
136
+ document.addEventListener('DOMContentLoaded', function() {
137
+ // Configuration des confirmations de suppression
138
+ document.querySelectorAll('.delete-form').forEach(form => {
139
+ form.addEventListener('submit', function(e) {
140
+ e.preventDefault();
141
+ const confirmMessage = this.querySelector('button').getAttribute('data-confirm');
142
+ if (confirm(confirmMessage)) {
143
+ this.submit();
144
+ }
145
+ });
146
+ });
147
+ });
148
+ </script>
149
+ {% endblock %}
templates/admin/manage_users.html ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'layout.html' %}
2
+
3
+ {% block title %}Gérer les Utilisateurs | Forum Communautaire{% endblock %}
4
+
5
+ {% block breadcrumb %}
6
+ <a href="{{ url_for('forum.index') }}" class="hover:text-blue-600">Accueil</a>
7
+ <span class="mx-2">/</span>
8
+ <a href="{{ url_for('admin.dashboard') }}" class="hover:text-blue-600">Administration</a>
9
+ <span class="mx-2">/</span>
10
+ <span class="text-gray-700">Gérer les Utilisateurs</span>
11
+ {% endblock %}
12
+
13
+ {% block content %}
14
+ <div class="bg-white rounded-lg shadow-sm p-6">
15
+ <div class="flex justify-between items-center mb-6">
16
+ <h1 class="text-2xl font-bold">Gérer les Utilisateurs</h1>
17
+
18
+ <div class="flex">
19
+ <form method="get" action="{{ url_for('admin.manage_users') }}" class="flex mr-2">
20
+ <input type="search" name="q" placeholder="Rechercher un utilisateur..."
21
+ class="px-4 py-2 border border-gray-300 rounded-l-lg focus-ring"
22
+ value="{{ request.args.get('q', '') }}">
23
+ <button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded-r-lg hover:bg-blue-700 focus:outline-none focus:ring">
24
+ <i data-feather="search" class="w-5 h-5"></i>
25
+ </button>
26
+ </form>
27
+ </div>
28
+ </div>
29
+
30
+ {% if users.items %}
31
+ <div class="overflow-x-auto">
32
+ <table class="min-w-full divide-y divide-gray-200">
33
+ <thead class="bg-gray-50">
34
+ <tr>
35
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
36
+ Utilisateur
37
+ </th>
38
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
39
+ Email
40
+ </th>
41
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
42
+ Rôle
43
+ </th>
44
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
45
+ Statut
46
+ </th>
47
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
48
+ Inscription
49
+ </th>
50
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
51
+ Actions
52
+ </th>
53
+ </tr>
54
+ </thead>
55
+ <tbody class="bg-white divide-y divide-gray-200">
56
+ {% for user in users.items %}
57
+ <tr>
58
+ <td class="px-6 py-4 whitespace-nowrap">
59
+ <div class="flex items-center">
60
+ <div class="flex-shrink-0 h-10 w-10">
61
+ <img class="h-10 w-10 rounded-full object-cover"
62
+ src="{{ url_for('static', filename='uploads/avatars/' + user.avatar) if user.avatar else url_for('static', filename='uploads/avatars/default.png') }}"
63
+ alt="{{ user.username }}">
64
+ </div>
65
+ <div class="ml-4">
66
+ <div class="text-sm font-medium text-gray-900">
67
+ {{ user.username }}
68
+ </div>
69
+ </div>
70
+ </div>
71
+ </td>
72
+ <td class="px-6 py-4 whitespace-nowrap">
73
+ <div class="text-sm text-gray-900">{{ user.email }}</div>
74
+ </td>
75
+ <td class="px-6 py-4 whitespace-nowrap">
76
+ <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full
77
+ {% if user.role == 'admin' %}
78
+ bg-purple-100 text-purple-800
79
+ {% elif user.role == 'moderator' %}
80
+ bg-blue-100 text-blue-800
81
+ {% else %}
82
+ bg-green-100 text-green-800
83
+ {% endif %}">
84
+ {% if user.role == 'admin' %}
85
+ Administrateur
86
+ {% elif user.role == 'moderator' %}
87
+ Modérateur
88
+ {% else %}
89
+ Membre
90
+ {% endif %}
91
+ </span>
92
+ </td>
93
+ <td class="px-6 py-4 whitespace-nowrap">
94
+ {% if user.is_banned %}
95
+ <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
96
+ Banni
97
+ </span>
98
+ {% elif not user.is_active %}
99
+ <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800">
100
+ Inactif
101
+ </span>
102
+ {% else %}
103
+ <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
104
+ Actif
105
+ </span>
106
+ {% endif %}
107
+ </td>
108
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
109
+ {{ user.created_at | format_datetime }}
110
+ </td>
111
+ <td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
112
+ <div class="flex space-x-2">
113
+ <a href="{{ url_for('user.profile', username=user.username) }}" class="text-blue-600 hover:text-blue-900">
114
+ <i data-feather="eye" class="w-4 h-4"></i>
115
+ <span class="sr-only">Voir</span>
116
+ </a>
117
+ <a href="{{ url_for('admin.edit_user', id=user.id) }}" class="text-green-600 hover:text-green-900">
118
+ <i data-feather="edit" class="w-4 h-4"></i>
119
+ <span class="sr-only">Modifier</span>
120
+ </a>
121
+ </div>
122
+ </td>
123
+ </tr>
124
+ {% endfor %}
125
+ </tbody>
126
+ </table>
127
+ </div>
128
+
129
+ <!-- Pagination -->
130
+ {% if users.pages > 1 %}
131
+ <div class="mt-4 flex justify-center">
132
+ <nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
133
+ {% if users.has_prev %}
134
+ <a href="{{ url_for('admin.manage_users', page=users.prev_num, q=request.args.get('q', '')) }}" class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
135
+ <span class="sr-only">Précédent</span>
136
+ <i data-feather="chevron-left" class="w-5 h-5"></i>
137
+ </a>
138
+ {% else %}
139
+ <span class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-gray-100 text-sm font-medium text-gray-400 cursor-not-allowed">
140
+ <span class="sr-only">Précédent</span>
141
+ <i data-feather="chevron-left" class="w-5 h-5"></i>
142
+ </span>
143
+ {% endif %}
144
+
145
+ {% for page_num in users.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
146
+ {% if page_num %}
147
+ {% if page_num == users.page %}
148
+ <span class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-blue-50 text-sm font-medium text-blue-600">
149
+ {{ page_num }}
150
+ </span>
151
+ {% else %}
152
+ <a href="{{ url_for('admin.manage_users', page=page_num, q=request.args.get('q', '')) }}" class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50">
153
+ {{ page_num }}
154
+ </a>
155
+ {% endif %}
156
+ {% else %}
157
+ <span class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700">
158
+ ...
159
+ </span>
160
+ {% endif %}
161
+ {% endfor %}
162
+
163
+ {% if users.has_next %}
164
+ <a href="{{ url_for('admin.manage_users', page=users.next_num, q=request.args.get('q', '')) }}" class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
165
+ <span class="sr-only">Suivant</span>
166
+ <i data-feather="chevron-right" class="w-5 h-5"></i>
167
+ </a>
168
+ {% else %}
169
+ <span class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-gray-100 text-sm font-medium text-gray-400 cursor-not-allowed">
170
+ <span class="sr-only">Suivant</span>
171
+ <i data-feather="chevron-right" class="w-5 h-5"></i>
172
+ </span>
173
+ {% endif %}
174
+ </nav>
175
+ </div>
176
+ {% endif %}
177
+
178
+ {% else %}
179
+ <div class="bg-gray-50 p-6 text-center rounded-lg">
180
+ <p class="text-gray-600">Aucun utilisateur trouvé.</p>
181
+ </div>
182
+ {% endif %}
183
+ </div>
184
+ {% endblock %}
templates/auth/login.html ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "layout.html" %}
2
+
3
+ {% block title %}Login - Community Forum{% endblock %}
4
+
5
+ {% block breadcrumb %}
6
+ <a href="{{ url_for('forum.index') }}" class="hover:text-blue-600">Home</a>
7
+ <span class="mx-2">/</span>
8
+ <span>Login</span>
9
+ {% endblock %}
10
+
11
+ {% block content %}
12
+ <div class="max-w-md mx-auto">
13
+ <div class="bg-white rounded-lg shadow overflow-hidden">
14
+ <div class="px-6 py-4 border-b border-gray-200">
15
+ <h1 class="text-2xl font-bold text-gray-800">Login</h1>
16
+ <p class="text-gray-600 mt-1">Sign in to access your account</p>
17
+ </div>
18
+
19
+ <div class="p-6">
20
+ <form method="POST" action="{{ url_for('auth.login') }}">
21
+ {{ form.hidden_tag() }}
22
+
23
+ <div class="mb-4">
24
+ <label for="username" class="block text-gray-700 font-medium mb-2">Username or Email</label>
25
+ {{ form.username(class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:border-blue-500 focus:ring focus:ring-blue-200", id="username") }}
26
+ {% if form.username.errors %}
27
+ <div class="text-red-600 text-sm mt-1">
28
+ {% for error in form.username.errors %}
29
+ <p>{{ error }}</p>
30
+ {% endfor %}
31
+ </div>
32
+ {% endif %}
33
+ </div>
34
+
35
+ <div class="mb-4">
36
+ <label for="password" class="block text-gray-700 font-medium mb-2">Password</label>
37
+ {{ form.password(class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:border-blue-500 focus:ring focus:ring-blue-200", id="password") }}
38
+ {% if form.password.errors %}
39
+ <div class="text-red-600 text-sm mt-1">
40
+ {% for error in form.password.errors %}
41
+ <p>{{ error }}</p>
42
+ {% endfor %}
43
+ </div>
44
+ {% endif %}
45
+ </div>
46
+
47
+ <div class="flex items-center mb-4">
48
+ {{ form.remember_me(class="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500", id="remember_me") }}
49
+ <label for="remember_me" class="ml-2 block text-gray-700">Remember me</label>
50
+ </div>
51
+
52
+ <div>
53
+ <button type="submit" class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
54
+ Sign In
55
+ </button>
56
+ </div>
57
+ </form>
58
+ </div>
59
+
60
+ <div class="px-6 py-4 bg-gray-50 border-t border-gray-200">
61
+ <p class="text-gray-600 text-center">
62
+ Don't have an account?
63
+ <a href="{{ url_for('auth.register') }}" class="text-blue-600 hover:underline">Register here</a>
64
+ </p>
65
+ </div>
66
+ </div>
67
+ </div>
68
+ {% endblock %}
templates/auth/register.html ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "layout.html" %}
2
+
3
+ {% block title %}Register - Community Forum{% endblock %}
4
+
5
+ {% block breadcrumb %}
6
+ <a href="{{ url_for('forum.index') }}" class="hover:text-blue-600">Home</a>
7
+ <span class="mx-2">/</span>
8
+ <span>Register</span>
9
+ {% endblock %}
10
+
11
+ {% block content %}
12
+ <div class="max-w-md mx-auto">
13
+ <div class="bg-white rounded-lg shadow overflow-hidden">
14
+ <div class="px-6 py-4 border-b border-gray-200">
15
+ <h1 class="text-2xl font-bold text-gray-800">Register</h1>
16
+ <p class="text-gray-600 mt-1">Create a new account</p>
17
+ </div>
18
+
19
+ <div class="p-6">
20
+ <form method="POST" action="{{ url_for('auth.register') }}">
21
+ {{ form.hidden_tag() }}
22
+
23
+ <div class="mb-4">
24
+ <label for="username" class="block text-gray-700 font-medium mb-2">Username</label>
25
+ {{ form.username(class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:border-blue-500 focus:ring focus:ring-blue-200", id="username") }}
26
+ {% if form.username.errors %}
27
+ <div class="text-red-600 text-sm mt-1">
28
+ {% for error in form.username.errors %}
29
+ <p>{{ error }}</p>
30
+ {% endfor %}
31
+ </div>
32
+ {% endif %}
33
+ <p class="text-gray-500 text-xs mt-1">Choose a unique username (3-64 characters)</p>
34
+ </div>
35
+
36
+ <div class="mb-4">
37
+ <label for="email" class="block text-gray-700 font-medium mb-2">Email</label>
38
+ {{ form.email(class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:border-blue-500 focus:ring focus:ring-blue-200", id="email") }}
39
+ {% if form.email.errors %}
40
+ <div class="text-red-600 text-sm mt-1">
41
+ {% for error in form.email.errors %}
42
+ <p>{{ error }}</p>
43
+ {% endfor %}
44
+ </div>
45
+ {% endif %}
46
+ </div>
47
+
48
+ <div class="mb-4">
49
+ <label for="password" class="block text-gray-700 font-medium mb-2">Password</label>
50
+ {{ form.password(class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:border-blue-500 focus:ring focus:ring-blue-200", id="password") }}
51
+ {% if form.password.errors %}
52
+ <div class="text-red-600 text-sm mt-1">
53
+ {% for error in form.password.errors %}
54
+ <p>{{ error }}</p>
55
+ {% endfor %}
56
+ </div>
57
+ {% endif %}
58
+ <p class="text-gray-500 text-xs mt-1">Must be at least 8 characters</p>
59
+ </div>
60
+
61
+ <div class="mb-4">
62
+ <label for="password2" class="block text-gray-700 font-medium mb-2">Confirm Password</label>
63
+ {{ form.password2(class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:border-blue-500 focus:ring focus:ring-blue-200", id="password2") }}
64
+ {% if form.password2.errors %}
65
+ <div class="text-red-600 text-sm mt-1">
66
+ {% for error in form.password2.errors %}
67
+ <p>{{ error }}</p>
68
+ {% endfor %}
69
+ </div>
70
+ {% endif %}
71
+ </div>
72
+
73
+ <div>
74
+ <button type="submit" class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
75
+ Register
76
+ </button>
77
+ </div>
78
+ </form>
79
+ </div>
80
+
81
+ <div class="px-6 py-4 bg-gray-50 border-t border-gray-200">
82
+ <p class="text-gray-600 text-center">
83
+ Already have an account?
84
+ <a href="{{ url_for('auth.login') }}" class="text-blue-600 hover:underline">Log in</a>
85
+ </p>
86
+ </div>
87
+ </div>
88
+ </div>
89
+ {% endblock %}
templates/base.html DELETED
@@ -1,65 +0,0 @@
1
- <!doctype html>
2
- <html lang="fr">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0"> <!-- Important pour le responsive -->
6
- <title>{% block title %}Forum Anonyme{% endblock %}</title>
7
- <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/tailwind.min.css" rel="stylesheet">
8
- <!-- <link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}"> --> <!-- Plus besoin de ce fichier -->
9
- <style>
10
- .quoted-text {
11
- background-color: #f0f4f8; /* Couleur de fond légère pour le texte cité */
12
- border-left: 4px solid #60a5fa; /* Barre verticale bleue */
13
- padding: 0.5rem 1rem; /* Espacement intérieur */
14
- margin-bottom: 0.5rem; /* Marge en dessous */
15
- font-style: italic; /* Texte en italique */
16
- }
17
- .quoted-message-id{
18
- font-size: 0.8em; /* Taille de police plus petite */
19
- color: #4a5568; /* Couleur de texte discrète */
20
- margin-bottom: 0.25rem; /* Petite marge en dessous */
21
- }
22
- </style>
23
- </head>
24
- <body class="bg-gray-100 font-sans"> <!-- Fond gris clair, police sans-serif -->
25
- <div class="container mx-auto p-4 md:p-8"> <!-- Conteneur principal, marges auto, padding -->
26
- <header class="mb-6">
27
- <h1 class="text-3xl font-bold text-blue-800 mb-2">
28
- <a href="{{ url_for('index') }}" class="hover:text-blue-600 transition-colors duration-200">Forum Anonyme</a> <!-- Lien principal, effet hover -->
29
- </h1>
30
- <nav class="flex flex-wrap items-center space-x-4 md:space-x-6 text-blue-700">
31
- <a href="{{ url_for('new_thread') }}" class="hover:text-blue-500 transition-colors duration-200">Nouveau fil</a>
32
- <a href="{{ url_for('moderate') }}" class="hover:text-blue-500 transition-colors duration-200">Modération</a>
33
- </nav>
34
- <form action="{{ url_for('search') }}" method="GET" class="mt-4"> <!-- Marge en haut -->
35
- <div class="flex"> <!--Utilisation flexbox pour bien aligner la barre et le bouton-->
36
- <input type="text" name="q" placeholder="Recherche..." required
37
- class="flex-grow p-2 border border-gray-300 rounded-l-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"> <!-- Input de recherche -->
38
- <button type="submit"
39
- class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-r-md transition-colors duration-200">
40
- Chercher
41
- </button>
42
- </div>
43
- </form>
44
- </header>
45
-
46
- {% with messages = get_flashed_messages(with_categories=true) %}
47
- {% if messages %}
48
- <div class="mb-6"> <!-- Marge en bas -->
49
- {% for category, message in messages %}
50
- <div class="flash {{ category }} p-4 rounded-md mb-2
51
- {% if category == 'error' %}bg-red-100 border border-red-400 text-red-700
52
- {% elif category == 'success' %}bg-green-100 border border-green-400 text-green-700
53
- {% else %}bg-blue-100 border border-blue-400 text-blue-700
54
- {% endif %}"> <!-- Couleurs conditionnelles -->
55
- {{ message }}
56
- </div>
57
- {% endfor %}
58
- </div>
59
- {% endif %}
60
- {% endwith %}
61
-
62
- {% block content %}{% endblock %}
63
- </div>
64
- </body>
65
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
templates/errors/403.html ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "layout.html" %}
2
+
3
+ {% block title %}Accès refusé - Forum Communautaire{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="max-w-md mx-auto">
7
+ <div class="bg-white rounded-lg shadow overflow-hidden text-center p-8">
8
+ <div class="text-6xl font-bold text-red-600 mb-4">403</div>
9
+ <h1 class="text-2xl font-bold text-gray-800 mb-4">Accès refusé</h1>
10
+ <p class="text-gray-600 mb-6">Vous n'avez pas la permission d'accéder à cette page.</p>
11
+
12
+ <div class="flex flex-col sm:flex-row justify-center gap-4 mt-4">
13
+ <a href="{{ url_for('forum.index') }}" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring transition-colors">
14
+ <i data-feather="home" class="w-4 h-4 inline-block mr-1"></i> Accueil
15
+ </a>
16
+ <button onclick="window.history.back()" class="px-4 py-2 bg-gray-200 text-gray-800 rounded-md hover:bg-gray-300 focus:outline-none focus:ring transition-colors">
17
+ <i data-feather="arrow-left" class="w-4 h-4 inline-block mr-1"></i> Retour
18
+ </button>
19
+ </div>
20
+ </div>
21
+ </div>
22
+ {% endblock %}
templates/errors/404.html ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "layout.html" %}
2
+
3
+ {% block title %}Page non trouvée - Forum Communautaire{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="max-w-md mx-auto">
7
+ <div class="bg-white rounded-lg shadow overflow-hidden text-center p-8">
8
+ <div class="text-6xl font-bold text-blue-600 mb-4">404</div>
9
+ <h1 class="text-2xl font-bold text-gray-800 mb-4">Page non trouvée</h1>
10
+ <p class="text-gray-600 mb-6">La page que vous recherchez n'existe pas ou a été déplacée.</p>
11
+
12
+ <div class="flex flex-col sm:flex-row justify-center gap-4 mt-4">
13
+ <a href="{{ url_for('forum.index') }}" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring transition-colors">
14
+ <i data-feather="home" class="w-4 h-4 inline-block mr-1"></i> Accueil
15
+ </a>
16
+ <button onclick="window.history.back()" class="px-4 py-2 bg-gray-200 text-gray-800 rounded-md hover:bg-gray-300 focus:outline-none focus:ring transition-colors">
17
+ <i data-feather="arrow-left" class="w-4 h-4 inline-block mr-1"></i> Retour
18
+ </button>
19
+ </div>
20
+ </div>
21
+ </div>
22
+ {% endblock %}
templates/errors/500.html ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "layout.html" %}
2
+
3
+ {% block title %}Erreur Serveur - Forum Communautaire{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="max-w-md mx-auto">
7
+ <div class="bg-white rounded-lg shadow overflow-hidden text-center p-8">
8
+ <div class="text-6xl font-bold text-red-600 mb-4">500</div>
9
+ <h1 class="text-2xl font-bold text-gray-800 mb-4">Erreur Serveur</h1>
10
+ <p class="text-gray-600 mb-6">Désolé, une erreur s'est produite de notre côté. Veuillez réessayer plus tard.</p>
11
+
12
+ <div class="flex flex-col sm:flex-row justify-center gap-4 mt-4">
13
+ <a href="{{ url_for('forum.index') }}" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring transition-colors">
14
+ <i data-feather="home" class="w-4 h-4 inline-block mr-1"></i> Accueil
15
+ </a>
16
+ <button onclick="window.history.back()" class="px-4 py-2 bg-gray-200 text-gray-800 rounded-md hover:bg-gray-300 focus:outline-none focus:ring transition-colors">
17
+ <i data-feather="arrow-left" class="w-4 h-4 inline-block mr-1"></i> Retour
18
+ </button>
19
+ </div>
20
+ </div>
21
+ </div>
22
+ {% endblock %}
templates/forum/category_list.html ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "layout.html" %}
2
+
3
+ {% block title %}Categories - Community Forum{% endblock %}
4
+
5
+ {% block breadcrumb %}
6
+ <a href="{{ url_for('forum.index') }}" class="hover:text-blue-600">Home</a>
7
+ <span class="mx-2">/</span>
8
+ <span>Categories</span>
9
+ {% endblock %}
10
+
11
+ {% block content %}
12
+ <div class="bg-white rounded-lg shadow overflow-hidden">
13
+ <div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
14
+ <div>
15
+ <h1 class="text-2xl font-bold text-gray-800">Categories</h1>
16
+ <p class="text-gray-600 mt-1">Browse all discussion categories</p>
17
+ </div>
18
+
19
+ {% if current_user.is_authenticated and current_user.is_admin() %}
20
+ <a href="{{ url_for('admin.create_category') }}" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring transition-colors">
21
+ <i data-feather="plus" class="w-4 h-4 inline-block mr-1"></i> New Category
22
+ </a>
23
+ {% endif %}
24
+ </div>
25
+
26
+ <!-- Categories -->
27
+ <div class="p-6">
28
+ {% if categories %}
29
+ <div class="space-y-4">
30
+ {% for category in categories %}
31
+ <div class="bg-white border border-gray-200 rounded-lg hover:border-blue-300 transition-colors">
32
+ <a href="{{ url_for('forum.category_view', id=category.id) }}" class="block p-4">
33
+ <div class="sm:flex justify-between items-start">
34
+ <div>
35
+ <h3 class="text-lg font-semibold text-gray-800 hover:text-blue-600">{{ category.name }}</h3>
36
+ {% if category.description %}
37
+ <p class="text-gray-600 mt-1">{{ category.description }}</p>
38
+ {% endif %}
39
+ </div>
40
+ <div class="mt-2 sm:mt-0 flex space-x-6 text-gray-500 text-sm">
41
+ <div>
42
+ <span class="font-medium">{{ category.topic_count() }}</span> topics
43
+ </div>
44
+ <div>
45
+ <span class="font-medium">{{ category.post_count() }}</span> posts
46
+ </div>
47
+ </div>
48
+ </div>
49
+ </a>
50
+ </div>
51
+ {% endfor %}
52
+ </div>
53
+ {% else %}
54
+ <div class="text-center py-8">
55
+ <p class="text-gray-500">No categories have been created yet.</p>
56
+ {% if current_user.is_authenticated and current_user.is_admin() %}
57
+ <a href="{{ url_for('admin.create_category') }}" class="mt-4 inline-block px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
58
+ Create First Category
59
+ </a>
60
+ {% endif %}
61
+ </div>
62
+ {% endif %}
63
+ </div>
64
+ </div>
65
+ {% endblock %}
templates/forum/create_post.html ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "layout.html" %}
2
+
3
+ {% block title %}{{ "Edit Post" if is_edit else "Post Reply" }} - Community Forum{% endblock %}
4
+
5
+ {% block breadcrumb %}
6
+ <a href="{{ url_for('forum.index') }}" class="hover:text-blue-600">Home</a>
7
+ <span class="mx-2">/</span>
8
+ <a href="{{ url_for('forum.category_view', id=topic.category_id) }}" class="hover:text-blue-600">{{ topic.category.name }}</a>
9
+ <span class="mx-2">/</span>
10
+ <a href="{{ url_for('forum.topic_view', id=topic.id) }}" class="hover:text-blue-600">{{ topic.title }}</a>
11
+ <span class="mx-2">/</span>
12
+ <span>{{ "Edit Post" if is_edit else "Reply" }}</span>
13
+ {% endblock %}
14
+
15
+ {% block content %}
16
+ <div class="bg-white rounded-lg shadow overflow-hidden">
17
+ <div class="px-6 py-4 border-b border-gray-200">
18
+ <h1 class="text-2xl font-bold text-gray-800">{{ "Edit Post" if is_edit else "Post Reply" }}</h1>
19
+ <p class="text-gray-600 mt-1">
20
+ {% if is_edit %}
21
+ Edit your message in "{{ topic.title }}"
22
+ {% elif is_quote %}
23
+ Reply with quote to "{{ topic.title }}"
24
+ {% else %}
25
+ Respond to "{{ topic.title }}"
26
+ {% endif %}
27
+ </p>
28
+ </div>
29
+
30
+ <div class="p-6">
31
+ {% if is_edit %}
32
+ <form method="POST" action="{{ url_for('forum.edit_post', id=post.id) }}">
33
+ {% else %}
34
+ <form method="POST" action="{{ url_for('forum.topic_view', id=topic.id) }}">
35
+ {% endif %}
36
+ {{ form.hidden_tag() }}
37
+ {{ form.topic_id }}
38
+
39
+ <div class="mb-4">
40
+ <label for="content" class="block text-gray-700 font-medium mb-2">Content</label>
41
+ <div class="editor-container">
42
+ <div class="editor-toolbar bg-white border border-gray-300">
43
+ <!-- Buttons will be added by the JavaScript -->
44
+ </div>
45
+
46
+ {{ form.content(class="w-full px-4 py-2 border border-gray-300 editor-textarea focus:outline-none focus:border-blue-500 focus:ring focus:ring-blue-200", id="content", rows="10") }}
47
+ </div>
48
+ {% if form.content.errors %}
49
+ <div class="text-red-600 text-sm mt-1">
50
+ {% for error in form.content.errors %}
51
+ <p>{{ error }}</p>
52
+ {% endfor %}
53
+ </div>
54
+ {% endif %}
55
+ </div>
56
+
57
+ <div class="flex items-center justify-between">
58
+ <a href="{{ url_for('forum.topic_view', id=topic.id) }}" class="px-4 py-2 bg-gray-200 text-gray-800 rounded-md hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-colors">
59
+ Cancel
60
+ </a>
61
+ <button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
62
+ {% if is_edit %}
63
+ Save Changes
64
+ {% else %}
65
+ Post Reply
66
+ {% endif %}
67
+ </button>
68
+ </div>
69
+ </form>
70
+ </div>
71
+ </div>
72
+ {% endblock %}
73
+
74
+ {% block extra_js %}
75
+ <script src="{{ url_for('static', filename='js/editor.js') }}"></script>
76
+ {% endblock %}
templates/forum/create_topic.html ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "layout.html" %}
2
+
3
+ {% block title %}Create New Topic - Community Forum{% endblock %}
4
+
5
+ {% block breadcrumb %}
6
+ <a href="{{ url_for('forum.index') }}" class="hover:text-blue-600">Home</a>
7
+ <span class="mx-2">/</span>
8
+ <a href="{{ url_for('forum.category_list') }}" class="hover:text-blue-600">Categories</a>
9
+ <span class="mx-2">/</span>
10
+ <span>Create New Topic</span>
11
+ {% endblock %}
12
+
13
+ {% block content %}
14
+ <div class="bg-white rounded-lg shadow overflow-hidden">
15
+ <div class="px-6 py-4 border-b border-gray-200">
16
+ <h1 class="text-2xl font-bold text-gray-800">Create New Topic</h1>
17
+ <p class="text-gray-600 mt-1">Start a new discussion</p>
18
+ </div>
19
+
20
+ <div class="p-6">
21
+ <form method="POST" action="{{ url_for('forum.create_topic') }}">
22
+ {{ form.hidden_tag() }}
23
+
24
+ <div class="mb-4">
25
+ <label for="category_id" class="block text-gray-700 font-medium mb-2">Category</label>
26
+ {{ form.category_id(class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:border-blue-500 focus:ring focus:ring-blue-200", id="category_id") }}
27
+ {% if form.category_id.errors %}
28
+ <div class="text-red-600 text-sm mt-1">
29
+ {% for error in form.category_id.errors %}
30
+ <p>{{ error }}</p>
31
+ {% endfor %}
32
+ </div>
33
+ {% endif %}
34
+ </div>
35
+
36
+ <div class="mb-4">
37
+ <label for="title" class="block text-gray-700 font-medium mb-2">Title</label>
38
+ {{ form.title(class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:border-blue-500 focus:ring focus:ring-blue-200", id="title", placeholder="Enter a descriptive title...") }}
39
+ {% if form.title.errors %}
40
+ <div class="text-red-600 text-sm mt-1">
41
+ {% for error in form.title.errors %}
42
+ <p>{{ error }}</p>
43
+ {% endfor %}
44
+ </div>
45
+ {% endif %}
46
+ </div>
47
+
48
+ <div class="mb-4">
49
+ <label for="content" class="block text-gray-700 font-medium mb-2">Content</label>
50
+ <div class="editor-container">
51
+ <div class="editor-toolbar bg-white border border-gray-300">
52
+ <!-- Buttons will be added by the JavaScript -->
53
+ </div>
54
+
55
+ {{ form.content(class="w-full px-4 py-2 border border-gray-300 editor-textarea focus:outline-none focus:border-blue-500 focus:ring focus:ring-blue-200", id="content", rows="10", placeholder="Write your post content here...") }}
56
+ </div>
57
+ {% if form.content.errors %}
58
+ <div class="text-red-600 text-sm mt-1">
59
+ {% for error in form.content.errors %}
60
+ <p>{{ error }}</p>
61
+ {% endfor %}
62
+ </div>
63
+ {% endif %}
64
+ </div>
65
+
66
+ <div class="mb-6">
67
+ <label for="tags" class="block text-gray-700 font-medium mb-2">Tags <span class="text-gray-500 text-sm">(comma separated)</span></label>
68
+ {{ form.tags(class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:border-blue-500 focus:ring focus:ring-blue-200", id="tags", placeholder="example, discussion, question") }}
69
+ {% if form.tags.errors %}
70
+ <div class="text-red-600 text-sm mt-1">
71
+ {% for error in form.tags.errors %}
72
+ <p>{{ error }}</p>
73
+ {% endfor %}
74
+ </div>
75
+ {% endif %}
76
+ <p class="text-gray-500 text-xs mt-1">Tags help categorize your topic and make it easier to find. Separate multiple tags with commas.</p>
77
+ </div>
78
+
79
+ <div class="flex items-center justify-between">
80
+ <a href="{{ url_for('forum.index') }}" class="px-4 py-2 bg-gray-200 text-gray-800 rounded-md hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-colors">
81
+ Cancel
82
+ </a>
83
+ <button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
84
+ Create Topic
85
+ </button>
86
+ </div>
87
+ </form>
88
+ </div>
89
+ </div>
90
+ {% endblock %}
91
+
92
+ {% block extra_js %}
93
+ <script src="{{ url_for('static', filename='js/editor.js') }}"></script>
94
+ {% endblock %}
templates/forum/topic_list.html ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "layout.html" %}
2
+
3
+ {% block title %}{{ category.name }} - Community Forum{% endblock %}
4
+
5
+ {% block breadcrumb %}
6
+ <a href="{{ url_for('forum.index') }}" class="hover:text-blue-600">Home</a>
7
+ <span class="mx-2">/</span>
8
+ <a href="{{ url_for('forum.category_list') }}" class="hover:text-blue-600">Categories</a>
9
+ <span class="mx-2">/</span>
10
+ <span>{{ category.name }}</span>
11
+ {% endblock %}
12
+
13
+ {% block content %}
14
+ <div class="bg-white rounded-lg shadow overflow-hidden">
15
+ <div class="px-6 py-4 border-b border-gray-200 flex flex-col sm:flex-row sm:justify-between sm:items-center">
16
+ <div>
17
+ <h1 class="text-2xl font-bold text-gray-800">{{ category.name }}</h1>
18
+ {% if category.description %}
19
+ <p class="text-gray-600 mt-1">{{ category.description }}</p>
20
+ {% endif %}
21
+ </div>
22
+
23
+ {% if current_user.is_authenticated %}
24
+ <a href="{{ url_for('forum.create_topic') }}" class="mt-4 sm:mt-0 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring transition-colors">
25
+ <i data-feather="plus-circle" class="w-4 h-4 inline-block mr-1"></i> New Topic
26
+ </a>
27
+ {% endif %}
28
+ </div>
29
+
30
+ <!-- Topics -->
31
+ <div class="p-6">
32
+ {% if topics.items %}
33
+ <div class="border-b border-gray-200 mb-4 pb-1">
34
+ <div class="flex justify-between items-center text-sm text-gray-500">
35
+ <div class="w-6/12 sm:w-7/12 px-2">Topic</div>
36
+ <div class="w-2/12 px-2 text-center hidden sm:block">Replies</div>
37
+ <div class="w-2/12 px-2 text-center hidden md:block">Views</div>
38
+ <div class="w-4/12 sm:w-3/12 md:w-2/12 px-2">Last Post</div>
39
+ </div>
40
+ </div>
41
+
42
+ <div class="space-y-2">
43
+ {% for topic in topics.items %}
44
+ <div class="flex bg-white hover:bg-gray-50 border border-gray-200 rounded-lg transition-colors overflow-hidden">
45
+ <div class="w-6/12 sm:w-7/12 p-3 flex items-start">
46
+ <div class="flex-shrink-0 mr-3">
47
+ {% if topic.is_pinned %}
48
+ <span class="flex items-center justify-center w-8 h-8 bg-yellow-100 text-yellow-600 rounded-full">
49
+ <i data-feather="star" class="w-4 h-4"></i>
50
+ </span>
51
+ {% elif topic.is_locked %}
52
+ <span class="flex items-center justify-center w-8 h-8 bg-red-100 text-red-600 rounded-full">
53
+ <i data-feather="lock" class="w-4 h-4"></i>
54
+ </span>
55
+ {% else %}
56
+ <span class="flex items-center justify-center w-8 h-8 bg-blue-100 text-blue-600 rounded-full">
57
+ <i data-feather="message-circle" class="w-4 h-4"></i>
58
+ </span>
59
+ {% endif %}
60
+ </div>
61
+ <div>
62
+ <a href="{{ url_for('forum.topic_view', id=topic.id) }}" class="text-gray-800 font-medium hover:text-blue-600">
63
+ {{ topic.title }}
64
+ </a>
65
+
66
+ {% if topic.tags %}
67
+ <div class="mt-1 flex flex-wrap">
68
+ {% for tag in topic.tags %}
69
+ <a href="{{ url_for('forum.tag_view', tag_name=tag.name) }}" class="tag">
70
+ {{ tag.name }}
71
+ </a>
72
+ {% endfor %}
73
+ </div>
74
+ {% endif %}
75
+
76
+ <div class="text-gray-500 text-xs mt-1">
77
+ Started by <a href="{{ url_for('user.profile', username=topic.author.username) }}" class="text-blue-600 hover:underline">{{ topic.author.username }}</a>,
78
+ {{ topic.created_at.strftime('%b %d, %Y') }}
79
+ </div>
80
+ </div>
81
+ </div>
82
+
83
+ <div class="w-2/12 p-3 text-center hidden sm:flex items-center justify-center">
84
+ <span class="text-gray-700">{{ topic.reply_count() }}</span>
85
+ </div>
86
+
87
+ <div class="w-2/12 p-3 text-center hidden md:flex items-center justify-center">
88
+ <span class="text-gray-700">{{ topic.views }}</span>
89
+ </div>
90
+
91
+ <div class="w-4/12 sm:w-3/12 md:w-2/12 p-3">
92
+ {% set last_post = topic.last_post() %}
93
+ {% if last_post %}
94
+ <div class="text-xs text-gray-500">
95
+ <a href="{{ url_for('user.profile', username=last_post.author.username) }}" class="text-blue-600 hover:underline">{{ last_post.author.username }}</a>
96
+ <div class="mt-1">{{ last_post.created_at.strftime('%b %d, %Y') }}</div>
97
+ </div>
98
+ {% else %}
99
+ <span class="text-gray-500 text-xs">No replies yet</span>
100
+ {% endif %}
101
+ </div>
102
+ </div>
103
+ {% endfor %}
104
+ </div>
105
+
106
+ <!-- Pagination -->
107
+ {% if topics.pages > 1 %}
108
+ <div class="mt-6 flex justify-center">
109
+ <nav class="inline-flex rounded-md shadow">
110
+ {% if topics.has_prev %}
111
+ <a href="{{ url_for('forum.category_view', id=category.id, page=topics.prev_num) }}" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-l-md hover:bg-gray-50">
112
+ Previous
113
+ </a>
114
+ {% else %}
115
+ <span class="px-4 py-2 text-sm font-medium text-gray-400 bg-gray-100 border border-gray-300 rounded-l-md cursor-not-allowed">
116
+ Previous
117
+ </span>
118
+ {% endif %}
119
+
120
+ {% for page_num in topics.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
121
+ {% if page_num %}
122
+ {% if page_num == topics.page %}
123
+ <span class="px-4 py-2 text-sm font-medium text-blue-600 bg-blue-50 border border-gray-300">
124
+ {{ page_num }}
125
+ </span>
126
+ {% else %}
127
+ <a href="{{ url_for('forum.category_view', id=category.id, page=page_num) }}" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 hover:bg-gray-50">
128
+ {{ page_num }}
129
+ </a>
130
+ {% endif %}
131
+ {% else %}
132
+ <span class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300">
133
+
134
+ </span>
135
+ {% endif %}
136
+ {% endfor %}
137
+
138
+ {% if topics.has_next %}
139
+ <a href="{{ url_for('forum.category_view', id=category.id, page=topics.next_num) }}" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-r-md hover:bg-gray-50">
140
+ Next
141
+ </a>
142
+ {% else %}
143
+ <span class="px-4 py-2 text-sm font-medium text-gray-400 bg-gray-100 border border-gray-300 rounded-r-md cursor-not-allowed">
144
+ Next
145
+ </span>
146
+ {% endif %}
147
+ </nav>
148
+ </div>
149
+ {% endif %}
150
+
151
+ {% else %}
152
+ <div class="text-center py-8">
153
+ <p class="text-gray-500">No topics have been created in this category yet.</p>
154
+ {% if current_user.is_authenticated %}
155
+ <a href="{{ url_for('forum.create_topic') }}" class="mt-4 inline-block px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
156
+ Create First Topic
157
+ </a>
158
+ {% endif %}
159
+ </div>
160
+ {% endif %}
161
+ </div>
162
+ </div>
163
+ {% endblock %}
templates/forum/topic_view.html ADDED
@@ -0,0 +1,334 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "layout.html" %}
2
+
3
+ {% block title %}{{ topic.title }} - Community Forum{% endblock %}
4
+
5
+ {% block breadcrumb %}
6
+ <a href="{{ url_for('forum.index') }}" class="hover:text-blue-600">Home</a>
7
+ <span class="mx-2">/</span>
8
+ <a href="{{ url_for('forum.category_list') }}" class="hover:text-blue-600">Categories</a>
9
+ <span class="mx-2">/</span>
10
+ <a href="{{ url_for('forum.category_view', id=topic.category_id) }}" class="hover:text-blue-600">{{ topic.category.name }}</a>
11
+ <span class="mx-2">/</span>
12
+ <span>{{ topic.title }}</span>
13
+ {% endblock %}
14
+
15
+ {% block content %}
16
+ <div class="bg-white rounded-lg shadow overflow-hidden">
17
+ <div class="px-6 py-4 border-b border-gray-200 flex flex-wrap justify-between items-center gap-2">
18
+ <div>
19
+ <h1 class="text-2xl font-bold text-gray-800">{{ topic.title }}</h1>
20
+
21
+ <div class="mt-1 flex flex-wrap items-center gap-2">
22
+ {% if topic.tags %}
23
+ {% for tag in topic.tags %}
24
+ <a href="{{ url_for('forum.tag_view', tag_name=tag.name) }}" class="tag">
25
+ {{ tag.name }}
26
+ </a>
27
+ {% endfor %}
28
+ {% endif %}
29
+
30
+ {% if topic.is_pinned %}
31
+ <span class="flex items-center text-xs text-yellow-600 bg-yellow-100 px-2 py-1 rounded-full">
32
+ <i data-feather="star" class="w-3 h-3 mr-1"></i> Pinned
33
+ </span>
34
+ {% endif %}
35
+
36
+ {% if topic.is_locked %}
37
+ <span class="flex items-center text-xs text-red-600 bg-red-100 px-2 py-1 rounded-full">
38
+ <i data-feather="lock" class="w-3 h-3 mr-1"></i> Locked
39
+ </span>
40
+ {% endif %}
41
+ </div>
42
+ </div>
43
+
44
+ <div class="flex flex-wrap gap-2">
45
+ {% if current_user.is_authenticated and current_user.is_moderator() %}
46
+ <form method="POST" action="{{ url_for('forum.pin_topic', id=topic.id) }}" class="inline">
47
+ <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
48
+ <button type="submit" id="pin-topic-btn" data-is-pinned="{{ 'true' if topic.is_pinned else 'false' }}" class="px-3 py-1 bg-yellow-100 text-yellow-700 rounded hover:bg-yellow-200 focus:outline-none focus:ring">
49
+ {% if topic.is_pinned %}
50
+ <i data-feather="star-off" class="w-4 h-4 inline-block mr-1"></i> Unpin
51
+ {% else %}
52
+ <i data-feather="star" class="w-4 h-4 inline-block mr-1"></i> Pin
53
+ {% endif %}
54
+ </button>
55
+ </form>
56
+
57
+ <form method="POST" action="{{ url_for('forum.lock_topic', id=topic.id) }}" class="inline">
58
+ <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
59
+ <button type="submit" id="lock-topic-btn" data-is-locked="{{ 'true' if topic.is_locked else 'false' }}" class="px-3 py-1 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 focus:outline-none focus:ring">
60
+ {% if topic.is_locked %}
61
+ <i data-feather="unlock" class="w-4 h-4 inline-block mr-1"></i> Unlock
62
+ {% else %}
63
+ <i data-feather="lock" class="w-4 h-4 inline-block mr-1"></i> Lock
64
+ {% endif %}
65
+ </button>
66
+ </form>
67
+ {% endif %}
68
+
69
+ {% if current_user.is_authenticated %}
70
+ <button class="report-button px-3 py-1 bg-red-100 text-red-700 rounded hover:bg-red-200 focus:outline-none focus:ring" data-topic-id="{{ topic.id }}">
71
+ <i data-feather="flag" class="w-4 h-4 inline-block mr-1"></i> Report
72
+ </button>
73
+ {% endif %}
74
+ </div>
75
+ </div>
76
+
77
+ <!-- Posts -->
78
+ <div class="divide-y divide-gray-200">
79
+ {% for post in posts.items %}
80
+ <div id="post-{{ post.id }}" class="post flex flex-col md:flex-row p-0 md:divide-x divide-gray-200">
81
+ <!-- Author Info -->
82
+ <div class="w-full md:w-56 lg:w-64 p-4 md:p-6 bg-gray-50">
83
+ <div class="flex flex-row md:flex-col items-center md:items-start">
84
+ <div class="flex-shrink-0 mr-4 md:mr-0 md:mb-3">
85
+ <img
86
+ src="{{ url_for('static', filename='uploads/avatars/' + post.author.avatar) if post.author.avatar else url_for('static', filename='uploads/avatars/default.png') }}"
87
+ alt="{{ post.author.username }}"
88
+ class="w-12 h-12 md:w-16 md:h-16 rounded-full object-cover"
89
+ >
90
+ </div>
91
+ <div>
92
+ <div class="post-author">
93
+ <a href="{{ url_for('user.profile', username=post.author.username) }}" class="text-blue-600 font-medium hover:underline">
94
+ {{ post.author.username }}
95
+ </a>
96
+ </div>
97
+ <div class="text-gray-500 text-sm">
98
+ {% if post.author.role == 'admin' %}
99
+ <span class="inline-block bg-red-100 text-red-800 text-xs px-2 py-1 rounded-full">Admin</span>
100
+ {% elif post.author.role == 'moderator' %}
101
+ <span class="inline-block bg-green-100 text-green-800 text-xs px-2 py-1 rounded-full">Moderator</span>
102
+ {% else %}
103
+ <span class="inline-block bg-gray-100 text-gray-800 text-xs px-2 py-1 rounded-full">Member</span>
104
+ {% endif %}
105
+ </div>
106
+ <div class="text-gray-500 text-xs mt-2 hidden md:block">
107
+ Joined: {{ post.author.created_at.strftime('%b %Y') }}
108
+ </div>
109
+ </div>
110
+ </div>
111
+
112
+ {% if post.author.signature and loop.index > 1 %}
113
+ <div class="mt-4 pt-4 border-t border-gray-200 text-xs text-gray-500 hidden md:block">
114
+ {{ post.author.signature }}
115
+ </div>
116
+ {% endif %}
117
+ </div>
118
+
119
+ <!-- Post Content -->
120
+ <div class="flex-1 p-4 md:p-6">
121
+ <div class="flex justify-between items-start mb-4">
122
+ <div class="text-sm text-gray-500">
123
+ <a href="#post-{{ post.id }}" class="hover:text-blue-600">
124
+ {{ post.created_at.strftime('%b %d, %Y %H:%M') }}
125
+ </a>
126
+ {% if post.updated_at and post.updated_at != post.created_at %}
127
+ <span class="text-xs ml-2">
128
+ (Edited {% if post.edited_by %}by {{ post.edited_by.username }}{% endif %}
129
+ on {{ post.updated_at.strftime('%b %d, %Y %H:%M') }})
130
+ </span>
131
+ {% endif %}
132
+ </div>
133
+ <div class="flex space-x-1">
134
+ <a href="{{ url_for('forum.quote_post', id=post.id) }}" class="quote-button p-1 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded">
135
+ <i data-feather="message-square" class="w-4 h-4"></i>
136
+ <span class="sr-only">Quote</span>
137
+ </a>
138
+
139
+ {% if current_user.is_authenticated and (current_user.id == post.author_id or current_user.is_moderator()) %}
140
+ <a href="{{ url_for('forum.edit_post', id=post.id) }}" class="p-1 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded">
141
+ <i data-feather="edit" class="w-4 h-4"></i>
142
+ <span class="sr-only">Edit</span>
143
+ </a>
144
+
145
+ <form method="POST" action="{{ url_for('forum.delete_post', id=post.id) }}" class="inline">
146
+ <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
147
+ <button type="submit" class="delete-button p-1 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded">
148
+ <i data-feather="trash-2" class="w-4 h-4"></i>
149
+ <span class="sr-only">Delete</span>
150
+ </button>
151
+ </form>
152
+ {% endif %}
153
+
154
+ {% if current_user.is_authenticated and current_user.id != post.author_id %}
155
+ <button class="report-button p-1 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded" data-post-id="{{ post.id }}">
156
+ <i data-feather="flag" class="w-4 h-4"></i>
157
+ <span class="sr-only">Report</span>
158
+ </button>
159
+ {% endif %}
160
+ </div>
161
+ </div>
162
+
163
+ <div class="post-content prose max-w-none">
164
+ {{ post.content|safe }}
165
+ </div>
166
+
167
+ <!-- Reactions -->
168
+ {% if current_user.is_authenticated %}
169
+ <div class="mt-6 flex space-x-2">
170
+ <button class="reaction-btn {% if current_user.is_authenticated and post.reactions.filter_by(user_id=current_user.id, reaction_type='like').first() %}active{% endif %}" data-post-id="{{ post.id }}" data-reaction-type="like">
171
+ <i data-feather="thumbs-up" class="w-4 h-4"></i>
172
+ <span class="reaction-count {% if post.get_reaction_count('like') == 0 %}hidden{% endif %}">{{ post.get_reaction_count('like') }}</span>
173
+ </button>
174
+
175
+ <button class="reaction-btn {% if current_user.is_authenticated and post.reactions.filter_by(user_id=current_user.id, reaction_type='heart').first() %}active{% endif %}" data-post-id="{{ post.id }}" data-reaction-type="heart">
176
+ <i data-feather="heart" class="w-4 h-4"></i>
177
+ <span class="reaction-count {% if post.get_reaction_count('heart') == 0 %}hidden{% endif %}">{{ post.get_reaction_count('heart') }}</span>
178
+ </button>
179
+
180
+ <button class="reaction-btn {% if current_user.is_authenticated and post.reactions.filter_by(user_id=current_user.id, reaction_type='smile').first() %}active{% endif %}" data-post-id="{{ post.id }}" data-reaction-type="smile">
181
+ <i data-feather="smile" class="w-4 h-4"></i>
182
+ <span class="reaction-count {% if post.get_reaction_count('smile') == 0 %}hidden{% endif %}">{{ post.get_reaction_count('smile') }}</span>
183
+ </button>
184
+ </div>
185
+ {% endif %}
186
+ </div>
187
+ </div>
188
+ {% endfor %}
189
+ </div>
190
+
191
+ <!-- Pagination -->
192
+ {% if posts.pages > 1 %}
193
+ <div class="px-6 py-4 bg-gray-50 border-t border-gray-200">
194
+ <div class="flex justify-center">
195
+ <nav class="inline-flex rounded-md shadow">
196
+ {% if posts.has_prev %}
197
+ <a href="{{ url_for('forum.topic_view', id=topic.id, page=posts.prev_num) }}" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-l-md hover:bg-gray-50">
198
+ Previous
199
+ </a>
200
+ {% else %}
201
+ <span class="px-4 py-2 text-sm font-medium text-gray-400 bg-gray-100 border border-gray-300 rounded-l-md cursor-not-allowed">
202
+ Previous
203
+ </span>
204
+ {% endif %}
205
+
206
+ {% for page_num in posts.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
207
+ {% if page_num %}
208
+ {% if page_num == posts.page %}
209
+ <span class="px-4 py-2 text-sm font-medium text-blue-600 bg-blue-50 border border-gray-300">
210
+ {{ page_num }}
211
+ </span>
212
+ {% else %}
213
+ <a href="{{ url_for('forum.topic_view', id=topic.id, page=page_num) }}" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 hover:bg-gray-50">
214
+ {{ page_num }}
215
+ </a>
216
+ {% endif %}
217
+ {% else %}
218
+ <span class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300">
219
+
220
+ </span>
221
+ {% endif %}
222
+ {% endfor %}
223
+
224
+ {% if posts.has_next %}
225
+ <a href="{{ url_for('forum.topic_view', id=topic.id, page=posts.next_num) }}" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-r-md hover:bg-gray-50">
226
+ Next
227
+ </a>
228
+ {% else %}
229
+ <span class="px-4 py-2 text-sm font-medium text-gray-400 bg-gray-100 border border-gray-300 rounded-r-md cursor-not-allowed">
230
+ Next
231
+ </span>
232
+ {% endif %}
233
+ </nav>
234
+ </div>
235
+ </div>
236
+ {% endif %}
237
+
238
+ <!-- Reply Form -->
239
+ {% if current_user.is_authenticated and not topic.is_locked or (current_user.is_authenticated and current_user.is_moderator()) %}
240
+ <div id="reply-form" class="p-6 bg-gray-50 border-t border-gray-200">
241
+ <h3 class="text-lg font-medium text-gray-800 mb-4">Post a Reply</h3>
242
+
243
+ <form method="POST" action="{{ url_for('forum.topic_view', id=topic.id) }}">
244
+ {{ post_form.hidden_tag() }}
245
+ {{ post_form.topic_id(value=topic.id) }}
246
+
247
+ <div class="editor-container mb-4">
248
+ <div class="editor-toolbar bg-white border border-gray-300">
249
+ <!-- Buttons will be added by the JavaScript -->
250
+ </div>
251
+
252
+ {{ post_form.content(class="w-full px-4 py-2 border border-gray-300 editor-textarea focus:outline-none focus:border-blue-500 focus:ring focus:ring-blue-200", id="content", rows="6") }}
253
+
254
+ {% if post_form.content.errors %}
255
+ <div class="text-red-600 text-sm mt-1">
256
+ {% for error in post_form.content.errors %}
257
+ <p>{{ error }}</p>
258
+ {% endfor %}
259
+ </div>
260
+ {% endif %}
261
+ </div>
262
+
263
+ <div class="mt-4">
264
+ <button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
265
+ Post Reply
266
+ </button>
267
+ </div>
268
+ </form>
269
+ </div>
270
+ {% elif current_user.is_authenticated and topic.is_locked %}
271
+ <div class="p-6 bg-gray-50 border-t border-gray-200">
272
+ <div class="flex items-center justify-center p-4 bg-red-50 text-red-700 rounded-md">
273
+ <i data-feather="lock" class="w-5 h-5 mr-2"></i>
274
+ <span>This topic is locked. New replies are not allowed.</span>
275
+ </div>
276
+ </div>
277
+ {% elif not current_user.is_authenticated %}
278
+ <div class="p-6 bg-gray-50 border-t border-gray-200">
279
+ <div class="flex items-center justify-center p-4 bg-blue-50 text-blue-700 rounded-md">
280
+ <i data-feather="info" class="w-5 h-5 mr-2"></i>
281
+ <span>Please <a href="{{ url_for('auth.login') }}" class="underline font-medium">log in</a> to post a reply.</span>
282
+ </div>
283
+ </div>
284
+ {% endif %}
285
+ </div>
286
+
287
+ <!-- Report Modal Dialog -->
288
+ {% if current_user.is_authenticated %}
289
+ <div id="report-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
290
+ <div class="bg-white rounded-lg shadow-xl w-full max-w-md">
291
+ <div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
292
+ <h3 class="text-lg font-medium text-gray-800">Report Content</h3>
293
+ <button type="button" class="close-modal text-gray-400 hover:text-gray-500">
294
+ <i data-feather="x" class="w-5 h-5"></i>
295
+ <span class="sr-only">Close</span>
296
+ </button>
297
+ </div>
298
+
299
+ <form id="report-form" method="POST" action="{{ url_for('forum.create_report') }}">
300
+ {{ report_form.hidden_tag() }}
301
+ {{ report_form.post_id(id="post_id") }}
302
+ {{ report_form.topic_id(id="topic_id") }}
303
+
304
+ <div class="p-6">
305
+ <div class="mb-4">
306
+ <label for="reason" class="block text-gray-700 font-medium mb-2">Reason for Report</label>
307
+ {{ report_form.reason(class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:border-blue-500 focus:ring focus:ring-blue-200", id="reason", rows="4", placeholder="Please provide a detailed explanation of why you're reporting this content...") }}
308
+ {% if report_form.reason.errors %}
309
+ <div class="text-red-600 text-sm mt-1">
310
+ {% for error in report_form.reason.errors %}
311
+ <p>{{ error }}</p>
312
+ {% endfor %}
313
+ </div>
314
+ {% endif %}
315
+ </div>
316
+
317
+ <div class="mt-4 flex justify-end">
318
+ <button type="button" class="close-modal px-4 py-2 bg-gray-200 text-gray-800 rounded-md mr-2 hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-colors">
319
+ Cancel
320
+ </button>
321
+ <button type="submit" class="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition-colors">
322
+ Submit Report
323
+ </button>
324
+ </div>
325
+ </div>
326
+ </form>
327
+ </div>
328
+ </div>
329
+ {% endif %}
330
+ {% endblock %}
331
+
332
+ {% block extra_js %}
333
+ <script src="{{ url_for('static', filename='js/editor.js') }}"></script>
334
+ {% endblock %}
templates/home.html ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "layout.html" %}
2
+
3
+ {% block title %}Community Forum - Home{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="bg-white rounded-lg shadow overflow-hidden">
7
+ <div class="px-6 py-4 border-b border-gray-200">
8
+ <h1 class="text-2xl font-bold text-gray-800">Welcome to our Community Forum</h1>
9
+ <p class="text-gray-600 mt-1">A place to discuss, share and learn together.</p>
10
+ </div>
11
+
12
+ {% if current_user.is_authenticated %}
13
+ <div class="p-6 bg-blue-50 border-b border-blue-100">
14
+ <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center">
15
+ <div>
16
+ <h2 class="text-lg font-semibold text-blue-800">Welcome back, {{ current_user.username }}!</h2>
17
+ <p class="text-blue-600 text-sm">What would you like to discuss today?</p>
18
+ </div>
19
+ <a href="{{ url_for('forum.create_topic') }}" class="mt-3 sm:mt-0 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring transition-colors">
20
+ <i data-feather="plus-circle" class="w-4 h-4 inline-block mr-1"></i> Create New Topic
21
+ </a>
22
+ </div>
23
+ </div>
24
+ {% endif %}
25
+
26
+ <!-- Categories -->
27
+ <div class="p-6">
28
+ {% if categories %}
29
+ <div class="space-y-4">
30
+ {% for category in categories %}
31
+ <div class="bg-white border border-gray-200 rounded-lg hover:border-blue-300 transition-colors">
32
+ <a href="{{ url_for('forum.category_view', id=category.id) }}" class="block p-4">
33
+ <div class="sm:flex justify-between items-start">
34
+ <div>
35
+ <h3 class="text-lg font-semibold text-gray-800 hover:text-blue-600">{{ category.name }}</h3>
36
+ {% if category.description %}
37
+ <p class="text-gray-600 mt-1">{{ category.description }}</p>
38
+ {% endif %}
39
+ </div>
40
+ <div class="mt-2 sm:mt-0 flex space-x-6 text-gray-500 text-sm">
41
+ <div>
42
+ <span class="font-medium">{{ category.topic_count() }}</span> topics
43
+ </div>
44
+ <div>
45
+ <span class="font-medium">{{ category.post_count() }}</span> posts
46
+ </div>
47
+ </div>
48
+ </div>
49
+ </a>
50
+ </div>
51
+ {% endfor %}
52
+ </div>
53
+ {% else %}
54
+ <div class="text-center py-8">
55
+ <p class="text-gray-500">No categories have been created yet.</p>
56
+ {% if current_user.is_authenticated and current_user.is_admin() %}
57
+ <a href="{{ url_for('admin.create_category') }}" class="mt-4 inline-block px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
58
+ Create First Category
59
+ </a>
60
+ {% endif %}
61
+ </div>
62
+ {% endif %}
63
+ </div>
64
+
65
+ <!-- Forum Statistics -->
66
+ <div class="px-6 py-4 bg-gray-50 border-t border-gray-200">
67
+ <h3 class="text-lg font-semibold text-gray-700 mb-3">Forum Statistics</h3>
68
+ <div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
69
+ {% set topic_count = namespace(total=0) %}
70
+ {% set post_count = namespace(total=0) %}
71
+
72
+ {% for category in categories %}
73
+ {% set topic_count.total = topic_count.total + category.topic_count() %}
74
+ {% set post_count.total = post_count.total + category.post_count() %}
75
+ {% endfor %}
76
+
77
+ <div class="bg-white p-4 rounded-lg border border-gray-200">
78
+ <div class="flex items-center">
79
+ <div class="p-2 rounded-md bg-blue-100 text-blue-500 mr-3">
80
+ <i data-feather="users" class="w-5 h-5"></i>
81
+ </div>
82
+ <div>
83
+ <p class="text-sm text-gray-500">Members</p>
84
+ <p class="text-lg font-semibold">{{ user_count if user_count is defined else '—' }}</p>
85
+ </div>
86
+ </div>
87
+ </div>
88
+
89
+ <div class="bg-white p-4 rounded-lg border border-gray-200">
90
+ <div class="flex items-center">
91
+ <div class="p-2 rounded-md bg-green-100 text-green-500 mr-3">
92
+ <i data-feather="layers" class="w-5 h-5"></i>
93
+ </div>
94
+ <div>
95
+ <p class="text-sm text-gray-500">Categories</p>
96
+ <p class="text-lg font-semibold">{{ categories|length }}</p>
97
+ </div>
98
+ </div>
99
+ </div>
100
+
101
+ <div class="bg-white p-4 rounded-lg border border-gray-200">
102
+ <div class="flex items-center">
103
+ <div class="p-2 rounded-md bg-purple-100 text-purple-500 mr-3">
104
+ <i data-feather="message-circle" class="w-5 h-5"></i>
105
+ </div>
106
+ <div>
107
+ <p class="text-sm text-gray-500">Topics</p>
108
+ <p class="text-lg font-semibold">{{ topic_count.total }}</p>
109
+ </div>
110
+ </div>
111
+ </div>
112
+
113
+ <div class="bg-white p-4 rounded-lg border border-gray-200">
114
+ <div class="flex items-center">
115
+ <div class="p-2 rounded-md bg-yellow-100 text-yellow-500 mr-3">
116
+ <i data-feather="file-text" class="w-5 h-5"></i>
117
+ </div>
118
+ <div>
119
+ <p class="text-sm text-gray-500">Posts</p>
120
+ <p class="text-lg font-semibold">{{ post_count.total }}</p>
121
+ </div>
122
+ </div>
123
+ </div>
124
+ </div>
125
+ </div>
126
+ </div>
127
+ {% endblock %}
templates/hshz.txt DELETED
File without changes