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
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 %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue