o9
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- app.py +106 -177
- attached_assets/Pasted--Cahier-des-Charges-Forum-Communautaire-Version-1-0-Date-26-Mai-2023-Auteur--1745353936750.txt +227 -0
- forms.py +91 -0
- instance/forum.db +0 -0
- instance/je.txt +0 -0
- main.py +8 -0
- models.py +188 -0
- pyproject.toml +21 -0
- routes/__init__.py +1 -0
- routes/admin.py +298 -0
- routes/auth.py +79 -0
- routes/cadmin.py +40 -0
- routes/forum.py +348 -0
- routes/user.py +121 -0
- static/css/styles.css +130 -0
- static/icons/icon-192x192.png +0 -0
- static/icons/icon-192x192.svg +10 -0
- static/icons/icon-512x512.png +0 -0
- static/icons/icon-512x512.svg +10 -0
- static/js/editor.js +165 -0
- static/js/forum.js +277 -0
- static/js/pwa-installer.js +51 -0
- static/js/service-worker.js +74 -0
- static/jsjz.txt +0 -0
- static/manifest.json +27 -0
- static/styles.css +0 -73
- static/uploads/avatars/20d41bee_generated-icon.png +0 -0
- templates/admin/create_admin.html +112 -0
- templates/admin/create_category.html +75 -0
- templates/admin/create_tag.html +47 -0
- templates/admin/dashboard.html +147 -0
- templates/admin/edit_category.html +75 -0
- templates/admin/edit_user.html +136 -0
- templates/admin/manage_categories.html +113 -0
- templates/admin/manage_reports.html +208 -0
- templates/admin/manage_tags.html +149 -0
- templates/admin/manage_users.html +184 -0
- templates/auth/login.html +68 -0
- templates/auth/register.html +89 -0
- templates/base.html +0 -65
- templates/errors/403.html +22 -0
- templates/errors/404.html +22 -0
- templates/errors/500.html +22 -0
- templates/forum/category_list.html +65 -0
- templates/forum/create_post.html +76 -0
- templates/forum/create_topic.html +94 -0
- templates/forum/topic_list.html +163 -0
- templates/forum/topic_view.html +334 -0
- templates/home.html +127 -0
- templates/hshz.txt +0 -0
app.py
CHANGED
@@ -1,180 +1,109 @@
|
|
1 |
-
|
|
|
|
|
|
|
|
|
2 |
from flask_sqlalchemy import SQLAlchemy
|
3 |
-
from
|
4 |
-
from
|
5 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
7 |
app = Flask(__name__)
|
8 |
-
|
9 |
-
|
10 |
-
app.
|
11 |
-
|
12 |
-
|
13 |
-
#
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
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
|