kuro223 commited on
Commit
8bdef7b
·
1 Parent(s): a563878
__pycache__/app.cpython-311.pyc CHANGED
Binary files a/__pycache__/app.cpython-311.pyc and b/__pycache__/app.cpython-311.pyc differ
 
app.py CHANGED
@@ -4,7 +4,7 @@ import os
4
  from werkzeug.utils import secure_filename
5
 
6
  app = Flask(__name__)
7
- app.config['SQLALCHEMY_DATABASE_URI'] = "sqlite:///courses.db"
8
  app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
9
  UPLOAD_FOLDER = 'static/uploads'
10
  app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
@@ -206,6 +206,11 @@ def admin_edit_subject(subject_id):
206
  @app.route('/admin/subjects/delete/<int:subject_id>', methods=['POST'])
207
  def admin_delete_subject(subject_id):
208
  subject = Subject.query.get_or_404(subject_id)
 
 
 
 
 
209
  db.session.delete(subject)
210
  db.session.commit()
211
  return redirect(url_for('admin_subjects'))
@@ -241,6 +246,8 @@ def admin_edit_category(category_id):
241
  @app.route('/admin/categories/delete/<int:category_id>', methods=['POST'])
242
  def admin_delete_category(category_id):
243
  category = Category.query.get_or_404(category_id)
 
 
244
  db.session.delete(category)
245
  db.session.commit()
246
  return redirect(url_for('admin_categories'))
@@ -266,10 +273,6 @@ def add_sample_data():
266
  Article(title="Lois de Newton", content="<p>La première loi de Newton...</p>", category=mechanics)
267
  db.session.commit()
268
 
269
- with app.app_context():
270
- db.create_all()
271
- add_sample_data()
272
-
273
 
274
  # Helper to convert a standard YouTube URL into an embeddable URL
275
  from urllib.parse import urlparse, parse_qs
@@ -492,5 +495,5 @@ if __name__ == '__main__':
492
  # If anything goes wrong, log and continue; user can perform a manual migration
493
  print('Migration skipped or failed:', e)
494
 
495
- add_sample_data()
496
  app.run(debug=True)
 
4
  from werkzeug.utils import secure_filename
5
 
6
  app = Flask(__name__)
7
+ app.config['SQLALCHEMY_DATABASE_URI'] = "postgresql://neondb_owner:npg_vz5FLSXfj2sp@ep-late-block-adp1o88t-pooler.c-2.us-east-1.aws.neon.tech/neondb?sslmode=require&channel_binding=require"
8
  app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
9
  UPLOAD_FOLDER = 'static/uploads'
10
  app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
 
206
  @app.route('/admin/subjects/delete/<int:subject_id>', methods=['POST'])
207
  def admin_delete_subject(subject_id):
208
  subject = Subject.query.get_or_404(subject_id)
209
+ # Supprimer toutes les catégories et articles associés au sujet
210
+ categories = Category.query.filter_by(subject_id=subject_id).all()
211
+ for category in categories:
212
+ Article.query.filter_by(category_id=category.id).delete()
213
+ db.session.delete(category)
214
  db.session.delete(subject)
215
  db.session.commit()
216
  return redirect(url_for('admin_subjects'))
 
246
  @app.route('/admin/categories/delete/<int:category_id>', methods=['POST'])
247
  def admin_delete_category(category_id):
248
  category = Category.query.get_or_404(category_id)
249
+ # Supprimer tous les articles de cette catégorie d'abord
250
+ Article.query.filter_by(category_id=category_id).delete()
251
  db.session.delete(category)
252
  db.session.commit()
253
  return redirect(url_for('admin_categories'))
 
273
  Article(title="Lois de Newton", content="<p>La première loi de Newton...</p>", category=mechanics)
274
  db.session.commit()
275
 
 
 
 
 
276
 
277
  # Helper to convert a standard YouTube URL into an embeddable URL
278
  from urllib.parse import urlparse, parse_qs
 
495
  # If anything goes wrong, log and continue; user can perform a manual migration
496
  print('Migration skipped or failed:', e)
497
 
498
+
499
  app.run(debug=True)
clear_data.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from app import app, db, Subject, Category, Article
2
+
3
+ with app.app_context():
4
+ # Delete in correct order to avoid foreign key constraints
5
+ Article.query.delete()
6
+ Category.query.delete()
7
+ Subject.query.delete()
8
+ db.session.commit()
9
+ print("Toutes les données d'exemple ont été supprimées.")
instance/coursels.db ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ad345892424808b56797a0eeec59a4537bb6901aa325ecea195c3982f66b7023
3
+ size 20480
reset_db.py CHANGED
@@ -3,5 +3,5 @@ from app import app, db, add_sample_data
3
  with app.app_context():
4
  db.drop_all()
5
  db.create_all()
6
- add_sample_data()
7
  print("Database reset complete.")
 
3
  with app.app_context():
4
  db.drop_all()
5
  db.create_all()
6
+
7
  print("Database reset complete.")
templates/admin_categories.html CHANGED
@@ -1,42 +1,40 @@
1
- <!DOCTYPE html>
2
- <html lang="fr">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>Gérer les Catégories</title>
6
- <style>
7
- body { font-family: Arial, sans-serif; margin: 20px; }
8
- table { width: 100%; border-collapse: collapse; }
9
- th, td { border: 1px solid #ccc; padding: 10px; text-align: left; }
10
- .actions a { margin-right: 10px; }
11
- </style>
12
- </head>
13
- <body>
14
- <h1>Gérer les Catégories</h1>
15
- <a href="{{ url_for('admin_home') }}">Retour à l'admin</a> | <a href="{{ url_for('admin_new_category') }}">Nouvelle Catégorie</a>
16
- <table>
17
- <thead>
18
- <tr>
19
- <th>ID</th>
20
- <th>Nom</th>
21
- <th>Sujet</th>
22
- <th>Actions</th>
23
- </tr>
24
- </thead>
25
- <tbody>
26
- {% for category in categories %}
27
- <tr>
28
- <td>{{ category.id }}</td>
29
- <td>{{ category.name }}</td>
30
- <td>{{ category.subject.name }}</td>
31
- <td class="actions">
32
- <a href="{{ url_for('admin_edit_category', category_id=category.id) }}">Éditer</a>
33
- <form method="post" action="{{ url_for('admin_delete_category', category_id=category.id) }}" style="display:inline;">
34
- <button type="submit" onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette catégorie ?')">Supprimer</button>
35
- </form>
36
- </td>
37
- </tr>
38
- {% endfor %}
39
- </tbody>
40
- </table>
41
- </body>
42
- </html>
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Gérer les Catégories - {{ super() }}{% endblock %}
4
+
5
+ {% block content %}
6
+ <h2 class="mb-4">Gérer les Catégories</h2>
7
+ <div class="d-flex gap-2 mb-4">
8
+ <a href="{{ url_for('admin_home') }}" class="btn btn-secondary">Retour à l'admin</a>
9
+ <a href="{{ url_for('admin_new_category') }}" class="btn btn-primary">Nouvelle Catégorie</a>
10
+ </div>
11
+ <div class="table-responsive">
12
+ <table class="table table-striped table-hover">
13
+ <thead class="table-dark">
14
+ <tr>
15
+ <th>ID</th>
16
+ <th>Nom</th>
17
+ <th>Sujet</th>
18
+ <th>Actions</th>
19
+ </tr>
20
+ </thead>
21
+ <tbody>
22
+ {% for category in categories %}
23
+ <tr>
24
+ <td>{{ category.id }}</td>
25
+ <td>{{ category.name }}</td>
26
+ <td>{{ category.subject.name }}</td>
27
+ <td>
28
+ <div class="btn-group" role="group">
29
+ <a href="{{ url_for('admin_edit_category', category_id=category.id) }}" class="btn btn-sm btn-outline-primary">Éditer</a>
30
+ <form method="post" action="{{ url_for('admin_delete_category', category_id=category.id) }}" style="display:inline;" onsubmit="return confirm('Êtes-vous sûr de vouloir supprimer cette catégorie ?')">
31
+ <button type="submit" class="btn btn-sm btn-outline-danger">Supprimer</button>
32
+ </form>
33
+ </div>
34
+ </td>
35
+ </tr>
36
+ {% endfor %}
37
+ </tbody>
38
+ </table>
39
+ </div>
40
+ {% endblock %}
 
 
templates/admin_edit_article.html CHANGED
@@ -393,12 +393,44 @@
393
  document.addEventListener('trix-change', updatePreview);
394
  document.addEventListener('DOMContentLoaded', updatePreview);
395
 
396
- // File input visual feedback
397
- document.querySelector('.file-input-wrapper input[type="file"]').addEventListener('change', function(e) {
398
- const trigger = this.parentElement.querySelector('.file-input-trigger');
399
- if (this.files.length > 0) {
400
- trigger.textContent = `Fichier sélectionné : ${this.files[0].name}`;
401
- }
402
- });
403
- </script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
404
  {% endblock %}
 
393
  document.addEventListener('trix-change', updatePreview);
394
  document.addEventListener('DOMContentLoaded', updatePreview);
395
 
396
+ // File input visual feedback
397
+ document.querySelector('.file-input-wrapper input[type="file"]').addEventListener('change', function(e) {
398
+ const trigger = this.parentElement.querySelector('.file-input-trigger');
399
+ if (this.files.length > 0) {
400
+ trigger.textContent = `Fichier sélectionné : ${this.files[0].name}`;
401
+ }
402
+ });
403
+
404
+ // Keyboard shortcuts for shortcodes
405
+ document.addEventListener('keydown', function(e) {
406
+ // Ctrl+Y for YouTube shortcode
407
+ if (e.ctrlKey && e.key === 'y') {
408
+ e.preventDefault();
409
+ const editor = document.querySelector('trix-editor');
410
+ if (editor && editor.editor) {
411
+ const position = editor.editor.getSelectedRange();
412
+ editor.editor.setSelectedRange(position);
413
+ editor.editor.insertString('[youtube:]');
414
+ // Position cursor inside the brackets
415
+ const newPosition = position[0] + '[youtube:'.length;
416
+ editor.editor.setSelectedRange([newPosition, newPosition]);
417
+ }
418
+ }
419
+
420
+ // Ctrl+I for responsive image shortcode
421
+ if (e.ctrlKey && e.key === 'i') {
422
+ e.preventDefault();
423
+ const editor = document.querySelector('trix-editor');
424
+ if (editor && editor.editor) {
425
+ const position = editor.editor.getSelectedRange();
426
+ editor.editor.setSelectedRange(position);
427
+ editor.editor.insertString('[image:URL|width=100%]');
428
+ // Select "URL" for easy replacement
429
+ const urlStart = position[0] + '[image:'.length;
430
+ const urlEnd = urlStart + 3; // "URL".length
431
+ editor.editor.setSelectedRange([urlStart, urlEnd]);
432
+ }
433
+ }
434
+ });
435
+ </script>
436
  {% endblock %}
templates/admin_edit_category.html CHANGED
@@ -1,30 +1,31 @@
1
- <!DOCTYPE html>
2
- <html lang="fr">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>Éditer la Catégorie</title>
6
- <style>
7
- body { font-family: Arial, sans-serif; margin: 20px; }
8
- form { max-width: 800px; }
9
- label { display: block; margin-top: 10px; }
10
- input, select, button { width: 100%; padding: 10px; margin-top: 5px; }
11
- </style>
12
- </head>
13
- <body>
14
- <h1>Éditer la Catégorie</h1>
15
- <a href="{{ url_for('admin_categories') }}">Retour aux catégories</a>
16
- <form method="post">
17
- <label for="name">Nom:</label>
18
- <input type="text" id="name" name="name" value="{{ category.name }}" required>
19
-
20
- <label for="subject_id">Sujet:</label>
21
- <select id="subject_id" name="subject_id" required>
22
- {% for subject in subjects %}
23
- <option value="{{ subject.id }}" {% if subject.id == category.subject_id %}selected{% endif %}>{{ subject.name }}</option>
24
- {% endfor %}
25
- </select>
26
-
27
- <button type="submit">Sauvegarder</button>
28
- </form>
29
- </body>
30
- </html>
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Éditer la Catégorie - {{ super() }}{% endblock %}
4
+
5
+ {% block content %}
6
+ <h2 class="mb-4">Éditer la Catégorie</h2>
7
+ <div class="mb-4">
8
+ <a href="{{ url_for('admin_categories') }}" class="btn btn-secondary">Retour aux catégories</a>
9
+ </div>
10
+ <div class="card">
11
+ <div class="card-body">
12
+ <form method="post">
13
+ <div class="mb-3">
14
+ <label for="name" class="form-label">Nom:</label>
15
+ <input type="text" class="form-control" id="name" name="name" value="{{ category.name }}" required>
16
+ </div>
17
+
18
+ <div class="mb-3">
19
+ <label for="subject_id" class="form-label">Sujet:</label>
20
+ <select class="form-select" id="subject_id" name="subject_id" required>
21
+ {% for subject in subjects %}
22
+ <option value="{{ subject.id }}" {% if subject.id == category.subject_id %}selected{% endif %}>{{ subject.name }}</option>
23
+ {% endfor %}
24
+ </select>
25
+ </div>
26
+
27
+ <button type="submit" class="btn btn-primary">Sauvegarder</button>
28
+ </form>
29
+ </div>
30
+ </div>
31
+ {% endblock %}
templates/admin_edit_subject.html CHANGED
@@ -1,30 +1,36 @@
1
- <!DOCTYPE html>
2
- <html lang="fr">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>Éditer le Sujet</title>
6
- <style>
7
- body { font-family: Arial, sans-serif; margin: 20px; }
8
- form { max-width: 800px; }
9
- label { display: block; margin-top: 10px; }
10
- input, button { width: 100%; padding: 10px; margin-top: 5px; }
11
- </style>
12
- </head>
13
- <body>
14
- <h1>Éditer le Sujet</h1>
15
- <a href="{{ url_for('admin_subjects') }}">Retour aux sujets</a>
16
- <form method="post" enctype="multipart/form-data">
17
- <label for="name">Nom:</label>
18
- <input type="text" id="name" name="name" value="{{ subject.name }}" required>
19
-
20
- {% if subject.icon_data %}
21
- <p>Icône actuelle: <img src="{{ url_for('get_subject_icon', subject_id=subject.id) }}" style="max-width: 50px; max-height: 50px;"></p>
22
- {% endif %}
23
-
24
- <label for="icon">Nouvelle icône (fichier image):</label>
25
- <input type="file" id="icon" name="icon" accept="image/*">
26
-
27
- <button type="submit">Sauvegarder</button>
28
- </form>
29
- </body>
30
- </html>
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Éditer le Sujet - {{ super() }}{% endblock %}
4
+
5
+ {% block content %}
6
+ <h2 class="mb-4">Éditer le Sujet</h2>
7
+ <div class="mb-4">
8
+ <a href="{{ url_for('admin_subjects') }}" class="btn btn-secondary">Retour aux sujets</a>
9
+ </div>
10
+ <div class="card">
11
+ <div class="card-body">
12
+ <form method="post" enctype="multipart/form-data">
13
+ <div class="mb-3">
14
+ <label for="name" class="form-label">Nom:</label>
15
+ <input type="text" class="form-control" id="name" name="name" value="{{ subject.name }}" required>
16
+ </div>
17
+
18
+ {% if subject.icon_data %}
19
+ <div class="mb-3">
20
+ <label class="form-label">Icône actuelle:</label>
21
+ <div>
22
+ <img src="{{ url_for('get_subject_icon', subject_id=subject.id) }}" alt="Icône actuelle" class="img-thumbnail" style="max-width: 100px; max-height: 100px;">
23
+ </div>
24
+ </div>
25
+ {% endif %}
26
+
27
+ <div class="mb-3">
28
+ <label for="icon" class="form-label">Nouvelle icône (fichier image):</label>
29
+ <input type="file" class="form-control" id="icon" name="icon" accept="image/*">
30
+ </div>
31
+
32
+ <button type="submit" class="btn btn-primary">Sauvegarder</button>
33
+ </form>
34
+ </div>
35
+ </div>
36
+ {% endblock %}
templates/admin_new_article.html CHANGED
@@ -1,57 +1,436 @@
1
- <!DOCTYPE html>
2
- <html lang="fr">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>Nouvel Article</title>
6
- <link rel="stylesheet" type="text/css" href="https://unpkg.com/trix@2.0.8/dist/trix.css">
7
- <script type="text/javascript" src="https://unpkg.com/trix@2.0.8/dist/trix.umd.min.js"></script>
8
- <style>
9
- body { font-family: Arial, sans-serif; margin: 20px; }
10
- form { max-width: 900px; margin: 0 auto; }
11
- label { display: block; margin-top: 10px; }
12
- input, select, button { width: 100%; padding: 10px; margin-top: 5px; box-sizing: border-box; }
13
- trix-editor { border: 1px solid #e9ecef; min-height: 300px; border-radius: 8px; }
14
- #editor-preview { margin-top: 1rem; }
15
- .article-preview { background: #fff; padding: 1rem; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.06); }
16
- .article-preview h1 { font-size: 1.4rem; margin-top: 0; }
17
- </style>
18
- </head>
19
- <body>
20
- <h1>Nouvel Article</h1>
21
- <a href="{{ url_for('admin_articles') }}">Retour aux articles</a>
22
- <form method="post" enctype="multipart/form-data">
23
- <label for="title">Titre:</label>
24
- <input type="text" id="title" name="title" required>
25
-
26
- <label for="category_id">Catégorie:</label>
27
- <select id="category_id" name="category_id" required>
28
- {% for category in categories %}
29
- <option value="{{ category.id }}">{{ category.name }}</option>
30
- {% endfor %}
31
- </select>
32
-
33
- <label for="icon">Image (fichier image):</label>
34
- <input type="file" id="icon" name="icon" accept="image/*">
35
-
36
- <label for="youtube_url">Lien YouTube (optionnel) :</label>
37
- <input type="url" id="youtube_url" name="youtube_url" placeholder="https://www.youtube.com/watch?v=...">
38
-
39
- <label for="content">Contenu:</label>
40
- <input id="content" type="hidden" name="content">
41
- <trix-editor input="content"></trix-editor>
42
-
43
- <p style="font-size:0.9rem;color:#555;margin-top:8px;">Pour insérer une vidéo à un emplacement précis, utilisez le shortcode : <code>[youtube:URL]</code> (ex. <code>[youtube:https://www.youtube.com/watch?v=dQw4w9WgXcQ]</code>).</p>
44
- <p style="font-size:0.9rem;color:#555;margin-top:6px;">Pour insérer une image à un emplacement précis, utilisez le shortcode : <code>[image:URL|opts]</code> où <code>opts</code> peut contenir <code>left</code>, <code>right</code>, <code>center</code>, <code>width=300</code>, <code>height=200</code>. Exemples :</p>
45
- <ul style="color:#555;margin-top:0;margin-bottom:1rem;">
46
- <li><code>[image:https://.../img.jpg|left|width=200]</code></li>
47
- <li><code>[image:https://.../img.jpg|center|width=80%]</code></li>
48
- </ul>
49
-
50
- <button type="submit">Créer</button>
51
- </form>
52
-
53
- <h3>Aperçu</h3>
54
- <div id="editor-preview" class="article-preview"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
 
56
  <script>
57
  // Configure Trix toolbar and allowed tags before initialization
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Nouvel Article - {{ super() }}{% endblock %}
4
+
5
+ {% block extra_head %}
6
+ <link rel="stylesheet" type="text/css" href="https://unpkg.com/trix@2.0.8/dist/trix.css">
7
+ <style>
8
+ /* Admin Editor Styles */
9
+ .admin-editor {
10
+ max-width: 1000px;
11
+ margin: 0 auto;
12
+ padding: var(--spacing-lg);
13
+ background: var(--bg-card);
14
+ border-radius: 8px;
15
+ box-shadow: var(--shadow-md);
16
+ }
17
+
18
+ .editor-header {
19
+ display: flex;
20
+ align-items: center;
21
+ justify-content: space-between;
22
+ margin-bottom: var(--spacing-lg);
23
+ flex-wrap: wrap;
24
+ gap: var(--spacing-md);
25
+ }
26
+
27
+ .editor-title {
28
+ margin: 0;
29
+ font-size: 1.5rem;
30
+ font-weight: 600;
31
+ background: linear-gradient(135deg, var(--primary-1), var(--accent-1));
32
+ -webkit-background-clip: text;
33
+ background-clip: text;
34
+ -webkit-text-fill-color: transparent;
35
+ }
36
+
37
+ .form-group {
38
+ margin-bottom: var(--spacing-md);
39
+ }
40
+
41
+ .form-group label {
42
+ display: block;
43
+ margin-bottom: var(--spacing-xs);
44
+ color: var(--text);
45
+ font-weight: 500;
46
+ }
47
+
48
+ .form-control {
49
+ width: 100%;
50
+ padding: 0.75rem 1rem;
51
+ border: 1px solid var(--border-color);
52
+ border-radius: 8px;
53
+ background: var(--bg);
54
+ color: var(--text);
55
+ transition: all var(--transition-fast);
56
+ }
57
+
58
+ .form-control:focus {
59
+ outline: none;
60
+ border-color: var(--primary-1);
61
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
62
+ }
63
+
64
+ trix-editor {
65
+ min-height: 300px;
66
+ border: 1px solid var(--border-color);
67
+ border-radius: 8px;
68
+ background: var(--bg);
69
+ color: var(--text);
70
+ padding: var(--spacing-md);
71
+ margin-bottom: var(--spacing-lg);
72
+ }
73
+
74
+ trix-editor:focus {
75
+ outline: none;
76
+ border-color: var(--primary-1);
77
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
78
+ }
79
+
80
+ .preview-section {
81
+ margin-top: var(--spacing-xl);
82
+ padding: var(--spacing-lg);
83
+ background: var(--bg-alt);
84
+ border-radius: 8px;
85
+ }
86
+
87
+ .preview-header {
88
+ margin-bottom: var(--spacing-md);
89
+ padding-bottom: var(--spacing-sm);
90
+ border-bottom: 1px solid var(--border-color);
91
+ }
92
+
93
+ .preview-content {
94
+ background: var(--bg);
95
+ padding: var(--spacing-lg);
96
+ border-radius: 8px;
97
+ box-shadow: var(--shadow-sm);
98
+ }
99
+
100
+ /* Custom file input */
101
+ .file-input-wrapper {
102
+ position: relative;
103
+ }
104
+
105
+ .file-input-wrapper input[type="file"] {
106
+ opacity: 0;
107
+ position: absolute;
108
+ top: 0;
109
+ left: 0;
110
+ width: 100%;
111
+ height: 100%;
112
+ cursor: pointer;
113
+ }
114
+
115
+ .file-input-trigger {
116
+ display: block;
117
+ padding: 0.75rem 1rem;
118
+ border: 2px dashed var(--border-color);
119
+ border-radius: 8px;
120
+ text-align: center;
121
+ color: var(--text-light);
122
+ transition: all var(--transition-fast);
123
+ }
124
+
125
+ .file-input-wrapper:hover .file-input-trigger {
126
+ border-color: var(--primary-1);
127
+ color: var(--primary-1);
128
+ }
129
+ </style>
130
+ {% endblock %}
131
+
132
+ {% block content %}
133
+ <div class="admin-editor">
134
+ <div class="editor-header">
135
+ <h1 class="editor-title">Nouvel Article</h1>
136
+ <a href="{{ url_for('admin_articles') }}" class="btn btn-secondary">← Retour aux articles</a>
137
+ </div>
138
+ <form method="post" enctype="multipart/form-data">
139
+ <div class="form-group">
140
+ <label for="title">Titre:</label>
141
+ <input type="text" class="form-control" id="title" name="title" required>
142
+ </div>
143
+
144
+ <div class="form-group">
145
+ <label for="category_id">Catégorie:</label>
146
+ <select class="form-control" id="category_id" name="category_id" required>
147
+ {% for category in categories %}
148
+ <option value="{{ category.id }}">{{ category.name }}</option>
149
+ {% endfor %}
150
+ </select>
151
+ </div>
152
+
153
+ <div class="form-group">
154
+ <label for="icon">Image:</label>
155
+ <div class="file-input-wrapper">
156
+ <div class="file-input-trigger">
157
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="upload-icon">
158
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
159
+ <polyline points="17 8 12 3 7 8"/>
160
+ <line x1="12" y1="3" x2="12" y2="15"/>
161
+ </svg>
162
+ <span>Glissez une image ou cliquez pour sélectionner</span>
163
+ </div>
164
+ <input type="file" id="icon" name="icon" accept="image/*">
165
+ </div>
166
+ </div>
167
+
168
+ <div class="form-group">
169
+ <label for="youtube_url">Lien YouTube (optionnel) :</label>
170
+ <input type="url" class="form-control" id="youtube_url" name="youtube_url"
171
+ placeholder="https://www.youtube.com/watch?v=...">
172
+ </div>
173
+
174
+ <div class="form-group">
175
+ <label for="content">Contenu:</label>
176
+ <input id="content" type="hidden" name="content">
177
+ <trix-editor input="content"></trix-editor>
178
+ </div>
179
+
180
+ <div class="form-group">
181
+ <button type="submit" class="btn btn-primary">Créer l'article</button>
182
+ </div>
183
+ </form>
184
+
185
+ <div class="preview-section">
186
+ <div class="preview-header">
187
+ <h3>Aperçu en direct</h3>
188
+ </div>
189
+ <div id="editor-preview" class="preview-content"></div>
190
+ </div>
191
+
192
+ </div>
193
+ {% endblock %}
194
+
195
+ {% block extra_scripts %}
196
+ <script type="text/javascript" src="https://unpkg.com/trix@2.0.8/dist/trix.umd.min.js"></script>
197
+ <script>
198
+ // Configure Trix before init
199
+ document.addEventListener('trix-before-initialize', function() {
200
+ if (window.Trix && Trix.config && Trix.config.dompurify) {
201
+ Trix.config.dompurify.ADD_TAGS = ["iframe", "video", "source", "table", "tr", "td", "th", "img"];
202
+ Trix.config.dompurify.ADD_ATTR = ["src", "controls", "width", "height", "class", "style", "allowfullscreen"];
203
+ }
204
+ Trix.config.toolbar.getDefaultHTML = function() {
205
+ return `
206
+ <div class="trix-button-row">
207
+ <span class="trix-button-group trix-button-group--text-tools" data-trix-button-group="text-tools">
208
+ <button type="button" class="trix-button trix-button--icon trix-button--icon-bold" data-trix-attribute="bold" title="Gras">Gras</button>
209
+ <button type="button" class="trix-button trix-button--icon trix-button--icon-italic" data-trix-attribute="italic" title="Italique">Italique</button>
210
+ <button type="button" class="trix-button" data-trix-action="x-heading" title="Titre">H</button>
211
+ <button type="button" class="trix-button" data-trix-action="x-quote" title="Citation">"</button>
212
+ <button type="button" class="trix-button trix-button--icon trix-button--icon-link" data-trix-attribute="href" title="Lien">Lien</button>
213
+ </span>
214
+ <span class="trix-button-group">
215
+ <button type="button" class="trix-button" data-trix-attribute="textAlign" data-trix-value="left" title="Aligner à gauche">←</button>
216
+ <button type="button" class="trix-button" data-trix-attribute="textAlign" data-trix-value="center" title="Centrer">⫸</button>
217
+ <button type="button" class="trix-button" data-trix-attribute="textAlign" data-trix-value="right" title="Aligner à droite">→</button>
218
+ </span>
219
+ </div>`;
220
+ };
221
+ });
222
+
223
+ function renderShortcodesForPreview(html) {
224
+ if (!html) return '';
225
+ html = html.replace(/\[youtube:([^\]]+)\]/g, function(_, url) {
226
+ try {
227
+ var parsed = new URL(url.trim());
228
+ var id = null;
229
+ if (parsed.hostname.includes('youtu.be')) id = parsed.pathname.slice(1);
230
+ else if (parsed.hostname.includes('youtube')) id = parsed.searchParams.get('v') || parsed.pathname.split('/').pop();
231
+ if (!id) return '<pre>' + url + '</pre>';
232
+ return '<div class="video-player"><iframe src="https://www.youtube.com/embed/' + id + '" allowfullscreen></iframe></div>';
233
+ } catch (e) {
234
+ return '<pre>' + url + '</pre>'
235
+ }
236
+ });
237
+ html = html.replace(/\[image:([^\]|]+)(?:\|([^\]]+))?\]/g, function(_, url, opts) {
238
+ var classes = '';
239
+ var style = '';
240
+ if (opts) {
241
+ opts.split(/[|,]/).forEach(function(o) {
242
+ o = o.trim();
243
+ if (/^(left|right|center)$/.test(o)) {
244
+ if (o === 'left') classes = 'float-left';
245
+ if (o === 'right') classes = 'float-right';
246
+ if (o === 'center') style = 'display:block;margin-left:auto;margin-right:auto';
247
+ } else if (/^width=/.test(o)) {
248
+ style += (style ? ';' : '') + 'width:' + o.split('=')[1];
249
+ } else if (/^height=/.test(o)) {
250
+ style += (style ? ';' : '') + 'height:' + o.split('=')[1];
251
+ }
252
+ });
253
+ }
254
+ return '<img src="' + url.trim() + '" class="' + classes + '" style="' + style + '"/>';
255
+ });
256
+ return html;
257
+ }
258
+
259
+ function uploadFileToServer(fileBlob, filename, attachment) {
260
+ var formData = new FormData();
261
+ formData.append('file', fileBlob, filename);
262
+ fetch('/upload', {
263
+ method: 'POST',
264
+ body: formData
265
+ }).then(function(r) {
266
+ return r.json()
267
+ }).then(function(data) {
268
+ if (data.url) attachment.setAttributes({
269
+ url: data.url
270
+ });
271
+ }).catch(function(e) {
272
+ console.error('Upload failed', e)
273
+ });
274
+ }
275
+
276
+ // File upload and image resize handling
277
+ document.addEventListener('trix-attachment-add', function(event) {
278
+ var attachment = event.attachment;
279
+ var file = attachment.file;
280
+ if (!file) return;
281
+ if (!file.type.startsWith('image/')) {
282
+ uploadFileToServer(file, file.name, attachment);
283
+ return;
284
+ }
285
+
286
+ // Create modal for image resize
287
+ const modal = document.createElement('div');
288
+ modal.style.cssText = `
289
+ position: fixed;
290
+ top: 0;
291
+ left: 0;
292
+ right: 0;
293
+ bottom: 0;
294
+ background: rgba(0,0,0,0.5);
295
+ display: flex;
296
+ align-items: center;
297
+ justify-content: center;
298
+ z-index: 9999;
299
+ `;
300
+
301
+ const modalContent = document.createElement('div');
302
+ modalContent.style.cssText = `
303
+ background: var(--bg);
304
+ padding: 2rem;
305
+ border-radius: var(--card-radius);
306
+ box-shadow: var(--shadow-lg);
307
+ max-width: 400px;
308
+ width: 90%;
309
+ `;
310
+
311
+ modalContent.innerHTML = `
312
+ <h3 style="margin-top:0">Redimensionner l'image ?</h3>
313
+ <p>Entrez la largeur souhaitée :</p>
314
+ <input type="text" placeholder="800px ou 80%" style="width:100%;padding:0.5rem;margin:1rem 0;border-radius:var(--btn-radius);border:1px solid var(--border-color)">
315
+ <div style="display:flex;gap:1rem;justify-content:flex-end">
316
+ <button class="btn btn-secondary cancel">Annuler</button>
317
+ <button class="btn btn-primary confirm">Confirmer</button>
318
+ </div>
319
+ `;
320
+
321
+ modal.appendChild(modalContent);
322
+ document.body.appendChild(modal);
323
+
324
+ const input = modalContent.querySelector('input');
325
+ const cancelBtn = modalContent.querySelector('.cancel');
326
+ const confirmBtn = modalContent.querySelector('.confirm');
327
+
328
+ cancelBtn.onclick = () => {
329
+ document.body.removeChild(modal);
330
+ };
331
+
332
+ confirmBtn.onclick = () => {
333
+ const choice = input.value;
334
+ document.body.removeChild(modal);
335
+
336
+ if (!choice) {
337
+ uploadFileToServer(file, file.name, attachment);
338
+ return;
339
+ }
340
+
341
+ const reader = new FileReader();
342
+ reader.onload = function(e) {
343
+ const img = new Image();
344
+ img.onload = function() {
345
+ const origW = img.naturalWidth,
346
+ origH = img.naturalHeight;
347
+ let targetW = origW;
348
+ const c = choice.toString().trim();
349
+
350
+ if (c.endsWith('%')) {
351
+ const p = parseFloat(c.replace('%', ''));
352
+ if (!isNaN(p)) targetW = Math.round(origW * p / 100);
353
+ } else {
354
+ const px = parseInt(c, 10);
355
+ if (!isNaN(px)) targetW = px;
356
+ }
357
+
358
+ if (targetW <= 0 || targetW === origW) {
359
+ uploadFileToServer(file, file.name, attachment);
360
+ return;
361
+ }
362
+
363
+ const scale = targetW / origW,
364
+ targetH = Math.round(origH * scale);
365
+ const canvas = document.createElement('canvas');
366
+ canvas.width = targetW;
367
+ canvas.height = targetH;
368
+ const ctx = canvas.getContext('2d');
369
+ ctx.drawImage(img, 0, 0, targetW, targetH);
370
+ canvas.toBlob(function(blob) {
371
+ uploadFileToServer(blob, file.name, attachment);
372
+ }, file.type || 'image/jpeg', 0.92);
373
+ };
374
+ img.src = e.target.result;
375
+ };
376
+ reader.readAsDataURL(file);
377
+ };
378
+ });
379
+
380
+
381
+ // Live preview updates
382
+ function updatePreview() {
383
+ var contentInput = document.querySelector('input[name="content"]');
384
+ var preview = document.getElementById('editor-preview');
385
+ if (preview && contentInput) {
386
+ preview.innerHTML = renderShortcodesForPreview(contentInput.value);
387
+ }
388
+ }
389
+
390
+ document.addEventListener('trix-change', updatePreview);
391
+ document.addEventListener('DOMContentLoaded', updatePreview);
392
+
393
+ // File input visual feedback
394
+ document.querySelector('.file-input-wrapper input[type="file"]').addEventListener('change', function(e) {
395
+ const trigger = this.parentElement.querySelector('.file-input-trigger');
396
+ if (this.files.length > 0) {
397
+ trigger.textContent = `Fichier sélectionné : ${this.files[0].name}`;
398
+ }
399
+ });
400
+
401
+ // Keyboard shortcuts for shortcodes
402
+ document.addEventListener('keydown', function(e) {
403
+ // Ctrl+Y for YouTube shortcode
404
+ if (e.ctrlKey && e.key === 'y') {
405
+ e.preventDefault();
406
+ const editor = document.querySelector('trix-editor');
407
+ if (editor && editor.editor) {
408
+ const position = editor.editor.getSelectedRange();
409
+ editor.editor.setSelectedRange(position);
410
+ editor.editor.insertString('[youtube:]');
411
+ // Position cursor inside the brackets
412
+ const newPosition = position[0] + '[youtube:'.length;
413
+ editor.editor.setSelectedRange([newPosition, newPosition]);
414
+ }
415
+ }
416
+
417
+ // Ctrl+I for responsive image shortcode
418
+ if (e.ctrlKey && e.key === 'i') {
419
+ e.preventDefault();
420
+ const editor = document.querySelector('trix-editor');
421
+ if (editor && editor.editor) {
422
+ const position = editor.editor.getSelectedRange();
423
+ editor.editor.setSelectedRange(position);
424
+ editor.editor.insertString('[image:URL|width=100%]');
425
+ // Select "URL" for easy replacement
426
+ const urlStart = position[0] + '[image:'.length;
427
+ const urlEnd = urlStart + 3; // "URL".length
428
+ editor.editor.setSelectedRange([urlStart, urlEnd]);
429
+ }
430
+ }
431
+ });
432
+ </script>
433
+ {% endblock %}
434
 
435
  <script>
436
  // Configure Trix toolbar and allowed tags before initialization
templates/admin_new_category.html CHANGED
@@ -1,30 +1,31 @@
1
- <!DOCTYPE html>
2
- <html lang="fr">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>Nouvelle Catégorie</title>
6
- <style>
7
- body { font-family: Arial, sans-serif; margin: 20px; }
8
- form { max-width: 800px; }
9
- label { display: block; margin-top: 10px; }
10
- input, select, button { width: 100%; padding: 10px; margin-top: 5px; }
11
- </style>
12
- </head>
13
- <body>
14
- <h1>Nouvelle Catégorie</h1>
15
- <a href="{{ url_for('admin_categories') }}">Retour aux catégories</a>
16
- <form method="post">
17
- <label for="name">Nom:</label>
18
- <input type="text" id="name" name="name" required>
19
-
20
- <label for="subject_id">Sujet:</label>
21
- <select id="subject_id" name="subject_id" required>
22
- {% for subject in subjects %}
23
- <option value="{{ subject.id }}">{{ subject.name }}</option>
24
- {% endfor %}
25
- </select>
26
-
27
- <button type="submit">Créer</button>
28
- </form>
29
- </body>
30
- </html>
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Nouvelle Catégorie - {{ super() }}{% endblock %}
4
+
5
+ {% block content %}
6
+ <h2 class="mb-4">Nouvelle Catégorie</h2>
7
+ <div class="mb-4">
8
+ <a href="{{ url_for('admin_categories') }}" class="btn btn-secondary">Retour aux catégories</a>
9
+ </div>
10
+ <div class="card">
11
+ <div class="card-body">
12
+ <form method="post">
13
+ <div class="mb-3">
14
+ <label for="name" class="form-label">Nom:</label>
15
+ <input type="text" class="form-control" id="name" name="name" required>
16
+ </div>
17
+
18
+ <div class="mb-3">
19
+ <label for="subject_id" class="form-label">Sujet:</label>
20
+ <select class="form-select" id="subject_id" name="subject_id" required>
21
+ {% for subject in subjects %}
22
+ <option value="{{ subject.id }}">{{ subject.name }}</option>
23
+ {% endfor %}
24
+ </select>
25
+ </div>
26
+
27
+ <button type="submit" class="btn btn-primary">Créer</button>
28
+ </form>
29
+ </div>
30
+ </div>
31
+ {% endblock %}
templates/admin_new_subject.html CHANGED
@@ -1,26 +1,27 @@
1
- <!DOCTYPE html>
2
- <html lang="fr">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>Nouveau Sujet</title>
6
- <style>
7
- body { font-family: Arial, sans-serif; margin: 20px; }
8
- form { max-width: 800px; }
9
- label { display: block; margin-top: 10px; }
10
- input, button { width: 100%; padding: 10px; margin-top: 5px; }
11
- </style>
12
- </head>
13
- <body>
14
- <h1>Nouveau Sujet</h1>
15
- <a href="{{ url_for('admin_subjects') }}">Retour aux sujets</a>
16
- <form method="post" enctype="multipart/form-data">
17
- <label for="name">Nom:</label>
18
- <input type="text" id="name" name="name" required>
19
-
20
- <label for="icon">Icône (fichier image):</label>
21
- <input type="file" id="icon" name="icon" accept="image/*">
22
-
23
- <button type="submit">Créer</button>
24
- </form>
25
- </body>
26
- </html>
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Nouveau Sujet - {{ super() }}{% endblock %}
4
+
5
+ {% block content %}
6
+ <h2 class="mb-4">Nouveau Sujet</h2>
7
+ <div class="mb-4">
8
+ <a href="{{ url_for('admin_subjects') }}" class="btn btn-secondary">Retour aux sujets</a>
9
+ </div>
10
+ <div class="card">
11
+ <div class="card-body">
12
+ <form method="post" enctype="multipart/form-data">
13
+ <div class="mb-3">
14
+ <label for="name" class="form-label">Nom:</label>
15
+ <input type="text" class="form-control" id="name" name="name" required>
16
+ </div>
17
+
18
+ <div class="mb-3">
19
+ <label for="icon" class="form-label">Icône (fichier image):</label>
20
+ <input type="file" class="form-control" id="icon" name="icon" accept="image/*">
21
+ </div>
22
+
23
+ <button type="submit" class="btn btn-primary">Créer</button>
24
+ </form>
25
+ </div>
26
+ </div>
27
+ {% endblock %}
templates/admin_subjects.html CHANGED
@@ -1,48 +1,46 @@
1
- <!DOCTYPE html>
2
- <html lang="fr">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>Gérer les Sujets</title>
6
- <style>
7
- body { font-family: Arial, sans-serif; margin: 20px; }
8
- table { width: 100%; border-collapse: collapse; }
9
- th, td { border: 1px solid #ccc; padding: 10px; text-align: left; }
10
- .actions a { margin-right: 10px; }
11
- </style>
12
- </head>
13
- <body>
14
- <h1>Gérer les Sujets</h1>
15
- <a href="{{ url_for('admin_home') }}">Retour à l'admin</a> | <a href="{{ url_for('admin_new_subject') }}">Nouveau Sujet</a>
16
- <table>
17
- <thead>
18
- <tr>
19
- <th>ID</th>
20
- <th>Nom</th>
21
- <th>Icône</th>
22
- <th>Actions</th>
23
- </tr>
24
- </thead>
25
- <tbody>
26
- {% for subject in subjects %}
27
- <tr>
28
- <td>{{ subject.id }}</td>
29
- <td>{{ subject.name }}</td>
30
- <td>
31
- {% if subject.icon_data %}
32
- <img src="{{ url_for('get_subject_icon', subject_id=subject.id) }}" style="max-width: 50px; max-height: 50px;">
33
- {% else %}
34
- Aucune icône
35
- {% endif %}
36
- </td>
37
- <td class="actions">
38
- <a href="{{ url_for('admin_edit_subject', subject_id=subject.id) }}">Éditer</a>
39
- <form method="post" action="{{ url_for('admin_delete_subject', subject_id=subject.id) }}" style="display:inline;">
40
- <button type="submit" onclick="return confirm('Êtes-vous sûr de vouloir supprimer ce sujet ?')">Supprimer</button>
41
- </form>
42
- </td>
43
- </tr>
44
- {% endfor %}
45
- </tbody>
46
- </table>
47
- </body>
48
- </html>
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Gérer les Sujets - {{ super() }}{% endblock %}
4
+
5
+ {% block content %}
6
+ <h2 class="mb-4">Gérer les Sujets</h2>
7
+ <div class="d-flex gap-2 mb-4">
8
+ <a href="{{ url_for('admin_home') }}" class="btn btn-secondary">Retour à l'admin</a>
9
+ <a href="{{ url_for('admin_new_subject') }}" class="btn btn-primary">Nouveau Sujet</a>
10
+ </div>
11
+ <div class="table-responsive">
12
+ <table class="table table-striped table-hover">
13
+ <thead class="table-dark">
14
+ <tr>
15
+ <th>ID</th>
16
+ <th>Nom</th>
17
+ <th>Icône</th>
18
+ <th>Actions</th>
19
+ </tr>
20
+ </thead>
21
+ <tbody>
22
+ {% for subject in subjects %}
23
+ <tr>
24
+ <td>{{ subject.id }}</td>
25
+ <td>{{ subject.name }}</td>
26
+ <td>
27
+ {% if subject.icon_data %}
28
+ <img src="{{ url_for('get_subject_icon', subject_id=subject.id) }}" alt="Icône" class="img-thumbnail" style="max-width: 50px; max-height: 50px;">
29
+ {% else %}
30
+ <span class="text-muted">Aucune icône</span>
31
+ {% endif %}
32
+ </td>
33
+ <td>
34
+ <div class="btn-group" role="group">
35
+ <a href="{{ url_for('admin_edit_subject', subject_id=subject.id) }}" class="btn btn-sm btn-outline-primary">Éditer</a>
36
+ <form method="post" action="{{ url_for('admin_delete_subject', subject_id=subject.id) }}" style="display:inline;" onsubmit="return confirm('Êtes-vous sûr de vouloir supprimer ce sujet ?')">
37
+ <button type="submit" class="btn btn-sm btn-outline-danger">Supprimer</button>
38
+ </form>
39
+ </div>
40
+ </td>
41
+ </tr>
42
+ {% endfor %}
43
+ </tbody>
44
+ </table>
45
+ </div>
46
+ {% endblock %}
 
 
templates/base.html CHANGED
@@ -34,7 +34,7 @@
34
  <div class="container">
35
  <ul class="nav nav-links">
36
  <li class="nav-item">
37
- <a class="nav-link" href="#" title="Aller vers mariam-241" rel="noopener" aria-label="Aller vers mariam-241">
38
  <!-- Home icon SVG -->
39
  <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
40
  <path d="M3 9.5L12 3l9 6.5V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1V9.5z" />
 
34
  <div class="container">
35
  <ul class="nav nav-links">
36
  <li class="nav-item">
37
+ <a class="nav-link" href="https://mariam-241.vercel.app" title="Aller vers mariam-241" rel="noopener" aria-label="Aller vers mariam-241">
38
  <!-- Home icon SVG -->
39
  <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
40
  <path d="M3 9.5L12 3l9 6.5V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1V9.5z" />