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:
parent
c74abf3fcc
commit
db0e42d285
|
@ -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"
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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."""
|
||||
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
|
@ -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 %}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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) }}
|
||||
|
||||
|
|
|
@ -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 %}
|
|
@ -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'>
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 != ""]
|
Loading…
Reference in New Issue