kuro223 commited on
Commit
a1be761
·
1 Parent(s): 25d11ec
__pycache__/config.cpython-312.pyc DELETED
Binary file (667 Bytes)
 
api/index.py ADDED
@@ -0,0 +1,948 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # --- START OF index.py ---
2
+
3
+ import os
4
+ import logging
5
+ import json
6
+ from flask import Flask, Blueprint, render_template, request, redirect, url_for, flash, jsonify, session, current_app, send_file, Response
7
+ from flask_sqlalchemy import SQLAlchemy
8
+ from sqlalchemy.orm import DeclarativeBase
9
+ from werkzeug.utils import secure_filename
10
+ from werkzeug.security import check_password_hash, generate_password_hash
11
+ from datetime import datetime
12
+ from functools import wraps
13
+ import requests
14
+ from io import BytesIO
15
+ import base64
16
+
17
+ # Configure logging
18
+ logging.basicConfig(level=logging.INFO)
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Configuration (Hardcoded as requested)
22
+ # ---------------------------------------------------------------------------
23
+
24
+ # Database configuration
25
+ SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL')
26
+
27
+ # Session secret key
28
+ SECRET_KEY = os.environ.get('SESSION_SECRET', 'dev_secret_key_change_in_production')
29
+ if SECRET_KEY == 'dev_secret_key_change_in_production':
30
+ print("WARNING: Using default SECRET_KEY. Set SESSION_SECRET environment variable for production.")
31
+
32
+ # Admin login credentials (simple authentication for single admin)
33
+ ADMIN_USERNAME = os.environ.get('ADMIN_USERNAME', 'admin')
34
+ ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD', 'password')
35
+
36
+ # Telegram configuration for feedback
37
+ TELEGRAM_BOT_TOKEN = "7126991043:AAEzeKswNo6eO7oJA49Hxn_bsbzgzUoJ-6A"
38
+ TELEGRAM_CHAT_ID = "-1002081124539"
39
+
40
+ # Application host/port/debug
41
+ DEBUG = os.environ.get('DEBUG', 'False') == 'True'
42
+ HOST = '0.0.0.0'
43
+ PORT = int(os.environ.get('PORT', 5000))
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Flask App Initialization and SQLAlchemy Setup
47
+ # ---------------------------------------------------------------------------
48
+
49
+ # Create base class for SQLAlchemy models
50
+ class Base(DeclarativeBase):
51
+ pass
52
+
53
+ # Initialize SQLAlchemy with the Base class
54
+ db = SQLAlchemy(model_class=Base)
55
+
56
+ # Create Flask application
57
+ app = Flask(__name__)
58
+
59
+ # Apply configuration
60
+ app.config['SQLALCHEMY_DATABASE_URI'] = SQLALCHEMY_DATABASE_URI
61
+ app.config['SECRET_KEY'] = SECRET_KEY
62
+ app.config['ADMIN_USERNAME'] = ADMIN_USERNAME
63
+ app.config['ADMIN_PASSWORD'] = ADMIN_PASSWORD
64
+ app.config['TELEGRAM_BOT_TOKEN'] = TELEGRAM_BOT_TOKEN
65
+ app.config['TELEGRAM_CHAT_ID'] = TELEGRAM_CHAT_ID
66
+ app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
67
+ app.config["SQLALCHEMY_ENGINE_OPTIONS"] = {
68
+ "pool_recycle": 300,
69
+ "pool_pre_ping": True,
70
+ }
71
+ app.config['HOST'] = HOST
72
+ app.config['PORT'] = PORT
73
+ app.config['DEBUG'] = DEBUG
74
+ app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max upload
75
+
76
+ # Initialize the app with SQLAlchemy
77
+ db.init_app(app)
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Database Models
81
+ # ---------------------------------------------------------------------------
82
+
83
+ # Matiere (Subject) Model
84
+ class Matiere(db.Model):
85
+ id = db.Column(db.Integer, primary_key=True)
86
+ nom = db.Column(db.String(100), unique=True, nullable=False)
87
+ color_code = db.Column(db.String(7), nullable=False, default="#3498db") # Add color code field for subject
88
+
89
+ # Relationships
90
+ sous_categories = db.relationship('SousCategorie', backref='matiere', lazy=True, cascade="all, delete-orphan")
91
+
92
+ def __repr__(self):
93
+ return f'<Matiere {self.nom}>'
94
+
95
+ # SousCategorie (SubCategory) Model
96
+ class SousCategorie(db.Model):
97
+ id = db.Column(db.Integer, primary_key=True)
98
+ nom = db.Column(db.String(100), nullable=False)
99
+ matiere_id = db.Column(db.Integer, db.ForeignKey('matiere.id'), nullable=False)
100
+
101
+ # Enforce unique constraint on name within same matiere
102
+ __table_args__ = (db.UniqueConstraint('nom', 'matiere_id', name='_nom_matiere_uc'),)
103
+
104
+ # Relationships
105
+ textes = db.relationship('Texte', backref='sous_categorie', lazy=True, cascade="all, delete-orphan")
106
+
107
+ def __repr__(self):
108
+ return f'<SousCategorie {self.nom} (Matiere ID: {self.matiere_id})>'
109
+
110
+ # ContentBlock model for the new block-based content
111
+ class ContentBlock(db.Model):
112
+ id = db.Column(db.Integer, primary_key=True)
113
+ texte_id = db.Column(db.Integer, db.ForeignKey('texte.id'), nullable=False)
114
+ title = db.Column(db.String(200), nullable=True)
115
+ content = db.Column(db.Text, nullable=False)
116
+ order = db.Column(db.Integer, nullable=False, default=0)
117
+ image_id = db.Column(db.Integer, db.ForeignKey('image.id'), nullable=True)
118
+ image_position = db.Column(db.String(10), nullable=True, default='left') # 'left', 'right', 'top', 'bottom'
119
+
120
+ # Relationship
121
+ image = db.relationship('Image', foreign_keys=[image_id])
122
+
123
+ def __repr__(self):
124
+ return f'<ContentBlock {self.id} (Texte ID: {self.texte_id})>'
125
+
126
+ # Texte (Text content) Model
127
+ class Texte(db.Model):
128
+ id = db.Column(db.Integer, primary_key=True)
129
+ titre = db.Column(db.String(200), nullable=False)
130
+ contenu = db.Column(db.Text, nullable=False)
131
+ sous_categorie_id = db.Column(db.Integer, db.ForeignKey('sous_categorie.id'), nullable=False)
132
+ created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
133
+ updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
134
+
135
+ # Relationships
136
+ historiques = db.relationship('TexteHistorique', backref='texte', lazy=True, cascade="all, delete-orphan")
137
+ content_blocks = db.relationship('ContentBlock', backref='texte', lazy=True, cascade="all, delete-orphan",
138
+ order_by="ContentBlock.order")
139
+
140
+ def __repr__(self):
141
+ return f'<Texte {self.titre} (SousCategorie ID: {self.sous_categorie_id})>'
142
+
143
+ # Image Model for storing content images
144
+ class Image(db.Model):
145
+ id = db.Column(db.Integer, primary_key=True)
146
+ nom_fichier = db.Column(db.String(255))
147
+ mime_type = db.Column(db.String(100), nullable=False)
148
+ data = db.Column(db.LargeBinary, nullable=False) # BLOB/BYTEA for image data
149
+ uploaded_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
150
+
151
+ # Additional fields for image management
152
+ description = db.Column(db.String(255), nullable=True)
153
+ alt_text = db.Column(db.String(255), nullable=True)
154
+
155
+ def __repr__(self):
156
+ return f'<Image {self.id} ({self.nom_fichier})>'
157
+
158
+ # TexteHistorique (Text History) Model
159
+ class TexteHistorique(db.Model):
160
+ id = db.Column(db.Integer, primary_key=True)
161
+ texte_id = db.Column(db.Integer, db.ForeignKey('texte.id'), nullable=False)
162
+ contenu_precedent = db.Column(db.Text, nullable=False)
163
+ date_modification = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
164
+
165
+ def __repr__(self):
166
+ return f'<TexteHistorique {self.id} (Texte ID: {self.texte_id})>'
167
+
168
+ # UserPreference Model to store user theme preferences
169
+ class UserPreference(db.Model):
170
+ id = db.Column(db.Integer, primary_key=True)
171
+ user_id = db.Column(db.String(50), unique=True, nullable=False) # Use session ID or similar for anonymous users
172
+ theme = db.Column(db.String(10), nullable=False, default='light') # 'light' or 'dark'
173
+ created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
174
+ updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
175
+
176
+ def __repr__(self):
177
+ return f'<UserPreference {self.id} (User ID: {self.user_id})>'
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # Utility Functions
181
+ # ---------------------------------------------------------------------------
182
+
183
+ # Admin authentication decorator
184
+ def admin_required(f):
185
+ @wraps(f)
186
+ def decorated_function(*args, **kwargs):
187
+ if not session.get('admin_logged_in'):
188
+ flash('Veuillez vous connecter pour accéder à cette page.', 'warning')
189
+ return redirect(url_for('admin_bp.login'))
190
+ return f(*args, **kwargs)
191
+ return decorated_function
192
+
193
+ # Admin login check
194
+ def check_admin_credentials(username, password):
195
+ # Access config via current_app proxy
196
+ admin_username = current_app.config['ADMIN_USERNAME']
197
+ admin_password = current_app.config['ADMIN_PASSWORD']
198
+
199
+ return username == admin_username and password == admin_password
200
+
201
+ # Send feedback to Telegram
202
+ def send_telegram_feedback(message):
203
+ token = current_app.config.get('TELEGRAM_BOT_TOKEN')
204
+ chat_id = current_app.config.get('TELEGRAM_CHAT_ID')
205
+
206
+ if not token or not chat_id:
207
+ current_app.logger.error("Telegram bot token or chat ID not configured")
208
+ return False
209
+
210
+ api_url = f"https://api.telegram.org/bot{token}/sendMessage"
211
+ payload = {
212
+ "chat_id": chat_id,
213
+ "text": f"📝 Nouveau feedback:\n\n{message}",
214
+ "parse_mode": "HTML"
215
+ }
216
+
217
+ try:
218
+ response = requests.post(api_url, data=payload, timeout=10)
219
+ response.raise_for_status()
220
+ current_app.logger.info("Feedback sent to Telegram successfully")
221
+ return True
222
+ except requests.exceptions.RequestException as e:
223
+ current_app.logger.error(f"Error sending feedback to Telegram: {str(e)}")
224
+ if hasattr(e, 'response') and e.response is not None:
225
+ current_app.logger.error(f"Telegram API Response: {e.response.text}")
226
+ return False
227
+ except Exception as e:
228
+ current_app.logger.error(f"Unexpected exception while sending feedback to Telegram: {str(e)}")
229
+ return False
230
+
231
+ # Get or create user preferences
232
+ def get_user_preferences():
233
+ user_id = session.get('user_id')
234
+ if not user_id:
235
+ # Generate a unique ID for new users
236
+ user_id = str(datetime.utcnow().timestamp())
237
+ session['user_id'] = user_id
238
+
239
+ user_pref = UserPreference.query.filter_by(user_id=user_id).first()
240
+ if not user_pref:
241
+ user_pref = UserPreference(user_id=user_id)
242
+ db.session.add(user_pref)
243
+ db.session.commit()
244
+
245
+ return user_pref
246
+
247
+ # Parse text content into blocks
248
+ def parse_content_to_blocks(text_content):
249
+ # Simple parser that creates blocks based on paragraphs or headings
250
+ blocks = []
251
+ current_block = {"title": None, "content": ""}
252
+
253
+ for line in text_content.split('\n'):
254
+ line = line.strip()
255
+ if not line:
256
+ # Empty line might indicate a block break
257
+ if current_block["content"]:
258
+ blocks.append(current_block)
259
+ current_block = {"title": None, "content": ""}
260
+ continue
261
+
262
+ # Check if line might be a heading (simplistic approach)
263
+ if len(line) < 100 and not current_block["content"]:
264
+ current_block["title"] = line
265
+ else:
266
+ if current_block["content"]:
267
+ current_block["content"] += "\n" + line
268
+ else:
269
+ current_block["content"] = line
270
+
271
+ # Add the last block if not empty
272
+ if current_block["content"]:
273
+ blocks.append(current_block)
274
+
275
+ # If no blocks were created, create one with all content
276
+ if not blocks:
277
+ blocks.append({"title": None, "content": text_content})
278
+
279
+ return blocks
280
+
281
+ # ---------------------------------------------------------------------------
282
+ # Blueprints Definition
283
+ # ---------------------------------------------------------------------------
284
+ main_bp = Blueprint('main_bp', __name__)
285
+ admin_bp = Blueprint('admin_bp', __name__, url_prefix='/gestion')
286
+
287
+
288
+ # ---------------------------------------------------------------------------
289
+ # Main Routes
290
+ # ---------------------------------------------------------------------------
291
+
292
+ @main_bp.route('/')
293
+ def index():
294
+ # Get user theme preference
295
+ user_pref = get_user_preferences()
296
+
297
+ # Fetch all subjects (matieres)
298
+ matieres = Matiere.query.all()
299
+
300
+ return render_template('index.html', matieres=matieres, theme=user_pref.theme)
301
+
302
+ @main_bp.route('/get_sous_categories/<int:matiere_id>')
303
+ def get_sous_categories(matiere_id):
304
+ sous_categories = SousCategorie.query.filter_by(matiere_id=matiere_id).all()
305
+ return jsonify([{'id': sc.id, 'nom': sc.nom} for sc in sous_categories])
306
+
307
+ @main_bp.route('/get_textes/<int:sous_categorie_id>')
308
+ def get_textes(sous_categorie_id):
309
+ textes = Texte.query.filter_by(sous_categorie_id=sous_categorie_id).all()
310
+ return jsonify([{'id': t.id, 'titre': t.titre} for t in textes])
311
+
312
+ @main_bp.route('/get_texte/<int:texte_id>')
313
+ def get_texte(texte_id):
314
+ texte = Texte.query.get_or_404(texte_id)
315
+
316
+ # Get the subject color for theming
317
+ matiere = Matiere.query.join(SousCategorie).filter(SousCategorie.id == texte.sous_categorie_id).first()
318
+ color_code = matiere.color_code if matiere else "#3498db"
319
+
320
+ # Check if the texte has content blocks
321
+ if texte.content_blocks:
322
+ blocks = []
323
+ for block in texte.content_blocks:
324
+ block_data = {
325
+ 'id': block.id,
326
+ 'title': block.title,
327
+ 'content': block.content,
328
+ 'order': block.order,
329
+ 'image_position': block.image_position,
330
+ 'image': None
331
+ }
332
+
333
+ # Add image data if available
334
+ if block.image:
335
+ image_data = base64.b64encode(block.image.data).decode('utf-8')
336
+ block_data['image'] = {
337
+ 'id': block.image.id,
338
+ 'src': f"data:{block.image.mime_type};base64,{image_data}",
339
+ 'alt': block.image.alt_text or block.title or "Image illustration"
340
+ }
341
+
342
+ blocks.append(block_data)
343
+ else:
344
+ # If no blocks exist yet, parse the content to create blocks
345
+ # This is useful for existing content migration
346
+ parsed_blocks = parse_content_to_blocks(texte.contenu)
347
+ blocks = []
348
+ for i, block_data in enumerate(parsed_blocks):
349
+ blocks.append({
350
+ 'id': None,
351
+ 'title': block_data['title'],
352
+ 'content': block_data['content'],
353
+ 'order': i,
354
+ 'image_position': 'left',
355
+ 'image': None
356
+ })
357
+
358
+ return jsonify({
359
+ 'id': texte.id,
360
+ 'titre': texte.titre,
361
+ 'contenu': texte.contenu,
362
+ 'blocks': blocks,
363
+ 'color_code': color_code
364
+ })
365
+
366
+ @main_bp.route('/feedback', methods=['POST'])
367
+ def submit_feedback():
368
+ message = request.form.get('message', '').strip()
369
+ if not message:
370
+ flash("Le message ne peut pas être vide.", "error")
371
+ return redirect(url_for('main_bp.index'))
372
+
373
+ success = send_telegram_feedback(message)
374
+ if success:
375
+ flash("Merci pour votre feedback!", "success")
376
+ else:
377
+ flash("Une erreur s'est produite lors de l'envoi de votre feedback. Veuillez réessayer plus tard.", "error")
378
+
379
+ return redirect(url_for('main_bp.index'))
380
+
381
+ @main_bp.route('/set_theme', methods=['POST'])
382
+ def set_theme():
383
+ theme = request.form.get('theme', 'light')
384
+ if theme not in ['light', 'dark']:
385
+ theme = 'light'
386
+
387
+ user_pref = get_user_preferences()
388
+ user_pref.theme = theme
389
+ db.session.commit()
390
+
391
+ return jsonify({'success': True, 'theme': theme})
392
+
393
+ @main_bp.route('/image/<int:image_id>')
394
+ def get_image(image_id):
395
+ image = Image.query.get_or_404(image_id)
396
+ return Response(image.data, mimetype=image.mime_type)
397
+
398
+ # ---------------------------------------------------------------------------
399
+ # Admin Routes
400
+ # ---------------------------------------------------------------------------
401
+
402
+ @admin_bp.route('/login', methods=['GET', 'POST'])
403
+ def login():
404
+ if request.method == 'POST':
405
+ username = request.form.get('username')
406
+ password = request.form.get('password')
407
+
408
+ if check_admin_credentials(username, password):
409
+ session['admin_logged_in'] = True
410
+ flash('Connexion réussie !', 'success')
411
+ return redirect(url_for('admin_bp.dashboard'))
412
+ else:
413
+ flash('Nom d\'utilisateur ou mot de passe incorrect.', 'danger')
414
+
415
+ return render_template('admin/login.html')
416
+
417
+ @admin_bp.route('/logout')
418
+ def logout():
419
+ session.pop('admin_logged_in', None)
420
+ flash('Vous avez été déconnecté.', 'info')
421
+ return redirect(url_for('admin_bp.login'))
422
+
423
+ @admin_bp.route('/')
424
+ @admin_required
425
+ def dashboard():
426
+ # Count of each entity type for dashboard stats
427
+ stats = {
428
+ 'matieres': Matiere.query.count(),
429
+ 'sous_categories': SousCategorie.query.count(),
430
+ 'textes': Texte.query.count(),
431
+ 'images': Image.query.count()
432
+ }
433
+
434
+ # Get recent texts for dashboard
435
+ recent_textes = Texte.query.order_by(Texte.updated_at.desc()).limit(5).all()
436
+
437
+ return render_template('admin/dashboard.html', stats=stats, recent_textes=recent_textes)
438
+
439
+ # Matières (Subjects) Management
440
+ @admin_bp.route('/matieres', methods=['GET', 'POST'])
441
+ @admin_required
442
+ def matieres():
443
+ if request.method == 'POST':
444
+ action = request.form.get('action')
445
+
446
+ if action == 'add':
447
+ nom = request.form.get('nom', '').strip()
448
+ color_code = request.form.get('color_code', '#3498db')
449
+
450
+ if not nom:
451
+ flash('Le nom de la matière est requis.', 'danger')
452
+ else:
453
+ matiere = Matiere.query.filter_by(nom=nom).first()
454
+ if matiere:
455
+ flash(f'La matière "{nom}" existe déjà.', 'warning')
456
+ else:
457
+ new_matiere = Matiere(nom=nom, color_code=color_code)
458
+ db.session.add(new_matiere)
459
+ db.session.commit()
460
+ flash(f'Matière "{nom}" ajoutée avec succès !', 'success')
461
+
462
+ elif action == 'edit':
463
+ matiere_id = request.form.get('matiere_id')
464
+ nom = request.form.get('nom', '').strip()
465
+ color_code = request.form.get('color_code', '#3498db')
466
+
467
+ if not matiere_id or not nom:
468
+ flash('Informations incomplètes pour la modification.', 'danger')
469
+ else:
470
+ matiere = Matiere.query.get(matiere_id)
471
+ if not matiere:
472
+ flash('Matière non trouvée.', 'danger')
473
+ else:
474
+ existing = Matiere.query.filter_by(nom=nom).first()
475
+ if existing and existing.id != int(matiere_id):
476
+ flash(f'Une autre matière avec le nom "{nom}" existe déjà.', 'warning')
477
+ else:
478
+ matiere.nom = nom
479
+ matiere.color_code = color_code
480
+ db.session.commit()
481
+ flash(f'Matière "{nom}" modifiée avec succès !', 'success')
482
+
483
+ elif action == 'delete':
484
+ matiere_id = request.form.get('matiere_id')
485
+
486
+ if not matiere_id:
487
+ flash('ID de matière manquant pour la suppression.', 'danger')
488
+ else:
489
+ matiere = Matiere.query.get(matiere_id)
490
+ if not matiere:
491
+ flash('Matière non trouvée.', 'danger')
492
+ else:
493
+ nom = matiere.nom
494
+ db.session.delete(matiere)
495
+ db.session.commit()
496
+ flash(f'Matière "{nom}" supprimée avec succès !', 'success')
497
+
498
+ matieres = Matiere.query.all()
499
+ return render_template('admin/matieres.html', matieres=matieres)
500
+
501
+ # Sous-Catégories (Subcategories) Management
502
+ @admin_bp.route('/sous_categories', methods=['GET', 'POST'])
503
+ @admin_required
504
+ def sous_categories():
505
+ if request.method == 'POST':
506
+ action = request.form.get('action')
507
+
508
+ if action == 'add':
509
+ nom = request.form.get('nom', '').strip()
510
+ matiere_id = request.form.get('matiere_id')
511
+
512
+ if not nom or not matiere_id:
513
+ flash('Le nom et la matière sont requis.', 'danger')
514
+ else:
515
+ sous_categorie = SousCategorie.query.filter_by(nom=nom, matiere_id=matiere_id).first()
516
+ if sous_categorie:
517
+ flash(f'La sous-catégorie "{nom}" existe déjà pour cette matière.', 'warning')
518
+ else:
519
+ new_sous_categorie = SousCategorie(nom=nom, matiere_id=matiere_id)
520
+ db.session.add(new_sous_categorie)
521
+ db.session.commit()
522
+ flash(f'Sous-catégorie "{nom}" ajoutée avec succès !', 'success')
523
+
524
+ elif action == 'edit':
525
+ sous_categorie_id = request.form.get('sous_categorie_id')
526
+ nom = request.form.get('nom', '').strip()
527
+ matiere_id = request.form.get('matiere_id')
528
+
529
+ if not sous_categorie_id or not nom or not matiere_id:
530
+ flash('Informations incomplètes pour la modification.', 'danger')
531
+ else:
532
+ sous_categorie = SousCategorie.query.get(sous_categorie_id)
533
+ if not sous_categorie:
534
+ flash('Sous-catégorie non trouvée.', 'danger')
535
+ else:
536
+ existing = SousCategorie.query.filter_by(nom=nom, matiere_id=matiere_id).first()
537
+ if existing and existing.id != int(sous_categorie_id):
538
+ flash(f'Une sous-catégorie avec le nom "{nom}" existe déjà pour cette matière.', 'warning')
539
+ else:
540
+ sous_categorie.nom = nom
541
+ sous_categorie.matiere_id = matiere_id
542
+ db.session.commit()
543
+ flash(f'Sous-catégorie "{nom}" modifiée avec succès !', 'success')
544
+
545
+ elif action == 'delete':
546
+ sous_categorie_id = request.form.get('sous_categorie_id')
547
+
548
+ if not sous_categorie_id:
549
+ flash('ID de sous-catégorie manquant pour la suppression.', 'danger')
550
+ else:
551
+ sous_categorie = SousCategorie.query.get(sous_categorie_id)
552
+ if not sous_categorie:
553
+ flash('Sous-catégorie non trouvée.', 'danger')
554
+ else:
555
+ nom = sous_categorie.nom
556
+ db.session.delete(sous_categorie)
557
+ db.session.commit()
558
+ flash(f'Sous-catégorie "{nom}" supprimée avec succès !', 'success')
559
+
560
+ sous_categories = SousCategorie.query.join(Matiere).all()
561
+ matieres = Matiere.query.all()
562
+ return render_template('admin/sous_categories.html', sous_categories=sous_categories, matieres=matieres)
563
+
564
+ # Textes (Content) Management
565
+ @admin_bp.route('/textes', methods=['GET', 'POST'])
566
+ @admin_required
567
+ def textes():
568
+ if request.method == 'POST':
569
+ action = request.form.get('action')
570
+
571
+ if action == 'add':
572
+ titre = request.form.get('titre', '').strip()
573
+ sous_categorie_id = request.form.get('sous_categorie_id')
574
+ contenu = request.form.get('contenu', '').strip()
575
+
576
+ if not titre or not sous_categorie_id or not contenu:
577
+ flash('Tous les champs sont requis.', 'danger')
578
+ else:
579
+ new_texte = Texte(
580
+ titre=titre,
581
+ sous_categorie_id=sous_categorie_id,
582
+ contenu=contenu
583
+ )
584
+ db.session.add(new_texte)
585
+ db.session.commit()
586
+
587
+ # Parse content into blocks
588
+ blocks = parse_content_to_blocks(contenu)
589
+ for i, block_data in enumerate(blocks):
590
+ new_block = ContentBlock(
591
+ texte_id=new_texte.id,
592
+ title=block_data['title'],
593
+ content=block_data['content'],
594
+ order=i
595
+ )
596
+ db.session.add(new_block)
597
+
598
+ db.session.commit()
599
+ flash(f'Texte "{titre}" ajouté avec succès !', 'success')
600
+ return redirect(url_for('admin_bp.edit_texte', texte_id=new_texte.id))
601
+
602
+ elif action == 'delete':
603
+ texte_id = request.form.get('texte_id')
604
+
605
+ if not texte_id:
606
+ flash('ID de texte manquant pour la suppression.', 'danger')
607
+ else:
608
+ texte = Texte.query.get(texte_id)
609
+ if not texte:
610
+ flash('Texte non trouvé.', 'danger')
611
+ else:
612
+ titre = texte.titre
613
+ db.session.delete(texte)
614
+ db.session.commit()
615
+ flash(f'Texte "{titre}" supprimé avec succès !', 'success')
616
+
617
+ matieres = Matiere.query.all()
618
+ textes = Texte.query.join(SousCategorie).join(Matiere).order_by(Matiere.nom, SousCategorie.nom, Texte.titre).all()
619
+
620
+ # Group texts by matiere and sous_categorie for easier display
621
+ grouped_textes = {}
622
+ for texte in textes:
623
+ matiere_id = texte.sous_categorie.matiere.id
624
+ if matiere_id not in grouped_textes:
625
+ grouped_textes[matiere_id] = {
626
+ 'nom': texte.sous_categorie.matiere.nom,
627
+ 'color': texte.sous_categorie.matiere.color_code,
628
+ 'sous_categories': {}
629
+ }
630
+
631
+ sous_cat_id = texte.sous_categorie.id
632
+ if sous_cat_id not in grouped_textes[matiere_id]['sous_categories']:
633
+ grouped_textes[matiere_id]['sous_categories'][sous_cat_id] = {
634
+ 'nom': texte.sous_categorie.nom,
635
+ 'textes': []
636
+ }
637
+
638
+ grouped_textes[matiere_id]['sous_categories'][sous_cat_id]['textes'].append({
639
+ 'id': texte.id,
640
+ 'titre': texte.titre,
641
+ 'updated_at': texte.updated_at
642
+ })
643
+
644
+ sous_categories = SousCategorie.query.all()
645
+ return render_template('admin/textes.html', grouped_textes=grouped_textes, matieres=matieres, sous_categories=sous_categories)
646
+
647
+ @admin_bp.route('/textes/edit/<int:texte_id>', methods=['GET', 'POST'])
648
+ @admin_required
649
+ def edit_texte(texte_id):
650
+ texte = Texte.query.get_or_404(texte_id)
651
+
652
+ if request.method == 'POST':
653
+ action = request.form.get('action')
654
+
655
+ if action == 'update_basic':
656
+ # Basic text info update
657
+ titre = request.form.get('titre', '').strip()
658
+ sous_categorie_id = request.form.get('sous_categorie_id')
659
+
660
+ if not titre or not sous_categorie_id:
661
+ flash('Le titre et la sous-catégorie sont requis.', 'danger')
662
+ else:
663
+ # Save previous content for history
664
+ historique = TexteHistorique(
665
+ texte_id=texte.id,
666
+ contenu_precedent=texte.contenu
667
+ )
668
+ db.session.add(historique)
669
+
670
+ texte.titre = titre
671
+ texte.sous_categorie_id = sous_categorie_id
672
+
673
+ db.session.commit()
674
+ flash('Informations de base mises à jour avec succès !', 'success')
675
+
676
+ elif action == 'update_blocks':
677
+ # Update content blocks
678
+ blocks_data = json.loads(request.form.get('blocks_data', '[]'))
679
+
680
+ # Save previous content for history if changes were made
681
+ historique = TexteHistorique(
682
+ texte_id=texte.id,
683
+ contenu_precedent=texte.contenu
684
+ )
685
+ db.session.add(historique)
686
+
687
+ # Update all blocks
688
+ # First, remove existing blocks
689
+ for block in texte.content_blocks:
690
+ db.session.delete(block)
691
+
692
+ # Then create new blocks from the submitted data
693
+ new_content = []
694
+ for i, block_data in enumerate(blocks_data):
695
+ new_block = ContentBlock(
696
+ texte_id=texte.id,
697
+ title=block_data.get('title'),
698
+ content=block_data.get('content', ''),
699
+ order=i,
700
+ image_position=block_data.get('image_position', 'left')
701
+ )
702
+
703
+ # Set image if provided
704
+ image_id = block_data.get('image_id')
705
+ if image_id and image_id != 'null' and image_id != 'undefined':
706
+ new_block.image_id = image_id
707
+
708
+ db.session.add(new_block)
709
+
710
+ # Append to full content for the main text field
711
+ if new_block.title:
712
+ new_content.append(new_block.title)
713
+ new_content.append(new_block.content)
714
+
715
+ # Update the main content field to match block content
716
+ texte.contenu = "\n\n".join(new_content)
717
+
718
+ db.session.commit()
719
+ flash('Contenu mis à jour avec succès !', 'success')
720
+
721
+ elif action == 'upload_image':
722
+ if 'image' not in request.files:
723
+ return jsonify({'success': False, 'error': 'Aucun fichier trouvé'})
724
+
725
+ file = request.files['image']
726
+ if file.filename == '':
727
+ return jsonify({'success': False, 'error': 'Aucun fichier sélectionné'})
728
+
729
+ # Check file type (accept only images)
730
+ allowed_mimetypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml']
731
+ if file.mimetype not in allowed_mimetypes:
732
+ return jsonify({'success': False, 'error': 'Type de fichier non autorisé'})
733
+
734
+ # Read file data
735
+ file_data = file.read()
736
+ if not file_data:
737
+ return jsonify({'success': False, 'error': 'Fichier vide'})
738
+
739
+ # Create new image record
740
+ new_image = Image(
741
+ nom_fichier=secure_filename(file.filename),
742
+ mime_type=file.mimetype,
743
+ data=file_data,
744
+ alt_text=request.form.get('alt_text', '')
745
+ )
746
+ db.session.add(new_image)
747
+ db.session.commit()
748
+
749
+ # Return image details
750
+ image_data = base64.b64encode(new_image.data).decode('utf-8')
751
+ return jsonify({
752
+ 'success': True,
753
+ 'image': {
754
+ 'id': new_image.id,
755
+ 'filename': new_image.nom_fichier,
756
+ 'src': f"data:{new_image.mime_type};base64,{image_data}",
757
+ 'alt': new_image.alt_text or "Image illustration"
758
+ }
759
+ })
760
+
761
+ # Get existing content blocks or create them if none exist
762
+ if not texte.content_blocks:
763
+ # Parse content into blocks
764
+ blocks = parse_content_to_blocks(texte.contenu)
765
+ for i, block_data in enumerate(blocks):
766
+ new_block = ContentBlock(
767
+ texte_id=texte.id,
768
+ title=block_data['title'],
769
+ content=block_data['content'],
770
+ order=i
771
+ )
772
+ db.session.add(new_block)
773
+ db.session.commit()
774
+
775
+ # Reload the texte to get the new blocks
776
+ texte = Texte.query.get(texte_id)
777
+
778
+ # Prepare block data for template
779
+ blocks = []
780
+ for block in texte.content_blocks:
781
+ block_data = {
782
+ 'id': block.id,
783
+ 'title': block.title,
784
+ 'content': block.content,
785
+ 'order': block.order,
786
+ 'image_position': block.image_position,
787
+ 'image': None
788
+ }
789
+
790
+ # Add image data if available
791
+ if block.image:
792
+ image_data = base64.b64encode(block.image.data).decode('utf-8')
793
+ block_data['image'] = {
794
+ 'id': block.image.id,
795
+ 'src': f"data:{block.image.mime_type};base64,{image_data}",
796
+ 'alt': block.image.alt_text or block.title or "Image illustration"
797
+ }
798
+
799
+ blocks.append(block_data)
800
+
801
+ # Get all images for selection
802
+ images = Image.query.order_by(Image.uploaded_at.desc()).all()
803
+ images_data = []
804
+ for image in images:
805
+ image_data = base64.b64encode(image.data).decode('utf-8')
806
+ images_data.append({
807
+ 'id': image.id,
808
+ 'filename': image.nom_fichier,
809
+ 'src': f"data:{image.mime_type};base64,{image_data}",
810
+ 'alt': image.alt_text or "Image illustration"
811
+ })
812
+
813
+ sous_categories = SousCategorie.query.all()
814
+ return render_template('admin/edit_texte.html',
815
+ texte=texte,
816
+ blocks=blocks,
817
+ images=images_data,
818
+ sous_categories=sous_categories)
819
+
820
+ @admin_bp.route('/historique/<int:texte_id>')
821
+ @admin_required
822
+ def historique(texte_id):
823
+ texte = Texte.query.get_or_404(texte_id)
824
+ historiques = TexteHistorique.query.filter_by(texte_id=texte_id).order_by(TexteHistorique.date_modification.desc()).all()
825
+
826
+ return render_template('admin/historique.html', texte=texte, historiques=historiques)
827
+
828
+ @admin_bp.route('/images', methods=['GET', 'POST'])
829
+ @admin_required
830
+ def images():
831
+ if request.method == 'POST':
832
+ action = request.form.get('action')
833
+
834
+ if action == 'upload':
835
+ if 'image' not in request.files:
836
+ flash('Aucun fichier trouvé.', 'danger')
837
+ return redirect(request.url)
838
+
839
+ file = request.files['image']
840
+ if file.filename == '':
841
+ flash('Aucun fichier sélectionné.', 'danger')
842
+ return redirect(request.url)
843
+
844
+ # Check file type (accept only images)
845
+ allowed_mimetypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml']
846
+ if file.mimetype not in allowed_mimetypes:
847
+ flash('Type de fichier non autorisé.', 'danger')
848
+ return redirect(request.url)
849
+
850
+ # Read file data
851
+ file_data = file.read()
852
+ if not file_data:
853
+ flash('Fichier vide.', 'danger')
854
+ return redirect(request.url)
855
+
856
+ # Create new image record
857
+ alt_text = request.form.get('alt_text', '')
858
+ description = request.form.get('description', '')
859
+
860
+ new_image = Image(
861
+ nom_fichier=secure_filename(file.filename),
862
+ mime_type=file.mimetype,
863
+ data=file_data,
864
+ alt_text=alt_text,
865
+ description=description
866
+ )
867
+ db.session.add(new_image)
868
+ db.session.commit()
869
+
870
+ flash('Image téléchargée avec succès !', 'success')
871
+
872
+ elif action == 'delete':
873
+ image_id = request.form.get('image_id')
874
+ if image_id:
875
+ image = Image.query.get(image_id)
876
+ if image:
877
+ # Check if image is used in any content block
878
+ usage_count = ContentBlock.query.filter_by(image_id=image.id).count()
879
+ if usage_count > 0:
880
+ flash(f'Cette image est utilisée dans {usage_count} blocs de contenu. Veuillez les modifier avant de supprimer l\'image.', 'warning')
881
+ else:
882
+ db.session.delete(image)
883
+ db.session.commit()
884
+ flash('Image supprimée avec succès !', 'success')
885
+ else:
886
+ flash('Image non trouvée.', 'danger')
887
+ else:
888
+ flash('ID d\'image manquant.', 'danger')
889
+
890
+ elif action == 'update':
891
+ image_id = request.form.get('image_id')
892
+ alt_text = request.form.get('alt_text', '')
893
+ description = request.form.get('description', '')
894
+
895
+ if image_id:
896
+ image = Image.query.get(image_id)
897
+ if image:
898
+ image.alt_text = alt_text
899
+ image.description = description
900
+ db.session.commit()
901
+ flash('Informations de l\'image mises à jour avec succès !', 'success')
902
+ else:
903
+ flash('Image non trouvée.', 'danger')
904
+ else:
905
+ flash('ID d\'image manquant.', 'danger')
906
+
907
+ # Get all images
908
+ images = Image.query.order_by(Image.uploaded_at.desc()).all()
909
+ images_data = []
910
+ for image in images:
911
+ image_data = base64.b64encode(image.data).decode('utf-8')
912
+ images_data.append({
913
+ 'id': image.id,
914
+ 'filename': image.nom_fichier,
915
+ 'description': image.description,
916
+ 'alt_text': image.alt_text,
917
+ 'uploaded_at': image.uploaded_at,
918
+ 'src': f"data:{image.mime_type};base64,{image_data}"
919
+ })
920
+
921
+ return render_template('admin/images.html', images=images_data)
922
+
923
+ # ---------------------------------------------------------------------------
924
+ # Register blueprints and setup database
925
+ # ---------------------------------------------------------------------------
926
+
927
+ app.register_blueprint(main_bp)
928
+ app.register_blueprint(admin_bp)
929
+
930
+ # Create tables if they don't exist
931
+ with app.app_context():
932
+ db.create_all()
933
+
934
+ # Application entry point
935
+ @app.route('/')
936
+ def index():
937
+ return redirect(url_for('main_bp.index'))
938
+
939
+ # ---------------------------------------------------------------------------
940
+ # Serve the app - This is only used when running locally
941
+ # ---------------------------------------------------------------------------
942
+
943
+ if __name__ == '__main__':
944
+ app.run(host=HOST, port=PORT, debug=DEBUG)
945
+
946
+ # For Vercel serverless function
947
+ def app_handler(environ, start_response):
948
+ return app(environ, start_response)
api/static/css/style.css ADDED
@@ -0,0 +1,683 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Base Styles & Theme Variables */
2
+ :root {
3
+ /* Light theme */
4
+ --background-color: #f8f9fa;
5
+ --text-color: #212529;
6
+ --primary-color: #3498db;
7
+ --secondary-color: #2ecc71;
8
+ --accent-color: #e74c3c;
9
+ --muted-color: #95a5a6;
10
+ --border-color: #dee2e6;
11
+ --card-bg: #ffffff;
12
+ --header-bg: #ffffff;
13
+ --footer-bg: #f1f1f1;
14
+ --shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
15
+ --hover-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
16
+ --block-bg: #f8f9fa;
17
+ --block-border: 1px solid #dee2e6;
18
+ --input-bg: #ffffff;
19
+ --btn-primary-bg: var(--primary-color);
20
+ --btn-primary-text: #ffffff;
21
+ --btn-secondary-bg: #6c757d;
22
+ --btn-secondary-text: #ffffff;
23
+ --modal-bg: #ffffff;
24
+ }
25
+
26
+ [data-theme="dark"] {
27
+ /* Dark theme */
28
+ --background-color: #121212;
29
+ --text-color: #e0e0e0;
30
+ --primary-color: #3498db;
31
+ --secondary-color: #2ecc71;
32
+ --accent-color: #e74c3c;
33
+ --muted-color: #95a5a6;
34
+ --border-color: #333333;
35
+ --card-bg: #1e1e1e;
36
+ --header-bg: #1a1a1a;
37
+ --footer-bg: #1a1a1a;
38
+ --shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
39
+ --hover-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
40
+ --block-bg: #1e1e1e;
41
+ --block-border: 1px solid #333333;
42
+ --input-bg: #2c2c2c;
43
+ --btn-primary-bg: var(--primary-color);
44
+ --btn-primary-text: #ffffff;
45
+ --btn-secondary-bg: #555555;
46
+ --btn-secondary-text: #ffffff;
47
+ --modal-bg: #1e1e1e;
48
+ }
49
+
50
+ /* General Styles */
51
+ body {
52
+ background-color: var(--background-color);
53
+ color: var(--text-color);
54
+ font-family: 'Roboto', 'Segoe UI', 'Arial', sans-serif;
55
+ line-height: 1.6;
56
+ transition: background-color 0.3s ease, color 0.3s ease;
57
+ margin: 0;
58
+ padding: 0;
59
+ min-height: 100vh;
60
+ display: flex;
61
+ flex-direction: column;
62
+ }
63
+
64
+ a {
65
+ color: var(--primary-color);
66
+ text-decoration: none;
67
+ transition: color 0.3s ease;
68
+ }
69
+
70
+ a:hover {
71
+ color: #2980b9;
72
+ text-decoration: underline;
73
+ }
74
+
75
+ /* Header Styles */
76
+ .main-header {
77
+ background-color: var(--header-bg);
78
+ padding: 1rem 0;
79
+ box-shadow: var(--shadow);
80
+ position: sticky;
81
+ top: 0;
82
+ z-index: 1000;
83
+ transition: background-color 0.3s ease, box-shadow 0.3s ease;
84
+ }
85
+
86
+ .header-container {
87
+ display: flex;
88
+ justify-content: space-between;
89
+ align-items: center;
90
+ }
91
+
92
+ .site-title {
93
+ font-size: 1.8rem;
94
+ font-weight: bold;
95
+ margin: 0;
96
+ }
97
+
98
+ .site-title a {
99
+ color: var(--text-color);
100
+ text-decoration: none;
101
+ }
102
+
103
+ /* Theme Toggle */
104
+ .theme-toggle {
105
+ background: none;
106
+ border: none;
107
+ cursor: pointer;
108
+ color: var(--text-color);
109
+ font-size: 1.5rem;
110
+ padding: 0.5rem;
111
+ transition: color 0.3s ease, transform 0.3s ease;
112
+ }
113
+
114
+ .theme-toggle:hover {
115
+ color: var(--primary-color);
116
+ transform: rotate(15deg);
117
+ }
118
+
119
+ /* Main Content */
120
+ .main-content {
121
+ flex: 1;
122
+ padding: 2rem 0;
123
+ }
124
+
125
+ /* Subject Selection Cards */
126
+ .subject-card {
127
+ background-color: var(--card-bg);
128
+ border-radius: 8px;
129
+ padding: 1.5rem;
130
+ margin-bottom: 1.5rem;
131
+ box-shadow: var(--shadow);
132
+ transition: box-shadow 0.3s ease, transform 0.3s ease;
133
+ cursor: pointer;
134
+ }
135
+
136
+ .subject-card:hover {
137
+ box-shadow: var(--hover-shadow);
138
+ transform: translateY(-5px);
139
+ }
140
+
141
+ .subject-card h3 {
142
+ margin-top: 0;
143
+ margin-bottom: 1rem;
144
+ font-size: 1.5rem;
145
+ color: var(--text-color);
146
+ }
147
+
148
+ /* Subject Color-Coding */
149
+ .subject-indicator {
150
+ width: 100%;
151
+ height: 8px;
152
+ border-radius: 4px;
153
+ margin-bottom: 1rem;
154
+ }
155
+
156
+ /* Content Blocks Styling */
157
+ .content-block {
158
+ background-color: var(--block-bg);
159
+ border-radius: 8px;
160
+ padding: 1.5rem;
161
+ margin-bottom: 2rem;
162
+ box-shadow: var(--shadow);
163
+ border: var(--block-border);
164
+ transition: box-shadow 0.3s ease;
165
+ overflow: hidden;
166
+ }
167
+
168
+ .content-block:hover {
169
+ box-shadow: var(--hover-shadow);
170
+ }
171
+
172
+ .content-block-title {
173
+ font-size: 1.4rem;
174
+ font-weight: bold;
175
+ margin-top: 0;
176
+ margin-bottom: 1rem;
177
+ color: var(--text-color);
178
+ padding-bottom: 0.5rem;
179
+ border-bottom: 2px solid var(--primary-color);
180
+ }
181
+
182
+ .content-block-content {
183
+ line-height: 1.7;
184
+ }
185
+
186
+ /* Image positioning styles */
187
+ .block-with-image {
188
+ display: flex;
189
+ flex-wrap: wrap;
190
+ gap: 1.5rem;
191
+ align-items: flex-start;
192
+ }
193
+
194
+ .block-with-image.image-left {
195
+ flex-direction: row;
196
+ }
197
+
198
+ .block-with-image.image-right {
199
+ flex-direction: row-reverse;
200
+ }
201
+
202
+ .block-with-image.image-top {
203
+ flex-direction: column;
204
+ }
205
+
206
+ .block-image-container {
207
+ flex: 0 0 auto;
208
+ max-width: 30%;
209
+ margin-bottom: 1rem;
210
+ }
211
+
212
+ .block-with-image.image-top .block-image-container {
213
+ max-width: 100%;
214
+ margin-bottom: 1.5rem;
215
+ }
216
+
217
+ .block-image {
218
+ width: 100%;
219
+ height: auto;
220
+ border-radius: 6px;
221
+ box-shadow: var(--shadow);
222
+ }
223
+
224
+ .block-content-container {
225
+ flex: 1;
226
+ }
227
+
228
+ @media (max-width: 768px) {
229
+ .block-with-image {
230
+ flex-direction: column !important;
231
+ }
232
+
233
+ .block-image-container {
234
+ max-width: 100%;
235
+ margin-bottom: 1.5rem;
236
+ }
237
+ }
238
+
239
+ /* Category Selection UI */
240
+ .selection-container {
241
+ background-color: var(--card-bg);
242
+ border-radius: 8px;
243
+ padding: 1.5rem;
244
+ margin-bottom: 2rem;
245
+ box-shadow: var(--shadow);
246
+ }
247
+
248
+ .selection-title {
249
+ font-size: 1.2rem;
250
+ font-weight: 600;
251
+ margin-bottom: 1rem;
252
+ padding-bottom: 0.5rem;
253
+ border-bottom: 1px solid var(--border-color);
254
+ }
255
+
256
+ .selection-list {
257
+ list-style: none;
258
+ padding: 0;
259
+ margin: 0;
260
+ }
261
+
262
+ .selection-item {
263
+ padding: 0.5rem 0.8rem;
264
+ margin-bottom: 0.5rem;
265
+ border-radius: 4px;
266
+ cursor: pointer;
267
+ transition: background-color 0.3s ease;
268
+ }
269
+
270
+ .selection-item:hover {
271
+ background-color: rgba(52, 152, 219, 0.1);
272
+ }
273
+
274
+ .selection-item.active {
275
+ background-color: var(--primary-color);
276
+ color: white;
277
+ }
278
+
279
+ /* Content Viewer */
280
+ .content-viewer {
281
+ background-color: var(--card-bg);
282
+ border-radius: 8px;
283
+ padding: 2rem;
284
+ box-shadow: var(--shadow);
285
+ margin-bottom: 2rem;
286
+ }
287
+
288
+ .content-title {
289
+ font-size: 2rem;
290
+ margin-top: 0;
291
+ margin-bottom: 1.5rem;
292
+ padding-bottom: 0.5rem;
293
+ border-bottom: 2px solid var(--primary-color);
294
+ }
295
+
296
+ /* Buttons */
297
+ .btn {
298
+ display: inline-block;
299
+ font-weight: 500;
300
+ text-align: center;
301
+ white-space: nowrap;
302
+ vertical-align: middle;
303
+ user-select: none;
304
+ border: 1px solid transparent;
305
+ padding: 0.5rem 1rem;
306
+ font-size: 1rem;
307
+ line-height: 1.5;
308
+ border-radius: 0.25rem;
309
+ transition: all 0.15s ease-in-out;
310
+ cursor: pointer;
311
+ }
312
+
313
+ .btn-primary {
314
+ background-color: var(--btn-primary-bg);
315
+ color: var(--btn-primary-text);
316
+ }
317
+
318
+ .btn-primary:hover {
319
+ background-color: #2980b9;
320
+ color: white;
321
+ }
322
+
323
+ .btn-secondary {
324
+ background-color: var(--btn-secondary-bg);
325
+ color: var(--btn-secondary-text);
326
+ }
327
+
328
+ .btn-secondary:hover {
329
+ background-color: #5a6268;
330
+ color: white;
331
+ }
332
+
333
+ /* Form Elements */
334
+ input, select, textarea {
335
+ background-color: var(--input-bg);
336
+ color: var(--text-color);
337
+ border: 1px solid var(--border-color);
338
+ border-radius: 4px;
339
+ padding: 0.75rem 1rem;
340
+ width: 100%;
341
+ transition: border-color 0.3s ease, box-shadow 0.3s ease;
342
+ }
343
+
344
+ input:focus, select:focus, textarea:focus {
345
+ border-color: var(--primary-color);
346
+ outline: none;
347
+ box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.25);
348
+ }
349
+
350
+ label {
351
+ display: block;
352
+ margin-bottom: 0.5rem;
353
+ font-weight: 500;
354
+ }
355
+
356
+ .form-group {
357
+ margin-bottom: 1.5rem;
358
+ }
359
+
360
+ /* Footer */
361
+ .main-footer {
362
+ background-color: var(--footer-bg);
363
+ padding: 2rem 0;
364
+ box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.05);
365
+ margin-top: auto;
366
+ }
367
+
368
+ .footer-content {
369
+ display: flex;
370
+ justify-content: space-between;
371
+ flex-wrap: wrap;
372
+ }
373
+
374
+ .footer-links {
375
+ list-style: none;
376
+ padding: 0;
377
+ display: flex;
378
+ gap: 1.5rem;
379
+ }
380
+
381
+ .feedback-form {
382
+ margin-top: 1rem;
383
+ }
384
+
385
+ .feedback-title {
386
+ font-size: 1.2rem;
387
+ margin-bottom: 0.75rem;
388
+ }
389
+
390
+ /* Admin specific styles */
391
+ .admin-container {
392
+ background-color: var(--card-bg);
393
+ border-radius: 8px;
394
+ padding: 2rem;
395
+ box-shadow: var(--shadow);
396
+ margin-bottom: 2rem;
397
+ }
398
+
399
+ .admin-title {
400
+ margin-top: 0;
401
+ margin-bottom: 1.5rem;
402
+ font-size: 1.8rem;
403
+ color: var(--text-color);
404
+ border-bottom: 2px solid var(--primary-color);
405
+ padding-bottom: 0.5rem;
406
+ }
407
+
408
+ .admin-sidebar {
409
+ background-color: var(--block-bg);
410
+ border-radius: 8px;
411
+ padding: 1.5rem;
412
+ margin-bottom: 2rem;
413
+ box-shadow: var(--shadow);
414
+ }
415
+
416
+ .admin-nav {
417
+ list-style: none;
418
+ padding: 0;
419
+ margin: 0;
420
+ }
421
+
422
+ .admin-nav-item {
423
+ margin-bottom: 0.5rem;
424
+ }
425
+
426
+ .admin-nav-link {
427
+ display: block;
428
+ padding: 0.75rem 1rem;
429
+ border-radius: 4px;
430
+ color: var(--text-color);
431
+ text-decoration: none;
432
+ transition: background-color 0.3s ease, color 0.3s ease;
433
+ }
434
+
435
+ .admin-nav-link:hover, .admin-nav-link.active {
436
+ background-color: var(--primary-color);
437
+ color: white;
438
+ }
439
+
440
+ .admin-nav-link i {
441
+ margin-right: 0.5rem;
442
+ width: 20px;
443
+ text-align: center;
444
+ }
445
+
446
+ .admin-card {
447
+ background-color: var(--block-bg);
448
+ border-radius: 8px;
449
+ padding: 1.5rem;
450
+ margin-bottom: 1.5rem;
451
+ box-shadow: var(--shadow);
452
+ border-left: 4px solid var(--primary-color);
453
+ }
454
+
455
+ .admin-card-title {
456
+ font-size: 1.2rem;
457
+ margin-top: 0;
458
+ margin-bottom: 1rem;
459
+ color: var(--text-color);
460
+ }
461
+
462
+ .admin-stat {
463
+ font-size: 2.5rem;
464
+ font-weight: bold;
465
+ margin-bottom: 0.5rem;
466
+ color: var(--primary-color);
467
+ }
468
+
469
+ /* Editor styles */
470
+ .editor-container {
471
+ background-color: var(--block-bg);
472
+ border-radius: 8px;
473
+ padding: 1.5rem;
474
+ margin-bottom: 2rem;
475
+ box-shadow: var(--shadow);
476
+ }
477
+
478
+ .block-editor {
479
+ background-color: var(--card-bg);
480
+ border-radius: 8px;
481
+ padding: 1.5rem;
482
+ margin-bottom: 1.5rem;
483
+ box-shadow: var(--shadow);
484
+ border: var(--block-border);
485
+ position: relative;
486
+ }
487
+
488
+ .block-editor-header {
489
+ display: flex;
490
+ justify-content: space-between;
491
+ align-items: center;
492
+ margin-bottom: 1rem;
493
+ padding-bottom: 0.5rem;
494
+ border-bottom: 1px solid var(--border-color);
495
+ }
496
+
497
+ .block-editor-title {
498
+ font-size: 1.2rem;
499
+ font-weight: 600;
500
+ margin: 0;
501
+ }
502
+
503
+ .block-editor-actions {
504
+ display: flex;
505
+ gap: 0.5rem;
506
+ }
507
+
508
+ .block-handle {
509
+ cursor: move;
510
+ padding: 0.25rem 0.5rem;
511
+ margin-right: 0.5rem;
512
+ background-color: var(--border-color);
513
+ border-radius: 4px;
514
+ }
515
+
516
+ .image-preview {
517
+ max-width: 100%;
518
+ height: auto;
519
+ margin-top: 1rem;
520
+ border-radius: 6px;
521
+ box-shadow: var(--shadow);
522
+ }
523
+
524
+ .image-gallery {
525
+ display: grid;
526
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
527
+ gap: 1rem;
528
+ margin-top: 1rem;
529
+ }
530
+
531
+ .gallery-item {
532
+ position: relative;
533
+ border-radius: 6px;
534
+ overflow: hidden;
535
+ box-shadow: var(--shadow);
536
+ cursor: pointer;
537
+ transition: transform 0.3s ease, box-shadow 0.3s ease;
538
+ }
539
+
540
+ .gallery-item:hover {
541
+ transform: scale(1.05);
542
+ box-shadow: var(--hover-shadow);
543
+ }
544
+
545
+ .gallery-image {
546
+ width: 100%;
547
+ height: 120px;
548
+ object-fit: cover;
549
+ display: block;
550
+ }
551
+
552
+ .gallery-image-select {
553
+ position: absolute;
554
+ top: 5px;
555
+ right: 5px;
556
+ background-color: rgba(0, 0, 0, 0.5);
557
+ color: white;
558
+ border: none;
559
+ border-radius: 50%;
560
+ width: 30px;
561
+ height: 30px;
562
+ display: flex;
563
+ align-items: center;
564
+ justify-content: center;
565
+ }
566
+
567
+ /* Login screen */
568
+ .login-container {
569
+ max-width: 400px;
570
+ margin: 3rem auto;
571
+ background-color: var(--card-bg);
572
+ border-radius: 8px;
573
+ padding: 2rem;
574
+ box-shadow: var(--shadow);
575
+ }
576
+
577
+ .login-logo {
578
+ text-align: center;
579
+ margin-bottom: 2rem;
580
+ }
581
+
582
+ .login-title {
583
+ font-size: 1.8rem;
584
+ text-align: center;
585
+ margin-bottom: 2rem;
586
+ }
587
+
588
+ /* Responsive design adjustments */
589
+ @media (max-width: 992px) {
590
+ .container {
591
+ max-width: 100%;
592
+ padding-left: 1rem;
593
+ padding-right: 1rem;
594
+ }
595
+ }
596
+
597
+ @media (max-width: 576px) {
598
+ .subject-card {
599
+ padding: 1rem;
600
+ }
601
+
602
+ .content-viewer {
603
+ padding: 1.5rem;
604
+ }
605
+
606
+ .admin-container {
607
+ padding: 1.5rem;
608
+ }
609
+
610
+ .content-title {
611
+ font-size: 1.6rem;
612
+ }
613
+ }
614
+
615
+ /* Animation keyframes */
616
+ @keyframes fadeIn {
617
+ from { opacity: 0; }
618
+ to { opacity: 1; }
619
+ }
620
+
621
+ .fade-in {
622
+ animation: fadeIn 0.5s ease-in;
623
+ }
624
+
625
+ @keyframes slideInUp {
626
+ from {
627
+ transform: translateY(50px);
628
+ opacity: 0;
629
+ }
630
+ to {
631
+ transform: translateY(0);
632
+ opacity: 1;
633
+ }
634
+ }
635
+
636
+ .slide-in-up {
637
+ animation: slideInUp 0.5s ease-out;
638
+ }
639
+
640
+ /* Utility classes */
641
+ .text-center {
642
+ text-align: center;
643
+ }
644
+
645
+ .mb-1 { margin-bottom: 0.5rem; }
646
+ .mb-2 { margin-bottom: 1rem; }
647
+ .mb-3 { margin-bottom: 1.5rem; }
648
+ .mb-4 { margin-bottom: 2rem; }
649
+
650
+ .mt-1 { margin-top: 0.5rem; }
651
+ .mt-2 { margin-top: 1rem; }
652
+ .mt-3 { margin-top: 1.5rem; }
653
+ .mt-4 { margin-top: 2rem; }
654
+
655
+ .alert {
656
+ padding: 1rem;
657
+ margin-bottom: 1rem;
658
+ border-radius: 4px;
659
+ }
660
+
661
+ .alert-success {
662
+ background-color: rgba(46, 204, 113, 0.2);
663
+ color: #2ecc71;
664
+ border: 1px solid rgba(46, 204, 113, 0.3);
665
+ }
666
+
667
+ .alert-danger {
668
+ background-color: rgba(231, 76, 60, 0.2);
669
+ color: #e74c3c;
670
+ border: 1px solid rgba(231, 76, 60, 0.3);
671
+ }
672
+
673
+ .alert-warning {
674
+ background-color: rgba(241, 196, 15, 0.2);
675
+ color: #f1c40f;
676
+ border: 1px solid rgba(241, 196, 15, 0.3);
677
+ }
678
+
679
+ .alert-info {
680
+ background-color: rgba(52, 152, 219, 0.2);
681
+ color: #3498db;
682
+ border: 1px solid rgba(52, 152, 219, 0.3);
683
+ }
api/static/js/admin.js ADDED
@@ -0,0 +1,676 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Admin JavaScript for the backend management interface
2
+
3
+ document.addEventListener('DOMContentLoaded', function() {
4
+ // Initialize theme
5
+ initTheme();
6
+
7
+ // Setup dashboard functionality
8
+ setupDashboardCards();
9
+
10
+ // Setup admin forms
11
+ setupMatiereForm();
12
+ setupSousCategorieForm();
13
+ setupTexteForm();
14
+
15
+ // Setup content block editor
16
+ setupContentBlockEditor();
17
+
18
+ // Setup image management
19
+ setupImageUploader();
20
+ setupImageGallery();
21
+
22
+ // Setup theme toggle
23
+ setupThemeToggle();
24
+ });
25
+
26
+ // Initialize theme based on user preference
27
+ function initTheme() {
28
+ const userPreference = localStorage.getItem('theme') || 'light';
29
+ document.documentElement.setAttribute('data-theme', userPreference);
30
+
31
+ // Update theme icon
32
+ updateThemeIcon(userPreference);
33
+ }
34
+
35
+ // Setup theme toggle functionality
36
+ function setupThemeToggle() {
37
+ const themeToggle = document.getElementById('theme-toggle');
38
+ if (!themeToggle) return;
39
+
40
+ themeToggle.addEventListener('click', function() {
41
+ const currentTheme = document.documentElement.getAttribute('data-theme');
42
+ const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
43
+
44
+ // Update theme attribute
45
+ document.documentElement.setAttribute('data-theme', newTheme);
46
+
47
+ // Save preference to localStorage
48
+ localStorage.setItem('theme', newTheme);
49
+
50
+ // Update icon
51
+ updateThemeIcon(newTheme);
52
+
53
+ // Send theme preference to server
54
+ saveThemePreference(newTheme);
55
+ });
56
+ }
57
+
58
+ // Update the theme toggle icon based on current theme
59
+ function updateThemeIcon(theme) {
60
+ const themeToggle = document.getElementById('theme-toggle');
61
+ if (!themeToggle) return;
62
+
63
+ // Update icon based on theme
64
+ if (theme === 'dark') {
65
+ themeToggle.innerHTML = '<i class="fas fa-sun"></i>';
66
+ themeToggle.setAttribute('title', 'Activer le mode clair');
67
+ } else {
68
+ themeToggle.innerHTML = '<i class="fas fa-moon"></i>';
69
+ themeToggle.setAttribute('title', 'Activer le mode sombre');
70
+ }
71
+ }
72
+
73
+ // Save theme preference to server
74
+ function saveThemePreference(theme) {
75
+ const formData = new FormData();
76
+ formData.append('theme', theme);
77
+
78
+ fetch('/set_theme', {
79
+ method: 'POST',
80
+ body: formData
81
+ })
82
+ .then(response => response.json())
83
+ .then(data => {
84
+ console.log('Theme preference saved:', data);
85
+ })
86
+ .catch(error => {
87
+ console.error('Error saving theme preference:', error);
88
+ });
89
+ }
90
+
91
+ // Setup dashboard cards with hover effects
92
+ function setupDashboardCards() {
93
+ const dashboardCards = document.querySelectorAll('.admin-card');
94
+
95
+ dashboardCards.forEach(card => {
96
+ card.addEventListener('mouseenter', function() {
97
+ this.style.transform = 'translateY(-5px)';
98
+ this.style.boxShadow = 'var(--hover-shadow)';
99
+ this.style.transition = 'transform 0.3s ease, box-shadow 0.3s ease';
100
+ });
101
+
102
+ card.addEventListener('mouseleave', function() {
103
+ this.style.transform = 'translateY(0)';
104
+ this.style.boxShadow = 'var(--shadow)';
105
+ });
106
+ });
107
+ }
108
+
109
+ // Setup matiere form functionality
110
+ function setupMatiereForm() {
111
+ // Show edit form when edit button is clicked
112
+ const editButtons = document.querySelectorAll('.edit-matiere-btn');
113
+ editButtons.forEach(button => {
114
+ button.addEventListener('click', function() {
115
+ const matiereId = this.getAttribute('data-id');
116
+ const matiereName = this.getAttribute('data-name');
117
+ const matiereColor = this.getAttribute('data-color');
118
+
119
+ const editForm = document.getElementById('edit-matiere-form');
120
+ if (editForm) {
121
+ const idInput = editForm.querySelector('input[name="matiere_id"]');
122
+ const nameInput = editForm.querySelector('input[name="nom"]');
123
+ const colorInput = editForm.querySelector('input[name="color_code"]');
124
+
125
+ idInput.value = matiereId;
126
+ nameInput.value = matiereName;
127
+ colorInput.value = matiereColor;
128
+
129
+ // Show the edit form
130
+ document.getElementById('add-matiere-section').classList.add('d-none');
131
+ document.getElementById('edit-matiere-section').classList.remove('d-none');
132
+
133
+ // Scroll to edit form
134
+ editForm.scrollIntoView({ behavior: 'smooth' });
135
+ }
136
+ });
137
+ });
138
+
139
+ // Cancel edit button
140
+ const cancelEditButton = document.getElementById('cancel-edit-matiere');
141
+ if (cancelEditButton) {
142
+ cancelEditButton.addEventListener('click', function(e) {
143
+ e.preventDefault();
144
+ document.getElementById('add-matiere-section').classList.remove('d-none');
145
+ document.getElementById('edit-matiere-section').classList.add('d-none');
146
+ });
147
+ }
148
+
149
+ // Color picker preview
150
+ const colorPickers = document.querySelectorAll('input[type="color"]');
151
+ colorPickers.forEach(picker => {
152
+ picker.addEventListener('input', function() {
153
+ // Find adjacent preview element or create one
154
+ let preview = this.nextElementSibling;
155
+ if (!preview || !preview.classList.contains('color-preview')) {
156
+ preview = document.createElement('span');
157
+ preview.className = 'color-preview';
158
+ preview.style.display = 'inline-block';
159
+ preview.style.width = '24px';
160
+ preview.style.height = '24px';
161
+ preview.style.borderRadius = '50%';
162
+ preview.style.marginLeft = '10px';
163
+ this.parentNode.insertBefore(preview, this.nextSibling);
164
+ }
165
+
166
+ preview.style.backgroundColor = this.value;
167
+ });
168
+
169
+ // Trigger once to initialize
170
+ const event = new Event('input');
171
+ picker.dispatchEvent(event);
172
+ });
173
+ }
174
+
175
+ // Setup sous categorie form functionality
176
+ function setupSousCategorieForm() {
177
+ // Show edit form when edit button is clicked
178
+ const editButtons = document.querySelectorAll('.edit-sous-categorie-btn');
179
+ editButtons.forEach(button => {
180
+ button.addEventListener('click', function() {
181
+ const sousCategorieId = this.getAttribute('data-id');
182
+ const sousCategorieName = this.getAttribute('data-name');
183
+ const matiereId = this.getAttribute('data-matiere-id');
184
+
185
+ const editForm = document.getElementById('edit-sous-categorie-form');
186
+ if (editForm) {
187
+ const idInput = editForm.querySelector('input[name="sous_categorie_id"]');
188
+ const nameInput = editForm.querySelector('input[name="nom"]');
189
+ const matiereSelect = editForm.querySelector('select[name="matiere_id"]');
190
+
191
+ idInput.value = sousCategorieId;
192
+ nameInput.value = sousCategorieName;
193
+ matiereSelect.value = matiereId;
194
+
195
+ // Show the edit form
196
+ document.getElementById('add-sous-categorie-section').classList.add('d-none');
197
+ document.getElementById('edit-sous-categorie-section').classList.remove('d-none');
198
+
199
+ // Scroll to edit form
200
+ editForm.scrollIntoView({ behavior: 'smooth' });
201
+ }
202
+ });
203
+ });
204
+
205
+ // Cancel edit button
206
+ const cancelEditButton = document.getElementById('cancel-edit-sous-categorie');
207
+ if (cancelEditButton) {
208
+ cancelEditButton.addEventListener('click', function(e) {
209
+ e.preventDefault();
210
+ document.getElementById('add-sous-categorie-section').classList.remove('d-none');
211
+ document.getElementById('edit-sous-categorie-section').classList.add('d-none');
212
+ });
213
+ }
214
+
215
+ // Matiere select filter
216
+ const matiereFilterSelect = document.getElementById('matiere-filter');
217
+ if (matiereFilterSelect) {
218
+ matiereFilterSelect.addEventListener('change', function() {
219
+ const selectedMatiereId = this.value;
220
+ const sousCategorieRows = document.querySelectorAll('.sous-categorie-row');
221
+
222
+ sousCategorieRows.forEach(row => {
223
+ if (selectedMatiereId === '' || row.getAttribute('data-matiere-id') === selectedMatiereId) {
224
+ row.style.display = '';
225
+ } else {
226
+ row.style.display = 'none';
227
+ }
228
+ });
229
+ });
230
+ }
231
+ }
232
+
233
+ // Setup texte form functionality
234
+ function setupTexteForm() {
235
+ // Matiere select change - populate sous-categories
236
+ const matiereSelect = document.getElementById('matiere-select');
237
+ if (matiereSelect) {
238
+ matiereSelect.addEventListener('change', function() {
239
+ const matiereId = this.value;
240
+ const sousCategorieSelect = document.getElementById('sous-categorie-select');
241
+
242
+ if (matiereId && sousCategorieSelect) {
243
+ // Clear current options
244
+ sousCategorieSelect.innerHTML = '<option value="">Sélectionnez une sous-catégorie</option>';
245
+
246
+ // Fetch sous-categories for the selected matiere
247
+ fetch(`/get_sous_categories/${matiereId}`)
248
+ .then(response => response.json())
249
+ .then(data => {
250
+ data.forEach(sousCategorie => {
251
+ const option = document.createElement('option');
252
+ option.value = sousCategorie.id;
253
+ option.textContent = sousCategorie.nom;
254
+ sousCategorieSelect.appendChild(option);
255
+ });
256
+ })
257
+ .catch(error => {
258
+ console.error('Error loading sous-categories:', error);
259
+ });
260
+ }
261
+ });
262
+ }
263
+ }
264
+
265
+ // Setup content block editor
266
+ function setupContentBlockEditor() {
267
+ const blocksContainer = document.getElementById('blocks-container');
268
+ const addBlockButton = document.getElementById('add-block-button');
269
+ const saveBlocksButton = document.getElementById('save-blocks-button');
270
+
271
+ if (!blocksContainer) return;
272
+
273
+ // Add new block
274
+ if (addBlockButton) {
275
+ addBlockButton.addEventListener('click', function() {
276
+ addContentBlock();
277
+ });
278
+ }
279
+
280
+ // Make blocks sortable
281
+ if (window.Sortable) {
282
+ new Sortable(blocksContainer, {
283
+ animation: 150,
284
+ handle: '.block-handle',
285
+ ghostClass: 'block-ghost',
286
+ onEnd: function() {
287
+ // Update order numbers
288
+ updateBlockOrder();
289
+ }
290
+ });
291
+ }
292
+
293
+ // Save blocks
294
+ if (saveBlocksButton) {
295
+ saveBlocksButton.addEventListener('click', function() {
296
+ saveContentBlocks();
297
+ });
298
+ }
299
+
300
+ // Add event listeners for existing blocks
301
+ setupExistingBlockControls();
302
+ }
303
+
304
+ // Setup controls for existing blocks
305
+ function setupExistingBlockControls() {
306
+ // Setup delete buttons
307
+ const deleteButtons = document.querySelectorAll('.delete-block-btn');
308
+ deleteButtons.forEach(button => {
309
+ button.addEventListener('click', function() {
310
+ if (confirm('Êtes-vous sûr de vouloir supprimer ce bloc ?')) {
311
+ const blockEditor = this.closest('.block-editor');
312
+ blockEditor.remove();
313
+ updateBlockOrder();
314
+ }
315
+ });
316
+ });
317
+
318
+ // Setup image position selects
319
+ const positionSelects = document.querySelectorAll('.image-position-select');
320
+ positionSelects.forEach(select => {
321
+ select.addEventListener('change', function() {
322
+ updateBlockImagePreview(this.closest('.block-editor'));
323
+ });
324
+ });
325
+
326
+ // Setup image selection buttons
327
+ const imageSelectButtons = document.querySelectorAll('.select-image-btn');
328
+ imageSelectButtons.forEach(button => {
329
+ button.addEventListener('click', function() {
330
+ const blockEditor = this.closest('.block-editor');
331
+ const galleryModal = document.getElementById('image-gallery-modal');
332
+
333
+ if (galleryModal) {
334
+ // Set current block ID as data attribute for the modal
335
+ galleryModal.setAttribute('data-target-block', blockEditor.getAttribute('data-block-id'));
336
+
337
+ // Show the modal
338
+ const modal = new bootstrap.Modal(galleryModal);
339
+ modal.show();
340
+ }
341
+ });
342
+ });
343
+
344
+ // Setup image remove buttons
345
+ const removeImageButtons = document.querySelectorAll('.remove-image-btn');
346
+ removeImageButtons.forEach(button => {
347
+ button.addEventListener('click', function() {
348
+ const blockEditor = this.closest('.block-editor');
349
+ const imageIdInput = blockEditor.querySelector('.block-image-id');
350
+ const imagePreview = blockEditor.querySelector('.image-preview');
351
+
352
+ if (imageIdInput) {
353
+ imageIdInput.value = '';
354
+ }
355
+
356
+ if (imagePreview) {
357
+ imagePreview.src = '';
358
+ imagePreview.style.display = 'none';
359
+ }
360
+
361
+ // Hide remove button
362
+ this.style.display = 'none';
363
+
364
+ // Show select button
365
+ const selectButton = blockEditor.querySelector('.select-image-btn');
366
+ if (selectButton) {
367
+ selectButton.style.display = 'inline-block';
368
+ }
369
+ });
370
+ });
371
+ }
372
+
373
+ // Add a new content block to the editor
374
+ function addContentBlock(data = null) {
375
+ const blocksContainer = document.getElementById('blocks-container');
376
+ if (!blocksContainer) return;
377
+
378
+ // Generate a unique ID for the block
379
+ const blockId = 'block-' + Date.now();
380
+
381
+ // Create block HTML
382
+ const blockHtml = `
383
+ <div class="block-editor" data-block-id="${blockId}">
384
+ <div class="block-editor-header">
385
+ <div class="d-flex align-items-center">
386
+ <span class="block-handle"><i class="fas fa-grip-vertical"></i></span>
387
+ <h3 class="block-editor-title">Bloc #${blocksContainer.children.length + 1}</h3>
388
+ </div>
389
+ <div class="block-editor-actions">
390
+ <button type="button" class="btn btn-danger btn-sm delete-block-btn">
391
+ <i class="fas fa-trash"></i>
392
+ </button>
393
+ </div>
394
+ </div>
395
+ <div class="form-group">
396
+ <label for="${blockId}-title">Titre du bloc (optionnel)</label>
397
+ <input type="text" class="form-control block-title" id="${blockId}-title" value="${data?.title || ''}">
398
+ </div>
399
+ <div class="form-group">
400
+ <label for="${blockId}-content">Contenu du bloc</label>
401
+ <textarea class="form-control block-content" id="${blockId}-content" rows="5">${data?.content || ''}</textarea>
402
+ </div>
403
+ <div class="form-group">
404
+ <label>Image</label>
405
+ <div class="d-flex align-items-center mb-2">
406
+ <button type="button" class="btn btn-primary btn-sm select-image-btn" style="${data?.image ? 'display:none;' : ''}">
407
+ <i class="fas fa-image"></i> Sélectionner une image
408
+ </button>
409
+ <button type="button" class="btn btn-warning btn-sm remove-image-btn ml-2" style="${data?.image ? '' : 'display:none;'}">
410
+ <i class="fas fa-times"></i> Retirer l'image
411
+ </button>
412
+ </div>
413
+ <input type="hidden" class="block-image-id" value="${data?.image?.id || ''}">
414
+ <img src="${data?.image?.src || ''}" alt="Preview" class="image-preview mb-2" style="${data?.image ? '' : 'display:none;'}">
415
+ <div class="form-group">
416
+ <label for="${blockId}-image-position">Position de l'image</label>
417
+ <select class="form-control image-position-select" id="${blockId}-image-position">
418
+ <option value="left" ${data?.image_position === 'left' ? 'selected' : ''}>Gauche</option>
419
+ <option value="right" ${data?.image_position === 'right' ? 'selected' : ''}>Droite</option>
420
+ <option value="top" ${data?.image_position === 'top' ? 'selected' : ''}>Haut</option>
421
+ </select>
422
+ </div>
423
+ </div>
424
+ </div>
425
+ `;
426
+
427
+ // Add the block to the container
428
+ blocksContainer.insertAdjacentHTML('beforeend', blockHtml);
429
+
430
+ // Setup event listeners for the new block
431
+ const newBlock = blocksContainer.lastElementChild;
432
+
433
+ // Delete button
434
+ const deleteButton = newBlock.querySelector('.delete-block-btn');
435
+ if (deleteButton) {
436
+ deleteButton.addEventListener('click', function() {
437
+ if (confirm('Êtes-vous sûr de vouloir supprimer ce bloc ?')) {
438
+ newBlock.remove();
439
+ updateBlockOrder();
440
+ }
441
+ });
442
+ }
443
+
444
+ // Image position select
445
+ const positionSelect = newBlock.querySelector('.image-position-select');
446
+ if (positionSelect) {
447
+ positionSelect.addEventListener('change', function() {
448
+ updateBlockImagePreview(newBlock);
449
+ });
450
+ }
451
+
452
+ // Image selection button
453
+ const imageSelectButton = newBlock.querySelector('.select-image-btn');
454
+ if (imageSelectButton) {
455
+ imageSelectButton.addEventListener('click', function() {
456
+ const galleryModal = document.getElementById('image-gallery-modal');
457
+
458
+ if (galleryModal) {
459
+ // Set current block ID as data attribute for the modal
460
+ galleryModal.setAttribute('data-target-block', blockId);
461
+
462
+ // Show the modal
463
+ const modal = new bootstrap.Modal(galleryModal);
464
+ modal.show();
465
+ }
466
+ });
467
+ }
468
+
469
+ // Image remove button
470
+ const removeImageButton = newBlock.querySelector('.remove-image-btn');
471
+ if (removeImageButton) {
472
+ removeImageButton.addEventListener('click', function() {
473
+ const imageIdInput = newBlock.querySelector('.block-image-id');
474
+ const imagePreview = newBlock.querySelector('.image-preview');
475
+
476
+ if (imageIdInput) {
477
+ imageIdInput.value = '';
478
+ }
479
+
480
+ if (imagePreview) {
481
+ imagePreview.src = '';
482
+ imagePreview.style.display = 'none';
483
+ }
484
+
485
+ // Hide remove button
486
+ removeImageButton.style.display = 'none';
487
+
488
+ // Show select button
489
+ if (imageSelectButton) {
490
+ imageSelectButton.style.display = 'inline-block';
491
+ }
492
+ });
493
+ }
494
+
495
+ // Scroll to the new block
496
+ newBlock.scrollIntoView({ behavior: 'smooth' });
497
+ }
498
+
499
+ // Update block order numbers in the UI
500
+ function updateBlockOrder() {
501
+ const blocks = document.querySelectorAll('.block-editor');
502
+ blocks.forEach((block, index) => {
503
+ const titleEl = block.querySelector('.block-editor-title');
504
+ if (titleEl) {
505
+ titleEl.textContent = `Bloc #${index + 1}`;
506
+ }
507
+ });
508
+ }
509
+
510
+ // Update image preview based on position
511
+ function updateBlockImagePreview(blockEditor) {
512
+ // This function would apply CSS classes to show how the image position
513
+ // will look in the frontend
514
+ const positionSelect = blockEditor.querySelector('.image-position-select');
515
+ const imagePreview = blockEditor.querySelector('.image-preview');
516
+
517
+ if (!positionSelect || !imagePreview || imagePreview.style.display === 'none') {
518
+ return;
519
+ }
520
+
521
+ const position = positionSelect.value;
522
+
523
+ // Remove existing position classes
524
+ imagePreview.classList.remove('position-left', 'position-right', 'position-top');
525
+
526
+ // Add the selected position class
527
+ imagePreview.classList.add(`position-${position}`);
528
+
529
+ // Apply some simple styling to demonstrate the position
530
+ switch (position) {
531
+ case 'left':
532
+ imagePreview.style.float = 'left';
533
+ imagePreview.style.marginRight = '15px';
534
+ imagePreview.style.marginBottom = '10px';
535
+ imagePreview.style.width = '30%';
536
+ break;
537
+ case 'right':
538
+ imagePreview.style.float = 'right';
539
+ imagePreview.style.marginLeft = '15px';
540
+ imagePreview.style.marginBottom = '10px';
541
+ imagePreview.style.width = '30%';
542
+ break;
543
+ case 'top':
544
+ imagePreview.style.float = 'none';
545
+ imagePreview.style.marginRight = '0';
546
+ imagePreview.style.marginLeft = '0';
547
+ imagePreview.style.marginBottom = '15px';
548
+ imagePreview.style.width = '100%';
549
+ break;
550
+ }
551
+ }
552
+
553
+ // Save content blocks
554
+ function saveContentBlocks() {
555
+ const blocksContainer = document.getElementById('blocks-container');
556
+ const blocksDataInput = document.getElementById('blocks-data');
557
+
558
+ if (!blocksContainer || !blocksDataInput) return;
559
+
560
+ const blocks = blocksContainer.querySelectorAll('.block-editor');
561
+ const blocksData = [];
562
+
563
+ blocks.forEach((block, index) => {
564
+ const blockId = block.getAttribute('data-block-id');
565
+ const title = block.querySelector('.block-title').value;
566
+ const content = block.querySelector('.block-content').value;
567
+ const imageId = block.querySelector('.block-image-id').value;
568
+ const imagePosition = block.querySelector('.image-position-select').value;
569
+
570
+ blocksData.push({
571
+ id: blockId,
572
+ title: title,
573
+ content: content,
574
+ image_id: imageId,
575
+ image_position: imagePosition,
576
+ order: index
577
+ });
578
+ });
579
+
580
+ // Set the blocks data as JSON in the hidden input
581
+ blocksDataInput.value = JSON.stringify(blocksData);
582
+
583
+ // Submit the form
584
+ const form = document.getElementById('blocks-form');
585
+ if (form) {
586
+ form.submit();
587
+ }
588
+ }
589
+
590
+ // Setup image uploader
591
+ function setupImageUploader() {
592
+ const imageUploadForm = document.getElementById('image-upload-form');
593
+ const imageFileInput = document.getElementById('image-file');
594
+ const imagePreview = document.getElementById('upload-image-preview');
595
+
596
+ if (imageFileInput && imagePreview) {
597
+ imageFileInput.addEventListener('change', function() {
598
+ if (this.files && this.files[0]) {
599
+ const reader = new FileReader();
600
+
601
+ reader.onload = function(e) {
602
+ imagePreview.src = e.target.result;
603
+ imagePreview.style.display = 'block';
604
+ };
605
+
606
+ reader.readAsDataURL(this.files[0]);
607
+ }
608
+ });
609
+ }
610
+
611
+ if (imageUploadForm) {
612
+ imageUploadForm.addEventListener('submit', function(e) {
613
+ const fileInput = this.querySelector('#image-file');
614
+
615
+ if (!fileInput.files || fileInput.files.length === 0) {
616
+ e.preventDefault();
617
+ alert('Veuillez sélectionner une image.');
618
+ }
619
+ });
620
+ }
621
+ }
622
+
623
+ // Setup image gallery
624
+ function setupImageGallery() {
625
+ // Handle image selection from gallery
626
+ const galleryItems = document.querySelectorAll('.gallery-item');
627
+
628
+ galleryItems.forEach(item => {
629
+ item.addEventListener('click', function() {
630
+ const imageId = this.getAttribute('data-image-id');
631
+ const imageSrc = this.querySelector('img').src;
632
+ const galleryModal = document.getElementById('image-gallery-modal');
633
+
634
+ if (galleryModal) {
635
+ const targetBlockId = galleryModal.getAttribute('data-target-block');
636
+ const blockEditor = document.querySelector(`.block-editor[data-block-id="${targetBlockId}"]`);
637
+
638
+ if (blockEditor) {
639
+ // Update the image ID input
640
+ const imageIdInput = blockEditor.querySelector('.block-image-id');
641
+ if (imageIdInput) {
642
+ imageIdInput.value = imageId;
643
+ }
644
+
645
+ // Update the image preview
646
+ const imagePreview = blockEditor.querySelector('.image-preview');
647
+ if (imagePreview) {
648
+ imagePreview.src = imageSrc;
649
+ imagePreview.style.display = 'block';
650
+ }
651
+
652
+ // Hide select button and show remove button
653
+ const selectButton = blockEditor.querySelector('.select-image-btn');
654
+ const removeButton = blockEditor.querySelector('.remove-image-btn');
655
+
656
+ if (selectButton) {
657
+ selectButton.style.display = 'none';
658
+ }
659
+
660
+ if (removeButton) {
661
+ removeButton.style.display = 'inline-block';
662
+ }
663
+
664
+ // Update image preview position
665
+ updateBlockImagePreview(blockEditor);
666
+ }
667
+
668
+ // Close the modal
669
+ const modal = bootstrap.Modal.getInstance(galleryModal);
670
+ if (modal) {
671
+ modal.hide();
672
+ }
673
+ }
674
+ });
675
+ });
676
+ }
api/static/js/main.js ADDED
@@ -0,0 +1,393 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Main application JavaScript for the frontend
2
+
3
+ // Wait for the DOM to be loaded before executing
4
+ document.addEventListener('DOMContentLoaded', function() {
5
+ // Initialize theme
6
+ initTheme();
7
+
8
+ // Setup interactive elements
9
+ setupSubjectSelection();
10
+ setupCategorySelection();
11
+ setupTextSelection();
12
+ setupThemeToggle();
13
+
14
+ // Setup feedback form submission
15
+ const feedbackForm = document.getElementById('feedback-form');
16
+ if (feedbackForm) {
17
+ feedbackForm.addEventListener('submit', function(e) {
18
+ const feedbackMessage = document.getElementById('feedback-message');
19
+ if (!feedbackMessage.value.trim()) {
20
+ e.preventDefault();
21
+ alert('Veuillez entrer un message avant d\'envoyer votre feedback.');
22
+ }
23
+ });
24
+ }
25
+ });
26
+
27
+ // Initialize theme based on user preference
28
+ function initTheme() {
29
+ const userPreference = localStorage.getItem('theme') || 'light';
30
+ document.documentElement.setAttribute('data-theme', userPreference);
31
+
32
+ // Update theme icon
33
+ updateThemeIcon(userPreference);
34
+ }
35
+
36
+ // Setup theme toggle functionality
37
+ function setupThemeToggle() {
38
+ const themeToggle = document.getElementById('theme-toggle');
39
+ if (!themeToggle) return;
40
+
41
+ themeToggle.addEventListener('click', function() {
42
+ const currentTheme = document.documentElement.getAttribute('data-theme');
43
+ const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
44
+
45
+ // Update theme attribute
46
+ document.documentElement.setAttribute('data-theme', newTheme);
47
+
48
+ // Save preference to localStorage
49
+ localStorage.setItem('theme', newTheme);
50
+
51
+ // Update icon
52
+ updateThemeIcon(newTheme);
53
+
54
+ // Send theme preference to server
55
+ saveThemePreference(newTheme);
56
+ });
57
+ }
58
+
59
+ // Update the theme toggle icon based on current theme
60
+ function updateThemeIcon(theme) {
61
+ const themeToggle = document.getElementById('theme-toggle');
62
+ if (!themeToggle) return;
63
+
64
+ // Update icon based on theme
65
+ if (theme === 'dark') {
66
+ themeToggle.innerHTML = '<i class="fas fa-sun"></i>';
67
+ themeToggle.setAttribute('title', 'Activer le mode clair');
68
+ } else {
69
+ themeToggle.innerHTML = '<i class="fas fa-moon"></i>';
70
+ themeToggle.setAttribute('title', 'Activer le mode sombre');
71
+ }
72
+ }
73
+
74
+ // Save theme preference to server
75
+ function saveThemePreference(theme) {
76
+ const formData = new FormData();
77
+ formData.append('theme', theme);
78
+
79
+ fetch('/set_theme', {
80
+ method: 'POST',
81
+ body: formData
82
+ })
83
+ .then(response => response.json())
84
+ .then(data => {
85
+ console.log('Theme preference saved:', data);
86
+ })
87
+ .catch(error => {
88
+ console.error('Error saving theme preference:', error);
89
+ });
90
+ }
91
+
92
+ // Setup subject selection functionality
93
+ function setupSubjectSelection() {
94
+ const subjectCards = document.querySelectorAll('.subject-card');
95
+ const subjectSelect = document.getElementById('matiere-select');
96
+
97
+ // Handle subject card clicks
98
+ subjectCards.forEach(card => {
99
+ card.addEventListener('click', function() {
100
+ const matiereId = this.getAttribute('data-matiere-id');
101
+
102
+ // Update select element if it exists
103
+ if (subjectSelect) {
104
+ subjectSelect.value = matiereId;
105
+ // Trigger change event to load subcategories
106
+ const event = new Event('change');
107
+ subjectSelect.dispatchEvent(event);
108
+ } else {
109
+ loadSubCategories(matiereId);
110
+ }
111
+
112
+ // Highlight the selected card
113
+ subjectCards.forEach(c => c.classList.remove('active'));
114
+ this.classList.add('active');
115
+
116
+ // Show the categories section
117
+ const categoriesSection = document.getElementById('sous-categories-section');
118
+ if (categoriesSection) {
119
+ categoriesSection.classList.remove('d-none');
120
+ categoriesSection.scrollIntoView({ behavior: 'smooth' });
121
+ }
122
+ });
123
+ });
124
+
125
+ // Handle subject select change
126
+ if (subjectSelect) {
127
+ subjectSelect.addEventListener('change', function() {
128
+ const matiereId = this.value;
129
+ if (matiereId) {
130
+ loadSubCategories(matiereId);
131
+
132
+ // Show the categories section
133
+ const categoriesSection = document.getElementById('sous-categories-section');
134
+ if (categoriesSection) {
135
+ categoriesSection.classList.remove('d-none');
136
+ }
137
+ }
138
+ });
139
+ }
140
+ }
141
+
142
+ // Load subcategories for the selected subject
143
+ function loadSubCategories(matiereId) {
144
+ fetch(`/get_sous_categories/${matiereId}`)
145
+ .then(response => response.json())
146
+ .then(data => {
147
+ // Update subcategories list
148
+ const sousCategoriesList = document.getElementById('sous-categories-list');
149
+ if (sousCategoriesList) {
150
+ sousCategoriesList.innerHTML = '';
151
+
152
+ data.forEach(category => {
153
+ const item = document.createElement('li');
154
+ item.className = 'selection-item';
155
+ item.setAttribute('data-category-id', category.id);
156
+ item.textContent = category.nom;
157
+
158
+ // Add click event
159
+ item.addEventListener('click', function() {
160
+ const categoryId = this.getAttribute('data-category-id');
161
+ loadTextes(categoryId);
162
+
163
+ // Highlight the selected category
164
+ const items = sousCategoriesList.querySelectorAll('.selection-item');
165
+ items.forEach(i => i.classList.remove('active'));
166
+ this.classList.add('active');
167
+
168
+ // Show the texts section
169
+ const textesSection = document.getElementById('textes-section');
170
+ if (textesSection) {
171
+ textesSection.classList.remove('d-none');
172
+ }
173
+ });
174
+
175
+ sousCategoriesList.appendChild(item);
176
+ });
177
+
178
+ // Show the subcategories section if it's hidden
179
+ const sousCategoriesSection = document.getElementById('sous-categories-section');
180
+ if (sousCategoriesSection) {
181
+ sousCategoriesSection.classList.remove('d-none');
182
+ }
183
+ }
184
+ })
185
+ .catch(error => {
186
+ console.error('Error loading subcategories:', error);
187
+ });
188
+ }
189
+
190
+ // Setup category selection functionality
191
+ function setupCategorySelection() {
192
+ const categorySelect = document.getElementById('sous-categorie-select');
193
+
194
+ if (categorySelect) {
195
+ categorySelect.addEventListener('change', function() {
196
+ const categoryId = this.value;
197
+ if (categoryId) {
198
+ loadTextes(categoryId);
199
+
200
+ // Show the texts section
201
+ const textesSection = document.getElementById('textes-section');
202
+ if (textesSection) {
203
+ textesSection.classList.remove('d-none');
204
+ }
205
+ }
206
+ });
207
+ }
208
+ }
209
+
210
+ // Load texts for the selected category
211
+ function loadTextes(categoryId) {
212
+ fetch(`/get_textes/${categoryId}`)
213
+ .then(response => response.json())
214
+ .then(data => {
215
+ // Update texts list
216
+ const textesList = document.getElementById('textes-list');
217
+ if (textesList) {
218
+ textesList.innerHTML = '';
219
+
220
+ data.forEach(texte => {
221
+ const item = document.createElement('li');
222
+ item.className = 'selection-item';
223
+ item.setAttribute('data-texte-id', texte.id);
224
+ item.textContent = texte.titre;
225
+
226
+ // Add click event
227
+ item.addEventListener('click', function() {
228
+ const texteId = this.getAttribute('data-texte-id');
229
+ displayTexte(texteId);
230
+
231
+ // Highlight the selected text
232
+ const items = textesList.querySelectorAll('.selection-item');
233
+ items.forEach(i => i.classList.remove('active'));
234
+ this.classList.add('active');
235
+ });
236
+
237
+ textesList.appendChild(item);
238
+ });
239
+
240
+ // Show the texts section
241
+ const textesSection = document.getElementById('textes-section');
242
+ if (textesSection) {
243
+ textesSection.classList.remove('d-none');
244
+ }
245
+
246
+ // Hide the content section since no text is selected yet
247
+ const contentSection = document.getElementById('content-section');
248
+ if (contentSection) {
249
+ contentSection.classList.add('d-none');
250
+ }
251
+ }
252
+ })
253
+ .catch(error => {
254
+ console.error('Error loading texts:', error);
255
+ });
256
+ }
257
+
258
+ // Setup text selection functionality
259
+ function setupTextSelection() {
260
+ const texteSelect = document.getElementById('texte-select');
261
+
262
+ if (texteSelect) {
263
+ texteSelect.addEventListener('change', function() {
264
+ const texteId = this.value;
265
+ if (texteId) {
266
+ displayTexte(texteId);
267
+ }
268
+ });
269
+ }
270
+ }
271
+
272
+ // Display the selected texte with content blocks
273
+ function displayTexte(texteId) {
274
+ fetch(`/get_texte/${texteId}`)
275
+ .then(response => response.json())
276
+ .then(data => {
277
+ const contentSection = document.getElementById('content-section');
278
+ const contentTitle = document.getElementById('content-title');
279
+ const contentBlocks = document.getElementById('content-blocks');
280
+
281
+ if (contentSection && contentTitle && contentBlocks) {
282
+ // Update content title
283
+ contentTitle.textContent = data.titre;
284
+
285
+ // Update content theme color based on matiere color
286
+ if (data.color_code) {
287
+ // Apply color to title underline
288
+ contentTitle.style.borderBottomColor = data.color_code;
289
+
290
+ // Apply color to all block titles
291
+ const style = document.createElement('style');
292
+ style.id = 'dynamic-block-styles';
293
+ const existingStyle = document.getElementById('dynamic-block-styles');
294
+ if (existingStyle) {
295
+ existingStyle.remove();
296
+ }
297
+
298
+ style.textContent = `
299
+ .content-block-title {
300
+ border-bottom-color: ${data.color_code} !important;
301
+ }
302
+ .content-block {
303
+ border-left: 4px solid ${data.color_code} !important;
304
+ }
305
+ `;
306
+ document.head.appendChild(style);
307
+ }
308
+
309
+ // Clear existing content
310
+ contentBlocks.innerHTML = '';
311
+
312
+ // Create content blocks
313
+ if (data.blocks && data.blocks.length > 0) {
314
+ data.blocks.forEach(block => {
315
+ // Create block container
316
+ const blockDiv = document.createElement('div');
317
+ blockDiv.className = 'content-block fade-in';
318
+
319
+ // Check if block has an image
320
+ if (block.image) {
321
+ blockDiv.classList.add('block-with-image');
322
+ blockDiv.classList.add(`image-${block.image_position || 'left'}`);
323
+
324
+ // Create image container
325
+ const imageDiv = document.createElement('div');
326
+ imageDiv.className = 'block-image-container';
327
+
328
+ // Create image element
329
+ const imageEl = document.createElement('img');
330
+ imageEl.className = 'block-image';
331
+ imageEl.src = block.image.src;
332
+ imageEl.alt = block.image.alt || 'Illustration';
333
+
334
+ imageDiv.appendChild(imageEl);
335
+ blockDiv.appendChild(imageDiv);
336
+
337
+ // Create content container
338
+ const contentDiv = document.createElement('div');
339
+ contentDiv.className = 'block-content-container';
340
+
341
+ // Add block title if present
342
+ if (block.title) {
343
+ const titleEl = document.createElement('h3');
344
+ titleEl.className = 'content-block-title';
345
+ titleEl.textContent = block.title;
346
+ contentDiv.appendChild(titleEl);
347
+ }
348
+
349
+ // Add block content
350
+ const contentEl = document.createElement('div');
351
+ contentEl.className = 'content-block-content';
352
+ contentEl.innerHTML = block.content.replace(/\n/g, '<br>');
353
+ contentDiv.appendChild(contentEl);
354
+
355
+ blockDiv.appendChild(contentDiv);
356
+ } else {
357
+ // No image - simple block
358
+
359
+ // Add block title if present
360
+ if (block.title) {
361
+ const titleEl = document.createElement('h3');
362
+ titleEl.className = 'content-block-title';
363
+ titleEl.textContent = block.title;
364
+ blockDiv.appendChild(titleEl);
365
+ }
366
+
367
+ // Add block content
368
+ const contentEl = document.createElement('div');
369
+ contentEl.className = 'content-block-content';
370
+ contentEl.innerHTML = block.content.replace(/\n/g, '<br>');
371
+ blockDiv.appendChild(contentEl);
372
+ }
373
+
374
+ // Add the block to the content area
375
+ contentBlocks.appendChild(blockDiv);
376
+ });
377
+ } else {
378
+ // Fallback to regular content if no blocks
379
+ const blockDiv = document.createElement('div');
380
+ blockDiv.className = 'content-block';
381
+ blockDiv.innerHTML = data.contenu.replace(/\n/g, '<br>');
382
+ contentBlocks.appendChild(blockDiv);
383
+ }
384
+
385
+ // Show the content section
386
+ contentSection.classList.remove('d-none');
387
+ contentSection.scrollIntoView({ behavior: 'smooth' });
388
+ }
389
+ })
390
+ .catch(error => {
391
+ console.error('Error loading texte:', error);
392
+ });
393
+ }
api/templates/admin/dashboard.html ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'base.html' %}
2
+
3
+ {% block title %}Tableau de bord - Administration{% endblock %}
4
+
5
+ {% block styles %}
6
+ <style>
7
+ .dashboard-stats {
8
+ display: grid;
9
+ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
10
+ gap: 1.5rem;
11
+ margin-bottom: 2rem;
12
+ }
13
+
14
+ .recent-activity {
15
+ background-color: var(--card-bg);
16
+ border-radius: 8px;
17
+ padding: 1.5rem;
18
+ box-shadow: var(--shadow);
19
+ }
20
+
21
+ .welcome-message {
22
+ margin-bottom: 2rem;
23
+ padding: 2rem;
24
+ background-color: var(--primary-color);
25
+ color: white;
26
+ border-radius: 8px;
27
+ box-shadow: var(--shadow);
28
+ }
29
+ </style>
30
+ {% endblock %}
31
+
32
+ {% block content %}
33
+ <div class="row">
34
+ <div class="col-md-3">
35
+ <div class="admin-sidebar">
36
+ <h3 class="mb-3">Administration</h3>
37
+ <ul class="admin-nav">
38
+ <li class="admin-nav-item">
39
+ <a href="{{ url_for('admin_bp.dashboard') }}" class="admin-nav-link active">
40
+ <i class="fas fa-tachometer-alt"></i> Tableau de bord
41
+ </a>
42
+ </li>
43
+ <li class="admin-nav-item">
44
+ <a href="{{ url_for('admin_bp.matieres') }}" class="admin-nav-link">
45
+ <i class="fas fa-book"></i> Matières
46
+ </a>
47
+ </li>
48
+ <li class="admin-nav-item">
49
+ <a href="{{ url_for('admin_bp.sous_categories') }}" class="admin-nav-link">
50
+ <i class="fas fa-list"></i> Sous-catégories
51
+ </a>
52
+ </li>
53
+ <li class="admin-nav-item">
54
+ <a href="{{ url_for('admin_bp.textes') }}" class="admin-nav-link">
55
+ <i class="fas fa-file-alt"></i> Textes
56
+ </a>
57
+ </li>
58
+ <li class="admin-nav-item">
59
+ <a href="{{ url_for('admin_bp.images') }}" class="admin-nav-link">
60
+ <i class="fas fa-images"></i> Images
61
+ </a>
62
+ </li>
63
+ <li class="admin-nav-item">
64
+ <a href="{{ url_for('admin_bp.logout') }}" class="admin-nav-link">
65
+ <i class="fas fa-sign-out-alt"></i> Déconnexion
66
+ </a>
67
+ </li>
68
+ </ul>
69
+ </div>
70
+ </div>
71
+
72
+ <div class="col-md-9">
73
+ <div class="welcome-message">
74
+ <h2><i class="fas fa-hand-sparkles"></i> Bienvenue dans l'interface d'administration</h2>
75
+ <p class="mb-0">Gérez ici l'ensemble des contenus de la plateforme éducative. Utilisez les différentes sections pour ajouter, modifier ou supprimer des contenus.</p>
76
+ </div>
77
+
78
+ <h2 class="mb-4">Tableau de bord</h2>
79
+
80
+ <!-- Stats Cards -->
81
+ <div class="dashboard-stats">
82
+ <div class="admin-card">
83
+ <h3 class="admin-card-title">Matières</h3>
84
+ <div class="admin-stat">{{ stats.matieres }}</div>
85
+ <p>Nombre total de matières</p>
86
+ <a href="{{ url_for('admin_bp.matieres') }}" class="btn btn-primary btn-sm">
87
+ <i class="fas fa-eye"></i> Voir
88
+ </a>
89
+ </div>
90
+
91
+ <div class="admin-card">
92
+ <h3 class="admin-card-title">Sous-catégories</h3>
93
+ <div class="admin-stat">{{ stats.sous_categories }}</div>
94
+ <p>Nombre total de sous-catégories</p>
95
+ <a href="{{ url_for('admin_bp.sous_categories') }}" class="btn btn-primary btn-sm">
96
+ <i class="fas fa-eye"></i> Voir
97
+ </a>
98
+ </div>
99
+
100
+ <div class="admin-card">
101
+ <h3 class="admin-card-title">Textes</h3>
102
+ <div class="admin-stat">{{ stats.textes }}</div>
103
+ <p>Nombre total de textes</p>
104
+ <a href="{{ url_for('admin_bp.textes') }}" class="btn btn-primary btn-sm">
105
+ <i class="fas fa-eye"></i> Voir
106
+ </a>
107
+ </div>
108
+
109
+ <div class="admin-card">
110
+ <h3 class="admin-card-title">Images</h3>
111
+ <div class="admin-stat">{{ stats.images }}</div>
112
+ <p>Nombre total d'images</p>
113
+ <a href="{{ url_for('admin_bp.images') }}" class="btn btn-primary btn-sm">
114
+ <i class="fas fa-eye"></i> Voir
115
+ </a>
116
+ </div>
117
+ </div>
118
+
119
+ <!-- Recent Activity -->
120
+ <div class="recent-activity">
121
+ <h3 class="mb-3">Textes récemment modifiés</h3>
122
+ {% if recent_textes %}
123
+ <ul class="list-group">
124
+ {% for texte in recent_textes %}
125
+ <li class="list-group-item d-flex justify-content-between align-items-center">
126
+ <div>
127
+ <h5 class="mb-1">{{ texte.titre }}</h5>
128
+ <small>{{ texte.updated_at.strftime('%d/%m/%Y à %H:%M') }}</small>
129
+ </div>
130
+ <a href="{{ url_for('admin_bp.edit_texte', texte_id=texte.id) }}" class="btn btn-sm btn-primary">
131
+ <i class="fas fa-edit"></i> Éditer
132
+ </a>
133
+ </li>
134
+ {% endfor %}
135
+ </ul>
136
+ {% else %}
137
+ <div class="alert alert-info">
138
+ Aucun texte n'a été modifié récemment.
139
+ </div>
140
+ {% endif %}
141
+ </div>
142
+ </div>
143
+ </div>
144
+ {% endblock %}
145
+
146
+ {% block scripts %}
147
+ <script src="{{ url_for('static', filename='js/admin.js') }}"></script>
148
+ {% endblock %}
api/templates/admin/edit_texte.html ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'base.html' %}
2
+
3
+ {% block title %}Éditer {{ texte.titre }} - Administration{% endblock %}
4
+
5
+ {% block styles %}
6
+ <style>
7
+ .block-editor {
8
+ position: relative;
9
+ transition: all 0.3s ease;
10
+ }
11
+
12
+ .block-ghost {
13
+ opacity: 0.5;
14
+ background: var(--primary-color);
15
+ }
16
+
17
+ .block-handle {
18
+ cursor: move;
19
+ color: var(--muted-color);
20
+ }
21
+
22
+ .image-position-example {
23
+ padding: 10px;
24
+ border: 1px dashed var(--border-color);
25
+ margin-top: 10px;
26
+ border-radius: 4px;
27
+ }
28
+
29
+ .block-image-container {
30
+ margin-bottom: 15px;
31
+ }
32
+
33
+ .image-preview {
34
+ max-height: 150px;
35
+ object-fit: contain;
36
+ }
37
+
38
+ .gallery-image {
39
+ transition: transform 0.2s;
40
+ cursor: pointer;
41
+ }
42
+
43
+ .gallery-image:hover {
44
+ transform: scale(1.05);
45
+ }
46
+ </style>
47
+ {% endblock %}
48
+
49
+ {% block content %}
50
+ <div class="row">
51
+ <div class="col-md-3">
52
+ <div class="admin-sidebar">
53
+ <h3 class="mb-3">Administration</h3>
54
+ <ul class="admin-nav">
55
+ <li class="admin-nav-item">
56
+ <a href="{{ url_for('admin_bp.dashboard') }}" class="admin-nav-link">
57
+ <i class="fas fa-tachometer-alt"></i> Tableau de bord
58
+ </a>
59
+ </li>
60
+ <li class="admin-nav-item">
61
+ <a href="{{ url_for('admin_bp.matieres') }}" class="admin-nav-link">
62
+ <i class="fas fa-book"></i> Matières
63
+ </a>
64
+ </li>
65
+ <li class="admin-nav-item">
66
+ <a href="{{ url_for('admin_bp.sous_categories') }}" class="admin-nav-link">
67
+ <i class="fas fa-list"></i> Sous-catégories
68
+ </a>
69
+ </li>
70
+ <li class="admin-nav-item">
71
+ <a href="{{ url_for('admin_bp.textes') }}" class="admin-nav-link active">
72
+ <i class="fas fa-file-alt"></i> Textes
73
+ </a>
74
+ </li>
75
+ <li class="admin-nav-item">
76
+ <a href="{{ url_for('admin_bp.images') }}" class="admin-nav-link">
77
+ <i class="fas fa-images"></i> Images
78
+ </a>
79
+ </li>
80
+ <li class="admin-nav-item">
81
+ <a href="{{ url_for('admin_bp.logout') }}" class="admin-nav-link">
82
+ <i class="fas fa-sign-out-alt"></i> Déconnexion
83
+ </a>
84
+ </li>
85
+ </ul>
86
+ </div>
87
+ </div>
88
+
89
+ <div class="col-md-9">
90
+ <div class="admin-container">
91
+ <h2 class="admin-title">Éditer la méthodologie : {{ texte.titre }}</h2>
92
+
93
+ <div class="mb-4">
94
+ <a href="{{ url_for('admin_bp.textes') }}" class="btn btn-secondary mb-3">
95
+ <i class="fas fa-arrow-left"></i> Retour à la liste
96
+ </a>
97
+ <a href="{{ url_for('admin_bp.historique', texte_id=texte.id) }}" class="btn btn-info mb-3">
98
+ <i class="fas fa-history"></i> Voir l'historique
99
+ </a>
100
+ </div>
101
+
102
+ <!-- Basic Information Form -->
103
+ <div class="card mb-4">
104
+ <div class="card-header">
105
+ <h4>Informations de base</h4>
106
+ </div>
107
+ <div class="card-body">
108
+ <form method="POST" action="{{ url_for('admin_bp.edit_texte', texte_id=texte.id) }}">
109
+ <input type="hidden" name="action" value="update_basic">
110
+
111
+ <div class="form-group mb-3">
112
+ <label for="titre">Titre</label>
113
+ <input type="text" class="form-control" id="titre" name="titre" value="{{ texte.titre }}" required>
114
+ </div>
115
+
116
+ <div class="form-group mb-3">
117
+ <label for="sous_categorie_id">Sous-catégorie</label>
118
+ <select class="form-control" id="sous_categorie_id" name="sous_categorie_id" required>
119
+ {% for sc in sous_categories %}
120
+ <option value="{{ sc.id }}" {% if sc.id == texte.sous_categorie_id %}selected{% endif %}>
121
+ {{ sc.matiere.nom }} - {{ sc.nom }}
122
+ </option>
123
+ {% endfor %}
124
+ </select>
125
+ </div>
126
+
127
+ <button type="submit" class="btn btn-primary">
128
+ <i class="fas fa-save"></i> Mettre à jour les informations
129
+ </button>
130
+ </form>
131
+ </div>
132
+ </div>
133
+
134
+ <!-- Content Blocks Editor -->
135
+ <div class="card mb-4">
136
+ <div class="card-header d-flex justify-content-between align-items-center">
137
+ <h4>Blocs de contenu</h4>
138
+ <button id="add-block-button" class="btn btn-success">
139
+ <i class="fas fa-plus"></i> Ajouter un bloc
140
+ </button>
141
+ </div>
142
+ <div class="card-body">
143
+ <p class="text-muted mb-4">
144
+ Organisez votre contenu en blocs distincts. Chaque bloc peut contenir un titre, du texte et une image.
145
+ Vous pouvez réorganiser les blocs en les faisant glisser.
146
+ </p>
147
+
148
+ <form id="blocks-form" method="POST" action="{{ url_for('admin_bp.edit_texte', texte_id=texte.id) }}">
149
+ <input type="hidden" name="action" value="update_blocks">
150
+ <input type="hidden" id="blocks-data" name="blocks_data" value="">
151
+
152
+ <div id="blocks-container">
153
+ {% for block in blocks %}
154
+ <div class="block-editor mb-4" data-block-id="{{ block.id }}">
155
+ <div class="block-editor-header">
156
+ <div class="d-flex align-items-center">
157
+ <span class="block-handle"><i class="fas fa-grip-vertical"></i></span>
158
+ <h3 class="block-editor-title">Bloc #{{ loop.index }}</h3>
159
+ </div>
160
+ <div class="block-editor-actions">
161
+ <button type="button" class="btn btn-danger btn-sm delete-block-btn">
162
+ <i class="fas fa-trash"></i>
163
+ </button>
164
+ </div>
165
+ </div>
166
+ <div class="form-group mb-3">
167
+ <label for="block-{{ block.id }}-title">Titre du bloc (optionnel)</label>
168
+ <input type="text" class="form-control block-title" id="block-{{ block.id }}-title" value="{{ block.title or '' }}">
169
+ </div>
170
+ <div class="form-group mb-3">
171
+ <label for="block-{{ block.id }}-content">Contenu du bloc</label>
172
+ <textarea class="form-control block-content" id="block-{{ block.id }}-content" rows="5">{{ block.content or '' }}</textarea>
173
+ </div>
174
+ <div class="form-group">
175
+ <label>Image</label>
176
+ <div class="d-flex align-items-center mb-2">
177
+ <button type="button" class="btn btn-primary btn-sm select-image-btn" {% if block.image %}style="display:none;"{% endif %}>
178
+ <i class="fas fa-image"></i> Sélectionner une image
179
+ </button>
180
+ <button type="button" class="btn btn-warning btn-sm remove-image-btn ml-2" {% if not block.image %}style="display:none;"{% endif %}>
181
+ <i class="fas fa-times"></i> Retirer l'image
182
+ </button>
183
+ </div>
184
+ <input type="hidden" class="block-image-id" value="{{ block.image.id if block.image else '' }}">
185
+
186
+ {% if block.image %}
187
+ <div class="block-image-container">
188
+ <img src="{{ block.image.src }}" alt="{{ block.image.alt }}" class="image-preview">
189
+ </div>
190
+ {% else %}
191
+ <img src="" alt="Preview" class="image-preview" style="display:none;">
192
+ {% endif %}
193
+
194
+ <div class="form-group mt-3">
195
+ <label for="block-{{ block.id }}-image-position">Position de l'image</label>
196
+ <select class="form-control image-position-select" id="block-{{ block.id }}-image-position">
197
+ <option value="left" {% if block.image_position == 'left' %}selected{% endif %}>Gauche</option>
198
+ <option value="right" {% if block.image_position == 'right' %}selected{% endif %}>Droite</option>
199
+ <option value="top" {% if block.image_position == 'top' %}selected{% endif %}>Haut</option>
200
+ </select>
201
+ </div>
202
+ </div>
203
+ </div>
204
+ {% endfor %}
205
+ </div>
206
+
207
+ <div class="mt-4 text-center">
208
+ <button type="button" id="save-blocks-button" class="btn btn-primary btn-lg">
209
+ <i class="fas fa-save"></i> Enregistrer les modifications
210
+ </button>
211
+ </div>
212
+ </form>
213
+ </div>
214
+ </div>
215
+
216
+ <!-- Image Upload Section -->
217
+ <div class="card mb-4">
218
+ <div class="card-header">
219
+ <h4>Ajouter une nouvelle image</h4>
220
+ </div>
221
+ <div class="card-body">
222
+ <form method="POST" action="{{ url_for('admin_bp.edit_texte', texte_id=texte.id) }}" enctype="multipart/form-data">
223
+ <input type="hidden" name="action" value="upload_image">
224
+
225
+ <div class="form-group mb-3">
226
+ <label for="image">Sélectionner une image</label>
227
+ <input type="file" class="form-control" id="image" name="image" accept="image/*" required>
228
+ </div>
229
+
230
+ <div class="form-group mb-3">
231
+ <label for="alt_text">Texte alternatif (pour l'accessibilité)</label>
232
+ <input type="text" class="form-control" id="alt_text" name="alt_text" placeholder="Description de l'image">
233
+ </div>
234
+
235
+ <button type="submit" class="btn btn-success">
236
+ <i class="fas fa-upload"></i> Télécharger l'image
237
+ </button>
238
+ </form>
239
+ </div>
240
+ </div>
241
+ </div>
242
+ </div>
243
+ </div>
244
+
245
+ <!-- Image Gallery Modal -->
246
+ <div class="modal fade" id="image-gallery-modal" tabindex="-1" aria-labelledby="imageGalleryModalLabel" aria-hidden="true">
247
+ <div class="modal-dialog modal-lg">
248
+ <div class="modal-content">
249
+ <div class="modal-header">
250
+ <h5 class="modal-title" id="imageGalleryModalLabel">Sélectionner une image</h5>
251
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
252
+ </div>
253
+ <div class="modal-body">
254
+ <div class="row">
255
+ {% for image in images %}
256
+ <div class="col-md-3 col-sm-4 col-6 mb-3">
257
+ <div class="gallery-item" data-image-id="{{ image.id }}">
258
+ <img src="{{ image.src }}" alt="{{ image.alt }}" class="gallery-image img-fluid">
259
+ </div>
260
+ </div>
261
+ {% else %}
262
+ <div class="col-12">
263
+ <div class="alert alert-info">
264
+ Aucune image disponible. Veuillez en télécharger une.
265
+ </div>
266
+ </div>
267
+ {% endfor %}
268
+ </div>
269
+ </div>
270
+ <div class="modal-footer">
271
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuler</button>
272
+ </div>
273
+ </div>
274
+ </div>
275
+ </div>
276
+ {% endblock %}
277
+
278
+ {% block scripts %}
279
+ <!-- Include Sortable.js for drag and drop functionality -->
280
+ <script src="https://cdn.jsdelivr.net/npm/[email protected]/Sortable.min.js"></script>
281
+ <script src="{{ url_for('static', filename='js/admin.js') }}"></script>
282
+ {% endblock %}
api/templates/admin/historique.html ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'base.html' %}
2
+
3
+ {% block title %}Historique des modifications - {{ texte.titre }} - Administration{% endblock %}
4
+
5
+ {% block styles %}
6
+ <style>
7
+ .history-entry {
8
+ position: relative;
9
+ padding: 20px;
10
+ border-left: 3px solid var(--primary-color);
11
+ margin-bottom: 20px;
12
+ background-color: var(--card-bg);
13
+ border-radius: 8px;
14
+ box-shadow: var(--shadow);
15
+ }
16
+
17
+ .history-date {
18
+ position: absolute;
19
+ top: 10px;
20
+ right: 15px;
21
+ font-size: 0.9rem;
22
+ color: var(--muted-color);
23
+ }
24
+
25
+ .history-content {
26
+ background-color: var(--block-bg);
27
+ padding: 15px;
28
+ border-radius: 8px;
29
+ margin-top: 15px;
30
+ white-space: pre-wrap;
31
+ max-height: 300px;
32
+ overflow-y: auto;
33
+ border: 1px solid var(--border-color);
34
+ }
35
+
36
+ .empty-history {
37
+ text-align: center;
38
+ padding: 50px 20px;
39
+ background-color: var(--block-bg);
40
+ border-radius: 8px;
41
+ margin-top: 20px;
42
+ }
43
+
44
+ .timeline-container {
45
+ position: relative;
46
+ margin-left: 20px;
47
+ }
48
+
49
+ .timeline-line {
50
+ position: absolute;
51
+ left: 0;
52
+ top: 0;
53
+ bottom: 0;
54
+ width: 3px;
55
+ background-color: var(--border-color);
56
+ }
57
+
58
+ .timeline-dot {
59
+ position: absolute;
60
+ left: -8px;
61
+ top: 20px;
62
+ width: 18px;
63
+ height: 18px;
64
+ border-radius: 50%;
65
+ background-color: var(--primary-color);
66
+ z-index: 2;
67
+ }
68
+ </style>
69
+ {% endblock %}
70
+
71
+ {% block content %}
72
+ <div class="row">
73
+ <div class="col-md-3">
74
+ <div class="admin-sidebar">
75
+ <h3 class="mb-3">Administration</h3>
76
+ <ul class="admin-nav">
77
+ <li class="admin-nav-item">
78
+ <a href="{{ url_for('admin_bp.dashboard') }}" class="admin-nav-link">
79
+ <i class="fas fa-tachometer-alt"></i> Tableau de bord
80
+ </a>
81
+ </li>
82
+ <li class="admin-nav-item">
83
+ <a href="{{ url_for('admin_bp.matieres') }}" class="admin-nav-link">
84
+ <i class="fas fa-book"></i> Matières
85
+ </a>
86
+ </li>
87
+ <li class="admin-nav-item">
88
+ <a href="{{ url_for('admin_bp.sous_categories') }}" class="admin-nav-link">
89
+ <i class="fas fa-list"></i> Sous-catégories
90
+ </a>
91
+ </li>
92
+ <li class="admin-nav-item">
93
+ <a href="{{ url_for('admin_bp.textes') }}" class="admin-nav-link active">
94
+ <i class="fas fa-file-alt"></i> Textes
95
+ </a>
96
+ </li>
97
+ <li class="admin-nav-item">
98
+ <a href="{{ url_for('admin_bp.images') }}" class="admin-nav-link">
99
+ <i class="fas fa-images"></i> Images
100
+ </a>
101
+ </li>
102
+ <li class="admin-nav-item">
103
+ <a href="{{ url_for('admin_bp.logout') }}" class="admin-nav-link">
104
+ <i class="fas fa-sign-out-alt"></i> Déconnexion
105
+ </a>
106
+ </li>
107
+ </ul>
108
+ </div>
109
+ </div>
110
+
111
+ <div class="col-md-9">
112
+ <div class="admin-container">
113
+ <h2 class="admin-title">Historique des modifications : {{ texte.titre }}</h2>
114
+
115
+ <div class="mb-4">
116
+ <a href="{{ url_for('admin_bp.edit_texte', texte_id=texte.id) }}" class="btn btn-secondary">
117
+ <i class="fas fa-arrow-left"></i> Retour à l'éditeur
118
+ </a>
119
+ <a href="{{ url_for('admin_bp.textes') }}" class="btn btn-secondary">
120
+ <i class="fas fa-list"></i> Liste des textes
121
+ </a>
122
+ </div>
123
+
124
+ <div class="card mb-4">
125
+ <div class="card-header">
126
+ <h4><i class="fas fa-history"></i> Versions précédentes</h4>
127
+ </div>
128
+ <div class="card-body">
129
+ {% if historiques %}
130
+ <div class="timeline-container">
131
+ <div class="timeline-line"></div>
132
+
133
+ <!-- Current version -->
134
+ <div class="history-entry">
135
+ <div class="timeline-dot"></div>
136
+ <h5>Version actuelle</h5>
137
+ <span class="history-date">
138
+ <i class="fas fa-clock"></i> {{ texte.updated_at.strftime('%d/%m/%Y à %H:%M') }}
139
+ </span>
140
+ <div class="history-content">{{ texte.contenu }}</div>
141
+ </div>
142
+
143
+ <!-- Previous versions -->
144
+ {% for historique in historiques %}
145
+ <div class="history-entry">
146
+ <div class="timeline-dot"></div>
147
+ <h5>Version antérieure #{{ loop.index }}</h5>
148
+ <span class="history-date">
149
+ <i class="fas fa-clock"></i> {{ historique.date_modification.strftime('%d/%m/%Y à %H:%M') }}
150
+ </span>
151
+ <div class="history-content">{{ historique.contenu_precedent }}</div>
152
+ </div>
153
+ {% endfor %}
154
+ </div>
155
+ {% else %}
156
+ <div class="empty-history">
157
+ <i class="fas fa-info-circle fa-3x mb-3"></i>
158
+ <h4>Aucun historique disponible</h4>
159
+ <p>Ce texte n'a pas encore été modifié depuis sa création.</p>
160
+ </div>
161
+ {% endif %}
162
+ </div>
163
+ </div>
164
+ </div>
165
+ </div>
166
+ </div>
167
+ {% endblock %}
168
+
169
+ {% block scripts %}
170
+ <script src="{{ url_for('static', filename='js/admin.js') }}"></script>
171
+ {% endblock %}
api/templates/admin/images.html ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'base.html' %}
2
+
3
+ {% block title %}Gestion des Images - Administration{% endblock %}
4
+
5
+ {% block styles %}
6
+ <style>
7
+ .image-card {
8
+ position: relative;
9
+ background-color: var(--card-bg);
10
+ border-radius: 8px;
11
+ padding: 10px;
12
+ margin-bottom: 20px;
13
+ box-shadow: var(--shadow);
14
+ transition: transform 0.3s ease, box-shadow 0.3s ease;
15
+ }
16
+
17
+ .image-card:hover {
18
+ transform: translateY(-5px);
19
+ box-shadow: var(--hover-shadow);
20
+ }
21
+
22
+ .image-container {
23
+ position: relative;
24
+ width: 100%;
25
+ height: 200px;
26
+ margin-bottom: 15px;
27
+ overflow: hidden;
28
+ border-radius: 6px;
29
+ background-color: var(--block-bg);
30
+ }
31
+
32
+ .image-preview {
33
+ width: 100%;
34
+ height: 100%;
35
+ object-fit: contain;
36
+ }
37
+
38
+ .image-info {
39
+ padding: 10px 0;
40
+ }
41
+
42
+ .image-actions {
43
+ display: flex;
44
+ justify-content: space-between;
45
+ margin-top: 10px;
46
+ }
47
+
48
+ .image-upload-preview {
49
+ max-width: 100%;
50
+ max-height: 200px;
51
+ margin-top: 15px;
52
+ border-radius: 6px;
53
+ display: none;
54
+ }
55
+
56
+ .image-date {
57
+ font-size: 0.8rem;
58
+ color: var(--muted-color);
59
+ }
60
+
61
+ .image-detail-modal img {
62
+ max-width: 100%;
63
+ max-height: 500px;
64
+ }
65
+
66
+ .image-filter {
67
+ margin-bottom: 20px;
68
+ }
69
+ </style>
70
+ {% endblock %}
71
+
72
+ {% block content %}
73
+ <div class="row">
74
+ <div class="col-md-3">
75
+ <div class="admin-sidebar">
76
+ <h3 class="mb-3">Administration</h3>
77
+ <ul class="admin-nav">
78
+ <li class="admin-nav-item">
79
+ <a href="{{ url_for('admin_bp.dashboard') }}" class="admin-nav-link">
80
+ <i class="fas fa-tachometer-alt"></i> Tableau de bord
81
+ </a>
82
+ </li>
83
+ <li class="admin-nav-item">
84
+ <a href="{{ url_for('admin_bp.matieres') }}" class="admin-nav-link">
85
+ <i class="fas fa-book"></i> Matières
86
+ </a>
87
+ </li>
88
+ <li class="admin-nav-item">
89
+ <a href="{{ url_for('admin_bp.sous_categories') }}" class="admin-nav-link">
90
+ <i class="fas fa-list"></i> Sous-catégories
91
+ </a>
92
+ </li>
93
+ <li class="admin-nav-item">
94
+ <a href="{{ url_for('admin_bp.textes') }}" class="admin-nav-link">
95
+ <i class="fas fa-file-alt"></i> Textes
96
+ </a>
97
+ </li>
98
+ <li class="admin-nav-item">
99
+ <a href="{{ url_for('admin_bp.images') }}" class="admin-nav-link active">
100
+ <i class="fas fa-images"></i> Images
101
+ </a>
102
+ </li>
103
+ <li class="admin-nav-item">
104
+ <a href="{{ url_for('admin_bp.logout') }}" class="admin-nav-link">
105
+ <i class="fas fa-sign-out-alt"></i> Déconnexion
106
+ </a>
107
+ </li>
108
+ </ul>
109
+ </div>
110
+ </div>
111
+
112
+ <div class="col-md-9">
113
+ <div class="admin-container">
114
+ <h2 class="admin-title">Gestion des Images</h2>
115
+
116
+ <!-- Upload Image Section -->
117
+ <div class="card mb-4">
118
+ <div class="card-header">
119
+ <h4><i class="fas fa-upload"></i> Télécharger une nouvelle image</h4>
120
+ </div>
121
+ <div class="card-body">
122
+ <form id="image-upload-form" method="POST" action="{{ url_for('admin_bp.images') }}" enctype="multipart/form-data">
123
+ <input type="hidden" name="action" value="upload">
124
+
125
+ <div class="row">
126
+ <div class="col-md-6">
127
+ <div class="form-group mb-3">
128
+ <label for="image-file">Sélectionner une image</label>
129
+ <input type="file" class="form-control" id="image-file" name="image" accept="image/*" required>
130
+ </div>
131
+
132
+ <div class="form-group mb-3">
133
+ <label for="alt_text">Texte alternatif (pour l'accessibilité)</label>
134
+ <input type="text" class="form-control" id="alt_text" name="alt_text" placeholder="Description de l'image">
135
+ </div>
136
+
137
+ <div class="form-group mb-3">
138
+ <label for="description">Description (optionnelle)</label>
139
+ <textarea class="form-control" id="description" name="description" rows="3" placeholder="Description ou notes sur l'image"></textarea>
140
+ </div>
141
+ </div>
142
+ <div class="col-md-6 d-flex align-items-center justify-content-center">
143
+ <img id="upload-image-preview" class="image-upload-preview" src="#" alt="Aperçu">
144
+ </div>
145
+ </div>
146
+
147
+ <button type="submit" class="btn btn-primary">
148
+ <i class="fas fa-upload"></i> Télécharger
149
+ </button>
150
+ </form>
151
+ </div>
152
+ </div>
153
+
154
+ <!-- Image Gallery -->
155
+ <div class="card">
156
+ <div class="card-header">
157
+ <h4><i class="fas fa-images"></i> Bibliothèque d'images</h4>
158
+ </div>
159
+ <div class="card-body">
160
+ <!-- Filter -->
161
+ <div class="image-filter mb-4">
162
+ <div class="input-group">
163
+ <span class="input-group-text"><i class="fas fa-search"></i></span>
164
+ <input type="text" id="image-search" class="form-control" placeholder="Rechercher par nom ou description...">
165
+ </div>
166
+ </div>
167
+
168
+ {% if images %}
169
+ <div class="row" id="image-gallery">
170
+ {% for image in images %}
171
+ <div class="col-lg-4 col-md-6 mb-4">
172
+ <div class="image-card">
173
+ <div class="image-container">
174
+ <img src="{{ image.src }}" alt="{{ image.alt_text or 'Image' }}" class="image-preview">
175
+ </div>
176
+ <div class="image-info">
177
+ <h5 class="mb-1" title="{{ image.filename }}">
178
+ {{ image.filename|truncate(20) }}
179
+ </h5>
180
+ <p class="image-date mb-1">
181
+ <i class="fas fa-calendar-alt"></i>
182
+ {{ image.uploaded_at.strftime('%d/%m/%Y') }}
183
+ </p>
184
+ <p class="text-muted small mb-0">
185
+ {{ image.description|default('Aucune description')|truncate(50) }}
186
+ </p>
187
+ </div>
188
+ <div class="image-actions">
189
+ <button class="btn btn-sm btn-info" data-bs-toggle="modal" data-bs-target="#imageModal{{ image.id }}">
190
+ <i class="fas fa-eye"></i> Détails
191
+ </button>
192
+ <form method="POST" action="{{ url_for('admin_bp.images') }}" onsubmit="return confirm('Êtes-vous sûr de vouloir supprimer cette image ?')">
193
+ <input type="hidden" name="action" value="delete">
194
+ <input type="hidden" name="image_id" value="{{ image.id }}">
195
+ <button type="submit" class="btn btn-sm btn-danger">
196
+ <i class="fas fa-trash"></i>
197
+ </button>
198
+ </form>
199
+ </div>
200
+ </div>
201
+
202
+ <!-- Image Modal -->
203
+ <div class="modal fade image-detail-modal" id="imageModal{{ image.id }}" tabindex="-1" aria-labelledby="imageModalLabel{{ image.id }}" aria-hidden="true">
204
+ <div class="modal-dialog modal-lg">
205
+ <div class="modal-content">
206
+ <div class="modal-header">
207
+ <h5 class="modal-title" id="imageModalLabel{{ image.id }}">{{ image.filename }}</h5>
208
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
209
+ </div>
210
+ <div class="modal-body">
211
+ <div class="text-center mb-4">
212
+ <img src="{{ image.src }}" alt="{{ image.alt_text or 'Image' }}" class="img-fluid">
213
+ </div>
214
+
215
+ <form method="POST" action="{{ url_for('admin_bp.images') }}">
216
+ <input type="hidden" name="action" value="update">
217
+ <input type="hidden" name="image_id" value="{{ image.id }}">
218
+
219
+ <div class="form-group mb-3">
220
+ <label>Téléchargée le</label>
221
+ <input type="text" class="form-control" value="{{ image.uploaded_at.strftime('%d/%m/%Y à %H:%M') }}" readonly>
222
+ </div>
223
+
224
+ <div class="form-group mb-3">
225
+ <label for="alt_text{{ image.id }}">Texte alternatif</label>
226
+ <input type="text" class="form-control" id="alt_text{{ image.id }}" name="alt_text" value="{{ image.alt_text or '' }}">
227
+ </div>
228
+
229
+ <div class="form-group mb-3">
230
+ <label for="description{{ image.id }}">Description</label>
231
+ <textarea class="form-control" id="description{{ image.id }}" name="description" rows="3">{{ image.description or '' }}</textarea>
232
+ </div>
233
+
234
+ <button type="submit" class="btn btn-primary">
235
+ <i class="fas fa-save"></i> Mettre à jour
236
+ </button>
237
+ </form>
238
+ </div>
239
+ </div>
240
+ </div>
241
+ </div>
242
+ </div>
243
+ {% endfor %}
244
+ </div>
245
+ {% else %}
246
+ <div class="alert alert-info">
247
+ Aucune image n'a été téléchargée. Utilisez le formulaire ci-dessus pour ajouter votre première image.
248
+ </div>
249
+ {% endif %}
250
+ </div>
251
+ </div>
252
+ </div>
253
+ </div>
254
+ </div>
255
+ {% endblock %}
256
+
257
+ {% block scripts %}
258
+ <script src="{{ url_for('static', filename='js/admin.js') }}"></script>
259
+ <script>
260
+ // Image search functionality
261
+ document.addEventListener('DOMContentLoaded', function() {
262
+ const searchInput = document.getElementById('image-search');
263
+ if (!searchInput) return;
264
+
265
+ searchInput.addEventListener('input', function() {
266
+ const searchTerm = this.value.toLowerCase();
267
+ const imageCards = document.querySelectorAll('.image-card');
268
+
269
+ imageCards.forEach(card => {
270
+ const filename = card.querySelector('h5').textContent.toLowerCase();
271
+ const description = card.querySelector('.text-muted').textContent.toLowerCase();
272
+
273
+ if (filename.includes(searchTerm) || description.includes(searchTerm)) {
274
+ card.closest('.col-lg-4').style.display = '';
275
+ } else {
276
+ card.closest('.col-lg-4').style.display = 'none';
277
+ }
278
+ });
279
+ });
280
+ });
281
+ </script>
282
+ {% endblock %}
api/templates/admin/login.html ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'base.html' %}
2
+
3
+ {% block title %}Connexion - Administration{% endblock %}
4
+
5
+ {% block styles %}
6
+ <style>
7
+ .login-container {
8
+ max-width: 500px;
9
+ margin: 50px auto;
10
+ padding: 30px;
11
+ background-color: var(--card-bg);
12
+ border-radius: 10px;
13
+ box-shadow: var(--shadow);
14
+ }
15
+
16
+ .login-header {
17
+ text-align: center;
18
+ margin-bottom: 30px;
19
+ }
20
+
21
+ .login-icon {
22
+ font-size: 60px;
23
+ color: var(--primary-color);
24
+ margin-bottom: 20px;
25
+ }
26
+
27
+ .login-title {
28
+ font-size: 24px;
29
+ margin-bottom: 10px;
30
+ }
31
+
32
+ .login-subtitle {
33
+ color: var(--muted-color);
34
+ }
35
+
36
+ .login-form {
37
+ margin-top: 20px;
38
+ }
39
+
40
+ .form-floating {
41
+ margin-bottom: 20px;
42
+ }
43
+
44
+ .login-footer {
45
+ text-align: center;
46
+ margin-top: 20px;
47
+ color: var(--muted-color);
48
+ }
49
+ </style>
50
+ {% endblock %}
51
+
52
+ {% block content %}
53
+ <div class="login-container">
54
+ <div class="login-header">
55
+ <div class="login-icon">
56
+ <i class="fas fa-user-shield"></i>
57
+ </div>
58
+ <h1 class="login-title">Espace d'administration</h1>
59
+ <p class="login-subtitle">Connectez-vous pour gérer le contenu</p>
60
+ </div>
61
+
62
+ <form method="POST" action="{{ url_for('admin_bp.login') }}" class="login-form">
63
+ <div class="form-floating mb-3">
64
+ <input type="text" class="form-control" id="username" name="username" placeholder="Nom d'utilisateur" required>
65
+ <label for="username">Nom d'utilisateur</label>
66
+ </div>
67
+
68
+ <div class="form-floating mb-4">
69
+ <input type="password" class="form-control" id="password" name="password" placeholder="Mot de passe" required>
70
+ <label for="password">Mot de passe</label>
71
+ </div>
72
+
73
+ <div class="d-grid">
74
+ <button type="submit" class="btn btn-primary btn-lg">
75
+ <i class="fas fa-sign-in-alt me-2"></i> Se connecter
76
+ </button>
77
+ </div>
78
+ </form>
79
+
80
+ <div class="login-footer">
81
+ <p>Seuls les administrateurs autorisés peuvent accéder à cette section.</p>
82
+ <a href="{{ url_for('main_bp.index') }}">
83
+ <i class="fas fa-arrow-left me-1"></i> Retour à l'accueil
84
+ </a>
85
+ </div>
86
+ </div>
87
+ {% endblock %}
api/templates/admin/matieres.html ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'base.html' %}
2
+
3
+ {% block title %}Gestion des Matières - Administration{% endblock %}
4
+
5
+ {% block styles %}
6
+ <style>
7
+ .color-preview {
8
+ display: inline-block;
9
+ width: 24px;
10
+ height: 24px;
11
+ border-radius: 50%;
12
+ margin-left: 10px;
13
+ }
14
+
15
+ .color-badge {
16
+ display: inline-block;
17
+ width: 20px;
18
+ height: 20px;
19
+ border-radius: 4px;
20
+ margin-right: 10px;
21
+ }
22
+
23
+ .matiere-card {
24
+ background-color: var(--card-bg);
25
+ border-radius: 8px;
26
+ padding: 16px;
27
+ margin-bottom: 16px;
28
+ box-shadow: var(--shadow);
29
+ transition: transform 0.3s ease, box-shadow 0.3s ease;
30
+ border-left: 5px solid #ddd;
31
+ }
32
+
33
+ .matiere-card:hover {
34
+ transform: translateY(-5px);
35
+ box-shadow: var(--hover-shadow);
36
+ }
37
+
38
+ .matiere-actions {
39
+ display: flex;
40
+ justify-content: flex-end;
41
+ gap: 8px;
42
+ }
43
+ </style>
44
+ {% endblock %}
45
+
46
+ {% block content %}
47
+ <div class="row">
48
+ <div class="col-md-3">
49
+ <div class="admin-sidebar">
50
+ <h3 class="mb-3">Administration</h3>
51
+ <ul class="admin-nav">
52
+ <li class="admin-nav-item">
53
+ <a href="{{ url_for('admin_bp.dashboard') }}" class="admin-nav-link">
54
+ <i class="fas fa-tachometer-alt"></i> Tableau de bord
55
+ </a>
56
+ </li>
57
+ <li class="admin-nav-item">
58
+ <a href="{{ url_for('admin_bp.matieres') }}" class="admin-nav-link active">
59
+ <i class="fas fa-book"></i> Matières
60
+ </a>
61
+ </li>
62
+ <li class="admin-nav-item">
63
+ <a href="{{ url_for('admin_bp.sous_categories') }}" class="admin-nav-link">
64
+ <i class="fas fa-list"></i> Sous-catégories
65
+ </a>
66
+ </li>
67
+ <li class="admin-nav-item">
68
+ <a href="{{ url_for('admin_bp.textes') }}" class="admin-nav-link">
69
+ <i class="fas fa-file-alt"></i> Textes
70
+ </a>
71
+ </li>
72
+ <li class="admin-nav-item">
73
+ <a href="{{ url_for('admin_bp.images') }}" class="admin-nav-link">
74
+ <i class="fas fa-images"></i> Images
75
+ </a>
76
+ </li>
77
+ <li class="admin-nav-item">
78
+ <a href="{{ url_for('admin_bp.logout') }}" class="admin-nav-link">
79
+ <i class="fas fa-sign-out-alt"></i> Déconnexion
80
+ </a>
81
+ </li>
82
+ </ul>
83
+ </div>
84
+ </div>
85
+
86
+ <div class="col-md-9">
87
+ <div class="admin-container">
88
+ <h2 class="admin-title">Gestion des Matières</h2>
89
+
90
+ <div class="row mb-4">
91
+ <!-- Add Matiere Section -->
92
+ <div id="add-matiere-section" class="col-md-6">
93
+ <div class="card">
94
+ <div class="card-header">
95
+ <h4><i class="fas fa-plus-circle"></i> Ajouter une matière</h4>
96
+ </div>
97
+ <div class="card-body">
98
+ <form method="POST" action="{{ url_for('admin_bp.matieres') }}">
99
+ <input type="hidden" name="action" value="add">
100
+
101
+ <div class="form-group mb-3">
102
+ <label for="nom">Nom de la matière</label>
103
+ <input type="text" class="form-control" id="nom" name="nom" required>
104
+ </div>
105
+
106
+ <div class="form-group mb-3">
107
+ <label for="color_code">Couleur</label>
108
+ <div class="input-group">
109
+ <input type="color" class="form-control form-control-color" id="color_code" name="color_code" value="#3498db">
110
+ <!-- Color preview will be added by JS -->
111
+ </div>
112
+ </div>
113
+
114
+ <button type="submit" class="btn btn-primary">
115
+ <i class="fas fa-save"></i> Ajouter
116
+ </button>
117
+ </form>
118
+ </div>
119
+ </div>
120
+ </div>
121
+
122
+ <!-- Edit Matiere Section (initially hidden) -->
123
+ <div id="edit-matiere-section" class="col-md-6 d-none">
124
+ <div class="card">
125
+ <div class="card-header">
126
+ <h4><i class="fas fa-edit"></i> Modifier une matière</h4>
127
+ </div>
128
+ <div class="card-body">
129
+ <form id="edit-matiere-form" method="POST" action="{{ url_for('admin_bp.matieres') }}">
130
+ <input type="hidden" name="action" value="edit">
131
+ <input type="hidden" name="matiere_id" value="">
132
+
133
+ <div class="form-group mb-3">
134
+ <label for="edit-nom">Nom de la matière</label>
135
+ <input type="text" class="form-control" id="edit-nom" name="nom" required>
136
+ </div>
137
+
138
+ <div class="form-group mb-3">
139
+ <label for="edit-color_code">Couleur</label>
140
+ <div class="input-group">
141
+ <input type="color" class="form-control form-control-color" id="edit-color_code" name="color_code" value="#3498db">
142
+ <!-- Color preview will be added by JS -->
143
+ </div>
144
+ </div>
145
+
146
+ <div class="btn-group">
147
+ <button type="submit" class="btn btn-primary">
148
+ <i class="fas fa-save"></i> Mettre à jour
149
+ </button>
150
+ <button type="button" id="cancel-edit-matiere" class="btn btn-secondary">
151
+ <i class="fas fa-times"></i> Annuler
152
+ </button>
153
+ </div>
154
+ </form>
155
+ </div>
156
+ </div>
157
+ </div>
158
+ </div>
159
+
160
+ <!-- List of Matieres -->
161
+ <div class="card">
162
+ <div class="card-header">
163
+ <h4><i class="fas fa-list"></i> Liste des matières</h4>
164
+ </div>
165
+ <div class="card-body">
166
+ {% if matieres %}
167
+ <div class="row">
168
+ {% for matiere in matieres %}
169
+ <div class="col-md-6 mb-3">
170
+ <div class="matiere-card" style="border-left-color: {{ matiere.color_code }};">
171
+ <div class="d-flex justify-content-between align-items-center mb-2">
172
+ <h5 class="mb-0">
173
+ <span class="color-badge" style="background-color: {{ matiere.color_code }};"></span>
174
+ {{ matiere.nom }}
175
+ </h5>
176
+ </div>
177
+ <div class="small mb-2">
178
+ <span class="badge bg-secondary">{{ matiere.sous_categories|length }} sous-catégories</span>
179
+ </div>
180
+ <div class="matiere-actions">
181
+ <button class="btn btn-sm btn-primary edit-matiere-btn"
182
+ data-id="{{ matiere.id }}"
183
+ data-name="{{ matiere.nom }}"
184
+ data-color="{{ matiere.color_code }}">
185
+ <i class="fas fa-edit"></i> Modifier
186
+ </button>
187
+ <form method="POST" action="{{ url_for('admin_bp.matieres') }}" style="display: inline-block" onsubmit="return confirm('Êtes-vous sûr de vouloir supprimer cette matière ? Toutes les sous-catégories et textes associés seront également supprimés.')">
188
+ <input type="hidden" name="action" value="delete">
189
+ <input type="hidden" name="matiere_id" value="{{ matiere.id }}">
190
+ <button type="submit" class="btn btn-sm btn-danger">
191
+ <i class="fas fa-trash"></i> Supprimer
192
+ </button>
193
+ </form>
194
+ </div>
195
+ </div>
196
+ </div>
197
+ {% endfor %}
198
+ </div>
199
+ {% else %}
200
+ <div class="alert alert-info">
201
+ Aucune matière n'a été ajoutée. Utilisez le formulaire ci-dessus pour créer votre première matière.
202
+ </div>
203
+ {% endif %}
204
+ </div>
205
+ </div>
206
+ </div>
207
+ </div>
208
+ </div>
209
+ {% endblock %}
210
+
211
+ {% block scripts %}
212
+ <script src="{{ url_for('static', filename='js/admin.js') }}"></script>
213
+ {% endblock %}
api/templates/admin/sous_categories.html ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'base.html' %}
2
+
3
+ {% block title %}Gestion des Sous-catégories - Administration{% endblock %}
4
+
5
+ {% block styles %}
6
+ <style>
7
+ .sous-categorie-row {
8
+ transition: background-color 0.3s ease;
9
+ }
10
+
11
+ .sous-categorie-row:hover {
12
+ background-color: rgba(52, 152, 219, 0.1);
13
+ }
14
+
15
+ .color-badge {
16
+ display: inline-block;
17
+ width: 16px;
18
+ height: 16px;
19
+ border-radius: 50%;
20
+ margin-right: 8px;
21
+ }
22
+
23
+ .filter-container {
24
+ background-color: var(--block-bg);
25
+ padding: 15px;
26
+ border-radius: 8px;
27
+ margin-bottom: 20px;
28
+ }
29
+
30
+ .matiere-container {
31
+ margin-top: 30px;
32
+ border-left: 3px solid var(--primary-color);
33
+ padding-left: 15px;
34
+ }
35
+ </style>
36
+ {% endblock %}
37
+
38
+ {% block content %}
39
+ <div class="row">
40
+ <div class="col-md-3">
41
+ <div class="admin-sidebar">
42
+ <h3 class="mb-3">Administration</h3>
43
+ <ul class="admin-nav">
44
+ <li class="admin-nav-item">
45
+ <a href="{{ url_for('admin_bp.dashboard') }}" class="admin-nav-link">
46
+ <i class="fas fa-tachometer-alt"></i> Tableau de bord
47
+ </a>
48
+ </li>
49
+ <li class="admin-nav-item">
50
+ <a href="{{ url_for('admin_bp.matieres') }}" class="admin-nav-link">
51
+ <i class="fas fa-book"></i> Matières
52
+ </a>
53
+ </li>
54
+ <li class="admin-nav-item">
55
+ <a href="{{ url_for('admin_bp.sous_categories') }}" class="admin-nav-link active">
56
+ <i class="fas fa-list"></i> Sous-catégories
57
+ </a>
58
+ </li>
59
+ <li class="admin-nav-item">
60
+ <a href="{{ url_for('admin_bp.textes') }}" class="admin-nav-link">
61
+ <i class="fas fa-file-alt"></i> Textes
62
+ </a>
63
+ </li>
64
+ <li class="admin-nav-item">
65
+ <a href="{{ url_for('admin_bp.images') }}" class="admin-nav-link">
66
+ <i class="fas fa-images"></i> Images
67
+ </a>
68
+ </li>
69
+ <li class="admin-nav-item">
70
+ <a href="{{ url_for('admin_bp.logout') }}" class="admin-nav-link">
71
+ <i class="fas fa-sign-out-alt"></i> Déconnexion
72
+ </a>
73
+ </li>
74
+ </ul>
75
+ </div>
76
+ </div>
77
+
78
+ <div class="col-md-9">
79
+ <div class="admin-container">
80
+ <h2 class="admin-title">Gestion des Sous-catégories</h2>
81
+
82
+ <div class="row mb-4">
83
+ <!-- Add Sous-Categorie Section -->
84
+ <div id="add-sous-categorie-section" class="col-md-6">
85
+ <div class="card">
86
+ <div class="card-header">
87
+ <h4><i class="fas fa-plus-circle"></i> Ajouter une sous-catégorie</h4>
88
+ </div>
89
+ <div class="card-body">
90
+ <form method="POST" action="{{ url_for('admin_bp.sous_categories') }}">
91
+ <input type="hidden" name="action" value="add">
92
+
93
+ <div class="form-group mb-3">
94
+ <label for="matiere_id">Matière</label>
95
+ <select class="form-control" id="matiere_id" name="matiere_id" required>
96
+ <option value="">Sélectionnez une matière</option>
97
+ {% for matiere in matieres %}
98
+ <option value="{{ matiere.id }}">{{ matiere.nom }}</option>
99
+ {% endfor %}
100
+ </select>
101
+ </div>
102
+
103
+ <div class="form-group mb-3">
104
+ <label for="nom">Nom de la sous-catégorie</label>
105
+ <input type="text" class="form-control" id="nom" name="nom" required>
106
+ </div>
107
+
108
+ <button type="submit" class="btn btn-primary">
109
+ <i class="fas fa-save"></i> Ajouter
110
+ </button>
111
+ </form>
112
+ </div>
113
+ </div>
114
+ </div>
115
+
116
+ <!-- Edit Sous-Categorie Section (initially hidden) -->
117
+ <div id="edit-sous-categorie-section" class="col-md-6 d-none">
118
+ <div class="card">
119
+ <div class="card-header">
120
+ <h4><i class="fas fa-edit"></i> Modifier une sous-catégorie</h4>
121
+ </div>
122
+ <div class="card-body">
123
+ <form id="edit-sous-categorie-form" method="POST" action="{{ url_for('admin_bp.sous_categories') }}">
124
+ <input type="hidden" name="action" value="edit">
125
+ <input type="hidden" name="sous_categorie_id" value="">
126
+
127
+ <div class="form-group mb-3">
128
+ <label for="edit-matiere_id">Matière</label>
129
+ <select class="form-control" id="edit-matiere_id" name="matiere_id" required>
130
+ {% for matiere in matieres %}
131
+ <option value="{{ matiere.id }}">{{ matiere.nom }}</option>
132
+ {% endfor %}
133
+ </select>
134
+ </div>
135
+
136
+ <div class="form-group mb-3">
137
+ <label for="edit-nom">Nom de la sous-catégorie</label>
138
+ <input type="text" class="form-control" id="edit-nom" name="nom" required>
139
+ </div>
140
+
141
+ <div class="btn-group">
142
+ <button type="submit" class="btn btn-primary">
143
+ <i class="fas fa-save"></i> Mettre à jour
144
+ </button>
145
+ <button type="button" id="cancel-edit-sous-categorie" class="btn btn-secondary">
146
+ <i class="fas fa-times"></i> Annuler
147
+ </button>
148
+ </div>
149
+ </form>
150
+ </div>
151
+ </div>
152
+ </div>
153
+ </div>
154
+
155
+ <!-- Filter by Matiere -->
156
+ <div class="filter-container mb-4">
157
+ <div class="row align-items-center">
158
+ <div class="col-md-3">
159
+ <label for="matiere-filter" class="form-label mb-0"><strong>Filtrer par matière :</strong></label>
160
+ </div>
161
+ <div class="col-md-9">
162
+ <select id="matiere-filter" class="form-select">
163
+ <option value="">Toutes les matières</option>
164
+ {% for matiere in matieres %}
165
+ <option value="{{ matiere.id }}">{{ matiere.nom }}</option>
166
+ {% endfor %}
167
+ </select>
168
+ </div>
169
+ </div>
170
+ </div>
171
+
172
+ <!-- List of Sous-Categories -->
173
+ <div class="card">
174
+ <div class="card-header">
175
+ <h4><i class="fas fa-list"></i> Liste des sous-catégories</h4>
176
+ </div>
177
+ <div class="card-body">
178
+ {% if sous_categories %}
179
+ <div class="table-responsive">
180
+ <table class="table table-hover">
181
+ <thead>
182
+ <tr>
183
+ <th>Matière</th>
184
+ <th>Sous-catégorie</th>
185
+ <th>Actions</th>
186
+ </tr>
187
+ </thead>
188
+ <tbody>
189
+ {% for sous_categorie in sous_categories %}
190
+ <tr class="sous-categorie-row" data-matiere-id="{{ sous_categorie.matiere.id }}">
191
+ <td>
192
+ <span class="color-badge" style="background-color: {{ sous_categorie.matiere.color_code }};"></span>
193
+ {{ sous_categorie.matiere.nom }}
194
+ </td>
195
+ <td>{{ sous_categorie.nom }}</td>
196
+ <td>
197
+ <button class="btn btn-sm btn-primary edit-sous-categorie-btn"
198
+ data-id="{{ sous_categorie.id }}"
199
+ data-name="{{ sous_categorie.nom }}"
200
+ data-matiere-id="{{ sous_categorie.matiere.id }}">
201
+ <i class="fas fa-edit"></i> Modifier
202
+ </button>
203
+ <form method="POST" action="{{ url_for('admin_bp.sous_categories') }}" style="display: inline-block" onsubmit="return confirm('Êtes-vous sûr de vouloir supprimer cette sous-catégorie ? Tous les textes associés seront également supprimés.')">
204
+ <input type="hidden" name="action" value="delete">
205
+ <input type="hidden" name="sous_categorie_id" value="{{ sous_categorie.id }}">
206
+ <button type="submit" class="btn btn-sm btn-danger">
207
+ <i class="fas fa-trash"></i> Supprimer
208
+ </button>
209
+ </form>
210
+ </td>
211
+ </tr>
212
+ {% endfor %}
213
+ </tbody>
214
+ </table>
215
+ </div>
216
+ {% else %}
217
+ <div class="alert alert-info">
218
+ Aucune sous-catégorie n'a été ajoutée. Utilisez le formulaire ci-dessus pour créer votre première sous-catégorie.
219
+ </div>
220
+ {% endif %}
221
+ </div>
222
+ </div>
223
+ </div>
224
+ </div>
225
+ </div>
226
+ {% endblock %}
227
+
228
+ {% block scripts %}
229
+ <script src="{{ url_for('static', filename='js/admin.js') }}"></script>
230
+ {% endblock %}
api/templates/admin/textes.html ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'base.html' %}
2
+
3
+ {% block title %}Gestion des Textes - Administration{% endblock %}
4
+
5
+ {% block styles %}
6
+ <style>
7
+ .texte-card {
8
+ background-color: var(--card-bg);
9
+ border-radius: 8px;
10
+ padding: 16px;
11
+ margin-bottom: 16px;
12
+ box-shadow: var(--shadow);
13
+ transition: transform 0.3s ease, box-shadow 0.3s ease;
14
+ border-left: 5px solid var(--primary-color);
15
+ }
16
+
17
+ .texte-card:hover {
18
+ transform: translateY(-5px);
19
+ box-shadow: var(--hover-shadow);
20
+ }
21
+
22
+ .texte-actions {
23
+ display: flex;
24
+ justify-content: flex-end;
25
+ gap: 8px;
26
+ margin-top: 10px;
27
+ }
28
+
29
+ .accordion-button:not(.collapsed) {
30
+ background-color: var(--block-bg);
31
+ color: var(--text-color);
32
+ }
33
+
34
+ .matiere-header {
35
+ padding: 10px 15px;
36
+ margin: 10px 0;
37
+ border-radius: 5px;
38
+ color: white;
39
+ }
40
+
41
+ .sous-categorie-header {
42
+ padding: 8px 15px;
43
+ border-radius: 5px;
44
+ background-color: var(--block-bg);
45
+ margin: 10px 0;
46
+ }
47
+
48
+ .date-info {
49
+ font-size: 12px;
50
+ color: var(--muted-color);
51
+ }
52
+ </style>
53
+ {% endblock %}
54
+
55
+ {% block content %}
56
+ <div class="row">
57
+ <div class="col-md-3">
58
+ <div class="admin-sidebar">
59
+ <h3 class="mb-3">Administration</h3>
60
+ <ul class="admin-nav">
61
+ <li class="admin-nav-item">
62
+ <a href="{{ url_for('admin_bp.dashboard') }}" class="admin-nav-link">
63
+ <i class="fas fa-tachometer-alt"></i> Tableau de bord
64
+ </a>
65
+ </li>
66
+ <li class="admin-nav-item">
67
+ <a href="{{ url_for('admin_bp.matieres') }}" class="admin-nav-link">
68
+ <i class="fas fa-book"></i> Matières
69
+ </a>
70
+ </li>
71
+ <li class="admin-nav-item">
72
+ <a href="{{ url_for('admin_bp.sous_categories') }}" class="admin-nav-link">
73
+ <i class="fas fa-list"></i> Sous-catégories
74
+ </a>
75
+ </li>
76
+ <li class="admin-nav-item">
77
+ <a href="{{ url_for('admin_bp.textes') }}" class="admin-nav-link active">
78
+ <i class="fas fa-file-alt"></i> Textes
79
+ </a>
80
+ </li>
81
+ <li class="admin-nav-item">
82
+ <a href="{{ url_for('admin_bp.images') }}" class="admin-nav-link">
83
+ <i class="fas fa-images"></i> Images
84
+ </a>
85
+ </li>
86
+ <li class="admin-nav-item">
87
+ <a href="{{ url_for('admin_bp.logout') }}" class="admin-nav-link">
88
+ <i class="fas fa-sign-out-alt"></i> Déconnexion
89
+ </a>
90
+ </li>
91
+ </ul>
92
+ </div>
93
+ </div>
94
+
95
+ <div class="col-md-9">
96
+ <div class="admin-container">
97
+ <h2 class="admin-title">Gestion des Textes</h2>
98
+
99
+ <!-- Add New Text Button -->
100
+ <div class="mb-4">
101
+ <button class="btn btn-primary" type="button" data-bs-toggle="collapse" data-bs-target="#addTextCollapse" aria-expanded="false" aria-controls="addTextCollapse">
102
+ <i class="fas fa-plus-circle"></i> Ajouter un nouveau texte
103
+ </button>
104
+ </div>
105
+
106
+ <!-- Add Text Form (Collapsible) -->
107
+ <div class="collapse mb-4" id="addTextCollapse">
108
+ <div class="card">
109
+ <div class="card-header">
110
+ <h4><i class="fas fa-plus-circle"></i> Nouveau texte</h4>
111
+ </div>
112
+ <div class="card-body">
113
+ <form method="POST" action="{{ url_for('admin_bp.textes') }}">
114
+ <input type="hidden" name="action" value="add">
115
+
116
+ <div class="form-group mb-3">
117
+ <label for="titre">Titre</label>
118
+ <input type="text" class="form-control" id="titre" name="titre" required>
119
+ </div>
120
+
121
+ <div class="row mb-3">
122
+ <div class="col-md-6">
123
+ <div class="form-group">
124
+ <label for="matiere-select">Matière</label>
125
+ <select class="form-control" id="matiere-select" required>
126
+ <option value="">Sélectionnez une matière</option>
127
+ {% for matiere in matieres %}
128
+ <option value="{{ matiere.id }}">{{ matiere.nom }}</option>
129
+ {% endfor %}
130
+ </select>
131
+ </div>
132
+ </div>
133
+ <div class="col-md-6">
134
+ <div class="form-group">
135
+ <label for="sous-categorie-select">Sous-catégorie</label>
136
+ <select class="form-control" id="sous-categorie-select" name="sous_categorie_id" required>
137
+ <option value="">Sélectionnez d'abord une matière</option>
138
+ </select>
139
+ </div>
140
+ </div>
141
+ </div>
142
+
143
+ <div class="form-group mb-3">
144
+ <label for="contenu">Contenu</label>
145
+ <textarea class="form-control" id="contenu" name="contenu" rows="10" required></textarea>
146
+ <small class="form-text text-muted">
147
+ Séparez les paragraphes par des lignes vides. Les titres des blocs peuvent être indiqués sur une ligne séparée et seront automatiquement détectés.
148
+ </small>
149
+ </div>
150
+
151
+ <button type="submit" class="btn btn-primary">
152
+ <i class="fas fa-save"></i> Créer et éditer
153
+ </button>
154
+ </form>
155
+ </div>
156
+ </div>
157
+ </div>
158
+
159
+ <!-- List of Textes -->
160
+ <div class="card">
161
+ <div class="card-header">
162
+ <h4><i class="fas fa-file-alt"></i> Liste des textes</h4>
163
+ </div>
164
+ <div class="card-body">
165
+ {% if grouped_textes %}
166
+ <div class="accordion" id="textesAccordion">
167
+ {% for matiere_id, matiere_data in grouped_textes.items() %}
168
+ <div class="accordion-item mb-3">
169
+ <h2 class="accordion-header" id="heading{{ matiere_id }}">
170
+ <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse{{ matiere_id }}" aria-expanded="false" aria-controls="collapse{{ matiere_id }}" style="background-color: {{ matiere_data.color }}; color: white;">
171
+ <i class="fas fa-book me-2"></i> {{ matiere_data.nom }}
172
+ </button>
173
+ </h2>
174
+ <div id="collapse{{ matiere_id }}" class="accordion-collapse collapse" aria-labelledby="heading{{ matiere_id }}" data-bs-parent="#textesAccordion">
175
+ <div class="accordion-body">
176
+ {% for sous_cat_id, sous_cat_data in matiere_data.sous_categories.items() %}
177
+ <div class="sous-categorie-group">
178
+ <div class="sous-categorie-header" style="border-left: 5px solid {{ matiere_data.color }};">
179
+ <i class="fas fa-list-ul me-2"></i> {{ sous_cat_data.nom }}
180
+ </div>
181
+
182
+ <div class="row">
183
+ {% for texte in sous_cat_data.textes %}
184
+ <div class="col-md-6 mb-3">
185
+ <div class="texte-card" style="border-left-color: {{ matiere_data.color }};">
186
+ <h5>{{ texte.titre }}</h5>
187
+ <p class="date-info">
188
+ Dernière mise à jour: {{ texte.updated_at.strftime('%d/%m/%Y à %H:%M') }}
189
+ </p>
190
+ <div class="texte-actions">
191
+ <a href="{{ url_for('admin_bp.edit_texte', texte_id=texte.id) }}" class="btn btn-sm btn-primary">
192
+ <i class="fas fa-edit"></i> Éditer
193
+ </a>
194
+ <a href="{{ url_for('admin_bp.historique', texte_id=texte.id) }}" class="btn btn-sm btn-info">
195
+ <i class="fas fa-history"></i> Historique
196
+ </a>
197
+ <form method="POST" action="{{ url_for('admin_bp.textes') }}" onsubmit="return confirm('Êtes-vous sûr de vouloir supprimer ce texte ?')">
198
+ <input type="hidden" name="action" value="delete">
199
+ <input type="hidden" name="texte_id" value="{{ texte.id }}">
200
+ <button type="submit" class="btn btn-sm btn-danger">
201
+ <i class="fas fa-trash"></i>
202
+ </button>
203
+ </form>
204
+ </div>
205
+ </div>
206
+ </div>
207
+ {% endfor %}
208
+ </div>
209
+ </div>
210
+ {% endfor %}
211
+ </div>
212
+ </div>
213
+ </div>
214
+ {% endfor %}
215
+ </div>
216
+ {% else %}
217
+ <div class="alert alert-info">
218
+ Aucun texte n'a été ajouté. Utilisez le formulaire ci-dessus pour créer votre premier texte.
219
+ </div>
220
+ {% endif %}
221
+ </div>
222
+ </div>
223
+ </div>
224
+ </div>
225
+ </div>
226
+ {% endblock %}
227
+
228
+ {% block scripts %}
229
+ <script src="{{ url_for('static', filename='js/admin.js') }}"></script>
230
+ {% endblock %}
api/templates/base.html ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="fr" data-theme="light">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{% block title %}Plateforme Éducative{% endblock %}</title>
7
+
8
+ <!-- Bootstrap CSS from CDN -->
9
+ <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
10
+
11
+ <!-- Font Awesome for icons -->
12
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
13
+
14
+ <!-- Google Fonts -->
15
+ <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
16
+
17
+ <!-- Custom CSS -->
18
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
19
+
20
+ <!-- Additional CSS specific to templates -->
21
+ {% block styles %}{% endblock %}
22
+ </head>
23
+ <body>
24
+ <!-- Header -->
25
+ <header class="main-header">
26
+ <div class="container header-container">
27
+ <h1 class="site-title">
28
+ <a href="{{ url_for('main_bp.index') }}">Méthodologies</a>
29
+ </h1>
30
+
31
+ <div class="header-actions">
32
+ <!-- Theme toggle button -->
33
+ <button id="theme-toggle" class="theme-toggle" title="Changer de thème">
34
+ <i class="fas fa-moon"></i>
35
+ </button>
36
+
37
+ <!-- Admin link if admin is logged in -->
38
+ {% if session.get('admin_logged_in') %}
39
+ <a href="{{ url_for('admin_bp.dashboard') }}" class="btn btn-primary btn-sm ms-2">
40
+ <i class="fas fa-cog"></i> Gestion
41
+ </a>
42
+ {% endif %}
43
+ </div>
44
+ </div>
45
+ </header>
46
+
47
+ <!-- Flash messages -->
48
+ <div class="container mt-3">
49
+ {% with messages = get_flashed_messages(with_categories=true) %}
50
+ {% if messages %}
51
+ {% for category, message in messages %}
52
+ <div class="alert alert-{{ category if category != 'message' else 'info' }} alert-dismissible fade show" role="alert">
53
+ {{ message }}
54
+ <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
55
+ </div>
56
+ {% endfor %}
57
+ {% endif %}
58
+ {% endwith %}
59
+ </div>
60
+
61
+ <!-- Main content -->
62
+ <main class="main-content">
63
+ <div class="container">
64
+ {% block content %}{% endblock %}
65
+ </div>
66
+ </main>
67
+
68
+ <!-- Footer -->
69
+ <footer class="main-footer">
70
+ <div class="container footer-content">
71
+ <div>
72
+ <p>&copy; {{ year|default(2023) }} Méthodologies - Tous droits réservés</p>
73
+ <ul class="footer-links">
74
+ <li><a href="#">À propos</a></li>
75
+ <li><a href="#">Contact</a></li>
76
+ <li><a href="#">Mentions légales</a></li>
77
+ </ul>
78
+ </div>
79
+
80
+ <!-- Feedback form -->
81
+ <div class="feedback-form">
82
+ <h4 class="feedback-title">Votre avis nous intéresse</h4>
83
+ <form id="feedback-form" action="{{ url_for('main_bp.submit_feedback') }}" method="POST">
84
+ <div class="form-group mb-2">
85
+ <textarea id="feedback-message" name="message" class="form-control" rows="2" placeholder="Laissez-nous un message..."></textarea>
86
+ </div>
87
+ <button type="submit" class="btn btn-primary">Envoyer</button>
88
+ </form>
89
+ </div>
90
+ </div>
91
+ </footer>
92
+
93
+ <!-- Bootstrap JS Bundle with Popper -->
94
+ <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
95
+
96
+ <!-- Main JavaScript -->
97
+ <script src="{{ url_for('static', filename='js/main.js') }}"></script>
98
+
99
+ <!-- Additional JavaScript -->
100
+ {% block scripts %}{% endblock %}
101
+ </body>
102
+ </html>
api/templates/index.html ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'base.html' %}
2
+
3
+ {% block title %}Accueil - Méthodologies{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="row">
7
+ <div class="col-12">
8
+ <h2 class="mb-4">Bienvenue sur la plateforme de méthodologies</h2>
9
+ <p class="lead mb-4">Sélectionnez une matière pour découvrir des méthodologies adaptées à vos besoins.</p>
10
+ </div>
11
+ </div>
12
+
13
+ <!-- Subject Selection Section -->
14
+ <section id="matieres-section" class="mb-5 slide-in-up">
15
+ <h3 class="mb-3">Matières</h3>
16
+ <div class="row">
17
+ {% for matiere in matieres %}
18
+ <div class="col-md-4 mb-4">
19
+ <div class="subject-card" data-matiere-id="{{ matiere.id }}">
20
+ <div class="subject-indicator" style="background-color: {{ matiere.color_code }};"></div>
21
+ <h3>{{ matiere.nom }}</h3>
22
+ <p>Cliquez pour explorer les méthodologies dans cette matière</p>
23
+ <div class="text-end">
24
+ <span class="btn btn-sm" style="background-color: {{ matiere.color_code }}; color: white;">
25
+ <i class="fas fa-arrow-right"></i>
26
+ </span>
27
+ </div>
28
+ </div>
29
+ </div>
30
+ {% else %}
31
+ <div class="col-12">
32
+ <div class="alert alert-info">
33
+ Aucune matière n'est disponible pour le moment.
34
+ </div>
35
+ </div>
36
+ {% endfor %}
37
+ </div>
38
+ </section>
39
+
40
+ <!-- Categories Section (initially hidden) -->
41
+ <section id="sous-categories-section" class="mb-5 d-none fade-in">
42
+ <h3 class="mb-3">Types de méthodologies</h3>
43
+ <div class="row">
44
+ <div class="col-md-6 col-lg-4">
45
+ <div class="selection-container">
46
+ <h4 class="selection-title">Sélectionnez un type</h4>
47
+ <ul id="sous-categories-list" class="selection-list">
48
+ <!-- Sous catégories will be loaded here -->
49
+ </ul>
50
+ </div>
51
+ </div>
52
+ </div>
53
+ </section>
54
+
55
+ <!-- Texts Section (initially hidden) -->
56
+ <section id="textes-section" class="mb-5 d-none fade-in">
57
+ <h3 class="mb-3">Méthodologies</h3>
58
+ <div class="row">
59
+ <div class="col-md-6 col-lg-4">
60
+ <div class="selection-container">
61
+ <h4 class="selection-title">Sélectionnez une méthodologie</h4>
62
+ <ul id="textes-list" class="selection-list">
63
+ <!-- Textes will be loaded here -->
64
+ </ul>
65
+ </div>
66
+ </div>
67
+ </div>
68
+ </section>
69
+
70
+ <!-- Content Display Section (initially hidden) -->
71
+ <section id="content-section" class="mb-5 d-none fade-in">
72
+ <div class="content-viewer">
73
+ <h2 id="content-title" class="content-title"><!-- Title will be populated here --></h2>
74
+ <div id="content-blocks" class="content-blocks">
75
+ <!-- Content blocks will be populated here -->
76
+ </div>
77
+ </div>
78
+ </section>
79
+ {% endblock %}
app.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from api.index import app, db
2
+
3
+ # Create database tables if they don't exist
4
+ with app.app_context():
5
+ db.create_all()
6
+
7
+ if __name__ == '__main__':
8
+ app.run(debug=True)
app/__init__.py DELETED
@@ -1,28 +0,0 @@
1
- from flask import Flask
2
- from flask_sqlalchemy import SQLAlchemy
3
- from flask_admin import Admin
4
- from flask_ckeditor import CKEditor
5
- from flask_migrate import Migrate
6
- from config import Config
7
-
8
- db = SQLAlchemy()
9
- migrate = Migrate() # Initialisation de Flask-Migrate
10
- admin = Admin(name='Mon Projet', template_mode='bootstrap3')
11
- ckeditor = CKEditor()
12
-
13
- def create_app(config_class=Config):
14
- app = Flask(__name__)
15
- app.config.from_object(config_class)
16
-
17
- db.init_app(app)
18
- migrate.init_app(app, db)
19
- admin.init_app(app)
20
- ckeditor.init_app(app)
21
-
22
- from app.admin import bp as custom_admin_bp #
23
- app.register_blueprint(custom_admin_bp)
24
-
25
- from app.views import bp as main_bp
26
- app.register_blueprint(main_bp)
27
-
28
- return app
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/__pycache__/__init__.cpython-312.pyc DELETED
Binary file (1.34 kB)
 
app/__pycache__/admin.cpython-312.pyc DELETED
Binary file (4.47 kB)
 
app/__pycache__/models.cpython-312.pyc DELETED
Binary file (2.91 kB)
 
app/__pycache__/views.cpython-312.pyc DELETED
Binary file (2.47 kB)
 
app/admin.py DELETED
@@ -1,70 +0,0 @@
1
- from flask import Blueprint
2
- from flask_admin.contrib.sqla import ModelView
3
- from flask_admin import BaseView, expose
4
- from app import db, admin
5
- from app.models import Matiere, SousCategorie, Texte
6
- from flask_ckeditor import CKEditorField
7
- from wtforms import StringField, TextAreaField
8
- from bleach import clean
9
- from bs4 import BeautifulSoup
10
-
11
-
12
- bp = Blueprint('custom_admin', __name__, url_prefix='/admin')
13
-
14
- def sanitize_html(html_content):
15
- # TRÈS PERMISSIF - UNIQUEMENT POUR LE TEST
16
- return clean(html_content, tags=[], attributes={}, strip=False)
17
-
18
-
19
-
20
- class MatiereView(ModelView):
21
- column_list = ('nom', 'sous_categories') # Colonnes à afficher dans la liste
22
- form_columns = ('nom',)
23
-
24
- class SousCategorieView(ModelView):
25
- column_list = ('nom', 'matiere')
26
- form_columns = ('nom', 'matiere')
27
- #form_overrides = dict(nom=StringField)
28
- form_args = { # Amélioration de la sélection de la matière
29
- 'matiere': {
30
- 'query_factory': lambda: Matiere.query.order_by(func.lower(Matiere.nom)) #Tri insensible à la casse
31
- }
32
- }
33
- def on_model_change(self, form, model, is_created):
34
- # Vérification de l'unicité (nom, matiere_id) *avant* l'insertion/mise à jour
35
- if is_created:
36
- existing = SousCategorie.query.filter(
37
- func.lower(SousCategorie.nom) == func.lower(form.nom.data),
38
- SousCategorie.matiere_id == form.matiere.data.id
39
- ).first()
40
- if existing:
41
- raise ValueError("Cette sous-catégorie existe déjà pour cette matière.")
42
- else: #Mise à jour
43
- existing = SousCategorie.query.filter(
44
- func.lower(SousCategorie.nom) == func.lower(form.nom.data),
45
- SousCategorie.matiere_id == form.matiere.data.id,
46
- SousCategorie.id != model.id
47
- ).first()
48
-
49
- if existing:
50
- raise ValueError("Cette sous-catégorie existe déjà pour cette matière.")
51
-
52
-
53
- class TexteView(ModelView):
54
- column_list = ('titre', 'sous_categorie')
55
- form_columns = ('titre', 'contenu', 'sous_categorie')
56
- form_overrides = dict(contenu=CKEditorField)
57
- form_args = {
58
- 'sous_categorie': {
59
- 'query_factory': lambda: SousCategorie.query.join(Matiere).order_by(func.lower(Matiere.nom), func.lower(SousCategorie.nom))
60
- }
61
- }
62
-
63
- def on_model_change(self, form, model, is_created):
64
- model.contenu = sanitize_html(form.contenu.data)
65
-
66
-
67
-
68
- admin.add_view(MatiereView(Matiere, db.session))
69
- admin.add_view(SousCategorieView(SousCategorie, db.session))
70
- admin.add_view(TexteView(Texte, db.session))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/models.py DELETED
@@ -1,34 +0,0 @@
1
- from app import db
2
- from sqlalchemy import func # pour lower()
3
-
4
- class Matiere(db.Model):
5
- id = db.Column(db.Integer, primary_key=True)
6
- nom = db.Column(db.String(64), unique=True, nullable=False)
7
- sous_categories = db.relationship('SousCategorie', backref='matiere', lazy='dynamic', cascade="all, delete-orphan")
8
-
9
- def __repr__(self):
10
- return f'<Matiere {self.nom}>'
11
-
12
-
13
- class SousCategorie(db.Model):
14
- id = db.Column(db.Integer, primary_key=True)
15
- nom = db.Column(db.String(64), nullable=False)
16
- matiere_id = db.Column(db.Integer, db.ForeignKey('matiere.id'), nullable=False)
17
- textes = db.relationship('Texte', backref='sous_categorie', lazy='dynamic', cascade="all, delete-orphan")
18
- # Contrainte d'unicité composite
19
- __table_args__ = (
20
- db.UniqueConstraint('nom', 'matiere_id', name='_nom_matiere_uc'),
21
- )
22
-
23
-
24
- def __repr__(self):
25
- return f'<SousCategorie {self.nom}>'
26
-
27
- class Texte(db.Model):
28
- id = db.Column(db.Integer, primary_key=True)
29
- titre = db.Column(db.String(128), nullable=False)
30
- contenu = db.Column(db.Text, nullable=False)
31
- sous_categorie_id = db.Column(db.Integer, db.ForeignKey('sous_categorie.id'), nullable=False)
32
-
33
- def __repr__(self):
34
- return f'<Texte {self.titre}>'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/static/css/style.css DELETED
@@ -1,585 +0,0 @@
1
- /* Style pour la page des matières */
2
- .page-title {
3
- text-align: center;
4
- margin: 2rem 0 3rem;
5
- font-size: 2.5rem;
6
- color: #2c3e50;
7
- position: relative;
8
- }
9
-
10
- .page-title::after {
11
- content: '';
12
- position: absolute;
13
- bottom: -10px;
14
- left: 50%;
15
- transform: translateX(-50%);
16
- width: 60px;
17
- height: 3px;
18
- background: #3498db;
19
- border-radius: 2px;
20
- }
21
-
22
- .subjects-grid {
23
- display: grid;
24
- grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
25
- gap: 1.5rem;
26
- padding: 1rem;
27
- }
28
-
29
- .subject-card {
30
- background: white;
31
- border-radius: 12px;
32
- overflow: hidden;
33
- transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
34
- box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
35
- position: relative;
36
- }
37
-
38
- .subject-card:hover {
39
- transform: translateY(-5px);
40
- box-shadow: 0 8px 15px rgba(52, 152, 219, 0.2);
41
- }
42
-
43
- .subject-link {
44
- display: flex;
45
- justify-content: space-between;
46
- align-items: center;
47
- padding: 1.5rem;
48
- text-decoration: none;
49
- color: inherit;
50
- position: relative;
51
- z-index: 1;
52
- }
53
-
54
- .subject-link::after {
55
- content: '';
56
- position: absolute;
57
- top: 0;
58
- left: 0;
59
- width: 100%;
60
- height: 100%;
61
- background: linear-gradient(135deg, #46a1dd 0%, #2980b9 100%);
62
- opacity: 0;
63
- transition: opacity 0.3s ease;
64
- z-index: -1;
65
- }
66
-
67
- .subject-link:hover {
68
- color: white;
69
- }
70
-
71
- .subject-link:hover::after {
72
- opacity: 1;
73
- }
74
-
75
- .subject-name {
76
- font-size: 1.2rem;
77
- font-weight: 600;
78
- transition: color 0.3s ease;
79
- }
80
-
81
- .subject-arrow {
82
- font-size: 1.5rem;
83
- opacity: 0;
84
- transform: translateX(-10px);
85
- transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
86
- }
87
-
88
- .subject-link:hover .subject-arrow {
89
- opacity: 1;
90
- transform: translateX(0);
91
- }
92
-
93
- .no-subjects {
94
- text-align: center;
95
- color: #95a5a6;
96
- font-size: 1.2rem;
97
- padding: 2rem;
98
- grid-column: 1 / -1;
99
- }
100
-
101
- /* Animation d'apparition */
102
- @keyframes cardEntrance {
103
- from {
104
- opacity: 0;
105
- transform: translateY(20px);
106
- }
107
- to {
108
- opacity: 1;
109
- transform: translateY(0);
110
- }
111
- }
112
-
113
- .subject-card {
114
- animation: cardEntrance 0.6s ease forwards;
115
- animation-delay: calc(var(--index) * 0.1s);
116
- }
117
-
118
- @media (max-width: 768px) {
119
- .subjects-grid {
120
- grid-template-columns: 1fr;
121
- }
122
-
123
- .subject-link {
124
- padding: 1rem;
125
- }
126
- }
127
-
128
-
129
- /* Page matière */
130
- .subject-container {
131
- max-width: 1200px;
132
- margin: 2rem auto;
133
- padding: 0 1rem;
134
- }
135
-
136
- .subject-header {
137
- text-align: center;
138
- margin-bottom: 3rem;
139
- position: relative;
140
- }
141
-
142
- .subject-title {
143
- font-size: 2.5rem;
144
- color: #2c3e50;
145
- margin-bottom: 1rem;
146
- position: relative;
147
- display: inline-block;
148
- }
149
-
150
- .subject-header-decoration {
151
- height: 4px;
152
- width: 80px;
153
- background: #3498db;
154
- margin: 0 auto;
155
- border-radius: 2px;
156
- position: relative;
157
- animation: headerLine 1s ease-out;
158
- }
159
-
160
- .subcategories-section {
161
- background: white;
162
- padding: 2rem;
163
- border-radius: 12px;
164
- box-shadow: 0 4px 20px rgba(0,0,0,0.05);
165
- }
166
-
167
- .section-title {
168
- color: #4a5568;
169
- font-size: 1.5rem;
170
- margin-bottom: 2rem;
171
- padding-bottom: 0.5rem;
172
- border-bottom: 2px solid #e2e8f0;
173
- }
174
-
175
- .subcategories-grid {
176
- display: grid;
177
- grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
178
- gap: 1.5rem;
179
- }
180
-
181
- .subcategory-card {
182
- background: white;
183
- border-radius: 8px;
184
- padding: 1.5rem;
185
- text-decoration: none;
186
- transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
187
- border: 1px solid #e2e8f0;
188
- position: relative;
189
- overflow: hidden;
190
- }
191
-
192
- .subcategory-card:hover {
193
- transform: translateY(-3px);
194
- box-shadow: 0 10px 15px rgba(52, 152, 219, 0.15);
195
- border-color: #3498db;
196
- }
197
-
198
- .subcategory-content {
199
- display: flex;
200
- justify-content: space-between;
201
- align-items: center;
202
- }
203
-
204
- .subcategory-name {
205
- color: #2c3e50;
206
- font-weight: 500;
207
- font-size: 1.1rem;
208
- transition: color 0.3s ease;
209
- }
210
-
211
- .arrow-icon {
212
- width: 24px;
213
- height: 24px;
214
- opacity: 0;
215
- transform: translateX(-10px);
216
- transition: all 0.3s ease;
217
- color: #3498db;
218
- }
219
-
220
- .subcategory-card:hover .arrow-icon {
221
- opacity: 1;
222
- transform: translateX(0);
223
- }
224
-
225
- .no-subcategories {
226
- text-align: center;
227
- padding: 3rem;
228
- grid-column: 1 / -1;
229
- }
230
-
231
- .empty-icon {
232
- width: 60px;
233
- height: 60px;
234
- margin-bottom: 1rem;
235
- color: #cbd5e0;
236
- }
237
-
238
- .no-subcategories p {
239
- color: #a0aec0;
240
- font-size: 1.1rem;
241
- }
242
-
243
- /* Animations */
244
- @keyframes headerLine {
245
- from { width: 0; opacity: 0; }
246
- to { width: 80px; opacity: 1; }
247
- }
248
-
249
- @media (max-width: 768px) {
250
- .subject-container {
251
- padding: 0;
252
- }
253
-
254
- .subcategories-section {
255
- padding: 1.5rem;
256
- }
257
-
258
- .subject-title {
259
- font-size: 2rem;
260
- }
261
- }
262
-
263
-
264
- /*sous catégorie*/
265
- /* Page textes */
266
- .content-container {
267
- max-width: 1200px;
268
- margin: 2rem auto;
269
- padding: 0 1.5rem;
270
- }
271
-
272
- .header-section {
273
- text-align: center;
274
- margin-bottom: 3rem;
275
- }
276
-
277
- .main-title {
278
- font-size: 2.3rem;
279
- color: #2c3e50;
280
- margin-bottom: 0.5rem;
281
- }
282
-
283
- .title-underline {
284
- width: 60px;
285
- height: 3px;
286
- background: linear-gradient(135deg, #46a1dd 0%, #2980b9 100%);
287
- margin: 0 auto;
288
- border-radius: 2px;
289
- }
290
-
291
- .texts-section {
292
- background: #fff;
293
- padding: 2rem;
294
- border-radius: 12px;
295
- box-shadow: 0 4px 20px rgba(0,0,0,0.05);
296
- }
297
-
298
- .section-subtitle {
299
- color: #4a5568;
300
- font-size: 1.4rem;
301
- margin-bottom: 2rem;
302
- padding-bottom: 0.75rem;
303
- border-bottom: 2px solid #edf2f7;
304
- }
305
-
306
- .texts-grid {
307
- display: grid;
308
- gap: 1.2rem;
309
- }
310
-
311
- .text-card {
312
- display: flex;
313
- justify-content: space-between;
314
- align-items: center;
315
- padding: 1.5rem;
316
- background: #fff;
317
- border-radius: 8px;
318
- border: 1px solid #e2e8f0;
319
- transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
320
- text-decoration: none;
321
- position: relative;
322
- }
323
-
324
- .text-card:hover {
325
- transform: translateY(-3px);
326
- box-shadow: 0 5px 15px rgba(52, 152, 219, 0.15);
327
- border-color: #3498db;
328
- }
329
-
330
- .text-title {
331
- color: #2c3e50;
332
- font-size: 1.1rem;
333
- margin-bottom: 0.5rem;
334
- }
335
-
336
- .text-meta {
337
- display: flex;
338
- gap: 1rem;
339
- font-size: 0.9rem;
340
- color: #718096;
341
- }
342
-
343
- .meta-item i {
344
- margin-right: 0.4rem;
345
- color: #3498db;
346
- }
347
-
348
- .card-arrow {
349
- color: #3498db;
350
- opacity: 0;
351
- }
352
-
353
- /* le texte, cours*/
354
-
355
- /* Page texte */
356
- .text-container {
357
- max-width: 800px;
358
- margin: 2rem auto;
359
- padding: 0 1.5rem;
360
- }
361
-
362
- .text-header {
363
- text-align: center;
364
- margin-bottom: 3rem;
365
- }
366
-
367
- .text-title {
368
- font-size: 2.4rem;
369
- color: #2c3e50;
370
- margin-bottom: 1rem;
371
- line-height: 1.3;
372
- background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%);
373
- -webkit-background-clip: text;
374
- background-clip: text;
375
- -webkit-text-fill-color: transparent;
376
- display: inline-block;
377
- }
378
-
379
- .text-meta {
380
- display: flex;
381
- gap: 1.5rem;
382
- justify-content: center;
383
- color: #718096;
384
- font-size: 0.95rem;
385
- }
386
-
387
- .meta-item i {
388
- margin-right: 0.5rem;
389
- color: #3498db;
390
- }
391
-
392
- .texte-contenu {
393
- content: '';
394
- position: absolute;
395
- top: 0;
396
- left: 0;
397
- width: 100%;
398
- height: 100%;
399
- background: linear-gradient(135deg, #46a1dd 0%, #2980b9 100%);
400
- opacity: 0;
401
- transition: opacity 0.3s ease;
402
- z-index: -1;
403
- box-shadow: 0 4px 20px rgba(0,0,0,0.05);
404
- animation: fadeIn 0.6s ease-out;
405
- }
406
-
407
- /* Styles de contenu riche */
408
- .texte-contenu h2,
409
- .texte-contenu h3 {
410
- color: #2c3e50;
411
- margin: 2rem 0 1rem;
412
- }
413
-
414
- .texte-contenu h2 {
415
- font-size: 1.6rem;
416
- border-bottom: 2px solid #e2e8f0;
417
- padding-bottom: 0.5rem;
418
- }
419
-
420
- .texte-contenu h3 {
421
- font-size: 1.4rem;
422
- }
423
-
424
- .texte-contenu p {
425
- margin-bottom: 1.5rem;
426
- }
427
-
428
- .texte-contenu img {
429
- max-width: 100%;
430
- height: auto;
431
- border-radius: 8px;
432
- margin: 1.5rem 0;
433
- box-shadow: 0 4px 6px rgba(0,0,0,0.1);
434
- }
435
-
436
- .texte-contenu ul,
437
- .texte-contenu ol {
438
- margin: 1.5rem 0;
439
- padding-left: 2rem;
440
- }
441
-
442
- .texte-contenu li {
443
- margin-bottom: 0.8rem;
444
- }
445
-
446
- .texte-contenu blockquote {
447
- border-left: 4px solid #3498db;
448
- margin: 1.5rem 0;
449
- padding: 1rem 1.5rem;
450
- background: #f8f9fa;
451
- border-radius: 0 6px 6px 0;
452
- }
453
-
454
- /* additif */
455
-
456
- /* Header */
457
- .main-navbar {
458
- background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
459
- box-shadow: 0 2px 15px rgba(0, 0, 0, 0.08);
460
- padding: 0.8rem 1rem;
461
- position: relative;
462
- z-index: 1000;
463
- }
464
-
465
- .navbar-brand {
466
- font-weight: 700;
467
- color: #2c3e50 !important;
468
- font-size: 1.4rem;
469
- display: flex;
470
- align-items: center;
471
- transition: transform 0.3s ease;
472
- }
473
-
474
- .navbar-brand:hover {
475
- transform: translateX(5px);
476
- }
477
-
478
- .navbar-brand::after {
479
- content: "";
480
- display: inline-block;
481
- width: 2px;
482
- height: 24px;
483
- background: #3498db;
484
- margin-left: 1rem;
485
- transform: skew(-15deg);
486
- }
487
-
488
- .nav-link {
489
- color: #4a5568 !important;
490
- font-weight: 500;
491
- padding: 0.5rem 1.2rem !important;
492
- border-radius: 8px;
493
- transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
494
- position: relative;
495
- }
496
-
497
- .nav-link:hover {
498
- color: #3498db !important;
499
- background: rgba(52, 152, 219, 0.08);
500
- }
501
-
502
- .nav-link.admin-link {
503
- background: rgba(52, 152, 219, 0.1);
504
- margin-left: 1rem;
505
- }
506
-
507
- .nav-link.admin-link:hover {
508
- background: rgba(52, 152, 219, 0.2);
509
- }
510
-
511
- .nav-link.admin-link::before {
512
- content: "\f023";
513
- font-family: "Font Awesome 5 Free";
514
- font-weight: 900;
515
- margin-right: 0.5rem;
516
- }
517
-
518
- /* Footer */
519
- .main-footer {
520
- background: #2c3e50;
521
- color: #ecf0f1;
522
- padding: 1rem 0; /* Réduc pour pc */
523
- margin-top: auto;
524
- border-top: 3px solid #3498db;
525
- }
526
-
527
- .main-footer .social-links {
528
- display: flex;
529
- justify-content: center;
530
- gap: 1.5rem;
531
- margin: 1.5rem 0;
532
- }
533
-
534
- .main-footer .social-links a {
535
- color: #bdc3c7;
536
- font-size: 1.4rem;
537
- transition: all 0.3s ease;
538
- }
539
-
540
- .main-footer .social-links a:hover {
541
- color: #3498db;
542
- transform: translateY(-3px);
543
- }
544
-
545
- .main-footer .copyright {
546
- font-size: 0.9rem;
547
- opacity: 0.8;
548
- margin-top: 1rem;
549
- }
550
-
551
- /* Responsive */
552
- @media (max-width: 768px) {
553
- .main-footer {
554
- padding: 0.75rem 0; /* Réduc pour mobile */
555
- }
556
- }
557
-
558
- .nav-link {
559
- padding: 0.5rem !important;
560
- }
561
-
562
- .main-footer {
563
- padding: 1.5rem 0;
564
- }
565
- }
566
- .copyright {
567
- font-size: 0.9rem;
568
- opacity: 0.8;
569
- margin-top: 1rem;
570
- text-align: center;
571
- }
572
-
573
- .text-meta {
574
- display: flex;
575
- gap: 1.5rem;
576
- margin-top: 0.8rem;
577
- }
578
-
579
- .meta-item {
580
- display: flex;
581
- align-items: center;
582
- gap: 0.5rem;
583
- font-size: 0.9rem;
584
- color: #6c757d;
585
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/templates/base.html DELETED
@@ -1,44 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="fr">
3
- <head>
4
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
5
- <meta charset="UTF-8">
6
- <title>{% block title %}Mon Projet{% endblock %}</title>
7
- <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
8
- <!-- Bootstrap CSS -->
9
- <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
10
- {% block head %}{% endblock %}
11
- </head>
12
- <body class="d-flex flex-column min-vh-100">
13
-
14
- <nav class="navbar navbar-expand-lg navbar-light bg-light">
15
- <a class="navbar-brand" href="{{ url_for('main.index') }}">Mariam Ai</a>
16
- <ul class="navbar-nav ml-auto"> <!-- ml-auto pour aligner à droite -->
17
-
18
- </ul>
19
- </nav>
20
-
21
- <div class="container mt-4 flex-grow-1">
22
- {% block content %}{% endblock %}
23
- </div> <!-- End of main container -->
24
-
25
- <footer class="main-footer">
26
- <div class="container">
27
- <div class="social-links">
28
- <a href="https://whatsapp.com/channel/0029VazlRNk9WtBuQuAzUU2d"><i class="fab fa-whatsapp"></i></a>
29
- <a href="https://www.tiktok.com/@mariamai241"><i class="fab fa-tiktok"></i></a>
30
- </div>
31
- <div class="copyright">
32
- ©2025 Mariam Ai. Tous droits réservés.
33
- </div>
34
- </div>
35
- </footer>
36
-
37
- <!-- Bootstrap JS, Popper.js, and jQuery (Optional, but often needed) -->
38
- <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script>
39
- <script src="https://cdn.jsdelivr.net/npm/@popperjs/[email protected]/dist/umd/popper.min.js"></script>
40
- <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
41
- {% block scripts %}{% endblock %}
42
- </body>
43
- </html>
44
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/templates/index.html DELETED
@@ -1,19 +0,0 @@
1
- {% extends "base.html" %}
2
- {% block content %}
3
- <div class="container">
4
- <h1 class="page-title">Matières</h1>
5
-
6
- <div class="subjects-grid">
7
- {% for matiere in matieres %}
8
- <div class="subject-card">
9
- <a class="subject-link" href="{{ url_for('main.matiere', matiere_id=matiere.id) }}">
10
- <span class="subject-name">{{ matiere.nom }}</span>
11
- <span class="subject-arrow">→</span>
12
- </a>
13
- </div>
14
- {% else %}
15
- <p class="no-subjects">Aucune matière disponible</p>
16
- {% endfor %}
17
- </div>
18
- </div>
19
- {% endblock %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/templates/matiere.html DELETED
@@ -1,34 +0,0 @@
1
- {% extends "base.html" %}
2
-
3
- {% block content %}
4
- <div class="subject-container">
5
- <div class="subject-header">
6
- <h1 class="subject-title">{{ matiere.nom }}</h1>
7
- <div class="subject-header-decoration"></div>
8
- </div>
9
-
10
- <div class="subcategories-section">
11
- <h2 class="section-title">Sous-catégories</h2>
12
-
13
- <div class="subcategories-grid">
14
- {% for sous_categorie in sous_categories %}
15
- <a href="{{ url_for('main.sous_categorie', sous_categorie_id=sous_categorie.id) }}" class="subcategory-card">
16
- <div class="subcategory-content">
17
- <span class="subcategory-name">{{ sous_categorie.nom }}</span>
18
- <svg class="arrow-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
19
- <path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
20
- </svg>
21
- </div>
22
- </a>
23
- {% else %}
24
- <div class="no-subcategories">
25
- <svg class="empty-icon" viewBox="0 0 24 24">
26
- <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8z"/>
27
- </svg>
28
- <p>Aucune sous-catégorie disponible</p>
29
- </div>
30
- {% endfor %}
31
- </div>
32
- </div>
33
- </div>
34
- {% endblock %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/templates/sous_categorie.html DELETED
@@ -1,35 +0,0 @@
1
- {% extends "base.html" %}
2
-
3
- {% block content %}
4
- <div class="content-container">
5
- <div class="header-section">
6
- <h1 class="main-title">{{ sous_categorie.nom }}</h1>
7
- <div class="title-underline"></div>
8
- </div>
9
-
10
- <div class="texts-section">
11
- <h2 class="section-subtitle">Textes</h2>
12
-
13
- <div class="texts-grid">
14
- {% for texte in textes %}
15
- <a href="{{ url_for('main.texte', texte_id=texte.id) }}" class="text-card">
16
- <div class="card-content">
17
- <h3 class="text-title">{{ texte.titre }}</h3>
18
- <div class="text-meta">
19
- <!-- Ajoutez des métadonnées si disponibles -->
20
- <span class="meta-item"><i class="fas fa-calendar-alt"></i> Date</span>
21
- <span class="meta-item"><i class="fas fa-user-edit"></i> Auteur</span>
22
- </div>
23
- </div>
24
- <i class="fas fa-arrow-right card-arrow"></i>
25
- </a>
26
- {% else %}
27
- <div class="empty-state">
28
- <i class="fas fa-book-open empty-icon"></i>
29
- <p>Aucun texte disponible dans cette sous-catégorie</p>
30
- </div>
31
- {% endfor %}
32
- </div>
33
- </div>
34
- </div>
35
- {% endblock %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/templates/texte.html DELETED
@@ -1,15 +0,0 @@
1
- {% extends "base.html" %}
2
-
3
- {% block content %}
4
- <div class="container">
5
- <h1>{{ texte.titre }}</h1>
6
- <p class="author">
7
-
8
- Auteur : Admin
9
-
10
- </p>
11
- <div>
12
- {{ texte.contenu|safe }}
13
- </div>
14
- </div>
15
- {% endblock %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/views.py DELETED
@@ -1,28 +0,0 @@
1
- from flask import Blueprint, render_template, abort
2
- from app.models import Matiere, SousCategorie, Texte
3
- from sqlalchemy import func
4
-
5
- bp = Blueprint('main', __name__)
6
-
7
-
8
- @bp.route('/')
9
- def index():
10
- matieres = Matiere.query.order_by(func.lower(Matiere.nom)).all()
11
- return render_template('index.html', matieres=matieres)
12
-
13
- @bp.route('/matiere/<int:matiere_id>')
14
- def matiere(matiere_id):
15
- matiere = Matiere.query.get_or_404(matiere_id)
16
- sous_categories = matiere.sous_categories.order_by(func.lower(SousCategorie.nom)).all() # Tri insensible
17
- return render_template('matiere.html', matiere=matiere, sous_categories=sous_categories)
18
-
19
- @bp.route('/sous_categorie/<int:sous_categorie_id>')
20
- def sous_categorie(sous_categorie_id):
21
- sous_categorie = SousCategorie.query.get_or_404(sous_categorie_id)
22
- textes = sous_categorie.textes.order_by(Texte.titre).all() # Tri par titre
23
- return render_template('sous_categorie.html', sous_categorie=sous_categorie, textes=textes)
24
-
25
- @bp.route('/texte/<int:texte_id>')
26
- def texte(texte_id):
27
- texte = Texte.query.get_or_404(texte_id)
28
- return render_template('texte.html', texte=texte)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
config.py DELETED
@@ -1,17 +0,0 @@
1
- import os
2
-
3
- class Config:
4
- SECRET_KEY = os.getenv("SECRET_KEY", "changeme") # Utilise une variable d'environnement, avec une valeur par défaut à changer
5
-
6
- SQLALCHEMY_DATABASE_URI =os.getenv("DATABASE_URL")
7
- SQLALCHEMY_TRACK_MODIFICATIONS = False
8
-
9
- SQLALCHEMY_ENGINE_OPTIONS = {
10
- # Recycle connections after 280 seconds (less than potential 5-min server timeout)
11
- 'pool_recycle': 280,
12
- # Optionally, enable pre-ping for extra robustness (recommended)
13
- 'pool_pre_ping': True
14
- }
15
-
16
-
17
- FLASK_ADMIN_SWATCH = 'cerulean' # Optionnel, permet de changer le style facilement
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
main.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from api.index import app, db
3
+
4
+ # Create database tables if they don't exist
5
+ with app.app_context():
6
+ db.create_all()
7
+
8
+ if __name__ == '__main__':
9
+ port = int(os.environ.get("PORT", 5000))
10
+ app.run(host="0.0.0.0", port=port)
migrations/README DELETED
@@ -1 +0,0 @@
1
- Single-database configuration for Flask.
 
 
migrations/__pycache__/env.cpython-312.pyc DELETED
Binary file (4.5 kB)
 
migrations/alembic.ini DELETED
@@ -1,50 +0,0 @@
1
- # A generic, single database configuration.
2
-
3
- [alembic]
4
- # template used to generate migration files
5
- # file_template = %%(rev)s_%%(slug)s
6
-
7
- # set to 'true' to run the environment during
8
- # the 'revision' command, regardless of autogenerate
9
- # revision_environment = false
10
-
11
-
12
- # Logging configuration
13
- [loggers]
14
- keys = root,sqlalchemy,alembic,flask_migrate
15
-
16
- [handlers]
17
- keys = console
18
-
19
- [formatters]
20
- keys = generic
21
-
22
- [logger_root]
23
- level = WARN
24
- handlers = console
25
- qualname =
26
-
27
- [logger_sqlalchemy]
28
- level = WARN
29
- handlers =
30
- qualname = sqlalchemy.engine
31
-
32
- [logger_alembic]
33
- level = INFO
34
- handlers =
35
- qualname = alembic
36
-
37
- [logger_flask_migrate]
38
- level = INFO
39
- handlers =
40
- qualname = flask_migrate
41
-
42
- [handler_console]
43
- class = StreamHandler
44
- args = (sys.stderr,)
45
- level = NOTSET
46
- formatter = generic
47
-
48
- [formatter_generic]
49
- format = %(levelname)-5.5s [%(name)s] %(message)s
50
- datefmt = %H:%M:%S
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
migrations/env.py DELETED
@@ -1,113 +0,0 @@
1
- import logging
2
- from logging.config import fileConfig
3
-
4
- from flask import current_app
5
-
6
- from alembic import context
7
-
8
- # this is the Alembic Config object, which provides
9
- # access to the values within the .ini file in use.
10
- config = context.config
11
-
12
- # Interpret the config file for Python logging.
13
- # This line sets up loggers basically.
14
- fileConfig(config.config_file_name)
15
- logger = logging.getLogger('alembic.env')
16
-
17
-
18
- def get_engine():
19
- try:
20
- # this works with Flask-SQLAlchemy<3 and Alchemical
21
- return current_app.extensions['migrate'].db.get_engine()
22
- except (TypeError, AttributeError):
23
- # this works with Flask-SQLAlchemy>=3
24
- return current_app.extensions['migrate'].db.engine
25
-
26
-
27
- def get_engine_url():
28
- try:
29
- return get_engine().url.render_as_string(hide_password=False).replace(
30
- '%', '%%')
31
- except AttributeError:
32
- return str(get_engine().url).replace('%', '%%')
33
-
34
-
35
- # add your model's MetaData object here
36
- # for 'autogenerate' support
37
- # from myapp import mymodel
38
- # target_metadata = mymodel.Base.metadata
39
- config.set_main_option('sqlalchemy.url', get_engine_url())
40
- target_db = current_app.extensions['migrate'].db
41
-
42
- # other values from the config, defined by the needs of env.py,
43
- # can be acquired:
44
- # my_important_option = config.get_main_option("my_important_option")
45
- # ... etc.
46
-
47
-
48
- def get_metadata():
49
- if hasattr(target_db, 'metadatas'):
50
- return target_db.metadatas[None]
51
- return target_db.metadata
52
-
53
-
54
- def run_migrations_offline():
55
- """Run migrations in 'offline' mode.
56
-
57
- This configures the context with just a URL
58
- and not an Engine, though an Engine is acceptable
59
- here as well. By skipping the Engine creation
60
- we don't even need a DBAPI to be available.
61
-
62
- Calls to context.execute() here emit the given string to the
63
- script output.
64
-
65
- """
66
- url = config.get_main_option("sqlalchemy.url")
67
- context.configure(
68
- url=url, target_metadata=get_metadata(), literal_binds=True
69
- )
70
-
71
- with context.begin_transaction():
72
- context.run_migrations()
73
-
74
-
75
- def run_migrations_online():
76
- """Run migrations in 'online' mode.
77
-
78
- In this scenario we need to create an Engine
79
- and associate a connection with the context.
80
-
81
- """
82
-
83
- # this callback is used to prevent an auto-migration from being generated
84
- # when there are no changes to the schema
85
- # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
86
- def process_revision_directives(context, revision, directives):
87
- if getattr(config.cmd_opts, 'autogenerate', False):
88
- script = directives[0]
89
- if script.upgrade_ops.is_empty():
90
- directives[:] = []
91
- logger.info('No changes in schema detected.')
92
-
93
- conf_args = current_app.extensions['migrate'].configure_args
94
- if conf_args.get("process_revision_directives") is None:
95
- conf_args["process_revision_directives"] = process_revision_directives
96
-
97
- connectable = get_engine()
98
-
99
- with connectable.connect() as connection:
100
- context.configure(
101
- connection=connection,
102
- target_metadata=get_metadata(),
103
- **conf_args
104
- )
105
-
106
- with context.begin_transaction():
107
- context.run_migrations()
108
-
109
-
110
- if context.is_offline_mode():
111
- run_migrations_offline()
112
- else:
113
- run_migrations_online()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
migrations/script.py.mako DELETED
@@ -1,24 +0,0 @@
1
- """${message}
2
-
3
- Revision ID: ${up_revision}
4
- Revises: ${down_revision | comma,n}
5
- Create Date: ${create_date}
6
-
7
- """
8
- from alembic import op
9
- import sqlalchemy as sa
10
- ${imports if imports else ""}
11
-
12
- # revision identifiers, used by Alembic.
13
- revision = ${repr(up_revision)}
14
- down_revision = ${repr(down_revision)}
15
- branch_labels = ${repr(branch_labels)}
16
- depends_on = ${repr(depends_on)}
17
-
18
-
19
- def upgrade():
20
- ${upgrades if upgrades else "pass"}
21
-
22
-
23
- def downgrade():
24
- ${downgrades if downgrades else "pass"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
migrations/versions/80f91a17b1db_initial_migration.py DELETED
@@ -1,112 +0,0 @@
1
- """Initial migration
2
-
3
- Revision ID: 80f91a17b1db
4
- Revises:
5
- Create Date: 2025-02-17 12:20:44.860732
6
-
7
- """
8
- from alembic import op
9
- import sqlalchemy as sa
10
- from sqlalchemy.dialects import postgresql
11
-
12
- # revision identifiers, used by Alembic.
13
- revision = '80f91a17b1db'
14
- down_revision = None
15
- branch_labels = None
16
- depends_on = None
17
-
18
-
19
- def upgrade():
20
- # ### commands auto generated by Alembic - please adjust! ###
21
- op.create_table('sous_categorie',
22
- sa.Column('id', sa.Integer(), nullable=False),
23
- sa.Column('nom', sa.String(length=64), nullable=False),
24
- sa.Column('matiere_id', sa.Integer(), nullable=False),
25
- sa.ForeignKeyConstraint(['matiere_id'], ['matiere.id'], ),
26
- sa.PrimaryKeyConstraint('id'),
27
- sa.UniqueConstraint('nom', 'matiere_id', name='_nom_matiere_uc')
28
- )
29
- op.create_table('texte',
30
- sa.Column('id', sa.Integer(), nullable=False),
31
- sa.Column('titre', sa.String(length=128), nullable=False),
32
- sa.Column('contenu', sa.Text(), nullable=False),
33
- sa.Column('sous_categorie_id', sa.Integer(), nullable=False),
34
- sa.ForeignKeyConstraint(['sous_categorie_id'], ['sous_categorie.id'], ),
35
- sa.PrimaryKeyConstraint('id')
36
- )
37
- op.drop_table('cours')
38
- op.drop_table('subjects')
39
- op.drop_table('texts')
40
- op.drop_table('commentaire')
41
- op.drop_table('categories')
42
- op.drop_table('categorie')
43
- with op.batch_alter_table('matiere', schema=None) as batch_op:
44
- batch_op.alter_column('nom',
45
- existing_type=sa.VARCHAR(length=255),
46
- type_=sa.String(length=64),
47
- existing_nullable=False)
48
- batch_op.create_unique_constraint(None, ['nom'])
49
- batch_op.drop_column('description')
50
-
51
- # ### end Alembic commands ###
52
-
53
-
54
- def downgrade():
55
- # ### commands auto generated by Alembic - please adjust! ###
56
- with op.batch_alter_table('matiere', schema=None) as batch_op:
57
- batch_op.add_column(sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True))
58
- batch_op.drop_constraint(None, type_='unique')
59
- batch_op.alter_column('nom',
60
- existing_type=sa.String(length=64),
61
- type_=sa.VARCHAR(length=255),
62
- existing_nullable=False)
63
-
64
- op.create_table('categorie',
65
- sa.Column('id', sa.INTEGER(), server_default=sa.text("nextval('categorie_id_seq'::regclass)"), autoincrement=True, nullable=False),
66
- sa.Column('nom', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
67
- sa.Column('matiere_id', sa.INTEGER(), autoincrement=False, nullable=False),
68
- sa.ForeignKeyConstraint(['matiere_id'], ['matiere.id'], name='fk_matiere', ondelete='CASCADE'),
69
- sa.PrimaryKeyConstraint('id', name='categorie_pkey'),
70
- postgresql_ignore_search_path=False
71
- )
72
- op.create_table('categories',
73
- sa.Column('id', sa.INTEGER(), server_default=sa.text("nextval('categories_id_seq'::regclass)"), autoincrement=True, nullable=False),
74
- sa.Column('name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
75
- sa.Column('subject_id', sa.INTEGER(), autoincrement=False, nullable=False),
76
- sa.ForeignKeyConstraint(['subject_id'], ['subjects.id'], name='categories_subject_id_fkey'),
77
- sa.PrimaryKeyConstraint('id', name='categories_pkey'),
78
- postgresql_ignore_search_path=False
79
- )
80
- op.create_table('commentaire',
81
- sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
82
- sa.Column('contenu', sa.TEXT(), autoincrement=False, nullable=False),
83
- sa.Column('auteur', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
84
- sa.Column('date', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
85
- sa.Column('cours_id', sa.INTEGER(), autoincrement=False, nullable=False),
86
- sa.ForeignKeyConstraint(['cours_id'], ['cours.id'], name='fk_cours', ondelete='CASCADE'),
87
- sa.PrimaryKeyConstraint('id', name='commentaire_pkey')
88
- )
89
- op.create_table('texts',
90
- sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
91
- sa.Column('title', sa.VARCHAR(length=150), autoincrement=False, nullable=True),
92
- sa.Column('content', sa.TEXT(), autoincrement=False, nullable=False),
93
- sa.Column('category_id', sa.INTEGER(), autoincrement=False, nullable=False),
94
- sa.ForeignKeyConstraint(['category_id'], ['categories.id'], name='texts_category_id_fkey'),
95
- sa.PrimaryKeyConstraint('id', name='texts_pkey')
96
- )
97
- op.create_table('subjects',
98
- sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
99
- sa.Column('name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
100
- sa.PrimaryKeyConstraint('id', name='subjects_pkey')
101
- )
102
- op.create_table('cours',
103
- sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
104
- sa.Column('titre', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
105
- sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True),
106
- sa.Column('categorie_id', sa.INTEGER(), autoincrement=False, nullable=False),
107
- sa.ForeignKeyConstraint(['categorie_id'], ['categorie.id'], name='fk_categorie', ondelete='CASCADE'),
108
- sa.PrimaryKeyConstraint('id', name='cours_pkey')
109
- )
110
- op.drop_table('texte')
111
- op.drop_table('sous_categorie')
112
- # ### end Alembic commands ###
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
migrations/versions/__pycache__/80f91a17b1db_initial_migration.cpython-312.pyc DELETED
Binary file (8.15 kB)
 
run.py DELETED
@@ -1,6 +0,0 @@
1
- from app import create_app
2
- import os
3
- app = create_app()
4
-
5
- if __name__ == '__main__':
6
- app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)))