Add comment system with models, forms, and UI integration for lessons
This commit is contained in:
parent
c22622ebc1
commit
95111240bc
26 changed files with 1001 additions and 77 deletions
|
|
@ -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)
|
||||
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")
|
||||
13
courses/forms.py
Normal file
13
courses/forms.py
Normal 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())
|
||||
31
courses/migrations/0002_comment.py
Normal file
31
courses/migrations/0002_comment.py
Normal 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',),
|
||||
},
|
||||
),
|
||||
]
|
||||
19
courses/migrations/0003_comment_parent.py
Normal file
19
courses/migrations/0003_comment_parent.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -42,3 +42,19 @@ class Lesson(models.Model):
|
|||
|
||||
def __str__(self):
|
||||
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}"
|
||||
0
courses/templatetags/__init__.py
Normal file
0
courses/templatetags/__init__.py
Normal file
108
courses/templatetags/comment_format.py
Normal file
108
courses/templatetags/comment_format.py
Normal 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)
|
||||
|
|
@ -4,5 +4,7 @@ from . import views
|
|||
app_name = 'courses'
|
||||
urlpatterns = [
|
||||
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"),
|
||||
]
|
||||
100
courses/views.py
100
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})
|
||||
# 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)
|
||||
|
|
@ -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 <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
|
||||
Utilise les tokens de couleurs et de rayons définis en haut de fichier.
|
||||
|
|
|
|||
8
templates/courses/lesson.html
Normal file
8
templates/courses/lesson.html
Normal 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 %}
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
<img class="thumbnails" src="{{ course.thumbnail.url }}" alt="Image des cours sur {{ course.name }}" class="src">
|
||||
</div>
|
||||
<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 }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
6
templates/courses/partials/_course_header.html
Normal file
6
templates/courses/partials/_course_header.html
Normal 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 <a href="{% url 'another_profile' course.author.id %}">{{ course.author }}</a>
|
||||
</p>
|
||||
<p>{{ course.content }}</p>
|
||||
216
templates/courses/partials/_course_toc.html
Normal file
216
templates/courses/partials/_course_toc.html
Normal 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>
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
<img class="thumbnails" src="{{ course.thumbnail.url }}" alt="Image des cours sur {{ course.name }}" class="src">
|
||||
</div>
|
||||
<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 }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,39 +2,8 @@
|
|||
|
||||
{% block content %}
|
||||
<section>
|
||||
<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 <a href="{% url 'another_profile' course.author.id %}">{{ course.author }}</a></p>
|
||||
<p>{{ course.content }}</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>
|
||||
{% include 'courses/partials/_course_header.html' %}
|
||||
{% include 'courses/partials/_course_toc.html' %}
|
||||
<p>Sélectionnez une leçon dans le sommaire pour commencer.</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
@ -114,7 +114,7 @@
|
|||
<h2>Cours en vedette</h2>
|
||||
<div class="carousel-track" role="list">
|
||||
{% 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">
|
||||
<img src="{{ course.thumbnail.url }}" alt="{{ course.name }}" loading="lazy">
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
<a href="{% url 'courses:list' %}">Les cours</a>
|
||||
<ul>
|
||||
{% 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 %}
|
||||
</ul>
|
||||
</li>
|
||||
|
|
@ -32,6 +32,11 @@
|
|||
{% endif %}
|
||||
</ul>
|
||||
</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>
|
||||
{% if user.is_authenticated %}
|
||||
<!-- On affiche le pseudo -->
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@
|
|||
<div class="profile-grid">
|
||||
<div class="profile-card">
|
||||
<h3>Paramètres du compte</h3>
|
||||
<form method="post">
|
||||
<form method="post" class="form">
|
||||
{% csrf_token %}
|
||||
{{ 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>
|
||||
<button type="submit" class="btn btn-primary">Enregistrer</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@
|
|||
<section class="form-section">
|
||||
<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>
|
||||
<form method="post">
|
||||
<form method="post" class="form">
|
||||
{% csrf_token %}
|
||||
{{ 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>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% block content %}
|
||||
<section>
|
||||
<section class="form-section">
|
||||
<h2>Create Post</h2>
|
||||
<form method="post">
|
||||
<form method="post" class="form">
|
||||
{% csrf_token %}
|
||||
<!-- 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>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
@ -4,10 +4,12 @@
|
|||
<h2>Login</h2>
|
||||
<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>
|
||||
<form method="post" class="login-form">
|
||||
<form method="post" class="login-form form">
|
||||
{% csrf_token %}
|
||||
{{ 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>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
@ -6,10 +6,10 @@
|
|||
{% endblock %}
|
||||
<div class="profile-details">
|
||||
<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>
|
||||
{% 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 %}
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
<a class="btn btn-secondary btn-sm" href="{% url 'user_courses' %}">Voir tous mes cours</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">Aucun cours publié pour le moment.</p>
|
||||
<p class="muted">Aucun cours suivi pour le moment.</p>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<form method="post" enctype="multipart/form-data" class="form">
|
||||
{% csrf_token %}
|
||||
{{ 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>
|
||||
<button type="submit" class="btn btn-primary">Enregistrer</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@
|
|||
{% block content %}
|
||||
<section class="form-section">
|
||||
<h2>Register</h2>
|
||||
<form method="post" class="login-form">
|
||||
<form method="post" class="login-form form">
|
||||
{% csrf_token %}
|
||||
{{ 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>
|
||||
</section>
|
||||
{% endblock %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue