Add comment system with models, forms, and UI integration for lessons

This commit is contained in:
mrtoine 2025-12-10 22:22:17 +01:00
parent c22622ebc1
commit 95111240bc
26 changed files with 1001 additions and 77 deletions

View file

@ -1,16 +1,56 @@
from django.contrib import admin 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) class LessonInline(admin.TabularInline):
admin.site.register(Lesson) 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")

13
courses/forms.py Normal file
View file

@ -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())

View file

@ -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',),
},
),
]

View file

@ -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'),
),
]

View file

@ -42,3 +42,19 @@ class Lesson(models.Model):
def __str__(self): def __str__(self):
return self.name 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}"

View file

View file

@ -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'<a class="comment-link" href="{url}" '
f'rel="nofollow noopener noreferrer" target="_blank">{text}</a>'
)
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"<code class=\"comment-code\">{m.group(1)}</code>",
text,
)
# bold **text**
text = re.sub(
r"\*\*([^*]+)\*\*",
lambda m: f"<strong class=\"comment-strong\">{m.group(1)}</strong>",
text,
)
# italic *text* or _text_
text = re.sub(
r"(?<!\*)\*([^*]+)\*(?!\*)",
lambda m: f"<em class=\"comment-em\">{m.group(1)}</em>",
text,
)
text = re.sub(
r"_([^_]+)_",
lambda m: f"<em class=\"comment-em\">{m.group(1)}</em>",
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("<ul class=\"comment-list\">")
in_ul = True
html_lines.append(f"<li>{_format_inline(m.group(1))}</li>")
else:
if in_ul:
html_lines.append("</ul>")
in_ul = False
html_lines.append(_format_inline(line))
if in_ul:
html_lines.append("</ul>")
return html_lines
@register.filter(name="comment_markdown")
def comment_markdown(value: str):
"""A very small Markdown subset renderer for comments with HTML escaping.
Supports: paragraphs, lists (- or *), **bold**, *italic* or _italic_, `code`, [text](url).
Fenced code blocks ```...``` will render as <pre><code>.
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"<pre class=\"comment-pre\"><code class=\"comment-code-block\">{part}</code></pre>")
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 = "<br />".join(l for l in formatted_lines if l != "")
if html.strip():
rendered.append(f"<p class=\"comment-paragraph\">{html}</p>")
html_out = "\n".join(rendered)
return mark_safe(html_out)

View file

@ -4,5 +4,7 @@ from . import views
app_name = 'courses' app_name = 'courses'
urlpatterns = [ urlpatterns = [
path('', views.list, name='list'), path('', views.list, name='list'),
path('<int:course_id>-<slug:course_name>/', views.show, name="show"), # Lesson detail: /courses/<course>/<module>/<lesson>/
path('<slug:course_slug>/<slug:module_slug>/<slug:lesson_slug>/', views.lesson_detail, name='lesson_detail'),
path('<slug:course_name>-<int:course_id>/', views.show, name="show"),
] ]

View file

@ -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 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): def list(request):
courses = Course.objects.all() courses = Course.objects.all()
return render(request, 'courses/list.html', {'courses': courses}) return render(request, 'courses/list.html', {'courses': courses})
def show(request, course_name, course_id): def show(request, course_name, course_id):
course = get_object_or_404(Course, pk=course_id) # Optimized course fetch with related author and profile (if present)
lessons = Lesson.objects.filter(course_id=course_id) course = get_object_or_404(
return render(request, 'courses/show.html', {'course' : course, 'lessons': lessons}) 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)

View file

@ -109,6 +109,103 @@
} }
/* Light theme values — applied via colors_light.css inclusion */ /* 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'] { [data-theme='light'] {
/* Palette: plus nuancé, moins "blanc" */ /* Palette: plus nuancé, moins "blanc" */
--bg: #eef3f7; /* fond légèrement teinté bleu-gris */ --bg: #eef3f7; /* fond légèrement teinté bleu-gris */
@ -719,7 +816,227 @@ pre {
/* Reduced motion */ /* Reduced motion */
@media (prefers-reduced-motion: reduce) { @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 */ /* Responsive */
@ -1345,7 +1662,7 @@ footer { background-color: var(--card); color: var(--text-muted); }
form { form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 50%; width: 90%;
margin: 20px auto; margin: 20px auto;
} }
@ -1360,7 +1677,7 @@ form {
border-radius: 10px; /* Bordure arrondie */ border-radius: 10px; /* Bordure arrondie */
box-shadow: var(--shadow-1); /* Ombre légère */ box-shadow: var(--shadow-1); /* Ombre légère */
margin: 20px auto; margin: 20px auto;
width: 50%; width: min(680px, 92%);
} }
.login-form { .login-form {
@ -1425,6 +1742,82 @@ input[type="text"], input[type="email"], input[type="password"], textarea {
color: var(--fg); color: var(--fg);
} }
/*
Système de formulaires générique pour un rendu cohérent avec le site
À appliquer avec class="form" sur <form> (fonctionne aussi avec {{ form.as_p }})
*/
.form {
width: 100%;
}
.form p { /* Django {{ form.as_p }} */
display: flex;
flex-direction: column;
gap: 6px;
margin: 0 0 14px 0;
}
.form label {
font-weight: 600;
color: var(--text);
}
.form input[type="text"],
.form input[type="email"],
.form input[type="password"],
.form input[type="url"],
.form input[type="number"],
.form input[type="file"],
.form input[type="search"],
.form input[type="tel"],
.form select,
.form textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--r-2);
background-color: var(--surface);
color: var(--text);
box-shadow: inset var(--shadow-1);
transition: border-color var(--transition-1), box-shadow var(--transition-1), background-color var(--transition-1);
}
.form input:focus,
.form select:focus,
.form textarea:focus {
outline: none;
box-shadow: var(--focus-ring);
border-color: var(--primary);
}
.form .helptext,
.form small.helptext {
color: var(--text-muted);
font-size: 0.9rem;
}
/* Erreurs Django: ul.errorlist > li */
.form ul.errorlist {
list-style: none;
margin: 0 0 6px 0;
padding: 8px 10px;
border: 1px solid color-mix(in oklab, var(--danger) 60%, var(--border));
background: color-mix(in oklab, var(--danger) 12%, var(--surface));
color: var(--danger);
border-radius: var(--r-2);
}
.form ul.errorlist li { margin: 0; }
.form .actions,
.form .form-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 12px;
}
/* /*
Système de boutons harmonisé avec le thème Système de boutons harmonisé avec le thème
Utilise les tokens de couleurs et de rayons définis en haut de fichier. Utilise les tokens de couleurs et de rayons définis en haut de fichier.

View file

@ -0,0 +1,8 @@
{% extends 'layout.html' %}
{% block content %}
<section>
{% include 'courses/partials/_course_header.html' %}
{% include 'courses/partials/_course_toc.html' %}
</section>
{% endblock %}

View file

@ -11,7 +11,7 @@
<img class="thumbnails" src="{{ course.thumbnail.url }}" alt="Image des cours sur {{ course.name }}" class="src"> <img class="thumbnails" src="{{ course.thumbnail.url }}" alt="Image des cours sur {{ course.name }}" class="src">
</div> </div>
<div class="card-body"> <div class="card-body">
<h2><a href="{% url 'courses:show' course.id course.name|slugify %}">{{ course.name }}</a></h2> <h2><a href="{% url 'courses:show' course.name|slugify course.id %}">{{ course.name }}</a></h2>
{{ course.content|truncatechars_html:250 }} {{ course.content|truncatechars_html:250 }}
</div> </div>
</div> </div>

View file

@ -0,0 +1,6 @@
<h1>{{ course.name }}</h1>
<p class="def-author">
<img src="/{{ course.author.profile.avatar }}" alt="Profile Picture" class="profile-picture-mini">
Un cours proposé par&nbsp;<a href="{% url 'another_profile' course.author.id %}">{{ course.author }}</a>
</p>
<p>{{ course.content }}</p>

View file

@ -0,0 +1,216 @@
{% load comment_format %}
<nav class="courseToc" aria-label="Sommaire du cours">
{% regroup lessons by module as modules %}
<ol class="tocModules">
{% for group in modules %}
<li class="tocModule">
<div class="tocModuleHeader">
<span class="tocModuleIndex">{{ forloop.counter }}</span>
<span class="tocModuleTitle">{{ group.grouper.name }}</span>
</div>
<ol class="tocLessons">
{% for item in group.list %}
<li class="tocLesson{% if lesson and lesson.id == item.id %} current{% endif %}">
<a class="tocLink" href="{% url 'courses:lesson_detail' course.slug item.module.slug item.slug %}">
{{ item.name }}
{% if lesson and lesson.id == item.id %}<span class="tocCurrentTag">(cours actuel)</span>{% endif %}
</a>
{% if lesson and lesson.id == item.id %}
<div class="lessonInline">
<article class="lesson">
{% if lesson.video_id %}
VIDEO {{ lesson.video_id }}<br />
{% endif %}
{{ lesson.content|safe }}
</article>
<h3 id="comments">Commentaires</h3>
<div class="lessonComments">
{% if comments %}
<ol class="commentsList">
{% for c in comments %}
<li class="commentItem" id="comment-{{ c.id }}">
<div class="commentHeader">
<span class="commentAuthor">{{ c.user.username }}</span>
<time class="commentDate" datetime="{{ c.created_at|date:'c' }}">{{ c.created_at|date:'d/m/Y H:i' }}</time>
</div>
<div class="commentContent">{{ c.content|comment_markdown }}</div>
{# Replies #}
{% if c.replies.all %}
{% with replies=c.replies.all %}
{% if replies %}
<ol class="commentsList" style="margin-top: var(--space-4); margin-left: var(--space-6);">
{% for r in replies %}
<li class="commentItem" id="comment-{{ r.id }}">
<div class="commentHeader">
<span class="commentAuthor">{{ r.user.username }}</span>
<time class="commentDate" datetime="{{ r.created_at|date:'c' }}">{{ r.created_at|date:'d/m/Y H:i' }}</time>
</div>
<div class="commentContent">{{ r.content|comment_markdown }}</div>
</li>
{% endfor %}
</ol>
{% endif %}
{% endwith %}
{% endif %}
{# Reply toggle + form #}
{% if user.is_authenticated %}
<button type="button" class="btn btnReply" aria-expanded="false" style="margin-top: var(--space-3);">Répondre</button>
<div class="commentFormBlock" style="display:none; margin-top: var(--space-3);">
<form method="post" action="{% url 'courses:lesson_detail' course.slug module.slug lesson.slug %}#comments" class="commentForm">
{% csrf_token %}
<input type="hidden" name="parent" value="{{ c.id }}" />
<div class="commentToolbar" role="toolbar" aria-label="Outils de mise en forme des réponses">
<button class="btnTool" type="button" data-wrap="**|**" title="Gras (Ctrl/Cmd+B)"><i class="fa-solid fa-bold"></i></button>
<button class="btnTool" type="button" data-wrap="*|*" title="Italique (Ctrl/Cmd+I)"><i class="fa-solid fa-italic"></i></button>
<button class="btnTool" type="button" data-wrap="`|`" title="Code inline"><i class="fa-solid fa-code"></i></button>
<button class="btnTool" type="button" data-insert="[texte](https://exemple.com)" title="Lien"><i class="fa-solid fa-link"></i></button>
<button class="btnTool" type="button" data-prefix="- " title="Liste à puces"><i class="fa-solid fa-list-ul"></i></button>
<button class="btnTool" type="button" data-fence="```
|\n```" title="Bloc de code"><i class="fa-solid fa-square-code"></i></button>
</div>
<div class="commentField">
<textarea name="content" rows="2" placeholder="Répondre à ce commentaire…"></textarea>
</div>
<div class="formActions">
<button type="submit" class="btn">Publier</button>
</div>
</form>
</div>
{% else %}
<p>
<a href="{% url 'login' %}?next={% url 'courses:lesson_detail' course.slug module.slug lesson.slug %}#comments">Se connecter pour répondre</a>
</p>
{% endif %}
</li>
{% endfor %}
</ol>
{% else %}
<p class="noComments">Aucun commentaire pour le moment.</p>
{% endif %}
<div class="commentFormBlock">
{% if user.is_authenticated %}
<form method="post" action="{% url 'courses:lesson_detail' course.slug module.slug lesson.slug %}#comments" class="commentForm">
{% csrf_token %}
<div class="commentToolbar" role="toolbar" aria-label="Outils de mise en forme des commentaires">
<button class="btnTool" type="button" data-wrap="**|**" title="Gras (Ctrl/Cmd+B)"><i class="fa-solid fa-bold"></i></button>
<button class="btnTool" type="button" data-wrap="*|*" title="Italique (Ctrl/Cmd+I)"><i class="fa-solid fa-italic"></i></button>
<button class="btnTool" type="button" data-wrap="`|`" title="Code inline"><i class="fa-solid fa-code"></i></button>
<button class="btnTool" type="button" data-insert="[texte](https://exemple.com)" title="Lien"><i class="fa-solid fa-link"></i></button>
<button class="btnTool" type="button" data-prefix="- " title="Liste à puces"><i class="fa-solid fa-list-ul"></i></button>
<button class="btnTool" type="button" data-fence="```\n|\n```" title="Bloc de code"><i class="fa-solid fa-square-code"></i></button>
</div>
<div class="commentField">
{{ comment_form.content }}
</div>
<p class="commentHelp">
Astuce: vous pouvez utiliser une mise en forme simple — **gras**, *italique*, `code`, listes (- item) et blocs de code avec ```.
</p>
<div class="formActions">
<button type="submit" class="btn">Publier</button>
</div>
</form>
{% else %}
<p>Vous devez être connecté pour publier un commentaire.
<a href="{% url 'login' %}?next={% url 'courses:lesson_detail' course.slug module.slug lesson.slug %}#comments">Se connecter</a>
</p>
{% endif %}
</div>
<script>
(function(){
const getTextareaFromButton = (btn) => {
const form = btn.closest('.commentForm');
if (!form) return document.getElementById('id_content');
return form.querySelector('textarea') || document.getElementById('id_content');
};
const wrap = (area, before, after) => {
if (!area) return;
const start = area.selectionStart || 0;
const end = area.selectionEnd || 0;
const sel = area.value.substring(start, end) || '';
const bef = area.value.substring(0, start);
const aft = area.value.substring(end);
area.value = bef + before + sel + (after ?? '') + aft;
const pos = (bef + before + sel).length;
area.focus();
area.setSelectionRange(pos, pos);
};
const addPrefix = (area, prefix) => {
if (!area) return;
const start = area.selectionStart || 0;
const end = area.selectionEnd || 0;
const text = area.value;
const sel = text.substring(start, end) || '';
const lines = sel.split('\n');
const out = lines.map(l => l ? (prefix + l) : l).join('\n');
const bef = text.substring(0, start);
const aft = text.substring(end);
area.value = bef + out + aft;
const pos = (bef + out).length;
area.focus();
area.setSelectionRange(pos, pos);
};
document.addEventListener('click', function(e){
const b = e.target.closest('.btnTool');
if (b) {
e.preventDefault();
const area = getTextareaFromButton(b);
const wrapSpec = b.getAttribute('data-wrap');
const insertSpec = b.getAttribute('data-insert');
const prefix = b.getAttribute('data-prefix');
const fence = b.getAttribute('data-fence');
if (wrapSpec) {
const [before, after] = wrapSpec.split('|');
wrap(area, before, after);
} else if (insertSpec) {
wrap(area, insertSpec, '');
} else if (prefix) {
addPrefix(area, prefix);
} else if (fence) {
const [before, after] = fence.split('|');
wrap(area, before, after);
}
return;
}
// Toggle reply form when clicking "Répondre"
const replyBtn = e.target.closest('.btnReply');
if (replyBtn) {
e.preventDefault();
const formBlock = replyBtn.nextElementSibling;
if (!formBlock || !formBlock.classList.contains('commentFormBlock')) return;
const isHidden = formBlock.style.display === 'none' || getComputedStyle(formBlock).display === 'none';
formBlock.style.display = isHidden ? '' : 'none';
replyBtn.setAttribute('aria-expanded', isHidden ? 'true' : 'false');
if (isHidden) {
const ta = formBlock.querySelector('textarea');
if (ta) ta.focus();
}
}
});
// Keyboard shortcuts for any textarea within comment forms
document.addEventListener('keydown', function(e){
const target = e.target;
if (!(target instanceof HTMLElement)) return;
if (!target.closest('.commentForm') || target.tagName !== 'TEXTAREA') return;
if ((e.metaKey || e.ctrlKey) && (e.key === 'b' || e.key === 'i')) {
e.preventDefault();
const isBold = e.key === 'b';
const spec = isBold ? ['**','**'] : ['*','*'];
wrap(target, spec[0], spec[1]);
}
});
})();
</script>
</div>
</div>
{% endif %}
</li>
{% endfor %}
</ol>
</li>
{% endfor %}
</ol>
</nav>

View file

@ -8,7 +8,7 @@
<img class="thumbnails" src="{{ course.thumbnail.url }}" alt="Image des cours sur {{ course.name }}" class="src"> <img class="thumbnails" src="{{ course.thumbnail.url }}" alt="Image des cours sur {{ course.name }}" class="src">
</div> </div>
<div class="card-body"> <div class="card-body">
<h2><a href="{% url 'courses:show' course.id course.name|slugify %}">{{ course.name }}</a></h2> <h2><a href="{% url 'courses:show' course.name|slugify course.id %}">{{ course.name }}</a></h2>
{{ course.content|truncatechars_html:250 }} {{ course.content|truncatechars_html:250 }}
</div> </div>
</div> </div>

View file

@ -2,39 +2,8 @@
{% block content %} {% block content %}
<section> <section>
<h1>{{ course.name }}</h1> {% include 'courses/partials/_course_header.html' %}
<p class="def-author"><img src="/{{ course.author.profile.avatar }}" alt="Profile Picture" class="profile-picture-mini">Un cours proposé par&nbsp;<a href="{% url 'another_profile' course.author.id %}">{{ course.author }}</a></p> {% include 'courses/partials/_course_toc.html' %}
<p>{{ course.content }}</p> <p>Sélectionnez une leçon dans le sommaire pour commencer.</p>
<aside class="courseNav">
<ol>
{% for lesson in lessons %}
<li><a href="#{{ lesson.name|slugify }}">{{ lesson.name }}</a></li>
{% endfor %}
<li><a href="#comments">Commentaires</a></li>
</ol>
</aside>
{% for lesson in lessons %}
<h2 id="{{ lesson.name|slugify }}">{{ lesson.name }}</h2>
{{ lesson.content|safe }}
{% endfor %}
<h1 id="comments">Commentaires</h1>
<div id="disqus_thread"></div>
<script>
/**
* RECOMMENDED CONFIGURATION VARIABLES: EDIT AND UNCOMMENT THE SECTION BELOW TO INSERT DYNAMIC VALUES FROM YOUR PLATFORM OR CMS.
* LEARN WHY DEFINING THESE VARIABLES IS IMPORTANT: https://disqus.com/admin/universalcode/#configuration-variables */
/**/
var disqus_config = function () {
this.page.url = "{{ request.build_absolute_uri|safe }}"; // Replace PAGE_URL with your page's canonical URL variable
this.page.identifier = "{{ course.id }}"; // Replace PAGE_IDENTIFIER with your page's unique identifier variable
};
(function() { // DON'T EDIT BELOW THIS LINE
var d = document, s = d.createElement('script');
s.src = 'https://partirdezero.disqus.com/embed.js';
s.setAttribute('data-timestamp', +new Date());
(d.head || d.body).appendChild(s);
})();
</script>
<noscript>Please enable JavaScript to view the <a href="https://disqus.com/?ref_noscript">comments powered by Disqus.</a></noscript>
</section> </section>
{% endblock %} {% endblock %}

View file

@ -114,7 +114,7 @@
<h2>Cours en vedette</h2> <h2>Cours en vedette</h2>
<div class="carousel-track" role="list"> <div class="carousel-track" role="list">
{% for course in courses|slice:":6" %} {% for course in courses|slice:":6" %}
<a class="carousel-item card" href="{% url 'courses:show' course.id course.name|slugify %}" role="listitem"> <a class="carousel-item card" href="{% url 'courses:show' course.name|slugify course.id %}" role="listitem">
<div class="ratio-16x9"> <div class="ratio-16x9">
<img src="{{ course.thumbnail.url }}" alt="{{ course.name }}" loading="lazy"> <img src="{{ course.thumbnail.url }}" alt="{{ course.name }}" loading="lazy">
</div> </div>

View file

@ -8,7 +8,7 @@
<a href="{% url 'courses:list' %}">Les cours</a> <a href="{% url 'courses:list' %}">Les cours</a>
<ul> <ul>
{% for course in courses %} {% for course in courses %}
<li><a href="{% url 'courses:show' course.id course.name|slugify %}">{{ course.name }}</a></li> <li><a href="{% url 'courses:show' course.name|slugify course.id %}">{{ course.name }}</a></li>
{% endfor %} {% endfor %}
</ul> </ul>
</li> </li>
@ -32,6 +32,11 @@
{% endif %} {% endif %}
</ul> </ul>
</li> </li>
{% if user.is_authenticated and user.is_staff %}
<li>
<a href="{% url 'admin:index' %}" class="button button-danger" title="Administration">Admin</a>
</li>
{% endif %}
<li> <li>
{% if user.is_authenticated %} {% if user.is_authenticated %}
<!-- On affiche le pseudo --> <!-- On affiche le pseudo -->

View file

@ -7,10 +7,10 @@
<div class="profile-grid"> <div class="profile-grid">
<div class="profile-card"> <div class="profile-card">
<h3>Paramètres du compte</h3> <h3>Paramètres du compte</h3>
<form method="post"> <form method="post" class="form">
{% csrf_token %} {% csrf_token %}
{{ user_form.as_p }} {{ user_form.as_p }}
<div class="text-right" style="margin-top:12px; display:flex; gap:8px; justify-content:flex-end;"> <div class="form-actions">
<a href="{% url 'profile' %}" class="btn btn-secondary">Annuler</a> <a href="{% url 'profile' %}" class="btn btn-secondary">Annuler</a>
<button type="submit" class="btn btn-primary">Enregistrer</button> <button type="submit" class="btn btn-primary">Enregistrer</button>
</div> </div>

View file

@ -4,10 +4,12 @@
<section class="form-section"> <section class="form-section">
<h2>Complète ton profil</h2> <h2>Complète ton profil</h2>
<p>La nouvelle mise à jour du site web te permet d'avoir un profil personnel. Tu peux remplir les champs suivant pour l'activer.</p> <p>La nouvelle mise à jour du site web te permet d'avoir un profil personnel. Tu peux remplir les champs suivant pour l'activer.</p>
<form method="post"> <form method="post" class="form">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {{ form.as_p }}
<button type="submit" class="btn-submit">Save</button> <div class="form-actions">
<button type="submit" class="btn btn-primary">Enregistrer</button>
</div>
</form> </form>
</section> </section>
{% endblock %} {% endblock %}

View file

@ -1,11 +1,13 @@
{% extends 'layout.html' %} {% extends 'layout.html' %}
{% block content %} {% block content %}
<section> <section class="form-section">
<h2>Create Post</h2> <h2>Create Post</h2>
<form method="post"> <form method="post" class="form">
{% csrf_token %} {% csrf_token %}
<!-- Add form fields for post creation here --> <!-- Add form fields for post creation here -->
<button type="submit">Create Post</button> <div class="form-actions">
<button type="submit" class="btn btn-primary">Create Post</button>
</div>
</form> </form>
</section> </section>
{% endblock %} {% endblock %}

View file

@ -4,10 +4,12 @@
<h2>Login</h2> <h2>Login</h2>
<p>Pas encore inscrit ? <a href="{% url 'register' %}">Inscrivez-vous</a></p> <p>Pas encore inscrit ? <a href="{% url 'register' %}">Inscrivez-vous</a></p>
<p class="login-info">En vous connectant, vous aurez accès à des fonctionnalités exclusives, telles que la gestion de vos cours, la participation aux discussions et la personnalisation de votre profil. Rejoignez notre communauté et profitez pleinement de tout ce que notre site a à offrir.</p> <p class="login-info">En vous connectant, vous aurez accès à des fonctionnalités exclusives, telles que la gestion de vos cours, la participation aux discussions et la personnalisation de votre profil. Rejoignez notre communauté et profitez pleinement de tout ce que notre site a à offrir.</p>
<form method="post" class="login-form"> <form method="post" class="login-form form">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {{ form.as_p }}
<button type="submit" class="btn-submit">Login</button> <div class="form-actions">
<button type="submit" class="btn btn-primary">Login</button>
</div>
</form> </form>
</section> </section>
{% endblock %} {% endblock %}

View file

@ -6,10 +6,10 @@
{% endblock %} {% endblock %}
<div class="profile-details"> <div class="profile-details">
<h2>Mes cours</h2> <h2>Mes cours</h2>
<p>Retrouvez ici la liste de tous les cours que vous avez rédigés.</p> <p>Retrouvez ici la liste de tous les cours que vous suivez.</p>
<ul> <ul>
{% for course in user_courses %} {% for course in user_courses %}
<li><a href="{% url 'courses:show' course.id course.name|slugify %}">{{ course.name }}</a></li> <li><a href="{% url 'courses:show' course.name|slugify course.id %}">{{ course.name }}</a></li>
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>

View file

@ -38,7 +38,7 @@
<a class="btn btn-secondary btn-sm" href="{% url 'user_courses' %}">Voir tous mes cours</a> <a class="btn btn-secondary btn-sm" href="{% url 'user_courses' %}">Voir tous mes cours</a>
</div> </div>
{% else %} {% else %}
<p class="muted">Aucun cours publié pour le moment.</p> <p class="muted">Aucun cours suivi pour le moment.</p>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
</div> </div>

View file

@ -15,10 +15,10 @@
</div> </div>
</div> </div>
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data" class="form">
{% csrf_token %} {% csrf_token %}
{{ profile_form.as_p }} {{ profile_form.as_p }}
<div class="text-right" style="margin-top:12px; display:flex; gap:8px; justify-content:flex-end;"> <div class="form-actions">
<a href="{% url 'profile' %}" class="btn btn-secondary">Annuler</a> <a href="{% url 'profile' %}" class="btn btn-secondary">Annuler</a>
<button type="submit" class="btn btn-primary">Enregistrer</button> <button type="submit" class="btn btn-primary">Enregistrer</button>
</div> </div>

View file

@ -2,10 +2,12 @@
{% block content %} {% block content %}
<section class="form-section"> <section class="form-section">
<h2>Register</h2> <h2>Register</h2>
<form method="post" class="login-form"> <form method="post" class="login-form form">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {{ form.as_p }}
<button type="submit" class="btn-submit">Register</button> <div class="form-actions">
<button type="submit" class="btn btn-primary">Register</button>
</div>
</form> </form>
</section> </section>
{% endblock %} {% endblock %}