programs: add tag input and display (#114)

* Add a TagListField which automatically validates its input against the
  TagInformation database, and has a richer .selected_tags() method
* Add a dynamic tag input widget, available through a macro (*import
  with context*), that supports both JS and non-JS input
* Add a TagInformation.all_tags() function
* Add colored tag display to all themes
* Fix a bug causing programs to have no names
* Add tags: games.action, games.narrative, courses.informatics

[MASTER] Run the 'update-tags' command of master.py.
This commit is contained in:
Lephe 2022-06-14 23:16:19 +01:00
parent c74abf3fcc
commit db0e42d285
Signed by: Lephenixnoir
GPG Key ID: 1BBA026E13FC0495
22 changed files with 308 additions and 26 deletions

View File

@ -52,10 +52,14 @@ lang:
pretty: "Langage: autre"
games:
action:
pretty: Action
adventure:
pretty: Aventure
fighting:
pretty: Combat
narrative:
pretty: Narratif
other:
pretty: "Jeu: autre"
platform:
@ -98,5 +102,7 @@ courses:
pretty: SI/Électronique
economics:
pretty: Économie
informatics:
pretty: Informatique
other:
pretty: "Cours: autre"

View File

@ -3,12 +3,13 @@ from wtforms import StringField, SubmitField, TextAreaField, MultipleFileField
from wtforms.validators import InputRequired, Length
import app.utils.validators as vf
from app.utils.antibot_field import AntibotField
from app.utils.tag_field import TagListField
from app.forms.forum import CommentForm
class ProgramCreationForm(CommentForm):
name = StringField('Nom du programme',
validators=[InputRequired(), Length(min=3, max=64)])
tags = StringField('Liste de tags', description='Séparés par des virgules')
tags = TagListField('Liste de tags')
submit = SubmitField('Soumettre le programme')

View File

@ -24,18 +24,18 @@ class Program(Post):
# * tags (inherited from Post)
# * attachements (available at thread.top_comment.attachments)
def __init__(self, author, title, thread):
def __init__(self, author, name, thread):
"""
Create a Program.
Arguments:
author -- post author (User, though only Members can post)
title -- program title (unicode string)
name -- program name (unicode string)
thread -- discussion thread attached to the topic
"""
Post.__init__(self, author)
self.title = title
self.name = name
self.thread = thread
@staticmethod

View File

@ -19,6 +19,19 @@ class TagInformation(db.Model):
def __init__(self, id):
self.id = id
def category(self):
return self.id.split(".", 1)[0]
@staticmethod
def all_tags():
all_tags = {}
for ti in TagInformation.query.all():
ctgy = ti.category()
if ctgy not in all_tags:
all_tags[ctgy] = []
all_tags[ctgy].append(ti)
return all_tags
class Tag(db.Model):
"""Association between a Post and a dot-string tag identifier."""

View File

@ -3,15 +3,16 @@ from flask import url_for
from config import V5Config
from slugify import slugify
from app.utils.login_as import is_vandal
from app.models.tag import TagInformation
@app.context_processor
def utilities_processor():
""" Add some utilities to render context """
return dict(
len=len,
# enumerate=enumerate,
_url_for=lambda route, args, **other: url_for(route, **args, **other),
V5Config=V5Config,
slugify=slugify,
is_vandal=is_vandal
is_vandal=is_vandal,
db_all_tags=TagInformation.all_tags,
)

View File

@ -34,9 +34,8 @@ def program_submit():
db.session.commit()
# Add tags
# TODO: Check tags against a predefined set
for tag in form.tags.data.split(","):
db.session.add(Tag(p, tag.strip()))
for tag in form.tags.selected_tags():
db.session.add(Tag(p, tag))
db.session.commit()
# Manage files

View File

@ -8,7 +8,7 @@
.form form label + .desc {
margin: 0 0 4px 0;
font-size: 80%;
opacity: .75;
opacity: .65;
}
.form form .avatar {
width: 128px;
@ -98,6 +98,21 @@
.form input[type='email'].abfield {
display: none;
}
form .dynamic-tag-selector {
display: none;
}
form .dynamic-tag-selector input[type="text"] {
display: none;
}
form .dynamic-tag-selector .tag {
cursor: pointer;
}
form .dynamic-tag-selector .tags-selected {
margin: 0 0 4px 0;
}
form .dynamic-tag-selector .tags-selected .tag {
display: none;
}
.form.filter {
margin-bottom: 16px;
}

View File

@ -148,3 +148,23 @@ div.editor-toolbar, div.CodeMirror {
--border: rgba(255, 255, 255, 0.8);
--selected: rgba(255, 0, 0, 1.0);
}
.tag {
--background: #22292c;
--color: white;
}
.tag.tag-calc {
--background: #917e1a;
}
.tag.tag-lang {
--background: #4a8033;
}
.tag.tag-games {
--background: #488695;
}
.tag.tag-tools {
--background: #70538a;
}
.tag.tag-courses {
--background: #884646;
}

View File

@ -172,3 +172,22 @@ div.pagination {
font-size: 14px;
margin: 13px;
}
.tag {
--background: #e0e0e0;
}
.tag.tag-calc {
--background: #f0ca81;
}
.tag.tag-lang {
--background: #aad796;
}
.tag.tag-games {
--background: #a7ccd5;
}
.tag.tag-tools {
--background: #c6aae1;
}
.tag.tag-courses {
--background: #f0a0a0;
}

View File

@ -142,3 +142,22 @@ div.editor-toolbar, div.CodeMirror {
table.codehilitetable td.linenos {
color: #808080;
}
.tag {
--background: #e0e0e0;
}
.tag.tag-calc {
--background: #f0ca81;
}
.tag.tag-lang {
--background: #aad796;
}
.tag.tag-games {
--background: #a7ccd5;
}
.tag.tag-tools {
--background: #c6aae1;
}
.tag.tag-courses {
--background: #f0a0a0;
}

View File

@ -207,4 +207,15 @@ hr.signature {
}
.gallery-spot * {
cursor: pointer;
}
.tag {
display: inline-block;
background: var(--background);
color: var(--color);
padding: 4px 12px;
margin: 4px 0;
border-radius: 8px;
border-radius: calc(4.5em);
user-select: none;
cursor: default;
}

View File

@ -13,7 +13,7 @@
& + .desc {
margin: 0 0 4px 0;
font-size: 80%;
opacity: .75;
opacity: .65;
}
}
@ -115,6 +115,29 @@
}
/* Interactive tag selector */
form .dynamic-tag-selector {
display: none;
input[type="text"] {
display: none;
}
.tag {
cursor: pointer;
}
.tags-selected {
margin: 0 0 4px 0;
.tag {
display: none;
}
}
}
/* Interactive filter forms */
.form.filter {

View File

@ -239,3 +239,17 @@ hr.signature {
cursor: pointer;
}
}
/* Tags */
.tag {
display: inline-block;
background: var(--background);
color: var(--color);
padding: 4px 12px;
margin: 4px 0;
border-radius: 8px;
border-radius: calc(0.5em + 4px);
user-select: none;
cursor: default;
}

View File

@ -0,0 +1,61 @@
function tag_selector_find(node) {
while(node != document.body) {
if(node.classList.contains("dynamic-tag-selector"))
return node;
node = node.parentNode;
}
return undefined;
}
function tag_selector_get(ts) {
return ts.querySelector("input").value
.split(",")
.map(str => str.trim())
.filter(str => str !== "");
}
function tag_selector_set(ts, values) {
ts.querySelector("input").value = values.join(", ");
tag_selector_update(ts);
}
function tag_selector_update(ts) {
if(ts === undefined) return;
const input_names = tag_selector_get(ts);
/* Update visibility of selected tags */
ts.querySelectorAll(".tags-selected .tag[data-name]").forEach(tag => {
const visible = input_names.includes(tag.dataset.name);
tag.style.display = visible ? "inline-block" : "none";
});
/* Update visibility of pool tags */
ts.querySelectorAll(".tags-pool .tag[data-name]").forEach(tag => {
const visible = !input_names.includes(tag.dataset.name);
tag.style.display = visible ? "inline-block" : "none";
});
}
function tag_selector_add(ts, id) {
if(ts === undefined) return;
let tags = tag_selector_get(ts);
if(!tags.includes(id))
tags.push(id);
tag_selector_set(ts, tags);
}
function tag_selector_remove(ts, id) {
if(ts === undefined) return;
let tags = tag_selector_get(ts);
tags = tags.filter(str => str !== id);
tag_selector_set(ts, tags);
}
document.querySelectorAll(".dynamic-tag-selector").forEach(ts => {
ts.style.display = "block";
tag_selector_update(ts);
});

View File

@ -46,7 +46,7 @@
<div>
{{ form.newsletter.label }}
{{ form.newsletter() }}
<div style="font-size:80%;color:rgba(0,0,0,.5)">{{ form.newsletter.description }}</div>
<div class="desc">{{ form.newsletter.description }}</div>
{% for error in form.newsletter.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}

View File

@ -20,10 +20,14 @@
<tr><th>ID</th><th>Nom</th><th>Auteur</th><th>Publié le</th><th>Tags</th></tr>
{% for p in programs %}
<tr><td>{{ p.id }}</td>
<td><a href='{{ url_for("program_view", page=(p,1)) }}'>{{ p.name }}</a></td>
<td><a href='{{ url_for("program_view", page=(p,1)) }}'>{{ p.name }}</a></td>
<td>{{ p.author.name }}</td>
<td>{{ p.date_created | dyndate }}</td>
<td>{% for tag in p.tags %}{{ tag.name }} {% endfor %}</td></tr>
<td>{{ p.date_created | dyndate }}</td>
<td>
{%- for tag in p.tags %}
<span class="tag tag-{{ tag.tag.category() }}">{{ tag.tag.pretty }}</span>
{% endfor -%}
</td></tr>
{% endfor %}
</table>
</section>

View File

@ -1,5 +1,6 @@
{% extends "base/base.html" %}
{% import "widgets/editor.html" as widget_editor %}
{% import "widgets/tag_selector.html" as widget_tag_selector with context %}
{% block title %}
<h1>Programmes de Planète Casio</h1>
@ -22,14 +23,7 @@
{% endfor %}
</div>
<div>
{{ form.tags.label }}
<div class=desc>{{ form.tags.description }}</div>
{{ form.tags() }}
{% for error in form.tags.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
{{ widget_tag_selector.tag_selector(form.tags) }}
{{ widget_editor.text_editor(form.message) }}

View File

@ -0,0 +1,57 @@
{# Import this module with context:
{% import "widgets/tag_selector.html" as widget_tag_selector with context %}
This is necessary because it uses global names from context processors. #}
{% macro tag_selector(field) %}
{% set all_tags = db_all_tags() %}
{# When Javascript is disabled, we use the text field directly #}
<div>
<noscript>
{{ field.label }}
{% if field.description %}
<div class="desc">{{ field.description }}</div>
{% endif %}
<div class="desc">
Saisie sans Javascript: saisissez directement les noms techniques des
tags, séparés par des virgules (eg. <code>calc.g90+e</code>).
</div>
{{ field() }}
{% for error in field.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</noscript>
</div>
{# When Javascript is enabled, use a dynamic system where the user can add
and remove tags with the mouse #}
<div class="dynamic-tag-selector">
<div>{{ field.label }}:
<span class="tags-selected">
{% for ctgy, tags in all_tags.items() %}
{% for tag in tags %}
<span class="tag tag-{{ ctgy }}" data-name="{{ tag.id }}"
onclick="tag_selector_remove(tag_selector_find(this), '{{ tag.id }}')">{{ tag.pretty }}</span>
{% endfor %}
{% endfor %}
</span>
</div>
{% if field.description %}
<div class="desc">{{ field.description }}</div>
{% endif %}
{{ field(oninput="tag_selector_update(tag_selector_find(this))") }}
{% for error in field.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
<div>Tags disponibles :</div>
{% for ctgy, tags in all_tags.items() %}
<div class="tags-pool">
{% for tag in tags %}
<span class="tag tag-{{ ctgy }}" data-name="{{ tag.id }}"
onclick="tag_selector_add(tag_selector_find(this), '{{ tag.id }}')">{{ tag.pretty }}</span>
{% endfor %}
</div>
{% endfor %}
</div>
{% endmacro %}

View File

@ -19,7 +19,7 @@
{% set suffix = " le programme" %}
{% endif %}
{% if can_edit or can_delete or can_punish or can_topcomm %}
{% if can_edit or can_move or can_delete or can_punish or can_topcomm %}
<details>
<summary><b></b></summary>
<div class='context-menu'>

View File

@ -77,11 +77,11 @@ class PageConverter(BaseConverter):
class TopicPageConverter(PageConverter):
object = Topic
get_title = lambda self, t: t.title
get_title = lambda self, t: t.title or "unnamed-topic-error"
class ProgramPageConverter(PageConverter):
object = Program
get_title = lambda self, p: p.name
get_title = lambda self, p: p.name or "unnamed-program-error"
# Export only the converter classes
__all__ = ["ForumConverter", "TopicPageConverter", "ProgramPageConverter"]

View File

@ -27,6 +27,7 @@ def render(*args, styles=[], scripts=[], **kwargs):
'scripts/simplemde.min.js',
'scripts/gallery.js',
'scripts/filter.js',
'scripts/tag_selector.js',
]
# Apply theme from user settings

24
app/utils/tag_field.py Normal file
View File

@ -0,0 +1,24 @@
from wtforms.fields.simple import StringField
from wtforms.validators import ValidationError
from app.models.tag import TagInformation
def tag_validator(form, field):
all_tags = TagInformation.all_tags()
for name in field.selected_tags():
if all(ti.id != name for ctgy in all_tags for ti in all_tags[ctgy]):
raise ValidationError(f"Tag inconnu: {name}")
return True
class TagListField(StringField):
def __init__(self, title, *args, **kwargs):
validators = kwargs.get("validators", []) + [tag_validator]
super().__init__(
title,
*args,
**kwargs,
validators=[tag_validator])
def selected_tags(self):
raw = map(lambda x: x.strip(), self.data.split(","))
return [name for name in raw if name != ""]