diff --git a/courses/admin.py b/courses/admin.py
index 0840729..b34bc19 100644
--- a/courses/admin.py
+++ b/courses/admin.py
@@ -1,16 +1,56 @@
from django.contrib import admin
-from .models import *
+from .models import Course, Module, Lesson, Comment
-# Register your models here.
-class CategoriesAdmin(admin.ModelAdmin):
- fieldsets = [
- ('Nom', {'fields': ['name']}),
- ('Description', {'fields': ['content']}),
- ('Date de cr"ation', {'fields': ['created_at']}),
- ]
- list_display = ('name', 'content', 'type')
- list_filter = ['type', 'name']
- search_fields = ['name', 'content']
-admin.site.register(Course)
-admin.site.register(Lesson)
\ No newline at end of file
+class LessonInline(admin.TabularInline):
+ model = Lesson
+ extra = 0
+ fields = ("name", "slug", "is_premium", "order")
+ ordering = ("order",)
+ prepopulated_fields = {"slug": ("name",)}
+
+
+class ModuleInline(admin.TabularInline):
+ model = Module
+ extra = 0
+ fields = ("name", "slug", "enable", "order")
+ ordering = ("order",)
+ prepopulated_fields = {"slug": ("name",)}
+
+
+@admin.register(Course)
+class CourseAdmin(admin.ModelAdmin):
+ list_display = ("name", "slug", "author", "enable", "created_at", "updated_at")
+ list_filter = ("enable", "author", "created_at", "updated_at")
+ search_fields = ("name", "tags", "description", "author__username")
+ prepopulated_fields = {"slug": ("name",)}
+ readonly_fields = ("created_at", "updated_at")
+ inlines = [ModuleInline]
+
+
+@admin.register(Module)
+class ModuleAdmin(admin.ModelAdmin):
+ list_display = ("name", "slug", "course", "enable", "order", "created_at", "updated_at")
+ list_filter = ("enable", "course", "created_at", "updated_at")
+ search_fields = ("name", "description", "course__name")
+ prepopulated_fields = {"slug": ("name",)}
+ readonly_fields = ("created_at", "updated_at")
+ ordering = ("course", "order")
+ inlines = [LessonInline]
+
+
+@admin.register(Lesson)
+class LessonAdmin(admin.ModelAdmin):
+ list_display = ("name", "slug", "module", "is_premium", "order")
+ list_filter = ("is_premium", "module")
+ search_fields = ("name", "content", "module__name")
+ prepopulated_fields = {"slug": ("name",)}
+ ordering = ("module", "order")
+
+
+@admin.register(Comment)
+class CommentAdmin(admin.ModelAdmin):
+ list_display = ("lesson", "user", "created_at", "is_active")
+ list_filter = ("is_active", "created_at")
+ search_fields = ("content", "user__username", "lesson__name")
+ readonly_fields = ("created_at", "updated_at")
\ No newline at end of file
diff --git a/courses/forms.py b/courses/forms.py
new file mode 100644
index 0000000..ee2c66d
--- /dev/null
+++ b/courses/forms.py
@@ -0,0 +1,13 @@
+from django import forms
+
+
+class CommentForm(forms.Form):
+ content = forms.CharField(
+ label="",
+ widget=forms.Textarea(attrs={
+ "rows": 3,
+ "placeholder": "Écrire un commentaire…",
+ }),
+ max_length=5000,
+ )
+ parent = forms.IntegerField(required=False, widget=forms.HiddenInput())
diff --git a/courses/migrations/0002_comment.py b/courses/migrations/0002_comment.py
new file mode 100644
index 0000000..c552f67
--- /dev/null
+++ b/courses/migrations/0002_comment.py
@@ -0,0 +1,31 @@
+# Generated by Django 6.0 on 2025-12-10 20:35
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('courses', '0001_initial'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Comment',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('content', models.TextField()),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('is_active', models.BooleanField(default=True)),
+ ('lesson', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='courses.lesson')),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lesson_comments', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'ordering': ('created_at',),
+ },
+ ),
+ ]
diff --git a/courses/migrations/0003_comment_parent.py b/courses/migrations/0003_comment_parent.py
new file mode 100644
index 0000000..cfbe40a
--- /dev/null
+++ b/courses/migrations/0003_comment_parent.py
@@ -0,0 +1,19 @@
+# Generated by Django 6.0 on 2025-12-10 22:15
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('courses', '0002_comment'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='comment',
+ name='parent',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='courses.comment'),
+ ),
+ ]
diff --git a/courses/models.py b/courses/models.py
index 83ced4d..1c5a096 100644
--- a/courses/models.py
+++ b/courses/models.py
@@ -41,4 +41,20 @@ class Lesson(models.Model):
self.content = self.content.replace('?>', '?>')
def __str__(self):
- return self.name
\ No newline at end of file
+ return self.name
+
+
+class Comment(models.Model):
+ lesson = models.ForeignKey(Lesson, on_delete=models.CASCADE, related_name='comments')
+ user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='lesson_comments')
+ content = models.TextField()
+ parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='replies')
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+ is_active = models.BooleanField(default=True)
+
+ class Meta:
+ ordering = ("created_at",)
+
+ def __str__(self):
+ return f"Comment by {self.user} on {self.lesson}"
\ No newline at end of file
diff --git a/courses/templatetags/__init__.py b/courses/templatetags/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/courses/templatetags/comment_format.py b/courses/templatetags/comment_format.py
new file mode 100644
index 0000000..2fbfd7e
--- /dev/null
+++ b/courses/templatetags/comment_format.py
@@ -0,0 +1,108 @@
+from django import template
+from django.utils.html import conditional_escape, escape
+from django.utils.safestring import mark_safe
+import re
+
+register = template.Library()
+
+
+def _link_repl(match):
+ text = match.group(1)
+ url = match.group(2)
+ # Allow only http/https links
+ if not re.match(r"^(https?://)", url, re.IGNORECASE):
+ # The whole input has already been HTML-escaped upstream.
+ return text
+ return (
+ f'{text}'
+ )
+
+
+def _format_inline(text: str) -> str:
+ # code spans first
+ text = re.sub(
+ r"`([^`]+)`",
+ # Content is already HTML-escaped by conditional_escape; avoid double-escaping
+ lambda m: f"{m.group(1)}",
+ text,
+ )
+ # bold **text**
+ text = re.sub(
+ r"\*\*([^*]+)\*\*",
+ lambda m: f"{m.group(1)}",
+ text,
+ )
+ # italic *text* or _text_
+ text = re.sub(
+ r"(?{m.group(1)}",
+ text,
+ )
+ text = re.sub(
+ r"_([^_]+)_",
+ lambda m: f"{m.group(1)}",
+ text,
+ )
+ # links [text](url)
+ text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", _link_repl, text)
+ return text
+
+
+def _format_lists(lines):
+ html_lines = []
+ in_ul = False
+ for line in lines:
+ m = re.match(r"^\s*[-*]\s+(.+)$", line)
+ if m:
+ if not in_ul:
+ html_lines.append("
.
+ All HTML is escaped.
+ """
+ if not value:
+ return ""
+
+ text = str(value)
+
+ # Normalize newlines
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
+
+ # Escape HTML entirely first
+ text = conditional_escape(text)
+
+ # Handle fenced code blocks ```
+ parts = re.split(r"```\n?", text)
+ rendered = []
+ for i, part in enumerate(parts):
+ if i % 2 == 1:
+ # inside code block
+ rendered.append(f"{part}
")
+ else:
+ # regular markdown in this chunk
+ # Split into paragraphs by blank lines
+ paragraphs = re.split(r"\n\s*\n", part)
+ for p in paragraphs:
+ lines = p.split("\n")
+ formatted_lines = _format_lists(lines)
+ html = "
".join(l for l in formatted_lines if l != "")
+ if html.strip():
+ rendered.append(f"{html}
")
+ html_out = "\n".join(rendered)
+ return mark_safe(html_out)
diff --git a/courses/urls.py b/courses/urls.py
index 4bc6314..11c4623 100644
--- a/courses/urls.py
+++ b/courses/urls.py
@@ -4,5 +4,7 @@ from . import views
app_name = 'courses'
urlpatterns = [
path('', views.list, name='list'),
- path('-/', views.show, name="show"),
+ # Lesson detail: /courses////
+ path('///', views.lesson_detail, name='lesson_detail'),
+ path('-/', views.show, name="show"),
]
\ No newline at end of file
diff --git a/courses/views.py b/courses/views.py
index d9a6f0c..37131cb 100644
--- a/courses/views.py
+++ b/courses/views.py
@@ -1,12 +1,102 @@
-from django.shortcuts import render, get_object_or_404
+from django.shortcuts import render, get_object_or_404, redirect
+from django.urls import reverse
from django.views import generic
-from .models import Course, Lesson
+from django.db.models import Prefetch
+from .models import Course, Lesson, Module, Comment
+from .forms import CommentForm
def list(request):
courses = Course.objects.all()
return render(request, 'courses/list.html', {'courses': courses})
def show(request, course_name, course_id):
- course = get_object_or_404(Course, pk=course_id)
- lessons = Lesson.objects.filter(course_id=course_id)
- return render(request, 'courses/show.html', {'course' : course, 'lessons': lessons})
\ No newline at end of file
+ # Optimized course fetch with related author and profile (if present)
+ course = get_object_or_404(
+ Course.objects.select_related('author', 'author__profile'),
+ pk=course_id,
+ )
+
+ # Fetch lessons through module -> course relation and keep a stable order
+ lessons = (
+ Lesson.objects.filter(module__course_id=course_id)
+ .select_related('module')
+ .order_by('order')
+ )
+
+ context = {
+ 'course': course,
+ 'lessons': lessons,
+ }
+ return render(request, 'courses/show.html', context)
+
+
+def lesson_detail(request, course_slug, module_slug, lesson_slug):
+ # Fetch course by slug
+ course = get_object_or_404(
+ Course.objects.select_related('author', 'author__profile'),
+ slug=course_slug,
+ )
+
+ # Fetch module within course
+ module = get_object_or_404(
+ Module.objects.filter(course=course),
+ slug=module_slug,
+ )
+
+ # Fetch lesson within module
+ lesson = get_object_or_404(
+ Lesson.objects.select_related('module').filter(module=module),
+ slug=lesson_slug,
+ )
+
+ # Handle comment submission
+ if request.method == 'POST':
+ if not request.user.is_authenticated:
+ login_url = reverse('login')
+ return redirect(f"{login_url}?next={request.path}")
+ form = CommentForm(request.POST)
+ if form.is_valid():
+ parent = None
+ parent_id = form.cleaned_data.get('parent')
+ if parent_id:
+ try:
+ parent = Comment.objects.get(id=parent_id, lesson=lesson, is_active=True)
+ except Comment.DoesNotExist:
+ parent = None
+
+ Comment.objects.create(
+ lesson=lesson,
+ user=request.user,
+ content=form.cleaned_data['content'],
+ parent=parent,
+ )
+ # Redirect to anchor to show the new comment
+ return redirect(request.path + "#comments")
+ else:
+ form = CommentForm()
+
+ # Lessons list for TOC (entire course ordered)
+ lessons = (
+ Lesson.objects.filter(module__course=course)
+ .select_related('module')
+ .order_by('order')
+ )
+
+ # Public comments for the current lesson (top-level) and their replies
+ replies_qs = Comment.objects.filter(is_active=True).select_related('user').order_by('created_at')
+ comments = (
+ Comment.objects.filter(lesson=lesson, is_active=True, parent__isnull=True)
+ .select_related('user')
+ .prefetch_related(Prefetch('replies', queryset=replies_qs))
+ .order_by('created_at')
+ )
+
+ context = {
+ 'course': course,
+ 'module': module,
+ 'lesson': lesson,
+ 'lessons': lessons,
+ 'comments': comments,
+ 'comment_form': form,
+ }
+ return render(request, 'courses/lesson.html', context)
\ No newline at end of file
diff --git a/static/css/app.css b/static/css/app.css
index 8a02811..8a5d52d 100644
--- a/static/css/app.css
+++ b/static/css/app.css
@@ -109,6 +109,103 @@
}
/* Light theme values — applied via colors_light.css inclusion */
+
+/* ==========================
+ Cours — Sommaire (TOC)
+ ========================== */
+.courseToc {
+ background: var(--surface);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--r-3);
+ box-shadow: var(--shadow-1);
+ /* Make the TOC span the full width of its section */
+ width: 100%;
+ margin: var(--space-5) 0;
+ padding: var(--space-5);
+ max-width: none;
+}
+
+.courseToc .tocModules,
+.courseToc .tocLessons {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.courseToc .tocModule + .tocModule {
+ margin-top: var(--space-5);
+ padding-top: var(--space-5);
+ border-top: 2px dashed var(--border-subtle);
+}
+
+.courseToc .tocModuleHeader {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ color: var(--text);
+ font-weight: 600;
+ letter-spacing: .2px;
+ margin-bottom: var(--space-3);
+}
+
+.courseToc .tocModuleIndex {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border-radius: 999px;
+ background: var(--primary);
+ color: var(--primary-contrast);
+ font-size: 14px;
+ font-weight: 700;
+ box-shadow: 0 0 0 3px rgba(78,158,214,0.15);
+}
+
+.courseToc .tocModuleTitle {
+ font-size: 18px;
+}
+
+.courseToc .tocLessons {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 6px;
+}
+
+.courseToc .tocLesson {
+ position: relative;
+}
+
+.courseToc .tocLink {
+ display: block;
+ padding: 10px 12px;
+ border-radius: var(--r-2);
+ text-decoration: none;
+ color: var(--link);
+ background: rgba(255,255,255,0.02);
+ border: 1px solid transparent;
+ transition: all var(--transition-1);
+}
+
+.courseToc .tocLink:hover {
+ color: var(--link-hover);
+ background: rgba(78,158,214,0.08);
+ border-color: var(--border-subtle);
+}
+
+.courseToc .tocLesson.current .tocLink {
+ color: var(--success);
+ background: rgba(47,168,108,0.12);
+ border-color: rgba(47,168,108,0.35);
+ font-weight: 700;
+}
+
+.courseToc .tocCurrentTag {
+ color: var(--text-muted);
+ font-weight: 600;
+ margin-left: 8px;
+ font-size: 12px;
+}
[data-theme='light'] {
/* Palette: plus nuancé, moins "blanc" */
--bg: #eef3f7; /* fond légèrement teinté bleu-gris */
@@ -719,7 +816,227 @@ pre {
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
- * { transition: none !important; animation: none !important; }
+ * { transition: none !important; animation: none !important; }
+}
+
+/* ---------------------------------------------
+ Container Global
+ --------------------------------------------- */
+.lessonComments {
+ margin-top: var(--space-6);
+ background: var(--surface); /* Assure-toi que c'est une couleur un peu plus claire que le fond de page */
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--r-3);
+ /* On enlève le padding global pour coller les éléments aux bords si besoin,
+ ou on le garde pour un effet "carte" */
+ padding: var(--space-5);
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
+}
+
+.lessonComments h3 {
+ font-size: 1.1rem;
+ font-weight: 700;
+ color: var(--text);
+ margin-bottom: var(--space-4);
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+/* ---------------------------------------------
+ Liste des commentaires
+ --------------------------------------------- */
+.commentsList {
+ list-style: none;
+ padding: 0;
+ margin: 0 0 var(--space-6) 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4); /* Espace entre les commentaires */
+}
+
+.commentItem {
+ background: var(--neutral-100); /* Un fond très léger pour détacher le commentaire */
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--r-2);
+ padding: var(--space-4);
+ transition: border-color 0.2s ease;
+}
+
+.commentItem:hover {
+ border-color: var(--border-strong);
+}
+
+/* En-tête du commentaire (Auteur + Date) */
+.commentHeader {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: var(--space-3);
+ padding-bottom: var(--space-3);
+ border-bottom: 1px solid var(--border-subtle);
+}
+
+.commentAuthor {
+ font-weight: 700;
+ color: var(--primary); /* Couleur primaire pour l'auteur */
+ font-size: 0.95rem;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+/* Petit point décoratif avant le nom */
+.commentAuthor::before {
+ content: '';
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ background-color: var(--primary);
+ border-radius: 50%;
+ opacity: 0.6;
+}
+
+.commentDate {
+ font-size: 0.8rem;
+ color: var(--text-muted);
+}
+
+/* Contenu du texte */
+.commentContent {
+ color: var(--text);
+ font-size: 0.95rem;
+ line-height: 1.6;
+}
+
+/* Markdown Styles dans les commentaires */
+.commentContent p { margin-bottom: 0.8em; }
+.commentContent p:last-child { margin-bottom: 0; }
+
+.commentContent code {
+ font-family: 'Fira Code', monospace;
+ background: rgba(124, 58, 237, 0.1); /* Petite teinte violette légère */
+ color: var(--primary);
+ padding: 2px 5px;
+ border-radius: 4px;
+ font-size: 0.9em;
+}
+
+.commentContent pre {
+ background: #1e1e2e; /* Fond sombre style IDE */
+ color: #cdd6f4;
+ padding: 15px;
+ border-radius: 8px;
+ overflow-x: auto;
+ margin: 10px 0;
+ border: 1px solid rgba(255,255,255,0.05);
+}
+
+.commentContent a {
+ color: var(--primary);
+ text-decoration: none;
+ border-bottom: 1px dotted var(--primary);
+}
+.commentContent a:hover { border-bottom-style: solid; }
+
+/* ---------------------------------------------
+ Formulaire "Éditeur Riche"
+ --------------------------------------------- */
+.commentFormBlock {
+ margin-top: var(--space-4);
+}
+
+.commentForm {
+ background: var(--surface);
+ border: 1px solid var(--border-strong); /* Bordure plus visible */
+ border-radius: var(--r-2);
+ overflow: hidden; /* Pour que les enfants ne dépassent pas des coins */
+ transition: box-shadow 0.2s;
+}
+
+.commentForm:focus-within {
+ box-shadow: 0 0 0 2px var(--primary-focus, rgba(124, 58, 237, 0.3));
+ border-color: var(--primary);
+}
+
+/* La barre d'outils collée au textarea */
+.commentToolbar {
+ background: var(--neutral-200); /* Fond gris clair/sombre pour la barre */
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--border-subtle);
+ display: flex;
+ gap: 8px;
+}
+
+.commentToolbar .btnTool {
+ background: transparent;
+ border: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ padding: 6px;
+ border-radius: 4px;
+ transition: all 0.2s;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.commentToolbar .btnTool:hover {
+ background: rgba(0,0,0,0.05); /* Ou blanc semi-transparent en dark mode */
+ color: var(--primary);
+}
+
+/* Le champ texte */
+.commentField {
+ padding: 0; /* On enlève le padding du container */
+}
+
+.commentField textarea {
+ width: 100%; /* Prend toute la largeur */
+ border: none; /* Pas de bordure, c'est le container .commentForm qui gère */
+ background: transparent;
+ color: var(--text);
+ padding: 15px;
+ min-height: 120px;
+ resize: vertical;
+ font-family: inherit;
+ outline: none; /* Le focus est géré par le parent */
+ display: block; /* Évite les espaces fantômes */
+ line-height: 1.5;
+}
+
+/* Footer du formulaire (Aide + Bouton) */
+.formActions {
+ padding: 10px 15px;
+ background: var(--neutral-100); /* Pied de formulaire légèrement différent */
+ border-top: 1px solid var(--border-subtle);
+ display: flex;
+ justify-content: space-between; /* Aide à gauche, Bouton à droite */
+ align-items: center;
+}
+
+.commentHelp {
+ margin: 0;
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ font-style: italic;
+}
+
+.lessonComments .btn {
+ background: var(--primary);
+ color: #fff;
+ border: none;
+ padding: 8px 20px;
+ border-radius: 6px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.2s;
+ font-size: 0.9rem;
+}
+
+.lessonComments .btn:hover {
+ background: var(--bg, #6d28d9);
+ transform: translateY(-1px);
}
/* Responsive */
@@ -1345,7 +1662,7 @@ footer { background-color: var(--card); color: var(--text-muted); }
form {
display: flex;
flex-direction: column;
- width: 50%;
+ width: 90%;
margin: 20px auto;
}
@@ -1360,7 +1677,7 @@ form {
border-radius: 10px; /* Bordure arrondie */
box-shadow: var(--shadow-1); /* Ombre légère */
margin: 20px auto;
- width: 50%;
+ width: min(680px, 92%);
}
.login-form {
@@ -1425,6 +1742,82 @@ input[type="text"], input[type="email"], input[type="password"], textarea {
color: var(--fg);
}
+/*
+ Système de formulaires générique — pour un rendu cohérent avec le site
+ À appliquer avec class="form" sur
+
{% csrf_token %}
{{ profile_form.as_p }}
-
+
diff --git a/templates/users/register.html b/templates/users/register.html
index 4417cf1..be887ad 100644
--- a/templates/users/register.html
+++ b/templates/users/register.html
@@ -2,10 +2,12 @@
{% block content %}
Register
-
+
{% csrf_token %}
{{ form.as_p }}
-
+
+
+
{% endblock %}
\ No newline at end of file