diff --git a/app/data/tags.yaml b/app/data/tags.yaml index 8e09d88..88a3bea 100644 --- a/app/data/tags.yaml +++ b/app/data/tags.yaml @@ -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" diff --git a/app/forms/programs.py b/app/forms/programs.py index 1f462e7..ca4cf16 100644 --- a/app/forms/programs.py +++ b/app/forms/programs.py @@ -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') diff --git a/app/models/program.py b/app/models/program.py index 23bf006..ba291ad 100644 --- a/app/models/program.py +++ b/app/models/program.py @@ -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 diff --git a/app/models/tag.py b/app/models/tag.py index a7ad50d..ed4f548 100644 --- a/app/models/tag.py +++ b/app/models/tag.py @@ -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.""" diff --git a/app/processors/utilities.py b/app/processors/utilities.py index 0b8edf2..18bbf1d 100644 --- a/app/processors/utilities.py +++ b/app/processors/utilities.py @@ -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, ) diff --git a/app/routes/programs/submit.py b/app/routes/programs/submit.py index 0675a84..8bbdb7e 100644 --- a/app/routes/programs/submit.py +++ b/app/routes/programs/submit.py @@ -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 diff --git a/app/static/css/form.css b/app/static/css/form.css index b780e4a..9465375 100644 --- a/app/static/css/form.css +++ b/app/static/css/form.css @@ -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; } diff --git a/app/static/css/themes/FK_dark_theme.css b/app/static/css/themes/FK_dark_theme.css index 2a8ae11..3d9bd13 100644 --- a/app/static/css/themes/FK_dark_theme.css +++ b/app/static/css/themes/FK_dark_theme.css @@ -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; +} diff --git a/app/static/css/themes/Tituya_v43_theme.css b/app/static/css/themes/Tituya_v43_theme.css index f2cb187..571176c 100644 --- a/app/static/css/themes/Tituya_v43_theme.css +++ b/app/static/css/themes/Tituya_v43_theme.css @@ -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; +} diff --git a/app/static/css/themes/default_theme.css b/app/static/css/themes/default_theme.css index b87e4f8..1168211 100644 --- a/app/static/css/themes/default_theme.css +++ b/app/static/css/themes/default_theme.css @@ -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; +} diff --git a/app/static/css/widgets.css b/app/static/css/widgets.css index a7bb6eb..fcf977d 100644 --- a/app/static/css/widgets.css +++ b/app/static/css/widgets.css @@ -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; } \ No newline at end of file diff --git a/app/static/less/form.less b/app/static/less/form.less index 36e37b4..046b7dc 100644 --- a/app/static/less/form.less +++ b/app/static/less/form.less @@ -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 { diff --git a/app/static/less/widgets.less b/app/static/less/widgets.less index c86f2e0..41d602e 100644 --- a/app/static/less/widgets.less +++ b/app/static/less/widgets.less @@ -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; +} diff --git a/app/static/scripts/tag_selector.js b/app/static/scripts/tag_selector.js new file mode 100644 index 0000000..9836205 --- /dev/null +++ b/app/static/scripts/tag_selector.js @@ -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); +}); diff --git a/app/templates/account/register.html b/app/templates/account/register.html index 6efe74f..272318b 100644 --- a/app/templates/account/register.html +++ b/app/templates/account/register.html @@ -46,7 +46,7 @@
{{ form.newsletter.label }} {{ form.newsletter() }} -
{{ form.newsletter.description }}
+
{{ form.newsletter.description }}
{% for error in form.newsletter.errors %} {{ error }} {% endfor %} diff --git a/app/templates/programs/index.html b/app/templates/programs/index.html index 17ba396..26fdc86 100644 --- a/app/templates/programs/index.html +++ b/app/templates/programs/index.html @@ -20,10 +20,14 @@ IDNomAuteurPublié leTags {% for p in programs %} {{ p.id }} - {{ p.name }} + {{ p.name }} {{ p.author.name }} - {{ p.date_created | dyndate }} - {% for tag in p.tags %}{{ tag.name }} {% endfor %} + {{ p.date_created | dyndate }} + + {%- for tag in p.tags %} + {{ tag.tag.pretty }} + {% endfor -%} + {% endfor %} diff --git a/app/templates/programs/submit.html b/app/templates/programs/submit.html index 0ac58f7..27d2bee 100644 --- a/app/templates/programs/submit.html +++ b/app/templates/programs/submit.html @@ -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 %}

Programmes de Planète Casio

@@ -22,14 +23,7 @@ {% endfor %}
-
- {{ form.tags.label }} -
{{ form.tags.description }}
- {{ form.tags() }} - {% for error in form.tags.errors %} - {{ error }} - {% endfor %} -
+ {{ widget_tag_selector.tag_selector(form.tags) }} {{ widget_editor.text_editor(form.message) }} diff --git a/app/templates/widgets/tag_selector.html b/app/templates/widgets/tag_selector.html new file mode 100644 index 0000000..8813e20 --- /dev/null +++ b/app/templates/widgets/tag_selector.html @@ -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 #} +
+ +
+ + {# When Javascript is enabled, use a dynamic system where the user can add + and remove tags with the mouse #} +
+
{{ field.label }}: + + {% for ctgy, tags in all_tags.items() %} + {% for tag in tags %} + {{ tag.pretty }} + {% endfor %} + {% endfor %} + +
+ {% if field.description %} +
{{ field.description }}
+ {% endif %} + {{ field(oninput="tag_selector_update(tag_selector_find(this))") }} + {% for error in field.errors %} + {{ error }} + {% endfor %} + +
Tags disponibles :
+ {% for ctgy, tags in all_tags.items() %} +
+ {% for tag in tags %} + {{ tag.pretty }} + {% endfor %} +
+ {% endfor %} +
+{% endmacro %} diff --git a/app/templates/widgets/thread.html b/app/templates/widgets/thread.html index a1e87f7..67cd270 100644 --- a/app/templates/widgets/thread.html +++ b/app/templates/widgets/thread.html @@ -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 %}
diff --git a/app/utils/converters.py b/app/utils/converters.py index 6936a61..a6fe429 100644 --- a/app/utils/converters.py +++ b/app/utils/converters.py @@ -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"] diff --git a/app/utils/render.py b/app/utils/render.py index 8ecf8e6..a7cd320 100644 --- a/app/utils/render.py +++ b/app/utils/render.py @@ -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 diff --git a/app/utils/tag_field.py b/app/utils/tag_field.py new file mode 100644 index 0000000..25117de --- /dev/null +++ b/app/utils/tag_field.py @@ -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 != ""]