Remplacer les liens d'actions des commentaires par un menu contextuel #100

Merged
Lephenixnoir merged 5 commits from context-menu into dev 2021-07-12 18:37:10 +02:00
10 changed files with 220 additions and 36 deletions

View File

@ -16,11 +16,11 @@
# publish.shared-files
#
# Moderation:
# edit.posts
# edit.posts (includes top comment selection)
# edit.tests
# edit.accounts
# edit.trophies
# delete.posts
# delete.posts (includes triple XP removal)
# delete.tests
# delete.accounts
# delete.shared-files

View File

@ -227,6 +227,17 @@ class Member(User):
return self.can_access_post(post) and \
((post.author == self) or self.priv("delete.posts"))
def can_punish_post(self, post):
"""Whether this member can delete the post with penalty."""
return self.can_access_post(post) and self.priv("delete.posts")
def can_set_topcomment(self, comment):
"""Whether this member can designate the comment as top comment."""
if comment.type != "comment":
return False
post = comment.thread.owner_post
return self.can_edit_post(post) and (comment.author == post.author)
def update(self, **data):
"""
Update all or part of the user's metadata. The [data] dictionary

View File

@ -1,6 +1,9 @@
from app import app, db
from app.models.user import Member
from app.models.post import Post
from app.models.attachment import Attachment
from app.models.topic import Topic
from app.models.program import Program
from app.utils.render import render
from app.utils.check_csrf import check_csrf
from app.forms.forum import CommentEditForm, AnonymousCommentEditForm
@ -64,11 +67,34 @@ def edit_post(postid):
@login_required
@check_csrf
def delete_post(postid):
next_page = request.referrer
p = Post.query.filter_by(id=postid).first_or_404()
if current_user.is_anonymous or not current_user.can_delete_post(p):
abort(403)
# When deleting topics, return to forum page
if isinstance(p, Topic):
next_page = url_for('forum_page', f=p.forum)
if isinstance(p.author, Member):
amount = -3 if request.args.get('penalty') == 'True' else -1
p.author.add_xp(amount)
db.session.add(p.author)
p.delete()
db.session.commit()
return redirect(next_page)
@app.route('/post/entete/<int:postid>', methods=['GET'])
@login_required
@check_csrf
def set_post_topcomment(postid):
comment = Post.query.filter_by(id=postid).first_or_404()
if current_user.can_set_topcomment(comment):
comment.thread.top_comment = comment
db.session.add(comment.thread)
db.session.commit()
return redirect(request.referrer)

View File

@ -115,28 +115,73 @@ table.thread td.author {
table.thread td {
vertical-align: top;
}
table.thread td.message {
padding-top: 8px;
}
table.thread.topcomment td.message {
padding-top: 0;
}
table.thread td.message > *:nth-child(2) {
margin-top: 0;
}
table.thread td.message img {
max-width: 100%;
}
table.thread div.info {
float: right;
text-align: right;
opacity: 0.7;
padding-top: 8px;
margin-left: 16px;
table.thread .topcomment-placeholder div {
font-style: italic;
opacity: 0.5;
padding: 8px 0;
}
table.thread div.info {
text-align: right;
position: relative;
}
table.thread.topcomment div.info {
padding-bottom: 4px;
}
table.thread:not(.topcomment) div.info {
float: right;
}
table.thread div.info > * {
display: inline-block;
vertical-align: top;
}
table.thread div.info summary {
list-style: none;
cursor: pointer;
user-select: none;
}
table.thread .context-menu {
position: absolute;
right: 0;
top: 100%;
margin: 0;
padding: 4px 0;
box-shadow: var(--shadow);
border-radius: 4px;
transition: none;
background: var(--background);
z-index: 2;
border: 1px solid var(--border);
}
table.thread .context-menu a {
display: block;
padding: 4px 8px;
text-align: center;
color: inherit;
}
table.thread .context-menu a:hover {
background: var(--background-light);
text-decoration: none;
}
@media screen and (max-width: 1199px) {
table.thread div.info {
float: none;
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin-left: 0;
}
table.thread div.info > *:not(:last-child):after {
content: '·';
margin: 0 4px;
}
table.thread td.author {
/* Includes padding */

View File

@ -108,6 +108,13 @@ footer {
--border-xp: 1px solid #d03333;
}
.context-menu {
--background: #1d2326;
--shadow: 0 0 12px -9px #000000;
--border: #404040;
--background-light: #262c2f;
}
table {
--border: #404040;
}

View File

@ -102,6 +102,13 @@ footer {
--border-xp: 1px solid #be1818;
}
.context-menu {
--background: #ffffff;
--shadow: 0 0 12px -9px #000000;
--border: #d0d0d0;
--background-light: #f0f0f0;
}
div.pagination {
font-size: 14px;
margin: 13px;
@ -129,4 +136,4 @@ div.editor-toolbar {
--separator: #aaa2a2;
--text-disabled: #c0c0c0;
--text: #515151;
}
}

View File

@ -29,7 +29,7 @@
table {
--border: #d8d8d8;
}
table tr:nth-child(even) {
table tr:nth-child(odd) {
--background: rgba(0, 0, 0, .1);
}
table th {
@ -110,6 +110,13 @@ footer {
--border-xp: 1px solid #d03333;
}
.context-menu {
--background: #ffffff;
--shadow: 0 0 12px -9px #000000;
--border: #d0d0d0;
--background-light: #f0f0f0;
}
div.editor-toolbar, div.CodeMirror {
--border: #c0c0c0;
--background-light: #d9d9d9;

View File

@ -11,3 +11,17 @@ function getCookie(name) {
if( end == -1 ) end = document.cookie.length;
return unescape( document.cookie.substring( debut+name.length+1, end ) );
}
/* Automatically close context menus when clicking out of them */
function closeContextMenus(e) {
document.querySelectorAll('details[open]>.context-menu').forEach(menu => {
if(!menu.contains(event.target)) {
menu.parentElement.open = false;
e.preventDefault();
}
});
}
(function(){
window.addEventListener("click", closeContextMenus);
})();

View File

@ -3,6 +3,7 @@
{% import "widgets/thread.html" as widget_thread with context %}
{% import "widgets/user.html" as widget_user %}
{% import "widgets/pagination.html" as widget_pagination with context %}
{% import "widgets/attachments.html" as widget_attachments %}
{% block title %}
<a href='/forum'>Forum de Planète Casio</a> » <a href="{{ url_for('forum_page', f=t.forum) }}">{{ t.forum.name }}</a> » <h1>{{ t.title }}</h1>
@ -11,7 +12,15 @@
{% block content %}
<section>
<h1>{{ t.title }}</h1>
{{ widget_thread.thread([t.thread.top_comment], None) }}
{% call widget_thread.thread_leader(t.thread.top_comment) %}
<div class="info">
<div>Posté le {{ t.date_created | dyndate }}</div>
{{ widget_thread.post_actions(t) }}
</div>
{{ t.thread.top_comment.text | md }}
{{ widget_attachments.attachments(t.thread.top_comment) }}
{% endcall %}
{{ widget_pagination.paginate(comments, 'forum_topic', t, {'f': t.forum}) }}

View File

@ -1,30 +1,68 @@
{% import "widgets/user.html" as widget_user %}
{% import "widgets/attachments.html" as widget_attachments %}
{% macro thread(comments, top_comment) %}
<table class="thread {{ 'topcomment' if top_comment == None else ''}} ">
{# Post actions: this widget expands to a context menu with actions controlling
a post, supporting different types of posts. #}
{% macro post_actions(post) %}
{# TODO (Guest edit): determine permissions in post_actions widget #}
{% set auth = current_user.is_authenticated %}
{% set can_edit = auth and current_user.can_edit_post(post) %}
{% set can_delete = auth and current_user.can_delete_post(post) %}
{% set can_punish = auth and current_user.can_punish_post(post) %}
{% set can_topcomm = auth and current_user.can_set_topcomment(post) %}
{% if post.type == "topic" %}
{% set suffix = " le topic" %}
{% elif post.type == "program" %}
{% set suffix = " le programme" %}
{% endif %}
{% if can_edit or can_delete or can_punish or can_topcomm %}
<details>
<summary><b></b></summary>
<div class='context-menu'>
{% if can_edit %}
<a href="{{ url_for('edit_post', postid=post.id, r=request.path) }}">Modifier{{ suffix }}</a>
{% endif %}
{% if can_punish %}
<a href="{{ url_for('delete_post', postid=post.id, penalty=False, csrf_token=csrf_token()) }}" onclick="return confirm('Le post sera supprimé.')">Supprimer{{ suffix }} (normal)</a>
<a href="{{ url_for('delete_post', postid=post.id, penalty=True, csrf_token=csrf_token()) }}" onclick="return confirm('Le post sera supprimé avec pénalité d\'XP.')">Supprimer{{ suffix }} (pénalité)</a>
{% elif can_delete %}
<a href="{{ url_for('delete_post', postid=post.id, penalty=False, csrf_token=csrf_token()) }}" onclick="return confirm('Le post sera supprimé !')">Supprimer{{ suffix }}</a>
{% endif %}
{% if can_topcomm %}
<a href="{{ url_for('set_post_topcomment', postid=post.id, csrf_token=csrf_token()) }}">Utiliser comme en-tête</a>
{% endif %}
</div>
</details>
{% endif %}
{% endmacro %}
{# Thread widget: this widget expands to a table that shows a list of comments
from a thread, along with message controls.
comments: List of comments to render
top_comment: Thread's top comment (will be elided if encountered) #}
{% macro thread(comments, top_comment, owner=None) %}
<table class="thread">
{% for c in comments %}
<tr id="{{ c.id }}">
{% if c != top_comment %}
<tr id="{{ c.id }}">
<td class="author">{{ widget_user.profile(c.author) }}</td>
<td class="message">
<div class="info">
<div>Posté le {{ c.date_created|dyndate }}</div>
<div>Posté le <a href="{{ request.path }}#{{ c.id }}">{{ c.date_created | dyndate }}</a></div>
{% if c.date_created != c.date_modified %}
<div>Modifié le {{ c.date_modified|dyndate }}</div>
{% endif %}
<div><a href="{{ request.path }}#{{ c.id }}">Permalien</a></div>
{# TODO: Let guests edit their posts #}
{% if current_user.is_authenticated and current_user.can_edit_post(c) %}
<div><a href="{{ url_for('edit_post', postid=c.id, r=request.path) }}">Modifier</a></div>
{% endif %}
{% if current_user.is_authenticated and current_user.can_delete_post(c) %}
<div><a href="{{ url_for('delete_post', postid=c.id, csrf_token=csrf_token()) }}" onclick="return confirm('Le message sera supprimé')">Supprimer</a></div>
<div>Modifié le <a href="{{ request.path }}#{{ c.id }}">{{ c.date_modified | dyndate }}</a></div>
{% endif %}
{{ post_actions(c) }}
</div>
{{ c.text|md }}
{{ widget_attachments.attachments(c) }}
{% if c.author.signature %}
@ -32,10 +70,30 @@
{{ c.author.signature|md }}
{% endif %}
</td>
{% elif loop.index0 != 0 %}
<div>Ce message est le top comment</div>
{% endif %}
</tr>
{% elif loop.index0 != 0 %}
<tr class="topcomment-placeholder">
<td></td>
<td><div>Le commentaire à cet endroit est actuellement utilisé comme en-tête.</div></td>
</tr>
{% endif %}
{% endfor %}
</table>
{% endmacro %}
{# Thread leader widget: this widget expands to a single-message thread which
can show more text when called. This is intended for programs and similar
objects which display metadata before description and comments.
leader: Posts's top comment (actual rendering is delegated to caller) #}
{% macro thread_leader(leader) %}
<table class="thread topcomment">
{# Empty line to get normal background (instead of alternate one) #}
<tr></tr>
<tr id="{{ leader.id }}">
<td class="author">{{ widget_user.profile(leader.author) }}</td>
<td class="message">{{ caller() }}</td>
</tr>
</table>
{% endmacro %}