Compare commits

...

126 Commits

Author SHA1 Message Date
Darks a2cf0b2c78
fix: correction d'une erreur de priorité dans les feuilles de style 2020-07-13 23:26:35 +02:00
Darks 54d27478e1
fix: correction de la couleur du footer (oups) 2020-07-13 23:06:57 +02:00
Darks 04fb3225c1
refactor: passage aux variables CSS4 2020-07-13 22:54:34 +02:00
Darks 0974e73411
Petit debug sur la config 2020-02-09 23:15:51 +01:00
Darks 30be296fd6
Debug configuration :-° 2020-02-09 23:10:02 +01:00
Darks 74862634df
Modification de la gestion de configuration
Cf https://gitea.planet-casio.com/devs/PCv5/wiki/Configuration-locale
2020-02-09 23:04:00 +01:00
Darks e8ffbd598e
Modif mineure sur les avatars 2019-12-22 15:02:20 +01:00
Darks 98878bda7d
Meilleur gestion des avatars (#42) 2019-12-22 14:58:19 +01:00
Darks 1e618fafd1
Ajout des permaliens sur les commentaires de topic 2019-12-17 13:16:06 +01:00
Darks e0e02d5423
Fix de merde n°2 2019-12-17 10:14:37 +01:00
Darks 83d5a0b385
Typo --' 2019-12-17 10:13:22 +01:00
Darks 4b8ce0334a
Ajout d'une favicon :D 2019-12-17 10:10:51 +01:00
Darks 19d09a71df
Passage des routes en français (#41) 2019-12-16 23:57:50 +01:00
Darks 11daef02a1
Ajout des prémices du titre affiché
Reste commenté le temps qu'on implémente ça mieux
2019-12-16 23:09:27 +01:00
Darks 255ce8ad60
Corrige un bug remonté par Lephe (#40) 2019-12-11 13:13:01 +01:00
Darks 415cfd8d8f
Ajout des conditions de création de topics
– L'utilisateur a le droit 'write-everywhere'
– C'est un topic de news et l'utilisateur a le droit 'write-news'
— Ce n'est pas un topic de news et le topic est une feuille de l'arbre 
du forum

Je me rends compte que c'est foireux pour la gestion des permissions. 
Faudra reprendre ça pour faire quelque chose de plus modulable…
2019-12-10 23:01:40 +01:00
Darks bf8f766131
Ajout des groupes dans le panel de modification d'un compte 2019-12-10 22:27:39 +01:00
Darks d63173b48d
Changement de l'avatar par défaut :p 2019-12-10 22:20:49 +01:00
Darks bd559b9fad
Modif du style sur les titres dans le panel admin 2019-12-10 20:52:09 +01:00
Darks 5e00e41bf1
Résoud un bug d'affichage 2019-12-10 19:03:08 +01:00
Darks 4516f775cc
Résoud le problème d'affichage des trop grandes lignes 2019-12-10 17:06:29 +01:00
Darks 177fb7d84f
Ajout des trophées de post 2019-12-10 11:22:56 +01:00
Darks 9f5b607c45
Correction d'un petit bug de routage du bouton mofidier un compte 2019-12-10 10:00:20 +01:00
Darks 275eacbcbb
Correction d'un bug de pagination 2019-12-10 00:42:09 +01:00
Darks 51d0ce1129
Meilleure gestion des erreurs sur le changement d'avatar 2019-12-10 00:38:04 +01:00
Darks 07f980c207
Ajout de python-pillow dans les dépendances 2019-12-10 00:25:29 +01:00
Darks b7930c96a4
Correction d'un bug lors du post d'un commentaire 2019-12-10 00:24:53 +01:00
Darks 6afb6085d1
Gestion des avatars, deuxième et dernier (?) passage 2019-12-10 00:16:01 +01:00
Darks 9341c5883c
Premier jet sur les avatars 2019-12-09 23:24:05 +01:00
Darks d447372bf3
Petite modif : on a toujours au moins un commentaire par thread 2019-12-07 16:51:54 +01:00
Darks 04e317285f
Ajout du style pour la barre de pagination 2019-12-07 16:43:19 +01:00
Darks 4427688193
Amélioration du menu
Les derniers topics redirigent automatique sur la dernière page du topic
2019-12-07 16:38:45 +01:00
Darks b4341ed0f8
Ajout de la pagination pour les topics de forum 2019-12-07 16:34:39 +01:00
Darks bdf23d8a67
Corrige un bug apporté par le commit a2e408da
Retire les utilitaires du scope global du rendu (inutile pour le moment)
2019-12-07 16:12:23 +01:00
Darks a2e408daf9
Ajout du load dynmaique des messages (#39) 2019-12-07 16:06:00 +01:00
Darks 94badf4bad
Merge branch 'dev' of gitea.planet-casio.com:devs/PCv5 into dev 2019-12-05 23:08:54 +01:00
Darks cdbecac166
Stage 2 pour le changement de config (#38) 2019-12-05 23:06:44 +01:00
Darks d480a95e43
Premier passage à la configuration unifiée (#38) 2019-12-05 22:49:18 +01:00
Eragon 90ac259177
Ajout de la date de publication et de modification d'un post 2019-12-05 17:14:40 +01:00
Eragon 9da11b62ca
Fix du problème de date différente au post initial
La date de publication et la date de modification n'étaient pas les
mêmes lors de la création du message.
2019-12-05 11:40:28 +01:00
Darks 16bcfe9e30
Ajout du validateur DataRequired pour le contenu des topics 2019-12-04 22:52:19 +01:00
Darks ab03daf527
Retrait du gitignore des dossiers scripts 2019-12-04 22:47:55 +01:00
Darks da96de2f14
Ajout d'un premier jet d'éditeur Js de LightScript 2019-12-04 22:16:29 +01:00
Darks 4e4508c4fd
Correction de bugs
- TextField → EmailField
- Problème au login si le nom n'est pas normalisable
2019-12-04 17:02:00 +01:00
Darks 0a85f1fbee
Ajout de l'éditeur :D 2019-12-04 16:25:05 +01:00
Darks ecefb03bb1
Correction de bug concernant l'update des points 2019-12-04 14:15:46 +01:00
Darks 47fdd68e30
Merge branch 'dev' of gitea.planet-casio.com:devs/PCv5 into dev
Merge de dev into dev
2019-12-04 13:59:22 +01:00
Darks cf61b43e17
Modification de la liste des topics actifs du menu
+ Correction de bugs
2019-12-04 13:58:48 +01:00
Eragon 907708154d
Merge branch 'dev' of gitea.planet-casio.com:devs/PCv5 into dev 2019-12-04 13:30:13 +01:00
Darks 1be5ec93da
Gain de points quand on créé un topic ou poste un message 2019-12-04 12:22:42 +01:00
Darks f6be314ed7
Correction d'une méga faille de sécu
Ajout d'un fail-safe si la clé est celle par défaut
2019-12-04 12:22:16 +01:00
Eragon 27ca00ffce
Merge branch 'dev' of gitea.planet-casio.com:devs/PCv5 into dev 2019-12-04 12:01:11 +01:00
Darks e99e45b4ca
Modifications majeures du rendu du menu
- Les context processors sont dans un dossier à part
- Ajout du dynamisme dans le menu (récupération des derniers messages de 
topic)
2019-12-04 01:16:16 +01:00
Darks a4d514f2d3
Correction du style pour les thèmes sombres 2019-12-04 00:33:29 +01:00
Eragon 055db1d164
Ajout des urls de navbar pour les forums 2019-12-04 00:08:01 +01:00
Darks 82b402229f
Ajout d'un avatar par défaut 2019-12-04 00:05:15 +01:00
Darks 9de5b27d6e
Petites modifs sur le style du forum 2019-12-03 23:34:13 +01:00
Eragon dac218b3b9
Merge branch 'dev' of gitea.planet-casio.com:devs/PCv5 into dev 2019-12-03 23:16:07 +01:00
Eragon 134eaa4d58
Ajout du style et d'un peut d'affichage pour les topics
J'ai fait mon possible pour que ça rende bien et que le css ne soit pas
trop moche. Il faudra peut-être modifier légèrement l'afichage du
profil, ce dernier est pas assez bien en place à mon goût.
2019-12-03 23:13:22 +01:00
Darks 1434b3152b
Amélioration des slugs (utilisation de python-slugify) 2019-12-03 21:03:23 +01:00
Darks b5e875e136
Redirection sur le topic après sa création 2019-12-03 20:36:26 +01:00
Darks 662882cc15
Ajout des commentaires de topics
On ne peut pas encore modifier le top comment ni commencer un topic à 
partir d'un thread externe, mais les bases sont là :)
2019-12-03 20:32:01 +01:00
Darks 089e851b4c
Desactivé les notifs 2019-12-03 16:57:49 +01:00
Darks 087dd56cb2
Retiré le mode fixed du menu version mobile 2019-12-03 12:54:28 +01:00
Darks c7318c6dd6
Correction de #34 (+ dernières modif pour #27)
#27 induisait un bug d'affichage sur le menu lorsqu'il passe en haut. Ce 
commit résoud le problème
2019-12-03 12:46:04 +01:00
Darks a38c5378e8
Merge branch 'dev' of gitea.planet-casio.com:devs/PCv5 into dev 2019-12-02 23:24:57 +01:00
Darks 7f63577c4f
Passage en flex-column du menu latéral 2019-12-02 22:54:01 +01:00
Eragon 3dc935c4f6
Merge branch 'dev' of gitea.planet-casio.com:devs/PCv5 into dev 2019-12-02 00:03:52 +01:00
Eragon 7884ca8bec
Pas de message d'optin de post en invité si connecté
Ajout d'un if dans le template de l'index du forum pour ne pas afficher l'option
de post en invité si l'utilisateur est connecté.

Remplacement des caractères d'espaces insécables pour html par des
espaces insécables en utf-8.
2019-12-02 00:00:04 +01:00
Darks f7e9715572
Menu latéral élargit 2019-11-29 22:11:55 +01:00
Darks 9ef8ae26d0
Minor changes 2019-11-29 21:37:05 +01:00
Eragon f3e47bd082
Merge branch 'dev' of gitea.planet-casio.com:devs/PCv5 into dev 2019-11-29 11:19:59 +01:00
Darks f111983bde
Update des requirements 2019-11-28 14:28:08 +01:00
Darks d4e1b05c29
Divers correctifs
- Le menu est utilisable sans Js (penser à mettre à jour les endpoints)
- Réorganisation des templates
- Ajout d'une page listant les outils
2019-11-28 14:14:35 +01:00
Darks f6706f2b66
Correction d'un bug au login sur le formulaire du menu 2019-11-28 13:17:13 +01:00
Darks 279c194a59
Ajout du support de LDAP
Mettre le flag à True dans local_config.py pour l'activer, et la doc 
dans VPS-config pour setup l'environnement
2019-11-28 13:10:50 +01:00
Darks a8756c2990
Ajout d'un fichier local_config.py d'exemple 2019-11-28 11:33:39 +01:00
Darks 3691520399
Quelques améliorations sur le code front-end 2019-11-28 11:32:53 +01:00
Eragon f62842216d
Add scripts/ folder into gitignore 2019-11-26 11:42:30 +01:00
Darks ad41b5be38
Solved a security issue
Users could create accounts named Admin, ROOT, etc.
2019-11-21 16:27:53 +01:00
Lephe 2ed10a5a9d
model: minor convention updates 2019-11-21 15:31:46 +01:00
Darks 0cb3966de6
Ajout de la directive font-display: swap;
Ça permet d'afficher le texte avec une autre police en attendant que la 
font soit téléchargée
2019-11-03 11:48:37 +01:00
Darks a194136d47
Ajout de la police pour les smileys :) 2019-09-25 16:36:06 +02:00
Darks 67899f3e32 Merge branch 'dev' of fomys/PCv5 into dev 2019-09-17 16:54:25 +02:00
Louis Chauvet a8090908e9
Reformatage des formulaires - Un peu de retard désolé :-) 2019-09-13 19:34:20 +02:00
Louis Chauvet c7743bfa78
Oubli d'un point 2019-09-09 19:58:25 +02:00
Louis Chauvet 49a93db5d9
Ajout des textes d'erreurs en français dans les formulaires 2019-09-09 19:55:04 +02:00
Darks aebe09de68
Merge branch 'dev' of gitea.planet-casio.com:devs/PCv5 into dev 2019-09-09 14:55:04 +02:00
Darks c5d9b39f06
Correction d'un bug 2019-09-09 14:22:55 +02:00
Eragon e3e38fde6f Supprimer 'package.txt' 2019-09-09 13:38:33 +02:00
Eragon c80398cba3 Supprimer 'Pipfile' 2019-09-09 13:37:22 +02:00
Eragon ac654d0232 Supprimer 'requirements.txt' 2019-09-09 13:37:20 +02:00
Eragon d2365a8444
Merge branch 'dev' of gitea.planet-casio.com:devs/PCv5 into dev 2019-09-09 13:34:57 +02:00
Eragon 75756c3b36
Ignore virtualenv files 2019-09-09 13:31:19 +02:00
Lephe e0dc6944f7
gitignore: add a generic "exclude" folder
For notes, TODOs, whatever at a personal level.
2019-09-09 08:11:39 +02:00
Lephe 3ad3eca470
forum: list subforum topics
Also introduces a 'date' filter that displays date in a readable format.
2019-09-09 08:11:39 +02:00
Lephe 79e5af7924
users: allow norm-equivalent names in profile url 2019-09-09 08:11:38 +02:00
Lephe 8a0ba309e0
forum: restructure models and add topic creation
This changes fixes #25 by restructuring the forum models in a way
compatible with the polymorphic behavior of SQLAlchemy. Incidentally,
the new form turns out to be more appropriate for our use than the
polymorphic one originally used.

The migration for this task is non-trivial because the Thread class was
created with a foreign-key id which thus had no auto-increment or
associated sequence. The most reliable way of getting it back was to
recreate the table because SQLAlchemy ony performs automated sequence
introduction at table creation time. Four separate migration files
perform the whole change.

This commit also adds views and forms to create topics, and the
boilerplate for an advanced markup editor that can be used as a widget.
2019-09-09 08:11:38 +02:00
Lephe d1a8333cae
forum: add forum listing routes
With this change, URLs for forum listings are now available. This
includes URLs like /news or /projects/games. Each of them show a
(currently empty) forum index.

Note that URLs that are not linked to in the forum index, namely URLs
for forums that have children (eg. /forum/news), are still accessible. We
could ban this by raising ValidationError if the forum has a non-empty
[sub_forums] attribute but displaying all subjects feels better.

URLs that point to /forum, but do not name a sub-forum and are not of
the form of a topic URL produce 404 errors.
2019-09-09 08:11:38 +02:00
Lephe 10e3c88bd4
errors: use the title block 2019-09-09 08:11:38 +02:00
Lephe 9f30bd36a0
forum: add the forum index page
Also prepare some functions for topic listings for each forum.
2019-09-09 08:11:38 +02:00
Lephe 35f1335f64
forum: better tree visualization, and topics
Turns the forum tree visualization in the admin panel into a tree-like
table, and exposes the Topic class to the application and database.
2019-09-09 08:11:38 +02:00
Lephe 153f303857
rebase migration scripts from the notifications branch 2019-09-09 08:11:38 +02:00
Lephe aa75ff09a1
forum: implement forum tree generation
This commit adds a forum tree YAML file (URL-based rather than an
actual tree...) and the 'forums' and 'create-forums' commands for
the master script.

A page /admin/forums is also used to currently display the forum
tree, although this will probably be turned into a full table with
forum descriptions, and a form with edition capabilities.
2019-09-09 08:11:37 +02:00
Darks de83f09024
Ajout d'un logo plus petit pour ne pas charger le gros en permanence
Dans l'idéal faudrait même faire une version svg
2019-09-08 23:10:28 +02:00
Darks a29657da24
Merge branch 'dev' of gitea.planet-casio.com:devs/PCv5 into dev 2019-09-08 22:38:57 +02:00
Darks 035e4f9062
Corrections de style
Cf post 
https://www.planet-casio.com/Fr/forums/lecture_sujet.php?id=15836&page=last#168760
2019-09-08 22:38:19 +02:00
Eragon b628510455
Merge branch 'dev' of gitea.planet-casio.com:devs/PCv5 into dev 2019-09-08 16:45:11 +02:00
Eragon 2b8a78fe20
Issue #3, remplir /register/validation 2019-09-08 16:44:10 +02:00
Darks 0a33161af0
Corrige une erreur 500 si l'on se connecte sur un compte inexistant 2019-09-08 12:28:39 +02:00
Darks 7cad3d4345
Correction préférence de newsletter
À l'inscription, la préférence de la newsletter n'était pas prise en 
compte. Merci Hackcell pour la remontée du bug.
2019-09-08 11:38:26 +02:00
Darks 5bf90f9d05
Corrections de CSS
Corrige #26 et améliore un poil le bouton « se souvenir de moi » en mode 
light.
2019-09-07 21:54:59 +02:00
Darks ab6275c08f
Correction de bug
Il manquait un import menant à une erreur 500 lors d'une redirection 
avec `?next=some_url`
2019-09-07 14:58:16 +02:00
Eragon dbef50cb86
Ajout du temps maximum d'inactivité pour une connexion
Issue n° #23 Fixer le temps d'une session par cookie (Remember me)
Corrigé, par l'ajout d'une option dans le fichier de configuration et
du code pour gèrer ça.
2019-09-07 14:15:31 +02:00
Darks 2e80a56596
Ajout d'un groupe `nologin` (corrige #22)
Les comptes GLaDOS et PlanèteCasio sont automatiquement ajoutés au 
groupe "No login", qui empêche l'utilisateur de se connecter, et ce même 
si les identifiants sont corrects.
2019-09-03 09:28:07 +02:00
Darks 6d43d742c8
Mieux quand les notifs sont dans l'ordre anti-chronologique 2019-09-01 22:54:53 +02:00
Darks 7971e47522
Debug sur notifs, ajout du nombre dans le menu latéral
L'affichage du nombre de notifs méritera peut être un coup de peinture… 
À voir.
2019-09-01 22:33:00 +02:00
Darks c2fbef7ace
Petite modif, test de CI sur dev 2019-09-01 21:59:40 +02:00
Darks 15a4d38ea0
Ajout des notifications 2019-09-01 12:30:41 +02:00
Darks 0c7c408e40
Ajout d'un fichier de configuration local, non tracé par git 2019-09-01 10:35:37 +02:00
Darks 4868774b96
Test d'intégration continue 2019-09-01 00:32:20 +02:00
Darks f508536805
Détaché le nom de la bdd du fichier de config 2019-08-31 23:00:43 +02:00
Lephe eeaab86d0a forum: improve model relationships (so that it works) 2019-08-24 19:17:13 +02:00
Lephe 11b19af199 forum: provide suitable migrations for the database
First migrate without the foreign key to create the tables, then add the
foreign key in a second migration.

Also removed unneeded imports that caused dependency cycles.

Minor "style" edits with the ambiguous use of super and unnecessary
db.Model inheritance.
2019-08-21 16:50:23 +02:00
Darks 201e961ba2
Ajout des stats sur la durée de chargement 2019-08-20 18:07:16 +02:00
Darks 81c910832b
Ajout des post/thread/comment/etc. 2019-08-20 17:34:00 +02:00
136 changed files with 2954 additions and 543 deletions

11
.gitignore vendored
View File

@ -6,13 +6,12 @@ app/static/avatars/
## Devlopement files
# virtualenv
requirements.txt
venv/
.venv/
# pipenv
Pipfile
Pipfile.lock
# Sublime Text files
*.sublime-project
*.sublime-workspace
## Deployment files
@ -22,7 +21,13 @@ uwsgi.ini
run.sh
# Update script to pull repository from SSH
update.sh
# Config to set up some server specific config
local_config.py
## Wiki
wiki/
## Personal folder
exclude/

25
Pipfile
View File

@ -1,25 +0,0 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
flask = ">=1.0"
flask-wtf = ">=0.14"
flask-login = ">=0.4"
flask-migrate = ">=2.3"
flask-sqlalchemy = ">=2.3"
flask-script = ">=2.0"
uwsgi = ">=2.0"
psycopg2-binary = ">=2.7"
pyyaml = ">=3.13"
[dev-packages]
[requires]
python_version = "3.7"
[scripts]
init = "scripts/init.sh"
migrate = "scripts/migrate.sh"
run_dev = "scripts/run_dev.sh"

View File

@ -1,6 +1,6 @@
# Bibliothèques nécessaires
Attention, l'environnement est sous `python3`. Vérifiez que ce soit bien le cas sur votre config, quitte
Attention, l'environnement est sous `python3`. Vérifiez que ce soit bien le cas sur votre config, quitte
à faire un virtual environment.
La liste de paquets fourni est pour Archlinux, les paquets peuvent avoir des noms légèrement différents dans votre distribution.
@ -12,7 +12,10 @@ python-flask-migrate
python-flask-script
python-flask-sqlalchemy
python-flask-wtf
python-ldap
python-uwsgi
python-psycopg2
python-pillow
python-pyyaml
python-slugify
```

2
V5.py
View File

@ -1,6 +1,6 @@
from app import app, db
from app.models.users import User, Guest, Member, Group, GroupPrivilege
# from app.models.models import Post
from app.models.topic import Topic
@app.shell_context_processor

View File

@ -1,11 +1,17 @@
from flask import Flask
from flask import Flask, g
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
from config import Config
import time
app = Flask(__name__)
app.config.from_object(Config)
# Check security of secret
if Config.SECRET_KEY == "a-random-secret-key":
raise Exception("Please use a strong secret key!")
db = SQLAlchemy(app)
migrate = Migrate(app, db)
@ -13,9 +19,32 @@ login = LoginManager(app)
login.login_view = 'login'
login.login_message = "Veuillez vous authentifier avant de continuer."
from app.utils.converters import *
app.url_map.converters['topicslug'] = TopicSlugConverter
app.url_map.converters['forum'] = ForumConverter
@app.before_request
def request_time():
g.request_start_time = time.time()
g.request_time = lambda: "%.5fs" % (time.time() - g.request_start_time)
from app.processors.menu import menu_processor
from app.processors.utilities import utilities_processor
from app import models # IDK why this is here, but it works
from app.routes import index, search, users # To load routes at initialization
from app.routes.account import login, account
from app.routes.admin import index, groups, account, trophies
from app.models.comment import Comment
from app.models.thread import Thread
from app.models.forum import Forum
from app.models.topic import Topic
from app.models.notification import Notification
from app.routes import index, search, users, tools # To load routes at initialization
from app.routes.account import login, account, notification
from app.routes.admin import index, groups, account, trophies, forums
from app.routes.forum import index, topic
from app.utils import pluralize # To use pluralize into the templates
from app.utils import date
from app.utils import is_title

105
app/data/forums.yaml Normal file
View File

@ -0,0 +1,105 @@
# This file is a list of forums to create when setting up Planète Casio.
#
# * Keys are used as URLs paths and for unique identification.
# * Prefixes represent the privilege category for a forum. Owning privileges
# with this prefix allows the user to post in this forum and all its
# sub-forum regardless of their settings ("forum-root-*" are hyper powerful).
# * For open forums, use the prefix "open".
/:
name: Forum de Planète Casio
prefix: root
# News
/actus:
name: Actualités
prefix: news
/actus/projets:
name: Actualités des projets
prefix: projectnews
descr: Nouveautés des projets de la communauté.
/actus/calc:
name: Actualités des constructeurs de calculatrices
prefix: calcnews
descr: Nouveautés CASIO, nouveaux modèles de calculatrices, mises à jour du
système ou nouveautés d'autres constructeurs.
/actus/evenements:
name: Événements organisés par Planète Casio
prefix: eventnews
descr: Tous les événements organisés par Planète Casio ou la communauté.
/actus/autres:
name: Autres nouveautés
prefix: othernews
descr: Actualités non catégorisées.
# Help
/aide:
name: Aide et questions
prefix: help
/aide/transferts:
name: Questions sur les tranferts
prefix: transferhelp
descr: Questions sur le transfert de fichiers et l'installation de programmes
sur la calculatrice.
/aide/calc:
name: Question sur l'utilisation des calculatrices
prefix: calchelp
descr: Questions sur l'utilisation des applications de la calculatrice,
paramètres, formats de fichiers...
/aide/prog:
name: Questions de programmation
prefix: proghelp
descr: Questions sur le développement et le debuggage de programmes.
/aide/autres:
name: Autres questions
prefix: otherhelp
descr: Questions non catégorisées.
# Projects
/projets:
name: Forum des projets
prefix: projects
/projets/jeux:
name: Projets de jeux
prefix: gameprojects
descr: Projets de jeux pour calculatrices, tous langages confondus et tous
modèles de calculatrices confondus.
/projets/applis:
name: Projets d'applications, utilitaires, outils pour calculatrice
prefix: appprojects
descr: Projets d'applications (hors jeux) pour calculatrice, tous langages et
modèles confondus.
/projets/outils:
name: Projets pour d'autres plateformes
prefix: toolprojetcs
descr: Tous les projets tournant sur ordinateur, téléphone, ou toute autre
plateforme que la calculatrice.
# Community
/communaute:
name: Vie communautaire
prefix: community
descr: Projets pour Planète Casio, remarques sur le fonctionnement du site et
de sa communauté.
# Discussion
/discussion:
name: Discussion
prefix: discussion
descr: Sujets hors-sujet et discussion libre.

View File

@ -1,8 +1,8 @@
-
name: Administrateur
css: "color: #ee0000"
css: "color: #ee0000;"
descr: "Vous voyez Chuck Norris? Pareil."
privs: access-admin-board access-assoc-board write-news
privs: access-admin-board access-assoc-board write-news write-anywhere
upload-shared-files delete-shared-files
edit-posts delete-posts scheduled-posting
delete-content move-public-content move-private-content showcase-content
@ -11,9 +11,10 @@
shoutbox-kick shoutbox-ban
unlimited-pms footer-statistics community-login
access-admin-panel edit-account delete-account edit-trophies
delete_notification
-
name: Modérateur
css: "color: green"
css: "color: green;"
descr: "Maîtres du kick, ils sont là pour faire respecter un semblant d'ordre."
privs: access-admin-board
edit-posts delete-posts
@ -23,7 +24,7 @@
unlimited-pms
-
name: Développeur
css: "color: #4169e1"
css: "color: #4169e1;"
descr: "Les développeurs maintiennent et améliorent le code du site."
privs: access-admin-board
upload-shared-files delete-shared-files
@ -33,7 +34,7 @@
access-admin-panel
-
name: Rédacteur
css: "color: blue"
css: "color: blue;"
descr: "Rédigent les meilleurs articles de la page d'accueil, rien que pour
vous <3"
privs: access-admin-board write-news
@ -42,7 +43,7 @@
showcase-content edit-static-content
-
name: Responsable communauté
css: "color: DarkOrange"
css: "color: DarkOrange;"
descr: "Anime les pages Twitter et Facebook de Planète Casio et surveille
l'évolution du monde autour de nous !"
privs: access-admin-board write-news
@ -51,22 +52,26 @@
showcase-content
-
name: Partenaire
css: "color: purple"
css: "color: purple;"
descr: "Membres de l'équipe d'administration des sites partenaires."
privs: write-news
upload-shared-files delete-shared-files
scheduled-posting
-
name: Compte communautaire
css: "background:#d8d8d8; border-radius:4px; color:#303030; padding:1px 2px"
css: "background:#d8d8d8; border-radius:4px; color:#303030; padding:1px 2px;"
descr: "Compte à usage général de l'équipe de Planète Casio."
-
name: Robot
css: "color: #cf25d0"
css: "color: #cf25d0;"
descr: "♫ Je suis Nono, le petit robot, l'ami d'Ulysse ♫"
privs: shoutbox-post shoutbox-kick shoutbox-ban
-
name: Membre de CreativeCalc
css: "color: #222222"
css: "color: #222222;"
descr: "CreativeCalc est l'association qui gère Planète Casio."
privs: access-assoc-board
-
name: No login
css: "color: #888888;"
descr: "Compte dont l'accès au site est désactivé."

View File

@ -1,6 +1,6 @@
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, TextAreaField, SubmitField, DecimalField, SelectField
from wtforms.fields.html5 import DateField
from wtforms.fields.html5 import DateField, EmailField
from wtforms.validators import DataRequired, InputRequired, Optional, Email, EqualTo
from flask_wtf.file import FileField # Cuz' wtforms' FileField is shitty
from app.models.trophies import Trophy
@ -8,53 +8,226 @@ import app.utils.validators as vd
class RegistrationForm(FlaskForm):
username = StringField('Pseudonyme', description='Ce nom est définitif !', validators=[DataRequired(), vd.name_valid, vd.name_available])
email = StringField('Adresse Email', validators=[DataRequired(), Email(), vd.email])
password = PasswordField('Mot de passe', validators=[DataRequired(), vd.password])
password2 = PasswordField('Répéter le mot de passe', validators=[DataRequired(), EqualTo('password')])
guidelines = BooleanField("""J'accepte les <a href="#">CGU</a>""", validators=[DataRequired()])
newsletter = BooleanField('Inscription à la newsletter', description='Un mail par trimestre environ, pour être prévenu des concours, évènements et nouveautés.')
submit = SubmitField("S'inscrire")
username = StringField(
'Pseudonyme',
description='Ce nom est définitif !',
validators=[
DataRequired(),
vd.name_valid,
vd.name_available,
],
)
email = EmailField(
'Adresse Email',
validators=[
DataRequired(),
Email(message="Adresse email invalide."),
vd.email,
],
)
password = PasswordField(
'Mot de passe',
validators=[
DataRequired(),
vd.password,
],
)
password2 = PasswordField(
'Répéter le mot de passe',
validators=[
DataRequired(),
EqualTo('password', message="Les mots de passe doivent être identiques."),
],
)
guidelines = BooleanField(
"""J'accepte les <a href="#">CGU</a>""",
validators=[
DataRequired(),
],
)
newsletter = BooleanField(
'Inscription à la newsletter',
description='Un mail par trimestre environ, pour être prévenu des concours, évènements et nouveautés.',
)
submit = SubmitField(
"S'inscrire",
)
class UpdateAccountForm(FlaskForm):
avatar = FileField('Avatar', validators=[Optional(), vd.avatar])
email = StringField('Adresse email', validators=[Optional(), Email(), vd.email, vd.old_password])
password = PasswordField('Mot de passe', validators=[Optional(), vd.password, vd.old_password])
password2 = PasswordField('Répéter le mot de passe', validators=[Optional(), EqualTo('password')])
old_password = PasswordField('Mot de passe actuel', validators=[Optional()])
birthday = DateField('Anniversaire', validators=[Optional()])
signature = TextAreaField('Signature', validators=[Optional()])
biography = TextAreaField('Présentation', validators=[Optional()])
newsletter = BooleanField('Inscription à la newsletter', description='Un mail par trimestre environ, pour être prévenu des concours, évènements et nouveautés.')
avatar = FileField(
'Avatar',
validators=[
Optional(),
vd.avatar,
],
)
email = EmailField(
'Adresse email',
validators=[
Optional(),
Email(message="Addresse email invalide."),
vd.email,
vd.old_password,
],
)
password = PasswordField(
'Mot de passe',
validators=[
Optional(),
vd.password,
vd.old_password,
],
)
password2 = PasswordField(
'Répéter le mot de passe',
validators=[
Optional(),
EqualTo('password', message="Les mots de passe doivent être identiques."),
],
)
old_password = PasswordField(
'Mot de passe actuel',
validators=[
Optional(),
],
)
birthday = DateField(
'Anniversaire',
validators=[
Optional(),
],
)
signature = TextAreaField(
'Signature',
validators=[
Optional(),
]
)
biography = TextAreaField(
'Présentation',
validators=[
Optional(),
]
)
newsletter = BooleanField(
'Inscription à la newsletter',
description='Un mail par trimestre environ, pour être prévenu des concours, évènements et nouveautés.',
)
submit = SubmitField('Mettre à jour')
class DeleteAccountForm(FlaskForm):
delete = BooleanField('Confirmer la suppression', validators=[DataRequired()], description='Attention, cette opération est irréversible!')
old_password = PasswordField('Mot de passe', validators=[DataRequired(), vd.old_password])
submit = SubmitField('Supprimer le compte')
delete = BooleanField(
'Confirmer la suppression',
validators=[
DataRequired(),
],
description='Attention, cette opération est irréversible!'
)
old_password = PasswordField(
'Mot de passe',
validators=[
DataRequired(),
vd.old_password,
],
)
submit = SubmitField(
'Supprimer le compte',
)
class AdminUpdateAccountForm(FlaskForm):
username = StringField('Pseudonyme', validators=[Optional(), vd.name_valid])
avatar = FileField('Avatar', validators=[Optional(), vd.avatar])
email = StringField('Adresse email', validators=[Optional(), Email(), vd.email])
email_validate = BooleanField("Envoyer un email de validation à la nouvelle adresse", description="Si décoché, l'utilisateur devra demander explicitement un email de validation, ou faire valider son adresse email par un administrateur.")
password = PasswordField('Mot de passe', description="L'ancien mot de passe ne pourra pas être récupéré !", validators=[Optional(), vd.password])
xp = DecimalField('XP', validators=[Optional()])
birthday = DateField('Anniversaire', validators=[Optional()])
signature = TextAreaField('Signature', validators=[Optional()])
biography = TextAreaField('Présentation', validators=[Optional()])
newsletter = BooleanField('Inscription à la newsletter', description='Un mail par trimestre environ, pour être prévenu des concours, évènements et nouveautés.')
submit = SubmitField('Mettre à jour')
username = StringField(
'Pseudonyme',
validators=[
Optional(),
vd.name_valid,
],
)
avatar = FileField(
'Avatar',
validators=[
Optional(),
vd.avatar,
],
)
email = EmailField(
'Adresse email',
validators=[
Optional(),
Email(message="Addresse email invalide."),
vd.email,
],
)
email_validate = BooleanField(
"Envoyer un email de validation à la nouvelle adresse",
description="Si décoché, l'utilisateur devra demander explicitement un email "\
"de validation, ou faire valider son adresse email par un administrateur.",
)
password = PasswordField(
'Mot de passe',
description="L'ancien mot de passe ne pourra pas être récupéré !",
validators=[
Optional(),
vd.password,
],
)
xp = DecimalField(
'XP',
validators=[
Optional(),
]
)
birthday = DateField(
'Anniversaire',
validators=[
Optional(),
],
)
signature = TextAreaField(
'Signature',
validators=[
Optional(),
],
)
biography = TextAreaField(
'Présentation',
validators=[
Optional(),
],
)
newsletter = BooleanField(
'Inscription à la newsletter',
description='Un mail par trimestre environ, pour être prévenu des concours, évènements et nouveautés.',
)
submit = SubmitField(
'Mettre à jour',
)
class AdminAccountEditTrophyForm(FlaskForm):
# Boolean inputs are generated on-the-fly from trophy list
submit = SubmitField('Modifier')
submit = SubmitField(
'Modifier',
)
class AdminAccountEditGroupForm(FlaskForm):
# Boolean inputs are generated on-the-fly from group list
submit = SubmitField(
'Modifier',
)
class AdminDeleteAccountForm(FlaskForm):
delete = BooleanField('Confirmer la suppression', validators=[DataRequired()], description='Attention, cette opération est irréversible!')
submit = SubmitField('Supprimer le compte')
delete = BooleanField(
'Confirmer la suppression',
validators=[
DataRequired(),
],
description='Attention, cette opération est irréversible!',
)
submit = SubmitField(
'Supprimer le compte',
)

13
app/forms/forum.py Normal file
View File

@ -0,0 +1,13 @@
from flask_wtf import FlaskForm
from wtforms import StringField, FormField, SubmitField, TextAreaField
from wtforms.validators import DataRequired, Length
class TopicCreationForm(FlaskForm):
title = StringField('Nom du sujet',
validators=[DataRequired(), Length(min=3, max=32)])
message = TextAreaField('Message principal', validators=[DataRequired()])
submit = SubmitField('Créer le sujet')
class CommentForm(FlaskForm):
message = TextAreaField('Commentaire', validators=[DataRequired()])
submit = SubmitField('Commenter')

View File

@ -4,7 +4,21 @@ from wtforms.validators import DataRequired
class LoginForm(FlaskForm):
username = StringField('Identifiant', validators=[DataRequired()])
password = PasswordField('Mot de passe', validators=[DataRequired()])
remember_me = BooleanField('Se souvenir de moi')
submit = SubmitField('Connexion')
username = StringField(
'Identifiant',
validators=[
DataRequired(),
],
)
password = PasswordField(
'Mot de passe',
validators=[
DataRequired(),
],
)
remember_me = BooleanField(
'Se souvenir de moi',
)
submit = SubmitField(
'Connexion',
)

View File

@ -6,10 +6,27 @@ from wtforms.validators import DataRequired, Optional
# TODO: compléter le formulaire de recherche avancée
class AdvancedSearchForm(FlaskForm):
q = StringField('Rechercher :', validators=[DataRequired()])
date = DateField('Date', validators=[Optional()])
submit = SubmitField('Affiner la recherche')
q = StringField(
'Rechercher :',
validators=[
DataRequired(),
],
)
date = DateField(
'Date',
validators=[
Optional(),
],
)
submit = SubmitField(
'Affiner la recherche',
)
class SearchForm(FlaskForm):
q = StringField('Rechercher', validators=[DataRequired()])
q = StringField(
'Rechercher',
validators=[
DataRequired(),
],
)

View File

@ -5,12 +5,38 @@ from flask_wtf.file import FileField # Cuz' wtforms' FileField is shitty
class TrophyForm(FlaskForm):
name = StringField('Nom', validators=[DataRequired()])
icon = FileField('Icône')
title = BooleanField('Titre', description='Un titre peut être affiché en dessous du pseudo.', validators=[Optional()])
css = StringField('CSS', description='CSS appliqué au titre, le cas échéant.')
submit = SubmitField('Envoyer')
name = StringField(
'Nom',
validators=[
DataRequired(),
],
)
icon = FileField(
'Icône',
)
title = BooleanField(
'Titre',
description='Un titre peut être affiché en dessous du pseudo.',
validators=[
Optional(),
],
)
css = StringField(
'CSS',
description='CSS appliqué au titre, le cas échéant.',
)
submit = SubmitField(
'Envoyer',
)
class DeleteTrophyForm(FlaskForm):
delete = BooleanField('Confirmer la suppression', validators=[DataRequired()], description='Attention, cette opération est irréversible!')
submit = SubmitField('Supprimer le trophée')
delete = BooleanField(
'Confirmer la suppression',
validators=[
DataRequired(),
],
description='Attention, cette opération est irréversible!',
)
submit = SubmitField(
'Supprimer le trophée',
)

43
app/models/comment.py Normal file
View File

@ -0,0 +1,43 @@
from app import db
from app.models.post import Post
from sqlalchemy.orm import backref
class Comment(Post):
__tablename__ = 'comment'
__mapper_args__ = {'polymorphic_identity': __tablename__}
# ID of the underlying Post object
id = db.Column(db.Integer, db.ForeignKey('post.id'), primary_key=True)
# Comment contents
text = db.Column(db.UnicodeText)
# Parent thread
thread_id = db.Column(db.Integer, db.ForeignKey('thread.id'),
nullable=False)
thread = db.relationship('Thread',
backref=backref('comments', lazy='dynamic'),
foreign_keys=thread_id)
def __init__(self, author, text, thread):
"""
Create a new Comment in a thread.
Arguments:
author -- comment poster (User)
text -- contents (unicode string)
thread -- parent discussion thread (Thread)
"""
Post.__init__(self, author)
self.thread = thread
self.text = text
def edit(self, new_text):
"""Edit a Comment's contents."""
self.text = new_text
self.touch()
def __repr__(self):
return f'<Comment: #{self.id}>'

41
app/models/forum.py Normal file
View File

@ -0,0 +1,41 @@
from app import db
class Forum(db.Model):
__tablename__ = 'forum'
id = db.Column(db.Integer, primary_key=True)
# Forum name, as displayed on the site (eg. "Problèmes de transfert")
name = db.Column(db.Unicode(64))
# Privilege prefix (sort of slug) for single-forum privileges (lowercase)
prefix = db.Column(db.Unicode(64))
# Forum description, as displayed on the site
descr = db.Column(db.UnicodeText)
# Forum URL, for dynamic routes
url = db.Column(db.String(64))
# Relationships
parent_id = db.Column(db.Integer, db.ForeignKey('forum.id'), nullable=True)
parent = db.relationship('Forum', backref='sub_forums', remote_side=id,
lazy=True, foreign_keys=parent_id)
# Other fields populated automatically through relations:
# <topics> List of topics in this exact forum (of type Topic)
def __init__(self, url, name, prefix, descr="", parent=None):
self.url = url
self.name = name
self.descr = descr
self.prefix = prefix
if isinstance(parent, str):
self.parent = Forum.query.filter_by(url=str).first()
else:
self.parent = parent
def post_count(self):
"""Number of posts in every topic of the forum, without subforums."""
return sum(len(t.thread.comments) for t in self.topics)
def __repr__(self):
return f'<Forum: {self.name}>'

View File

@ -0,0 +1,24 @@
from app import db
from datetime import datetime
class Notification(db.Model):
""" A long-term `flash` notification. It is deleted when watched """
__tablename__ = 'notification'
id = db.Column(db.Integer, primary_key=True)
text = db.Column(db.UnicodeText)
href = db.Column(db.UnicodeText)
date = db.Column(db.DateTime, default=datetime.now())
owner_id = db.Column(db.Integer, db.ForeignKey('member.id'),nullable=False)
owner = db.relationship('Member', backref='notifications',
foreign_keys=owner_id)
def __init__(self, owner, text, href=None):
self.text = text
self.href = href
self.owner = owner
def __repr__(self):
return f'<Notification to {self.owner.name}: {self.text} ({self.href})>'

View File

@ -1,42 +1,59 @@
from datetime import datetime
from app import db
from app.models.users import *
from app.models.users import User
from datetime import datetime
class Post(db.Model):
"""Contents created and published by Users."""
__tablename__ = 'post'
# Unique Post ID for the whole site
id = db.Column(db.Integer, primary_key=True)
# Post type (polymorphic discriminator)
type = db.Column(db.String(20))
# Creation and edition date
date_created = db.Column(db.DateTime)
date_modified = db.Column(db.DateTime)
# Post author
author_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
author = db.relationship('User', backref="posts", foreign_keys=author_id)
# TODO: Post attachments?
__mapper_args__ = {
'polymorphic_identity': __tablename__,
'polymorphic_on': type
}
# Standalone properties
text = db.Column(db.Text(convert_unicode=True))
date_created = db.Column(db.DateTime, default=datetime.now)
date_modified = db.Column(db.DateTime, default=datetime.now)
# Relationships
author_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
def __init__(self, author, text):
""" Create a Post """
self.text = text
if type(author) == Member:
author = author.id
self.author_id = author
def __init__(self, author):
"""
Create a new Post.
Arguments:
author -- post author (User)
"""
self.author = author
self.date_created = datetime.now()
self.date_modified = self.date_created
def touch(self):
"""Touch a Post when it is edited."""
def update(self, text):
""" Update a post. Check whether the request sender has the right to do
this! """
self.text = text
self.date_modified = datetime.now()
def change_ownership(self, new_author):
""" Change ownership of a post. Check whether the request sender has the
right to do this! """
if type(new_author) == Member:
new_author = new_author.id
self.author_id = new_author
"""
Change ownership of a Post. This is a privileged operation!
Arguments:
new_author -- new post author (User)
"""
self.author = new_author
def __repr__(self):
return f'<Post: #{self.id}>'

View File

@ -26,7 +26,7 @@ class SpecialPrivilege(db.Model):
self.priv = priv
def __repr__(self):
return f'<Privilege "{self.priv}" of member #{self.mid}>'
return f'<Privilege: {self.priv} of member #{self.mid}>'
# Group: User group, corresponds to a community role and a set of privileges
@ -70,7 +70,7 @@ class Group(db.Model):
return sorted(gp.priv for gp in gps)
def __repr__(self):
return f'<Group "{self.name}">'
return f'<Group: {self.name}>'
# Many-to-many relation for users belonging to groups
@ -79,7 +79,7 @@ GroupMember = db.Table('group_member', db.Model.metadata,
db.Column('uid', db.Integer, db.ForeignKey('member.id')))
# Meny-to-many relationship for privileges granted to groups
# Many-to-many relationship for privileges granted to groups
class GroupPrivilege(db.Model):
__tablename__ = 'group_privilege'
id = db.Column(db.Integer, primary_key=True)

41
app/models/thread.py Normal file
View File

@ -0,0 +1,41 @@
from app import db
class Thread(db.Model):
"""Some thread, such as a topic, program, tutorial."""
__tablename__ = 'thread'
# Unique ID
id = db.Column(db.Integer, primary_key=True)
# Top comment
top_comment_id = db.Column(db.Integer, db.ForeignKey('comment.id'))
top_comment = db.relationship('Comment', foreign_keys=top_comment_id)
# Other fields populated automatically through relations:
# <comments> The list of comments (of type Comment)
def __init__(self):
"""
Create a empty Thread. Normally threads are not meant to be empty, so
you should create a Comment with this thread as parent, then assign it
as top comment with a call to set_top_comment().
"""
self.top_comment_id = None
def set_top_comment(self, top_comment):
"""
Changes the top comment of the thread. The old top comment will usually
become visible in the flow of posts instead of being pinned at the top.
Arguments:
top_comment -- new top comment, must belong to this thread
"""
if top_comment not in self.comments:
raise Exception("Cannot set foreign comment as top thread comment")
self.top_comment = top_comment
def __repr__(self):
return f'<Thread: #{self.id}>'

44
app/models/topic.py Normal file
View File

@ -0,0 +1,44 @@
from app import db
from app.models.post import Post
from config import V5Config
class Topic(Post):
__tablename__ = 'topic'
__mapper_args__ = {'polymorphic_identity': __tablename__}
# ID of the underlying [Post] object
id = db.Column(db.Integer, db.ForeignKey('post.id'), primary_key=True)
# Topic title
title = db.Column(db.Unicode(V5Config.THREAD_NAME_MAXLEN))
# Parent forum
forum_id = db.Column(db.Integer, db.ForeignKey('forum.id'), nullable=False)
forum = db.relationship('Forum', backref='topics',foreign_keys=forum_id)
# Associated thread
thread_id = db.Column(db.Integer,db.ForeignKey('thread.id'),nullable=False)
thread = db.relationship('Thread', foreign_keys=thread_id)
# Number of views in the forum
views = db.Column(db.Integer)
def __init__(self, forum, author, title, thread):
"""
Create a Topic.
Arguments:
forum -- parent forum or sub-forum (Forum)
author -- post author (User)
title -- topic title (unicode string)
thread -- discussion thread attached to the topic (Thread)
"""
Post.__init__(self, author)
self.title = title
self.views = 0
self.thread = thread
self.forum = forum
def __repr__(self):
return f'<Topic: #{self.id}>'

View File

@ -1,21 +1,29 @@
from datetime import date
from app import db
from flask import flash
from flask_login import UserMixin
from app.models.post import Post
from sqlalchemy import func as SQLfunc
from os.path import isfile
from PIL import Image
from app import app, db
from app.models.privs import SpecialPrivilege, Group, GroupMember, \
GroupPrivilege
from app.models.trophies import Trophy, TrophyMember
from app.models.notification import Notification
import app.utils.unicode_names as unicode_names
from app.utils.notify import notify
import app.utils.ldap as ldap
from config import V5Config
import werkzeug.security
import re
import math
import app
import os
# User: Website user that performs actions on the post
class User(UserMixin, db.Model):
""" Website user that performs actions on the post """
__tablename__ = 'user'
# User ID, should be used to refer to any user. Thea actual user can either
@ -24,8 +32,7 @@ class User(UserMixin, db.Model):
# User type (polymorphic discriminator)
type = db.Column(db.String(30))
# TODO: add good relation
posts = db.relationship('Post', backref="author", lazy=False)
# Also a [posts] relationship populated from the Post class.
__mapper_args__ = {
'polymorphic_identity': __tablename__,
@ -35,8 +42,10 @@ class User(UserMixin, db.Model):
def __repr__(self):
return f'<User: #{self.id}>'
# Guest: Unregistered user with minimal privileges
class Guest(User, db.Model):
class Guest(User):
""" Unregistered user with minimal privileges """
__tablename__ = 'guest'
__mapper_args__ = {'polymorphic_identity': __tablename__}
@ -52,8 +61,9 @@ class Guest(User, db.Model):
return f'<Guest: {self.username} ({self.ip})>'
# Member: Registered user with full access to the website's services
class Member(User, db.Model):
class Member(User):
""" Registered user with full access to the website's services """
__tablename__ = 'member'
__mapper_args__ = {'polymorphic_identity': __tablename__}
@ -69,10 +79,10 @@ class Member(User, db.Model):
xp = db.Column(db.Integer)
register_date = db.Column(db.Date, default=date.today)
# Avatars # TODO: rendre ça un peu plus propre
avatar_id = db.Column(db.Integer, default=0)
@property
def avatar(self):
return 'avatars/' + str(self.id) + '.png'
return f'{self.id}_{self.avatar_id}.png'
@property
def level(self):
@ -95,12 +105,21 @@ class Member(User, db.Model):
trophies = db.relationship('Trophy', secondary=TrophyMember,
back_populates='owners')
# Displayed title
# title_id = db.Column(db.Integer, db.ForeignKey('title.id'))
# title = db.relationship('Title', foreign_keys=title_id)
# Other fields populated automatically through relations:
# <notifications> List of unseen notifications (of type Notification)
def __init__(self, name, email, password):
"""Register a new user."""
self.name = name
self.norm = unicode_names.normalize(name)
self.email = email
self.set_password(password)
if not V5Config.USE_LDAP:
self.set_password(password)
# Workflow with LDAP enabled is User → Postgresql → LDAP → set password
self.xp = 0
self.bio = ""
@ -144,6 +163,8 @@ class Member(User, db.Model):
"signature" str Post signature
"birthday" date Birthday date
"newsletter" bool Newsletter setting
"xp" int Experience points
"avatar" File Avatar image
For future compatibility, other attributes are silently ignored. None
values can be specified and are ignored.
@ -155,8 +176,11 @@ class Member(User, db.Model):
data = {key: data[key] for key in data if data[key] is not None}
# TODO: verify good type of those args, think about the password mgt
# Beware of LDAP injections
if "email" in data:
self.email = data["email"]
if V5Config.USE_LDAP:
ldap.set_email(self.norm, self.email)
if "password" in data:
self.set_password(data["password"])
if "bio" in data:
@ -167,13 +191,33 @@ class Member(User, db.Model):
self.birthday = data["birthday"]
if "newsletter" in data:
self.newsletter = data["newsletter"]
if "avatar" in data:
self.set_avatar(data["avatar"])
# For admins only
if "xp" in data:
self.xp = data["xp"]
def set_avatar(self, avatar):
# Save old avatar filepath
old_avatar = V5Config.AVATARS_FOLDER + self.avatar
# Resize & convert image
size = 128, 128
im = Image.open(avatar)
im.thumbnail(size, Image.ANTIALIAS)
# Change avatar id
# TODO: verify concurrency behavior
current_id = db.session.query(SQLfunc.max(Member.avatar_id)).first()[0]
self.avatar_id = current_id + 1
db.session.merge(self)
db.session.commit()
# Save the new avatar
im.save(V5Config.AVATARS_FOLDER + self.avatar, 'PNG')
# If nothing has failed, remove old one
os.remove(old_avatar)
def get_public_data(self):
"""Returns the public information of the member."""
""" Returns the public information of the member."""
return {
"name": self.name,
"xp": self.xp,
@ -188,25 +232,66 @@ class Member(User, db.Model):
Reward xp to a member. If [amount] is negative, the xp total of the
member will decrease, down to zero.
"""
self.xp_points = min(max(self.xp_points + amount, 0), 1000000000)
self.xp = min(max(self.xp + amount, 0), 1000000000)
def set_password(self, password):
"""
Set the user's password. Check whether the request sender has the right
to do this!
"""
self.password_hash = werkzeug.security.generate_password_hash(password,
method='pbkdf2:sha512', salt_length=10)
if V5Config.USE_LDAP:
ldap.set_password(self, password)
else:
self.password_hash = werkzeug.security.generate_password_hash(
password, method='pbkdf2:sha512', salt_length=10)
def check_password(self, password):
"""Compares password against member hash."""
return werkzeug.security.check_password_hash(self.password_hash,
password)
""" Compares password against member hash or LDAP record """
if V5Config.USE_LDAP:
return ldap.check_password(self, password)
else:
return werkzeug.security.check_password_hash(self.password_hash,
password)
def notify(self, message, href=None):
"""
Notify a user with a message.
An hyperlink can be added to redirect to the notification source
"""
return
n = Notification(self.id, message, href=href)
db.session.add(n)
db.session.commit()
def add_group(self, g):
"""
Add a group to the user.
Check wheter or not the request sender has the right to do this!
"""
if type(g) == int:
g = Group.query.get(g)
if type(g) == str:
g = Group.query.filter_by(name=g).first()
if g not in self.groups:
self.groups.append(g)
self.notify(f"Vous avez été ajouté au groupe '{g.name}'")
def del_group(self, g):
"""
Remove a group to the user.
Check wheter or not the request sender has the right to do this!
"""
if type(g) == int:
g = Group.query.get(g)
if type(g) == str:
g = Group.query.filter_by(name=g).first()
if g in self.groups:
self.groups.remove(g)
def add_trophy(self, t):
"""
Add a trophy to the current user. Check whether the request sender has
the right to do this!
Add a trophy to the current user.
Check whether the request sender has the right to do this!
"""
if type(t) == int:
t = Trophy.query.get(t)
@ -214,18 +299,17 @@ class Member(User, db.Model):
t = Trophy.query.filter_by(name=t).first()
if t not in self.trophies:
self.trophies.append(t)
# TODO: implement the notification system
# self.notify(f"Vous venez de débloquer le trophée '{t.name}'")
self.notify(f"Vous avez débloqué le trophée '{t.name}'")
def del_trophy(self, t):
"""
Add a trophy to the current user. Check whether the request sender has
the right to do this!
Delete a trophy to the current user.
Check whether the request sender has the right to do this!
"""
if type(t) == int:
t = Trophy.query.get(t)
if type(t) == str:
t = Trophy.query.filter_by(name=name).first()
t = Trophy.query.filter_by(name=t).first()
if t in self.trophies:
self.trophies.remove(t)
@ -255,8 +339,10 @@ class Member(User, db.Model):
if context in ["new-post", "new-program", "new-tutorial", "new-test",
None]:
# TODO: Amount of posts by the user
post_count = 0
# Cannot use ORM tools because it adds circular import issues
post_count = db.session.execute(f"""SELECT COUNT(*) FROM post
INNER JOIN member ON member.id = post.author_id
WHERE member.id = {self.id}""").first()[0]
levels = {
20: "Premiers mots",
@ -355,8 +441,10 @@ class Member(User, db.Model):
# TODO: Trophy "actif"
if context in ["on-profile-update", None]:
# TODO: add a better condition (this is for test)
self.add_trophy("Artiste")
if isfile(V5Config.AVATARS_FOLDER + self.avatar):
self.add_trophy("Artiste")
else:
self.del_trophy("Artiste")
db.session.merge(self)
db.session.commit()

28
app/processors/menu.py Normal file
View File

@ -0,0 +1,28 @@
from app import app, db
from app.forms.login import LoginForm
from app.forms.search import SearchForm
from app.models.forum import Forum
from app.models.topic import Topic
from app.models.thread import Thread
from app.models.comment import Comment
from app.models.users import Member
@app.context_processor
def menu_processor():
""" All items used to render main menu. Includes search form """
login_form = LoginForm(prefix="menu_")
search_form = SearchForm()
main_forum = Forum.query.filter_by(parent=None).first()
# Constructing last active topics
raw = db.session.execute( """SELECT topic.id FROM topic
INNER JOIN comment ON topic.thread_id = comment.thread_id
INNER JOIN post ON post.id = comment.id
GROUP BY topic.id
ORDER BY MAX(post.date_created) DESC
LIMIT 10;""")
last_active_topics = [Topic.query.get(id) for id in raw]
return dict(login_form=login_form, search_form=search_form,
main_forum=main_forum, last_active_topics=last_active_topics)

View File

@ -0,0 +1,11 @@
from app import app
from flask import url_for
@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),
)

View File

@ -4,18 +4,18 @@ from app import app, db
from app.forms.account import UpdateAccountForm, RegistrationForm, DeleteAccountForm
from app.models.users import Member
from app.utils.render import render
import app.utils.ldap as ldap
from config import V5Config
@app.route('/account', methods=['GET', 'POST'])
@app.route('/compte', methods=['GET', 'POST'])
@login_required
def edit_account():
form = UpdateAccountForm()
if form.submit.data:
if form.validate_on_submit():
if form.avatar.data:
f = form.avatar.data
f.save("./app/static/" + current_user.avatar)
current_user.update(
avatar=form.avatar.data or None,
email=form.email.data or None,
password=form.password.data or None,
birthday=form.birthday.data,
@ -27,13 +27,14 @@ def edit_account():
db.session.commit()
current_user.update_trophies("on-profile-update")
flash('Modifications effectuées', 'ok')
return redirect(request.url)
else:
flash('Erreur lors de la modification', 'error')
return render('account.html', form=form)
return render('account/account.html', form=form)
@app.route('/account/delete', methods=['GET', 'POST'])
@app.route('/compte/supprimer', methods=['GET', 'POST'])
@login_required
def delete_account():
del_form = DeleteAccountForm()
@ -47,25 +48,31 @@ def delete_account():
else:
flash('Erreur lors de la suppression du compte', 'error')
del_form.delete.data = False # Force to tick to delete the account
return render('delete_account.html', del_form=del_form)
return render('account/delete_account.html', del_form=del_form)
@app.route('/register', methods=['GET', 'POST'])
@app.route('/inscription', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated:
return redirect(url_for('index'))
form = RegistrationForm()
if form.validate_on_submit():
member = Member(form.username.data, form.email.data, form.password.data)
member.newsletter = form.newsletter.data
db.session.add(member)
db.session.commit()
# Workflow with LDAP is User → Postgresql → LDAP → Change password
if V5Config.USE_LDAP:
ldap.add_member(member)
ldap.set_password(member, form.password.data)
flash('Inscription réussie', 'ok')
return redirect(url_for('validation'))
return render('register.html', title='Register', form=form)
return redirect(url_for('validation') + "?email=" + form.email.data)
return render('account/register.html', title='Register', form=form)
@app.route('/register/validation/')
@app.route('/register/validation/', methods=['GET', 'POST'])
def validation():
mail = request.args['email']
if current_user.is_authenticated:
return redirect(url_for('index'))
return render('validation.html')
return render('account/validation.html', mail=mail)

View File

@ -1,33 +1,64 @@
from flask import redirect, url_for, request, flash
from flask_login import login_user, logout_user, login_required, current_user
from urllib.parse import urlparse, urljoin
from app import app
from app.forms.login import LoginForm
from app.models.users import Member
from app.models.privs import Group
from app.utils.render import render
from config import V5Config
@app.route('/login', methods=['GET', 'POST'])
@app.route('/connexion', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('index'))
form = LoginForm()
lateral = LoginForm(prefix="menu_")
if lateral.validate_on_submit():
form = lateral
if form.validate_on_submit():
member = Member.query.filter_by(name=form.username.data).first()
# Check if member can login
if member is not None and "No login" in [g.name for g in member.groups]:
flash('Cet utilisateur ne peut pas se connecter', 'error')
if request.referrer:
return redirect(request.referrer)
return redirect(url_for('index'))
# Check if password is ok
if member is None or not member.check_password(form.password.data):
flash('Pseudo ou mot de passe invalide', 'error')
return redirect(request.referrer)
login_user(member, remember=form.remember_me.data)
if request.referrer:
return redirect(request.referrer)
return redirect(url_for('index'))
# Login & update time-based trophies
login_user(member, remember=form.remember_me.data,
duration=V5Config.REMEMBER_COOKIE_DURATION)
member.update_trophies("on-login")
if request.args.get('next'):
return redirect(request.args.get('next'))
# Redirect safely (https://huit.re/open-redirect)
def is_safe_url(target):
ref_url = urlparse(request.host_url)
test_url = urlparse(urljoin(request.host_url, target))
return test_url.scheme in ('http', 'https') and \
ref_url.netloc == test_url.netloc
next = request.args.get('next')
if next and is_safe_url(next):
return redirect(next)
if request.referrer:
return redirect(request.referrer)
return redirect(url_for('index'))
return render('login.html', form=form)
return render('account/login.html', form=form)
@app.route('/logout')
@app.route('/deconnexion')
@login_required
def logout():
try:

View File

@ -0,0 +1,51 @@
from flask import redirect, url_for, request, flash, abort
from flask_login import login_required, current_user
from app import app, db
from app.models.notification import Notification
from app.utils.render import render
@app.route('/notifications', methods=['GET'])
@login_required
def list_notifications():
notifications = current_user.notifications
return render('account/notifications.html', notifications=notifications)
@app.route('/notifications/supprimer/<id>', methods=['GET'])
@login_required
# TODO: [SECURITY ISSUE] prevent CSRF
def delete_notification(id=None):
# Try to convert id to int
try:
id = int(id)
except ValueError:
pass
if type(id) == int:
notification = Notification.query.get(id)
print(">", notification)
if notification:
# Only current user or admin can delete notifications
if notification.owner_id == current_user.id:
db.session.delete(notification)
db.session.commit()
return redirect(url_for('list_notifications'))
elif 'delete_notification' in current_user.privs:
db.session.delete(notification)
db.session.commit()
if request.referrer:
return redirect(request.referrer)
return redirect(url_for('adm'))
else:
abort(403)
abort(404)
elif id == "all":
for n in current_user.notifications:
db.session.delete(n)
db.session.commit()
return redirect(url_for('list_notifications'))
# TODO: add something to allow an admin to delete all notifs for a user
# with a GET parameter
else:
abort(404)

View File

@ -1,15 +1,19 @@
from flask import flash, redirect, url_for
from flask import flash, redirect, url_for, request
from flask_login import current_user
from wtforms import BooleanField
from app.utils.priv_required import priv_required
from app.models.users import Member
from app.models.trophies import Trophy
from app.models.privs import Group
from app.forms.account import AdminUpdateAccountForm, AdminDeleteAccountForm, \
AdminAccountEditTrophyForm
AdminAccountEditTrophyForm, AdminAccountEditGroupForm
from app.utils.render import render
from app.utils.notify import notify
from app import app, db
from config import V5Config
@app.route('/admin/account/<user_id>/edit', methods=['GET', 'POST'])
@app.route('/admin/compte/<user_id>/editer', methods=['GET', 'POST'])
@priv_required('access-admin-panel', 'edit-account')
def adm_edit_account(user_id):
user = Member.query.filter_by(id=user_id).first_or_404()
@ -18,23 +22,32 @@ def adm_edit_account(user_id):
class TrophyForm(AdminAccountEditTrophyForm):
pass
class GroupForm(AdminAccountEditGroupForm):
pass
for t in Trophy.query.all():
setattr(TrophyForm, f't{t.id}', BooleanField(t.name))
setattr(TrophyForm, "user_trophies", [f't{t.id}' for t in user.trophies])
trophy_form = TrophyForm(prefix="trophies")
for g in Group.query.all():
setattr(GroupForm, f'g{g.id}', BooleanField(g.name))
setattr(GroupForm, "user_groups", [f'g{g.id}' for g in user.groups])
group_form = GroupForm(prefix="group")
print(group_form.__dict__.items())
if form.submit.data:
if form.validate_on_submit():
if form.avatar.data:
f = form.avatar.data
f.save("./app/static/" + user.avatar)
newname = form.username.data
names = list(Member.query.filter(Member.id != user.id).values(Member.name))
if newname in names:
# TODO: avoid this exception
# You cannot user vd.name_available because name will always be
# invalid! Maybe you can add another validator with arguments
raise Exception(f'{newname} is not available')
user.update(
avatar=form.avatar.data or None,
name=form.username.data or None,
email=form.email.data or None,
password=form.password.data or None,
@ -47,36 +60,60 @@ def adm_edit_account(user_id):
db.session.merge(user)
db.session.commit()
# TODO: send an email to member saying his account has been modified
user.notify(f"Vos informations personnelles ont été modifiées par {current_user.name}.")
flash('Modifications effectuées', 'ok')
return redirect(request.url)
else:
flash('Erreur lors de la modification', 'error')
# Trophies
if trophy_form.submit.data:
if trophy_form.validate_on_submit():
for id, field in trophy_form.__dict__.items():
if id[0] == "t":
print(f"id: {id[1:]}, name: {field.label}, checked={field.data}", end=" ")
if field.data:
print(f"Add trophy {id[1:]}")
user.add_trophy(int(id[1:]))
else:
print(f"Del trophy {id[1:]}")
user.del_trophy(int(id[1:]))
db.session.merge(user)
db.session.commit()
flash('Modifications effectuées', 'ok')
return redirect(request.url)
else:
flash("Erreur lors de l'ajout du trophée", 'error')
flash("Erreur lors de la modification des trophées", 'error')
user_owned = set()
# Groups
if group_form.submit.data:
if group_form.validate_on_submit():
for id, field in group_form.__dict__.items():
if id[0] == "g":
if field.data:
user.add_group(int(id[1:]))
else:
user.del_group(int(id[1:]))
db.session.merge(user)
db.session.commit()
flash('Modifications effectuées', 'ok')
return redirect(request.url)
else:
flash("Erreur lors de la modification des groupes", 'error')
trophies_owned = set()
for t in user.trophies:
user_owned.add(f"t{t.id}")
trophies_owned.add(f"t{t.id}")
return render('admin/edit_account.html', user=user,
form=form, trophy_form=trophy_form, user_owned=user_owned)
groups_owned = set()
for g in user.groups:
groups_owned.add(f"g{g.id}")
return render('admin/edit_account.html', user=user, form=form,
trophy_form=trophy_form, trophies_owned=trophies_owned,
group_form=group_form, groups_owned=groups_owned)
@app.route('/admin/account/<user_id>/delete', methods=['GET', 'POST'])
@app.route('/admin/compte/<user_id>/supprimer', methods=['GET', 'POST'])
@priv_required('access-admin-panel', 'delete-account')
def adm_delete_account(user_id):
user = Member.query.filter_by(id=user_id).first_or_404()

View File

@ -0,0 +1,11 @@
from app.utils.priv_required import priv_required
from app.utils.render import render
from app.models.forum import Forum
from app import app, db
@app.route('/admin/forums', methods=['GET'])
@priv_required('access-admin-panel')
def adm_forums():
main_forum = Forum.query.filter_by(parent=None).first()
return render('admin/forums.html', main_forum=main_forum)

View File

@ -9,7 +9,7 @@ import yaml
import os
@app.route('/admin/groups', methods=['GET', 'POST'])
@app.route('/admin/groupes', methods=['GET', 'POST'])
@priv_required('access-admin-panel')
def adm_groups():
users = Member.query.all()

View File

@ -6,7 +6,7 @@ from app.utils.render import render
from app import app, db
@app.route('/admin/trophies', methods=['GET', 'POST'])
@app.route('/admin/trophees', methods=['GET', 'POST'])
@priv_required('access-admin-panel', 'edit-trophies')
def adm_trophies():
form = TrophyForm()
@ -28,7 +28,7 @@ def adm_trophies():
form=form)
@app.route('/admin/trophies/<trophy_id>/edit', methods=['GET', 'POST'])
@app.route('/admin/trophees/<trophy_id>/editer', methods=['GET', 'POST'])
@priv_required('access-admin-panel', 'edit-trophies')
def adm_edit_trophy(trophy_id):
trophy = Trophy.query.filter_by(id=trophy_id).first_or_404()
@ -52,7 +52,7 @@ def adm_edit_trophy(trophy_id):
return render('admin/edit_trophy.html', trophy=trophy, form=form)
@app.route('/admin/trophies/<trophy_id>/delete', methods=['GET', 'POST'])
@app.route('/admin/trophees/<trophy_id>/supprimer', methods=['GET', 'POST'])
@priv_required('access-admin-panel', 'edit-trophies')
def adm_delete_trophy(trophy_id):
trophy = Trophy.query.filter_by(id=trophy_id).first_or_404()

56
app/routes/forum/index.py Normal file
View File

@ -0,0 +1,56 @@
from flask_login import current_user
from flask import request, redirect, url_for, abort, flash
from app import app, db
from config import V5Config
from app.utils.render import render
from app.forms.forum import TopicCreationForm
from app.models.forum import Forum
from app.models.topic import Topic
from app.models.thread import Thread
from app.models.comment import Comment
@app.route('/forum/')
def forum_index():
return render('/forum/index.html')
@app.route('/forum/<forum:f>/', methods=['GET', 'POST'])
def forum_page(f):
form = TopicCreationForm()
# TODO: do not hardcode name of news forums
if form.validate_on_submit() and (
# User can write anywhere
(current_user.is_authenticated and current_user.priv('write-anywhere'))
# Forum is news forum TODO: add good condition to check if it's news
or ("/actus" in f.url and current_user.is_authenticated
and current_user.priv('write-news'))
# Forum is not news and is a leaf:
or ("/actus" not in f.url and not f.sub_forums)):
# First create the thread, then the comment, then the topic
th = Thread()
db.session.add(th)
db.session.commit()
c = Comment(current_user, form.message.data, th)
db.session.add(c)
db.session.commit()
th.set_top_comment(c)
db.session.merge(th)
t = Topic(f, current_user, form.title.data, th)
db.session.add(t)
db.session.commit()
# Update member's xp and trophies
current_user.add_xp(V5Config.XP_POINTS['topic'])
current_user.update_trophies('new-post')
flash('Le sujet a bien été créé', 'ok')
return redirect(url_for('forum_topic', f=f, t=t))
return render('/forum/forum.html', f=f, form=form)

49
app/routes/forum/topic.py Normal file
View File

@ -0,0 +1,49 @@
from flask_login import current_user
from flask import request, redirect, url_for, flash, abort
from app import app, db
from config import V5Config
from app.utils.render import render
from app.forms.forum import CommentForm
from app.models.forum import Forum
from app.models.topic import Topic
from app.models.thread import Thread
from app.models.comment import Comment
@app.route('/forum/<forum:f>/<topicslug:t>', methods=['GET', 'POST'])
def forum_topic(f, t):
# Quick n' dirty workaround to converters
if f != t.forum:
abort(404)
form = CommentForm()
if form.validate_on_submit():
c = Comment(current_user, form.message.data, t.thread)
db.session.add(c)
db.session.commit()
# Update member's xp and trophies
current_user.add_xp(V5Config.XP_POINTS['comment'])
current_user.update_trophies('new-post')
flash('Message envoyé', 'ok')
# Redirect to empty the form
return redirect(url_for('forum_topic', f=f, t=t, page="last"))
# Update views
t.views += 1
db.session.merge(t)
db.session.commit()
if request.args.get('page') == "last":
page = (t.thread.comments.count() - 1) \
// V5Config.COMMENTS_PER_PAGE + 1
else:
page = request.args.get('page', 1, type=int)
comments = t.thread.comments.paginate(page,
V5Config.COMMENTS_PER_PAGE, True)
return render('/forum/topic.html', t=t, form=form, comments=comments)

View File

@ -3,7 +3,7 @@ from app.forms.search import AdvancedSearchForm
from app.utils.render import render
@app.route('/search')
@app.route('/rechercher')
def search():
form = AdvancedSearchForm()
return render('search.html', form=form)

8
app/routes/tools.py Normal file
View File

@ -0,0 +1,8 @@
from app import app
from app.utils.render import render
@app.route('/outils')
def tools():
return render('tools.html')

View File

@ -1,16 +1,28 @@
from flask import redirect, url_for
from flask import redirect, url_for, send_from_directory
from werkzeug.utils import secure_filename
import os.path
from app import app
from app.models.users import Member
from app.utils import unicode_names
from app.utils.render import render
from config import V5Config
@app.route('/user/<username>')
@app.route('/membre/<username>')
def user(username):
member = Member.query.filter_by(name=username).first_or_404()
return render('user.html', member=member)
norm = unicode_names.normalize(username)
member = Member.query.filter_by(norm=norm).first_or_404()
return render('account/user.html', member=member)
@app.route('/user/id/<int:user_id>')
@app.route('/membre/id/<int:user_id>')
def user_by_id(user_id):
member = Member.query.filter_by(id=user_id).first_or_404()
return redirect(url_for('user', username=member.name))
@app.route('/avatar/<filename>')
def avatar(filename):
filename = secure_filename(filename) # No h4ckers allowed
if os.path.isfile(V5Config.AVATARS_FOLDER + filename):
return send_from_directory(V5Config.AVATARS_FOLDER, filename)
return redirect(url_for('static', filename='images/default_avatar.png'))

View File

@ -1,5 +1,5 @@
.container {
margin-left: 60px;
margin-left: 110px;
}
section {
@ -26,3 +26,26 @@ section .avatar {
display: block;
width: 128px; height: 128px;
}
/* Some grid */
.flex-grid {
display: flex;
flex-flow: row wrap;
}
.flex-grid > * {
min-width: 250px;
flex: auto;
}
/* Two columns */
.flex-grid.fg2 > * {
width: 50%;
}
/* Three columns */
.flex-grid.fg3 > * {
width: 33%;
}
/* Four columns */
.flex-grid.fg4 > * {
width: 25%;
}

22
app/static/css/editor.css Normal file
View File

@ -0,0 +1,22 @@
.editor div {
display: flex; flex-direction: row;
flex-wrap: wrap; align-items: center;
margin-bottom: 5px;
}
.editor button {
height: 25px; margin: 0 0px; padding: 0 3px;
border: var(--border); border-radius: 2px;
cursor: pointer;
background: var(--background);
}
.editor button > img {
opacity: .7;
}
.editor button:hover,
.editor button:focus {
border: var(--border-focused);
}
.editor button:hover > img,
.editor button:focus > img {
opacity: 1;
}

View File

@ -6,40 +6,38 @@
position: fixed; left: 15%;
display: flex; align-items: center;
width: 70%; z-index: 10;
font-family: NotoSans; font-size: 14px; color: #212121;
background: #ffffff;
border-bottom: 5px solid #4caf50;
border-radius: 1px; box-shadow: 0 1px 12px rgba(0, 0, 0, 0.3);
font-family: NotoSans; font-size: 14px; color: var(--text);
background: var(--background);
border-bottom: 5px solid var(--info);
border-radius: 1px; box-shadow: var(--shadow);
transition: opacity .15s ease;
transition: top .2s ease;
}
.flash.info {
border-color: #2e7aec;
border-color: var(--info);
}
.flash.ok {
border-color: #4caf50;
border-color: var(--ok);
}
.flash.warning {
border-color: #fbbc26;
border-color: var(--warn);
}
.flash.error {
border-color: #f44336;
border-color: var(--error);
}
.flash span {
flex-grow: 1; margin: 15px 10px 10px 0;
}
.flash input[type="button"] {
margin: 3px 30px 0 0; padding: 10px 15px;
border: none;
background: rgba(0, 0, 0, 0); color: #727272;
}
.flash input[type="button"]:hover {
background: rgba(0, 0, 0, .1);
}
.flash input[type="button"]:focus {
background: rgba(0, 0, 0, .2);
}
.flash svg {
margin: 15px 20px 10px 30px;
}
.flash input[type="button"] {
margin: 3px 30px 0 0; padding: 10px 15px;
border: none;
background: var(--btn-bg); color: var(--btn-text);
}
.flash input[type="button"]:hover,
.flash input[type="button"]:focus {
background: var(--btn-bg-active);
}

View File

@ -3,11 +3,11 @@
*/
footer {
margin: 20px 10% 5px 10%; padding: 10px 0;
margin: 20px 0 0 0; padding: 10px 10%;
text-align: center; font-size: 11px; font-style: italic;
color: #a0a0a0;
border-top: 1px solid rgba(0, 0, 0, .1);
background: var(--background); color: var(--text);
border-top: var(--border);
}
footer p {
margin: 3px 0;
}
}

View File

@ -3,7 +3,7 @@
}
.form .avatar + input[type="file"] {
margin: 16px 0 0 0;
margin: 16px 0 0 0;
vertical-align: middle;
}
@ -29,7 +29,8 @@
.trophies-panel > div {
display: block;
width: 100%; padding: 6px 8px;
border: 1px solid #c8c8c8;
background: var(--background); color: var(--text);
border: var(--border);
/* Transitions when resizing with the mouse produces apparent lag */
transition: all .15s ease, width 0s, height 0s;
@ -40,8 +41,7 @@
.form input[type='password']:focus,
.form input[type='search']:focus,
.form textarea:focus {
border-color: #7cade0;
box-shadow: 0 0 0 3px rgba(87, 143, 228, 0.5);
border-color: var(--border-focused);
}
.form textarea {
@ -60,20 +60,19 @@
}
.form form .msgerror {
color: red;
color: var(--error);
font-weight: 400;
margin-top: 5px;
}
.form .desc {
font-size: 80%;
color: gray;
opacity: .75;
}
.form hr {
color: white;
height: 3px;
border: 0 solid #b0b0b0;
border: var(--hr-border);
border-width: 1px 0;
margin: 24px 0;
}
@ -86,3 +85,10 @@
.trophies-panel p label {
margin: 0;
}
/* Editor */
.editor textarea {
font-family: monospace;
height: 192px;
}

View File

@ -1,8 +1,9 @@
/* Fonts */
@font-face { font-family: NotoSans; src: url(../fonts/noto_sans.ttf); }
@font-face { font-family: Cantarell; font-weight: normal; src: url(../fonts/Cantarell-Regular.otf); }
@font-face { font-family: Cantarell; font-weight: bold; src: url(../fonts/Cantarell-Bold.otf); }
/* @font-face { font-family: NotoSans; src: url(../fonts/noto_sans.ttf); font-display: swap; }
@font-face { font-family: Twemoji; src: url(../fonts/TwitterColorEmoji.ttf); font-display: swap; }
@font-face { font-family: Cantarell; font-weight: normal; src: url(../fonts/Cantarell-Regular.otf); font-display: swap; }
@font-face { font-family: Cantarell; font-weight: bold; src: url(../fonts/Cantarell-Bold.otf); font-display: swap; } */
/* Whole page */
@ -15,15 +16,15 @@
body {
margin: 0;
background: #ffffff;
font-family: 'DejaVu Sans', sans-serif;
background: var(--background); color: var(--text);
font-family: Twemoji, 'DejaVu Sans', sans-serif;
}
/* General */
a {
text-decoration: none;
color: #c61a1a;
color: var(--links);
}
a:hover {
text-decoration: underline;
@ -34,6 +35,7 @@ a:focus {
section p {
line-height: 20px;
word-wrap: anywhere;
}
section ul {
@ -95,6 +97,7 @@ input[type="submit"]:hover,
margin: -1px;
}
@media screen and (max-width: 1499px) {
.profile-avatar {
width: 96px;
@ -124,47 +127,34 @@ input[type="submit"]:hover,
display: flex;
}
.bg-green,
.bg-green {
background: #149641;
color: #ffffff;
.bg-ok,
.bg-ok {
background: var(--ok);
color: var(--ok-text);
}
.bg-green:hover,
.bg-green:focus,
.bg-green:active {
background: #0f7331;
.bg-ok:hover,
.bg-ok:focus,
.bg-ok:active {
background: var(--ok-active);
}
.bg-red,
.bg-red {
background: #d23a2f;
color: #ffffff;
.bg-error,
.bg-error {
background: var(--error);
color: var(--error-text);
}
.bg-red:hover,
.bg-red:focus,
.bg-red:active {
background: #b32a20;
.bg-error:hover,
.bg-error:focus,
.bg-error:active {
background: var(--error-active);
}
.bg-orange {
background: #f59f25;
color: #ffffff;
.bg-warn {
background: var(--warn);
color: var(--warn-text);
}
.bg-orange:hover,
.bg-orange:focus,
.bg-orange:active {
background: #ea9720;
}
.bg-white,
.bg-white {
border: 1px solid #e5e5e5;
background: #ffffff;
color: #000000;
}
.bg-white:hover,
.bg-white:focus,
.bg-white:active {
background: #f0f0f0;
border-color: #e3e3e3;
.bg-warn:hover,
.bg-warn:focus,
.bg-warn:active {
background: var(--warn-active);
}

View File

@ -4,7 +4,7 @@
header {
height: 50px; margin: 0; padding: 0 16px;
background: #f4f4f6; border-bottom: 1px solid #d0d0d0;
background: var(--background); border-bottom: var(--border);
display: flex; align-items: center; justify-content: space-between;
flex-flow: row wrap;
@ -33,7 +33,6 @@ header .title a {
}
header .title h1 {
font-family: Cantarell; font-weight: bold; font-size: 18px;
color: #181818;
display: inline;
}
@ -49,7 +48,7 @@ header svg {
transition: .15s ease;
}
header a:hover > svg, header a:focus > svg {
fill: black;
fill: var(--text);
}
header a {
fill: #363636;
@ -66,18 +65,20 @@ header .form {
header .form input[type="search"] {
display: inline-block; width: 250px;
padding: 5px 35px 5px 10px;
border-color: #d8d8d8;
}
header .form input[type="search"] ~ a {
position: relative; left: -33px;
opacity: .7;
}
header .form input[type="search"] ~ a > svg > path {
fill: #cccccc; transition: .15s ease;
fill: var(--text);
}
header .form input[type="search"]:focus ~ a > svg > path {
fill: #333333;
header .form input[type="search"] ~ a:hover,
header .form input[type="search"]:focus ~ a {
opacity: 1;
}
#spotlight {
margin-left: 16px;
}

View File

@ -10,11 +10,8 @@
/* Menu */
#spacer-menu {
height: 60px;
}
#light-menu {
position: unset;
display: flex; flex-direction: row; align-items: center;
width: 100%; height: 60px;
overflow-x: auto; overflow-y: hidden;
@ -24,7 +21,7 @@
width: auto; height: 100%; margin-bottom: 0;
}
#logo img {
width: 60px;
width: 60px; height: inherit;
margin-bottom: -4.5px;
}
@ -35,7 +32,7 @@
padding: 0 2px;
}
#light-menu li > a {
cursor: pointer;
cursor: pointer; margin: 0;
}
#light-menu li > a:hover {
text-decoration: none;
@ -106,7 +103,7 @@
margin: 5px 0;
}
@media all and (max-width: 549px) {
@media all and (max-width: 500px) {
#light-menu, #spacer-menu {
height: 40px;
}
@ -122,7 +119,10 @@
display: block;
margin: 5px 15px; padding: 5px 10px;
font-size: 14px;
background: #e8e8e8; transition: background .15s ease;
transition: .15s ease;
}
#menu form label {
float: left; margin-right: 10px;
}
#menu form input:first-child {
margin-bottom: 0; border-bottom: none;

View File

@ -1,6 +1,5 @@
nav a {
color: #ffffff;
opacity: 0.75;
opacity: .8;
cursor: pointer;
}
nav a:hover,
@ -14,11 +13,11 @@ nav a:focus {
#light-menu {
position: fixed; z-index: 10;
list-style: none;
width: 60px;
width: 110px;
height: 100%; overflow-y: auto;
margin: 0; padding: 0;
text-indent: 0;
background: #22292c; box-shadow: 0 0 2px rgba(0, 0, 0, 0.3);
background: var(--background); box-shadow: var(--shadow);
}
#logo {
@ -26,96 +25,70 @@ nav a:focus {
width: 100%;
margin-bottom: 10px;
opacity: 1;
background: -moz-linear-gradient(top, #bf1c11, #ba1203);
background: -webkit-linear-gradient(top, #bf1c11, #ba1203);
background: #bf1c11;
background: var(--logo-bg);
transition: .15s ease;
}
#logo img {
width: 100%;
margin: 0; padding: 0;
margin-bottom: -4.5px;
display: block; height: 65px;
margin: 0 auto; padding: 0;
filter: drop-shadow(0 0 2px rgba(0, 0, 0, .0));
transition: filter .15s ease;
}
#logo:hover,
#logo:focus {
background: #d72411;
background: var(--logo-active);
}
#logo:hover img,
#logo:focus img {
filter: drop-shadow(0 0 2px rgba(0, 0, 0, .7));
filter: drop-shadow(var(--logo-shadow));
}
#light-menu li {
width: 100%; height: 45px;
text-align: center;
color: #ffffff;
width: 100%;
color: var(--text);
}
#light-menu li > a {
display: flex; flex-direction: column; flex-grow: 1;
display: flex; flex-direction: column; flex-grow: 0;
align-items: center; justify-content: center;
width: 100%; height: 100%;
margin: 20px 0;
color: var(--text);
transition: opacity .15s ease; /* because Chrome sucks */
}
#light-menu li > a > svg {
display: block; width: 35%; flex-shrink: 0;
margin: 0 auto 5px auto;
display: block; width: 25px; flex-shrink: 0; flex-grow: 0;
margin: 0 7px;
}
#light-menu li div {
display: none;
}
#light-menu li > a::after {
content: attr(label);
position: fixed; display: none;
padding: 4px 8px; left: 63px;
font-family: NotoSans; border-radius: 3px;
background: rgba(0, 0, 0, 0.9);
}
#light-menu li:not(.opened) > a:hover::after,
#light-menu li:not(.opened) > a:focus::after {
display: block;
#light-menu li > a > svg > path {
fill: var(--icons);
}
/*nav li span[notifications]:not([notifications="0"])::before {
content: attr(notifications);
display: inline-block; margin-right: 6px;
vertical-align: middle;
padding: 0 5px 0 4px; border-radius: 5px;
font-family: NotoSans;
background: #ffffff; color: #000000;
}*/
#light-menu li div {
/*flex-grow: 1;*/
}
/* Overlay */
#menu {
position: fixed; z-index: 5;
left: -240px; width: 300px; /* left-to-right animation */
/*left: 60px; width: 0;*/ /* scroll animation */
left: -190px; width: 300px; /* default: left-to-right animation */
height: 100%; overflow-x: hidden; overflow-y: auto;
background: #1c2124; box-shadow: 0 0 5px rgba(0, 0, 0, 0.3);
background: var(--background); color: var(--text);
box-shadow: var(--shadow);
transition: .15s ease;
}
#menu.opened {
left: 60px; /* left-to-right animation */
/*width: 300px;*/ /* scroll animation */
left: 110px;
}
/* Just apply class="scroll-animation" to menu to change to scroll animation */
#menu.scroll-animation {
left: 60px; width: 0;
left: 110px; width: 0;
}
#menu.scroll-animation.opened {
width: 300px;
}
#menu.left-to-right-animation {
left: -240px; width: 300px;
}
#menu.left-to-right-animation.opened {
left: 60px;
}
#menu > div {
@ -130,7 +103,7 @@ nav a:focus {
#menu h2 {
margin: 0 0 20px 0;
font-family: Cantarell; font-weight: bold; font-size: 18px;
color: #ffffff;
color: var(--text);
display: flex; align-items: center;
}
#menu h2 a {
@ -151,7 +124,7 @@ nav a:focus {
#menu h3 {
margin: 16px 0;
font-family: Cantarell; font-weight: bold; font-size: 15px;
color: #ffffff;
color: var(--text);
}
#menu hr {
margin: 15px 0;
@ -165,11 +138,9 @@ nav a:focus {
#menu a,
#menu li {
display: block; margin: 10px 0;
color: var(--text);
transition: opacity .15s ease;
}
#menu li {
color: #b8b8b8;
}
#menu li > a {
display: inline;
margin: 0; font-style: normal;
@ -191,18 +162,17 @@ nav a:focus {
#menu form input[type="password"] {
margin: 8px 0; padding: 5px 2%;
font-size: 14px; color: inherit;
border: none; border-color: #141719;
border: var(--input-border);
background: var(--input-bg); color: var(--input-text); opacity: .8;
}
#menu form input[type="text"]:focus,
#menu form input[type="password"]:focus {
background: #ffffff;
box-shadow: 0 0 0 4px rgba(87, 143, 228, 0.65);
border-color: #325871;
opacity: 1;
}
#menu form input[type="submit"] {
width: 100%;
margin: 16px 0 5px 0;
margin: 8px 0 5px 0;
}
#menu form label {
font-size: 13px; color: #FFFFFF; opacity: .7;
font-size: 13px; opacity: .8;
}

View File

@ -0,0 +1,4 @@
.pagination {
text-align: center;
margin: 5px 0;
}

View File

@ -17,3 +17,63 @@ table th {
table td {
padding: 4px 6px;
}
/* Forum and sub-forum listings */
table.forumlist {
border-collapse: separate;
border-spacing: 0;
margin: 16px 0;
width: 100%;
}
/* table.forumlist th {
background: #d05950;
border-color: #b04940;
color: white;
} */
table.forumlist tr {
background: unset;
}
table.forumlist tr:nth-child(4n+2),
table.forumlist tr:nth-child(4n+3) {
background: rgba(0, 0, 0, .05);
}
/* Topic table */
table.topiclist {
width: 100%;
margin: auto;
}
table.topiclist tr > *:nth-child(n+2) {
/* This matches all children except the first column */
text-align: center;
}
table.forumlist th > td:last-child,
table.forumlist tr > td:last-child,
table.topiclist th > td:last-child,
table.topiclist tr > td:last-child {
width: 20%; text-align: center;
}
/* Thread table */
table.thread {
width: 100%;
}
table.thread td.member {
width: 20%;
}
table.thread td {
vertical-align: top;
}
table.thread td:nth-child(2) {
padding-top: 10px;
}

90
app/static/css/theme.css Normal file
View File

@ -0,0 +1,90 @@
/* Some colors, variables etc. to be used as theme */
:root {
--background: #ffffff;
--text: #000000;
--links: #c61a1a;
--ok: #149641;
--ok-text: #ffffff;
--ok-active: #0f7331;
--warn: #f59f25;
--warn-text: #ffffff;
--warn-active: #ea9720;
--error: #d23a2f;
--error-text: #ffffff;
--error-active: #b32a20;
--info: #2e7aec;
--info-text: #ffffff;
--info-active: #215ab0;
--hr-border: 1px solid #b0b0b0;
}
.form {
--background: #ffffff;
--text: #000000;
--border: 1px solid #c8c8c8;
--border-focused: #7cade0;
}
.editor button {
--background: #ffffff;
--text: #000000;
--border: 1px solid rgba(0, 0, 0, 0);
--border-focused: 1px solid rgba(0, 0, 0, .5);
}
#light-menu {
--background: #22292c;
--text: #ffffff;
--icons: #ffffff;
--shadow: 0 0 4px rgba(0, 0, 0, 0.3);
--logo-bg: #bf1c11;
--logo-shadow: 0 0 2px rgba(0, 0, 0, .7);
--logo-active: #d72411;
}
#menu {
--background: #1c2124;
--text: #ffffff;
--icons: #ffffff;
--shadow: 0 0 8px rgba(0, 0, 0, 0.3);
--input-bg: #22292c;
--input-text: #ffffff;
--input-border: 1px solid #474747;
}
header {
--background: #f4f4f6;
--text: #000000;
--border: 1px solid #d0d0d0;
}
footer {
--background: #ffffff;
--text: #a0a0a0;
--border: #d0d0d0;
}
.flash {
--background: #ffffff;
--text: #212121;
--shadow: 0 1px 12px rgba(0, 0, 0, 0.3);
/* Uncomment to inherit :root values
--ok: #149641;
--warn: #f59f25;
--error: #d23a2f;
--info: #2e7aec; */
--btn-bg: rgba(0, 0, 0, 0);
--btn-text: #000000;
--btn-bg-active: rgba(0, 0, 0, .15);
}

Binary file not shown.

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M8,3A2,2 0 0,0 6,5V9A2,2 0 0,1 4,11H3V13H4A2,2 0 0,1 6,15V19A2,2 0 0,0 8,21H10V19H8V14A2,2 0 0,0 6,12A2,2 0 0,0 8,10V5H10V3M16,3A2,2 0 0,1 18,5V9A2,2 0 0,0 20,11H21V13H20A2,2 0 0,0 18,15V19A2,2 0 0,1 16,21H14V19H16V14A2,2 0 0,1 18,12A2,2 0 0,1 16,10V5H14V3H16Z" /></svg>

After

Width:  |  Height:  |  Size: 555 B

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M9,22A1,1 0 0,1 8,21V18H4A2,2 0 0,1 2,16V4C2,2.89 2.9,2 4,2H20A2,2 0 0,1 22,4V16A2,2 0 0,1 20,18H13.9L10.2,21.71C10,21.9 9.75,22 9.5,22V22H9M10,16V19.08L13.08,16H20V4H4V16H10M13,10H11V6H13V10M13,14H11V12H13V14Z" /></svg>

After

Width:  |  Height:  |  Size: 505 B

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M13.5,15.5H10V12.5H13.5A1.5,1.5 0 0,1 15,14A1.5,1.5 0 0,1 13.5,15.5M10,6.5H13A1.5,1.5 0 0,1 14.5,8A1.5,1.5 0 0,1 13,9.5H10M15.6,10.79C16.57,10.11 17.25,9 17.25,8C17.25,5.74 15.5,4 13.25,4H7V18H14.04C16.14,18 17.75,16.3 17.75,14.21C17.75,12.69 16.89,11.39 15.6,10.79Z" /></svg>

After

Width:  |  Height:  |  Size: 561 B

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M3,4H5V10H9V4H11V18H9V12H5V18H3V4M14,18V16H16V6.31L13.5,7.75V5.44L16,4H18V16H20V18H14Z" /></svg>

After

Width:  |  Height:  |  Size: 381 B

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M3,4H5V10H9V4H11V18H9V12H5V18H3V4M21,18H15A2,2 0 0,1 13,16C13,15.47 13.2,15 13.54,14.64L18.41,9.41C18.78,9.05 19,8.55 19,8A2,2 0 0,0 17,6A2,2 0 0,0 15,8H13A4,4 0 0,1 17,4A4,4 0 0,1 21,8C21,9.1 20.55,10.1 19.83,10.83L15,16H21V18Z" /></svg>

After

Width:  |  Height:  |  Size: 523 B

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M3,4H5V10H9V4H11V18H9V12H5V18H3V4M15,4H19A2,2 0 0,1 21,6V16A2,2 0 0,1 19,18H15A2,2 0 0,1 13,16V15H15V16H19V12H15V10H19V6H15V7H13V6A2,2 0 0,1 15,4Z" /></svg>

After

Width:  |  Height:  |  Size: 441 B

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M10,4V7H12.21L8.79,15H6V18H14V15H11.79L15.21,7H18V4H10Z" /></svg>

After

Width:  |  Height:  |  Size: 350 B

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M7,5H21V7H7V5M7,13V11H21V13H7M4,4.5A1.5,1.5 0 0,1 5.5,6A1.5,1.5 0 0,1 4,7.5A1.5,1.5 0 0,1 2.5,6A1.5,1.5 0 0,1 4,4.5M4,10.5A1.5,1.5 0 0,1 5.5,12A1.5,1.5 0 0,1 4,13.5A1.5,1.5 0 0,1 2.5,12A1.5,1.5 0 0,1 4,10.5M7,19V17H21V19H7M4,16.5A1.5,1.5 0 0,1 5.5,18A1.5,1.5 0 0,1 4,19.5A1.5,1.5 0 0,1 2.5,18A1.5,1.5 0 0,1 4,16.5Z" /></svg>

After

Width:  |  Height:  |  Size: 609 B

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M7,13V11H21V13H7M7,19V17H21V19H7M7,7V5H21V7H7M3,8V5H2V4H4V8H3M2,17V16H5V20H2V19H4V18.5H3V17.5H4V17H2M4.25,10A0.75,0.75 0 0,1 5,10.75C5,10.95 4.92,11.14 4.79,11.27L3.12,13H5V14H2V13.08L4,11H2V10H4.25Z" /></svg>

After

Width:  |  Height:  |  Size: 494 B

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M14,17H17L19,13V7H13V13H16M6,17H9L11,13V7H5V13H8L6,17Z" /></svg>

After

Width:  |  Height:  |  Size: 349 B

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M3,14H21V12H3M5,4V7H10V10H14V7H19V4M10,19H14V16H10V19Z" /></svg>

After

Width:  |  Height:  |  Size: 349 B

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M5,21H19V19H5V21M12,17A6,6 0 0,0 18,11V3H15.5V11A3.5,3.5 0 0,1 12,14.5A3.5,3.5 0 0,1 8.5,11V3H6V11A6,6 0 0,0 12,17Z" /></svg>

After

Width:  |  Height:  |  Size: 410 B

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M10.59,13.41C11,13.8 11,14.44 10.59,14.83C10.2,15.22 9.56,15.22 9.17,14.83C7.22,12.88 7.22,9.71 9.17,7.76V7.76L12.71,4.22C14.66,2.27 17.83,2.27 19.78,4.22C21.73,6.17 21.73,9.34 19.78,11.29L18.29,12.78C18.3,11.96 18.17,11.14 17.89,10.36L18.36,9.88C19.54,8.71 19.54,6.81 18.36,5.64C17.19,4.46 15.29,4.46 14.12,5.64L10.59,9.17C9.41,10.34 9.41,12.24 10.59,13.41M13.41,9.17C13.8,8.78 14.44,8.78 14.83,9.17C16.78,11.12 16.78,14.29 14.83,16.24V16.24L11.29,19.78C9.34,21.73 6.17,21.73 4.22,19.78C2.27,17.83 2.27,14.66 4.22,12.71L5.71,11.22C5.7,12.04 5.83,12.86 6.11,13.65L5.64,14.12C4.46,15.29 4.46,17.19 5.64,18.36C6.81,19.54 8.71,19.54 9.88,18.36L13.41,14.83C14.59,13.66 14.59,11.76 13.41,10.59C13,10.2 13,9.56 13.41,9.17Z" /></svg>

After

Width:  |  Height:  |  Size: 1011 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -0,0 +1,113 @@
/* Add callbacks on text formatting buttons */
function edit(e, type) {
function inline(type, str, repeat, insert) {
// Characters used to format inline blocs
// repeat: if true, add one more char to the longest suite found
// insert: insert <insert> between char and str (before and after)
var chars = {
'bold': '*',
'italic': '/',
'underline': '_',
'strikethrough': '~',
'inline-code': '`',
'h1': '===',
'h2': '---',
'h3': '...',
}
if (repeat) {
// Detect longest suite of similar chars
var n = 1; var tmp = 1;
for(var i = 0; i < str.length; i++) {
if(str[i] == chars[type]) tmp++;
else tmp = 1;
n = (tmp > n) ? tmp : n;
}
return chars[type].repeat(n) + insert + str + insert + chars[type].repeat(n);
}
return chars[type] + insert + str + insert + chars[type];
}
function list(type, str) {
switch(type) {
case 'list-bulleted':
return '* ' + str.replaceAll('\n', '\n* ');
break;
case 'list-numbered':
return '1. ' + str;
break;
}
}
var ta = e.parentNode.parentNode.querySelector('textarea');
var start = ta.selectionStart;
var end = ta.selectionEnd;
switch(type) {
case 'bold':
case 'italic':
case 'underline':
case 'strikethrough':
case 'inline-code':
ta.value = ta.value.substring(0, start)
+ inline(type, ta.value.substring(start, end), true, '')
+ ta.value.substring(end);
break;
case 'h1':
case 'h2':
case 'h3':
ta.value = ta.value.substring(0, start)
+ inline(type, ta.value.substring(start, end), false, ' ')
+ ta.value.substring(end);
break;
case 'list-bulleted':
case 'list-numbered':
ta.value = ta.value.substring(0, start)
+ list(type, ta.value.substring(start, end))
+ ta.value.substring(end);
break;
}
}
function pre(type, str, multiline) {
}
function bold(e) {
var ta = e.parentNode.parentNode.querySelector('textarea');
var indexStart = ta.selectionStart;
var indexEnd = ta.selectionEnd;
var txt = ta.value.substring(indexStart, indexEnd);
ta.value += '\n' + inline('bold', txt);
}
// Tab insert some spaces
// Ctrl+Enter send the form
ta = document.querySelector(".editor textarea");
ta.addEventListener('keydown', function(e) {
var keyCode = e.keyCode || e.which;
if (keyCode == 9) {
e.preventDefault();
var start = e.target.selectionStart;
var end = e.target.selectionEnd;
// set textarea value to: text before caret + tab + text after caret
e.target.value = e.target.value.substring(0, start) + "\t" + e.target.value.substring(end);
e.target.selectionEnd = start + 1;
}
if (e.ctrlKey && keyCode == 13) {
var e = e.target;
while(! (e instanceof HTMLFormElement)) {
e = e.parentNode;
}
try {
e.submit();
} catch(exception) {
e.submit.click();
}
}
});

View File

@ -50,59 +50,3 @@ function flash_close(element) {
}
}, 0);
}
/*
Send post ajax request to url defined in action.
Callback the function defined in the callback attribute from the submit type.
*/
/* We don't need Ajax at that time. Maybe later
function ajaxWrapper(evt){
evt.preventDefault();
var elems = evt.target;
var params = "";
// do not embed submit value (-1)
for(i = 0; i < elems.length-1; i++){
if(params) params += "&";
params += encodeURIComponent(elems[i].name)+"="+encodeURIComponent(elems[i].value);
}
const req = new XMLHttpRequest();
req.open("POST", evt.target.action, true);
req.setRequestHeader('Content-Type',"application/x-www-form-urlencoded");
req.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
req.onreadystatechange = function(){
if(req.readyState == 4 && (req.status == 200 || req.status == 0)){
var fn = window[elems[elems.length-1].getAttribute("callback")];
if(typeof fn == 'function'){
fn(req.responseText);
}
}
}
req.send(params);
}
// Add event listener on submit for all form with class with-ajax.
window.onload = function(){
var ele;
var elems = document.getElementsByClassName('with-ajax');
for(i = 0; i < elems.length; i++){
ele = elems[i];
if(ele.addEventListener){ // Normal people
ele.addEventListener("submit", ajaxWrapper, false);
}else if(ele.attachEvent){ // Retarded user using IE
ele.attachEvent("onsubmit", ajaxWrapper);
}
}
if(getCookie('pc_notif') == 'true')
document.getElementsByClassName('alert')[0].parentNode.removeChild(document.getElementsByClassName('alert')[0]);
if(getCookie('pc_notif_2') == 'true')
document.getElementsByClassName('alert')[0].parentNode.removeChild(document.getElementsByClassName('alert')[0]);
}
function login(response){
alert(response);
}
//*/

View File

@ -1,10 +1,11 @@
/* Trigger actions for the menu */
/* Initialization */
var b = document.getElementById('light-menu').getElementsByTagName('a')
var b = document.querySelectorAll('#light-menu a');
for(var i = 1; i < b.length; i++) {
b[i].setAttribute('onfocus', "this.setAttribute('f', 'true');");
b[i].setAttribute('onblur', "this.setAttribute('f', 'false');");
b[i].removeAttribute('href');
}
var trigger_menu = function(active) {
@ -15,8 +16,8 @@ var trigger_menu = function(active) {
element.classList.remove('opened');
}
var menu = document.getElementById('menu');
var buttons = document.getElementById('light-menu').getElementsByTagName('li');
var menu = document.querySelector('#menu');
var buttons = document.querySelectorAll('#light-menu li');
var menus = document.querySelectorAll('#menu > div');
if(active == -1 || buttons[active].classList.contains('opened')) {
@ -39,8 +40,8 @@ var trigger_menu = function(active) {
}
var mouse_trigger = function(event) {
var menu = document.getElementById('menu');
var buttons = document.getElementById('light-menu').getElementsByTagName('li');
var menu = document.querySelector('#menu');
var buttons = document.querySelectorAll('#light-menu li');
if(!menu.contains(event.target)) {
var active = -1;
@ -48,7 +49,7 @@ var mouse_trigger = function(event) {
for(i = 0; i < buttons.length; i++) {
if(buttons[i].contains(event.target))
active = i;
buttons[i].getElementsByTagName('a')[0].blur();
buttons[i].querySelector('a').blur();
}
trigger_menu(active);
@ -57,11 +58,11 @@ var mouse_trigger = function(event) {
var keyboard_trigger = function(event) {
var menu = document.getElementById('menu');
var buttons = document.getElementById('light-menu').getElementsByTagName('li');
var buttons = document.querySelectorAll('#light-menu li');
if(event.keyCode == 13) {
for(var i = 0; i < buttons.length; i++) {
if(buttons[i].getElementsByTagName('a')[0].getAttribute('f') == 'true') {
if(buttons[i].querySelector('a').getAttribute('f') == 'true') {
trigger_menu(i);
}
}

View File

@ -13,9 +13,12 @@
<div>
{{ form.avatar.label }}
<div>
<img class="avatar" src="{{ url_for('static', filename=current_user.avatar) }}" meta="{{ current_user.avatar }}" />
<img class="avatar" src="{{ url_for('avatar', filename=current_user.avatar) }}" meta="{{ current_user.avatar }}" />
{{ form.avatar }}
</div>
{% for error in form.avatar.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ form.email.label }}
@ -78,11 +81,11 @@
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>{{ form.submit(class_="bg-green") }}</div>
<div>{{ form.submit(class_="bg-ok") }}</div>
</form>
<h2 style="margin-top:30px;">Supprimer le compte</h2>
<a href="{{ url_for('delete_account') }}" class="button bg-red">Supprimer le compte</a>
<a href="{{ url_for('delete_account') }}" class="button bg-error">Supprimer le compte</a>
</section>
{% endblock %}

View File

@ -20,7 +20,7 @@
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>{{ del_form.submit(class_="bg-red") }}</div>
<div>{{ del_form.submit(class_="bg-error") }}</div>
</form>
</section>
{% endblock %}

View File

@ -20,8 +20,8 @@
{% endfor %}
</p>
<p>{{ form.remember_me() }} {{ form.remember_me.label }}</p>
<p>{{ form.submit(class_="bg-green") }}</p>
<p>{{ form.submit(class_="bg-ok") }}</p>
</form>
<p>Pas encore de compte&nbsp;? <a href="{{ url_for('register') }}">Créé-en un&nbsp;!</a></p>
<p>Pas encore de compte ? <a href="{{ url_for('register') }}">Créé-en un !</a></p>
</form>
{% endblock %}

View File

@ -0,0 +1,33 @@
{% extends "base/base.html" %}
{% block title %}
<h1>Notifications</h1>
{% endblock %}
{% block content %}
<section>
{% if notifications %}
<table style="width: 100%;">
<tr>
<th>Date</th>
<th>Notification</th>
<th><a href="{{ url_for('delete_notification', id='all') }}">Tout supprimer</a></th>
</tr>
{% for n in notifications|reverse %}
<tr>
<td>{{ n.date.strftime('Le %Y-%m-%d à %H:%M') }}</td>
<td>
{% if n.href %}<a href="{{ n.href }}">{% endif %}
{{ n.text }}
{% if n.href %}</a>{% endif %}
</td>
<td style="text-align: center;"><a href="{{ url_for('delete_notification', id=n.id)}}">Supprimer</a>
</tr>
{% endfor %}
</table>
{% else %}
Aucune notification.
{% endif %}
</div>
</section>
{% endblock %}

View File

@ -50,7 +50,7 @@
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>{{ form.submit(class_="bg-green") }}</div>
<div>{{ form.submit(class_="bg-ok") }}</div>
</form>
</div>
</section>

View File

@ -9,8 +9,12 @@
<section>
{{ widget_member.profile(member) }}
{% if current_user.is_authenticated and current_user.priv('access-admin-panel') %}
<div><a href="{{ url_for('adm_edit_account', user_id=member.id) }}">Modifier</a></div>
{% if current_user.is_authenticated %}
{% if current_user == member %}
<div><a href="{{ url_for('edit_account') }}">Modifier le compte</a></div>
{% elif current_user.priv('access-admin-panel') %}
<div><a href="{{ url_for('adm_edit_account', user_id=member.id) }}">Modifier le compte</a></div>
{% endif %}
{% endif %}
<h2>Trophées</h2>

View File

@ -0,0 +1,18 @@
{% extends "base/base.html" %}
{% block content %}
<section>
<div>
<h2>Inscription réussie !</h2>
<p>
Nous vous avons envoyé un mail de vérification à l'adresse {{mail}}<br>
Votre compte sera actif une fois que vous aurez cliqué sur le lien présent dans le mail.<br>
Le mail n'est pas arrivé ? Vérifiez bien dans vos messages indésirables(ou spam) si il ne s'y trouve pas.<br>
Si le mail ne s'y trouve pas réessayez plus tard, c'est peut-être un problème passager.<br>
Sinon, si le problème persiste n'hésitez pas à venir nous le signaler, sur
<a href="https://gitea.planet-casio.com/devs/PCv5/issues/new">la page dédié.</a><br>
</p>
<a href="{{url_for('index')}}">Retour à la page d'accueil</a>
</div>
</section>
{% endblock content %}

View File

@ -24,7 +24,7 @@
<span class=msgerror>{{ error }}</span>
{% endfor %}
</div>
<div>{{ del_form.submit(class_="bg-red") }}</div>
<div>{{ del_form.submit(class_="bg-error") }}</div>
</form>
</section>
{% endblock %}

View File

@ -22,7 +22,7 @@
<span class=msgerror>{{ error }}</span>
{% endfor %}
</div>
<div>{{ del_form.submit(class_="bg-red") }}</div>
<div>{{ del_form.submit(class_="bg-error") }}</div>
</form>
</section>
{% endblock %}

View File

@ -14,9 +14,12 @@
<div>
{{ form.avatar.label }}
<div>
<img class="avatar" src="{{ url_for('static', filename=user.avatar) }}" meta="{{ user.avatar }}" />
<img class="avatar" src="{{ url_for('avatar', filename=user.avatar) }}" meta="{{ user.avatar }}" />
{{ form.avatar }}
</div>
{% for error in form.avatar.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>
@ -88,7 +91,7 @@
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>{{ form.submit(class_="bg-green") }}</div>
<div>{{ form.submit(class_="bg-ok") }}</div>
</form>
<hr>
@ -96,24 +99,43 @@
<form action="{{ url_for('adm_edit_account', user_id=user.id) }}" method="post">
{{ trophy_form.hidden_tag() }}
<h2>Trophées</h2>
<div class="trophies-panel">
<div class="trophies-panel flex-grid fg3">
{% for id, input in trophy_form.__dict__.items() %}
{% if id[0] == "t" %}
<div>
{# TODO: add trophies icons #}
{{ input(checked=id in user_owned) }}
{{ input(checked=id in trophies_owned) }}
{{ input.label }}
</div>
{% endif %}
{% endfor %}
</div>
<div>{{ trophy_form.submit(class_="bg-green") }}</div>
<div>{{ trophy_form.submit(class_="bg-ok") }}</div>
</form>
<hr>
<form action="{{ url_for('adm_edit_account', user_id=user.id) }}" method="post">
{{ group_form.hidden_tag() }}
<h2>Groupes</h2>
<div class="groups-panel flex-grid fg3">
{% for id, input in group_form.__dict__.items() %}
{% if id[0] == "g" %}
<div>
{# TODO: add trophies icons #}
{{ input(checked=id in groups_owned) }}
{{ input.label }}
</div>
{% endif %}
{% endfor %}
</div>
<div>{{ group_form.submit(class_="bg-ok") }}</div>
</form>
<hr>
<h2 style="margin-top:30px;">Supprimer le compte</h2>
<a href="{{ url_for('adm_delete_account', user_id=user.id) }}" class="button bg-red">Supprimer le compte</a>
<a href="{{ url_for('adm_delete_account', user_id=user.id) }}" class="button bg-error">Supprimer le compte</a>
</section>
{% endblock %}

View File

@ -33,6 +33,6 @@
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>{{ form.submit(class_="bg-green") }}</div>
<div>{{ form.submit(class_="bg-ok") }}</div>
</section>
{% endblock %}

View File

@ -0,0 +1,38 @@
{% extends "base/base.html" %}
{# This macro will allow us to perform recursive HTML generation #}
{% macro forumtree(f, level) %}
<tr>
<td><code>{{ f.url }}</code></td>
<td style='padding-left: {{ 6+24*level }}px'>
<a href='/forum{{ f.url }}'>{{ f.name }}</a>
</td>
<td>{{ f.topics | length }}</td>
<td>{{ f.post_count() }}</td>
</tr>
{% for subf in f.sub_forums %}
{{ forumtree(subf, level+1) }}
{% endfor %}
{% endmacro %}
{% block title %}
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Forums</h1>
{% endblock %}
{% block content %}
<section>
<p>Cette page permet de gérer l'arbre des forums.</p>
<h2>Arbre des forums</h2>
{% if main_forum == None %}
<p>Il n'y a aucun forum.</p>
{% else %}
<table style='width: 90%; margin: auto'>
<tr><th>URL</th><th>Nom</th><th>Sujets</th><th>Messages</th></tr>
{{ forumtree(main_forum, 0) }}
</table>
{% endif %}
</section>
{% endblock %}

View File

@ -26,7 +26,7 @@
<code>{{ priv }}</code>
{{- ', ' if not loop.last }}
{% endfor %}</td>
<td><a href="{{ url_for('adm_edit_account', user_id=user.id) }}">Modifier</a></td>
<td style="text-align: center"><a href="{{ url_for('adm_edit_account', user_id=user.id) }}">Modifier</a></td>
</tr>
{% endfor %}
</table>

View File

@ -8,8 +8,9 @@
<section>
<p>Pages générales du panneau d'administration :</p>
<ul>
<li><a href="{{ url_for('adm_groups') }}">Groupes et privilèges</a></li>
<li><a href="{{ url_for('adm_groups') }}">Groupes et privilèges</a></li>
<li><a href="{{ url_for('adm_trophies') }}">Titres et trophées</a></li>
<li><a href="{{ url_for('adm_forums') }}">Arbre des forums</a></li>
</ul>
</section>
{% endblock %}

View File

@ -14,13 +14,17 @@
<table style="width:90%; margin: auto;">
<tr><th>ID</th><th>Icône</th><th>Nom</th><th>Titre</th>
<th>Style</th><th>Modifier</th><th>Supprimer</th></tr>
<th>Style</th><th>Modifier</th><th>Supprimer</th></tr>
{% for trophy in trophies %}
<tr><td>{{ trophy.id }}</td>
<td><img src="{{ url_for('static', filename='images/account-circle.svg') }}" alt="{{ trophy.name }}"></td>
<td style="{{ trophy.css }}">{{ trophy.name }}</td>
<td>{{ trophy | is_title }}</td>
{% if trophy | is_title %}
<td style="color:green">Oui</td>
{% else %}
<td style="color:red">Non</td>
{% endif %}
<td><code>{{ trophy.css }}</code></td>
<td style="text-align: center"><a href="{{ url_for('adm_edit_trophy', trophy_id=trophy.id) }}">Modifier</a></td>
<td style="text-align: center"><a href="{{ url_for('adm_delete_trophy', trophy_id=trophy.id) }}">Supprimer</a></td>
@ -56,6 +60,6 @@
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>{{ form.submit(class_="bg-green") }}</div>
<div>{{ form.submit(class_="bg-ok") }}</div>
</section>
{% endblock %}

View File

@ -13,7 +13,7 @@
{% block content %}
{% endblock %}
{% include "base/footer.html" %}
</div>

View File

@ -1,4 +1,8 @@
<footer>
<p>Planète Casio est un site communautaire non affilié à CASIO. Toute reproduction de Planète Casio, même partielle, est interdite.</p>
<p>Les programmes et autres publications présentes sur Planète Casio restent la propriété de leurs auteurs et peuvent être soumis à des licences ou des copyrights.</p>
{% if current_user.is_authenticated and current_user.priv('footer-statistics') %}
<p>Page générée en {{ g.request_time() }}</p>
{% endif %}
<p>Ceci est un environnement de test. Tout contenu peut être supprimé sans avertissement préalable.</p>
</footer>

View File

@ -4,6 +4,8 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="{{ url_for('static', filename='icons/favicon-96.ico') }}" type="image/x-icon">
{% for s in styles %}
<link rel="stylesheet" type="text/css" href={{url_for('static', filename = s)}}>
{% endfor %}

View File

@ -3,11 +3,11 @@
<input type="search" name="q" id="q" placeholder="{{search_form.label}}" />
<a role=button onclick="this.parentNode.submit();" href=#>
<svg viewBox="0 0 24 24">
<path fill="#adb0b4"d="M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z"></path>
<path d="M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z"></path>
</svg>
</a>
</form>
<div id="spotlight">
<a href="#" class="button bg-red">Jeu du mois : février 2019</a>
<a href="#" class="button bg-error">Jeu du mois : février 2019</a>
</div>

View File

@ -1,13 +1,17 @@
<nav>
<ul id="light-menu">
<a id="logo" href="{{ url_for('index') }}">
<img src="{{ url_for('static',filename= 'images/logo_noshadow.png') }}" alt="logo"/>
<img src="{{ url_for('static',filename= 'images/logo_noshadow-small.png') }}" alt="logo"/>
</a>
<li>
<a role="button" label="Compte" tabindex="0">
{% if current_user.is_authenticated %}
<a href="{{ url_for('user', username=current_user.name) }}" role="button" label="Compte" tabindex="0">
{% else %}
<a href="{{ url_for('login') }}" role="button" label="Compte" tabindex="0">
{% endif %}
<svg viewBox="0 0 24 24">
<path fill="#ffffff" d="M12,19.2C9.5,19.2 7.29,17.92 6,16C6.03,14 10,12.9 12,12.9C14,12.9 17.97,14 18,16C16.71,17.92 14.5,19.2 12,19.2M12,5A3,3 0 0,1 15,8A3,3 0 0,1 12,11A3,3 0 0,1 9,8A3,3 0 0,1 12,5M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12C22,6.47 17.5,2 12,2Z"></path>
<path d="M12,19.2C9.5,19.2 7.29,17.92 6,16C6.03,14 10,12.9 12,12.9C14,12.9 17.97,14 18,16C16.71,17.92 14.5,19.2 12,19.2M12,5A3,3 0 0,1 15,8A3,3 0 0,1 12,11A3,3 0 0,1 9,8A3,3 0 0,1 12,5M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12C22,6.47 17.5,2 12,2Z"></path>
</svg>
<div>Compte</div>
</a>
@ -16,16 +20,16 @@
<li>
<a role="button" label="Actualités" tabindex="0">
<svg viewBox="0 0 24 24">
<path fill="#ffffff" d="M20,11H4V8H20M20,15H13V13H20M20,19H13V17H20M11,19H4V13H11M20.33,4.67L18.67,3L17,4.67L15.33,3L13.67,4.67L12,3L10.33,4.67L8.67,3L7,4.67L5.33,3L3.67,4.67L2,3V19A2,2 0 0,0 4,21H20A2,2 0 0,0 22,19V3L20.33,4.67Z"></path>
<path d="M20,11H4V8H20M20,15H13V13H20M20,19H13V17H20M11,19H4V13H11M20.33,4.67L18.67,3L17,4.67L15.33,3L13.67,4.67L12,3L10.33,4.67L8.67,3L7,4.67L5.33,3L3.67,4.67L2,3V19A2,2 0 0,0 4,21H20A2,2 0 0,0 22,19V3L20.33,4.67Z"></path>
</svg>
<div>Actualités</div>
</a>
</li>
<li>
<a role="button" label="Forum" tabindex="0">
<a href="{{ url_for('forum_index') }}" role="button" label="Forum" tabindex="0">
<svg viewBox="0 0 24 24">
<path fill="#ffffff" d="M17,12V3A1,1 0 0,0 16,2H3A1,1 0 0,0 2,3V17L6,13H16A1,1 0 0,0 17,12M21,6H19V15H6V17A1,1 0 0,0 7,18H18L22,22V7A1,1 0 0,0 21,6Z"></path>
<path d="M17,12V3A1,1 0 0,0 16,2H3A1,1 0 0,0 2,3V17L6,13H16A1,1 0 0,0 17,12M21,6H19V15H6V17A1,1 0 0,0 7,18H18L22,22V7A1,1 0 0,0 21,6Z"></path>
</svg>
<div>Forum</div>
</a>
@ -34,7 +38,7 @@
<li>
<a role="button" label="Programmes" tabindex="0">
<svg viewBox="0 0 24 24">
<path fill="#ffffff" d="M8,3A2,2 0 0,0 6,5V9A2,2 0 0,1 4,11H3V13H4A2,2 0 0,1 6,15V19A2,2 0 0,0 8,21H10V19H8V14A2,2 0 0,0 6,12A2,2 0 0,0 8,10V5H10V3M16,3A2,2 0 0,1 18,5V9A2,2 0 0,0 20,11H21V13H20A2,2 0 0,0 18,15V19A2,2 0 0,1 16,21H14V19H16V14A2,2 0 0,1 18,12A2,2 0 0,1 16,10V5H14V3H16Z"></path>
<path d="M8,3A2,2 0 0,0 6,5V9A2,2 0 0,1 4,11H3V13H4A2,2 0 0,1 6,15V19A2,2 0 0,0 8,21H10V19H8V14A2,2 0 0,0 6,12A2,2 0 0,0 8,10V5H10V3M16,3A2,2 0 0,1 18,5V9A2,2 0 0,0 20,11H21V13H20A2,2 0 0,0 18,15V19A2,2 0 0,1 16,21H14V19H16V14A2,2 0 0,1 18,12A2,2 0 0,1 16,10V5H14V3H16Z"></path>
</svg>
<div>Programmes</div>
</a>
@ -43,7 +47,7 @@
<li>
<a role="button" label="Tutoriels" tabindex="0">
<svg viewBox="0 0 24 24">
<path fill="#ffffff" d="M12,3L1,9L12,15L21,10.09V17H23V9M5,13.18V17.18L12,21L19,17.18V13.18L12,17L5,13.18Z"></path>
<path d="M12,3L1,9L12,15L21,10.09V17H23V9M5,13.18V17.18L12,21L19,17.18V13.18L12,17L5,13.18Z"></path>
</svg>
<div>Tutoriels</div>
</a>
@ -52,24 +56,22 @@
<li>
<a role="button" label="Sprites" tabindex="0">
<svg viewBox="0 0 24 24">
<path fill="#ffffff" d="M20.71,4.63L19.37,3.29C19,2.9 18.35,2.9 17.96,3.29L9,12.25L11.75,15L20.71,6.04C21.1,5.65 21.1,5 20.71,4.63M7,14A3,3 0 0,0 4,17C4,18.31 2.84,19 2,19C2.92,20.22 4.5,21 6,21A4,4 0 0,0 10,17A3,3 0 0,0 7,14Z"></path>
<path d="M20.71,4.63L19.37,3.29C19,2.9 18.35,2.9 17.96,3.29L9,12.25L11.75,15L20.71,6.04C21.1,5.65 21.1,5 20.71,4.63M7,14A3,3 0 0,0 4,17C4,18.31 2.84,19 2,19C2.92,20.22 4.5,21 6,21A4,4 0 0,0 10,17A3,3 0 0,0 7,14Z"></path>
</svg>
<div>Sprites</div>
</a>
</li>
<li>
<a role="button" label="Outils" tabindex="0">
<a href="{{ url_for('tools') }}" role="button" label="Outils" tabindex="0">
<svg viewBox="0 0 24 24">
<path fill="#ffffff" d="M22.7,19L13.6,9.9C14.5,7.6 14,4.9 12.1,3C10.1,1 7.1,0.6 4.7,1.7L9,6L6,9L1.6,4.7C0.4,7.1 0.9,10.1 2.9,12.1C4.8,14 7.5,14.5 9.8,13.6L18.9,22.7C19.3,23.1 19.9,23.1 20.3,22.7L22.6,20.4C23.1,20 23.1,19.3 22.7,19Z"></path>
<path d="M22.7,19L13.6,9.9C14.5,7.6 14,4.9 12.1,3C10.1,1 7.1,0.6 4.7,1.7L9,6L6,9L1.6,4.7C0.4,7.1 0.9,10.1 2.9,12.1C4.8,14 7.5,14.5 9.8,13.6L18.9,22.7C19.3,23.1 19.9,23.1 20.3,22.7L22.6,20.4C23.1,20 23.1,19.3 22.7,19Z"></path>
</svg>
<div>Outils</div>
</a>
</li>
</ul>
<div id=spacer-menu></div>
<div id=menu>
{% include "base/navbar/account.html" %}

View File

@ -2,14 +2,14 @@
<div>
<h2>
<a href="{{ url_for('user', username=current_user.name) }}">
<img src="{{ url_for('static', filename=current_user.avatar) }}"></a>
<img src="{{ url_for('avatar', filename=current_user.avatar) }}"></a>
<a href="{{ url_for('user', username=current_user.name) }}">
{{ current_user.name }}</a>
</h2>
<a href="#">
<a href="{{ url_for('list_notifications') }}">
<svg viewBox="0 0 24 24">
<path fill="#ffffff" d="M20,2A2,2 0 0,1 22,4V16A2,2 0 0,1 20,18H6L2,22V4C2,2.89 2.9,2 4,2H20M4,4V17.17L5.17,16H20V4H4M6,7H18V9H6V7M6,11H15V13H6V11Z"></path>
</svg>Notifications
</svg>Notifications{{ " ({})".format(current_user.notifications|length) if current_user.notifications|length }}
</a>
<a href="#">
<svg viewBox="0 0 24 24">
@ -48,10 +48,10 @@
<form method="post" action="{{url_for('login')}}" class="login form">
{{ login_form.hidden_tag() }}
{{ login_form.username.label }}
{{ login_form.username(size=32, placeholder="Identifiant") }}
{{ login_form.username(size=32) }}
{{ login_form.password.label }}
{{ login_form.password(size=32, placeholder="Mot de passe") }}
{{ login_form.submit(class_="bg-green") }}
{{ login_form.password(size=32) }}
{{ login_form.submit(class_="bg-ok") }}
{{ login_form.remember_me.label }} {{ login_form.remember_me() }}
</form>
<hr />

View File

@ -5,21 +5,22 @@
</svg>
Forum
</h2>
<a href="#">Vie communautaire</a>
<a href="#">Projets de programmation</a>
<a href="#">Questions et problèmes</a>
<a href="#">Discussions</a>
<a href="#">Administration</a>
<a href="#">CreativeCalc</a>
<a href='{{ url_for('forum_index') }}'>Index du forum</a>
<hr />
<hr>
<h3>Derniers commentaires</h3>
{% for f in main_forum.sub_forums %}
<a href="{{ url_for('forum_page', f=f) }}">{{ f.name }}</a>
{% endfor %}
<hr>
<h3>Derniers topics actifs</h3>
<ul>
<li><a href="#">Legolas</a> sur <a href="#">Bugs de la v5</a></li>
<li><a href="#">Dark Storm</a> sur <a href="#">fxSDK support</a></li>
<li><a href="#">Gollum</a> sur <a href="#">Le nom de topic qui fout le bordel car il est trop long…</a></li>
<li><a href="#">Lephenixnoir</a> sur <a href="#">fxSDK support</a></li>
<li><a href="#">Kristaba</a> sur <a href="#">FiXos, le retour</a></li>
{% for t in last_active_topics %}
<li>
<a href="{{ url_for('forum_topic', f=t.forum, t=t, page='last')}}">{{ t.title }}</a>
</li>
{% endfor %}
</ul>
</div>

View File

@ -5,12 +5,16 @@
</svg>
Actualités
</h2>
<a href="#">Casio</a>
<a href="#">Arduino</a>
<a href="#">Projets communautaires</a>
<a href="#">Divers</a>
<a href='/forum/news'>Toutes les nouveautés</a>
<hr />
<hr>
<a href='/forum/news/calc'>Nouveautés Casio</a>
<a href='/forum/news/projects'>Projets communutaires</a>
<a href='/forum/news/events'>Événements de Planète Casio</a>
<a href='/forum/news/other'>Autres nouveautés</a>
<hr>
<h3>Derniers articles</h3>
<ul>

View File

@ -1,3 +1,3 @@
<script type="text/javascript" src={{url_for('static', filename = 'scripts/trigger_menu.js')}}></script>
<script type="text/javascript" src={{url_for('static', filename = 'scripts/smartphone_patch.js')}}></script>
<script type="text/javascript" src={{url_for('static', filename = 'scripts/pc-utils.js')}}></script>
{% for s in scripts %}
<script type="text/javascript" src={{url_for('static', filename=s)}}></script>
{% endfor %}

View File

@ -1,9 +1,11 @@
{% extends "base/base.html" %}
{% block title %}
<h1>403 - Accès non autorisé</h1>
{% endblock %}
{% block content %}
<section>
<h1>403 - Accès non autorisé</h1>
<img src="{{url_for('static', filename = 'images/403.webp')}}" style="display:block;margin:auto"; />
</section>
{% endblock %}

View File

@ -1,9 +1,11 @@
{% extends "base/base.html" %}
{% block title %}
<h1>404 - Page non trouvée</h1>
{% endblock %}
{% block content %}
<section>
<h1>404 - Page non trouvée</h1>
<img src="{{url_for('static', filename = 'images/404.webp')}}" style="display:block;margin:auto"; />
</section>
{% endblock %}

View File

@ -0,0 +1,21 @@
{% extends "base/base.html" %}
{% import "widgets/editor.html" as widget_editor %}
{% import "widgets/member.html" as widget_member %}
{% import "widgets/pagination.html" as widget_pagination with context %}
{% 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>
{% endblock %}
{% block content %}
<section>
<h1>Édition du topic {{ t.title }}</h1>
<div class=form>
<h3>Commenter le sujet</h3>
<form action="" method="post" enctype="multipart/form-data">
Un formulaire
</form>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,67 @@
{% extends "base/base.html" %}
{% import "widgets/editor.html" as widget_editor %}
{% block title %}
<a href='/forum'>Forum de Planète Casio</a> » <h1>{{ f.name }}</h1>
{% endblock %}
{% block content %}
<section>
<p>{{ f.descr }}</p>
{% if f.topics %}
<h2>Sujets</h2>
<table class=topiclist>
<tr><th>Sujet</th><th>Auteur</th><th>Date de création</th>
<th>Commentaires</th><th>Vues</th></tr>
{% for t in f.topics %}
<tr><td><a href='{{ url_for('forum_topic', f=t.forum, t=t) }}'>{{ t.title }}</a></td>
<td><a href='{{ url_for('user', username=t.author.name) }}'>{{ t.author.name }}</a></td>
<td>{{ t.date_created | date }}</td>
<td>{{ t.thread.comments.count() }}</td>
<td>{{ t.views }} </td></tr>
{% endfor %}
</table>
{% elif not f.sub_forums %}
<p>Il n'y a aucun topic sur ce forum ! Animons-le vite !</p>
{% endif %}
{% if f.sub_forums %}
<h2>Forums</h2>
<table class=forumlist>
<tr><th>{{ f.name }}</th><th>Nombre de sujets</th></tr>
{% for sf in f.sub_forums %}
<tr><td><a href='/forum{{ sf.url }}'>{{ sf.name }}</td>
<td>{{ sf.topics | length }}</td></tr>
<tr><td>{{ sf.descr }}</td><td></td></tr>
{% endfor %}
</table>
{% endif %}
{% if (current_user.is_authenticated and current_user.priv('write-anywhere'))
or ("/actus" in f.url and current_user.is_authenticated and current_user.priv('write-news'))
or ("/actus" not in f.url and not f.sub_forums) %}
<div class=form>
<h2>Créer un nouveau sujet</h2>
<form action="" method="post" enctype="multipart/form-data">
{{ form.hidden_tag() }}
<div>
{{ form.title.label }}
{{ form.title() }}
{% for error in form.title.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
{{ widget_editor.text_editor(form.message) }}
<div>{{ form.submit(class_='bg-ok') }}</div>
</form>
</div>
{% endif %}
</section>
{% endblock %}

View File

@ -0,0 +1,43 @@
{% extends "base/base.html" %}
{% block title %}
<h1>Forum de Planète Casio</h1>
{% endblock %}
{% block content %}
<section>
<p>
Bienvenue sur le forum de Planète Casio ! Vous pouvez créer des
nouveaux sujets ou poster des réponses avec un compte
{% if not current_user.is_authenticated %}
ou en postant en tant qu'invité
{% endif %}
.
</p>
{% if main_forum == None %}
<p>Il n'y a aucun forum.</p>
{% else %}
{% for l1 in main_forum.sub_forums %}
<table class=forumlist>
<tr><th>{{ l1.name }}</th><th>Nombre de sujets</th></tr>
{% if l1.sub_forums == [] %}
<tr><td><a href='/forum{{ l1.url }}'>{{ l1.name }}</td>
<td>{{ l1.topics | length }}</td></tr>
<tr><td>{{ l1.descr }}</td><td></td></tr>
{% endif %}
{% for l2 in l1.sub_forums %}
<tr><td><a href='/forum{{ l2.url }}'>{{ l2.name }}</td>
<td>{{ l2.topics | length }}</td></tr>
<tr><td>{{ l2.descr }}</td><td></td></tr>
{% endfor %}
</table>
{% endfor %}
{% endif %}
</section>
{% endblock %}

View File

@ -0,0 +1,58 @@
{% extends "base/base.html" %}
{% import "widgets/editor.html" as widget_editor %}
{% import "widgets/member.html" as widget_member %}
{% import "widgets/pagination.html" as widget_pagination with context %}
{% 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>
{% endblock %}
{% block content %}
<section>
<h1>{{ t.title }}</h1>
<table class="thread"><tr>
<td class="member">{{ widget_member.profile(t.author ) }}</td>
<td>{{ t.thread.top_comment.text }}</td>
</tr></table>
{{ widget_pagination.paginate(comments, 'forum_topic', {'f': t.forum, 't':t}) }}
<table class="thread">
{% for c in comments.items %}
<tr id="{{ c.id }}">
{% if c != t.thread.top_comment %}
<td class="member">{{ widget_member.profile(c.author ) }}</td>
<td>
<div>{% if c.date_created != c.date_modified %}
Posté le {{ c.date_created|date }} (Modifié le {{ c.date_modified|date }})
{% else %}
Posté le {{ c.date_created|date }}
{% endif %}
| <a href="{{ url_for('forum_topic', f=t.forum, t=t, page=comments.page, _anchor=c.id) }}">#</a>
| <a href="#">Modifier</a>
| <a href="#">Supprimer</a>
</div>
<!--<hr>-->
<p>{{ c.text }}</p>
{% elif loop.index0 != 0 %}
<div>Ce message est le top comment</div>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{{ widget_pagination.paginate(comments, 'forum_topic', {'f': t.forum, 't':t}) }}
<div class=form>
<h3>Commenter le sujet</h3>
<form action="" method="post" enctype="multipart/form-data">
{{ form.hidden_tag() }}
{{ widget_editor.text_editor(form.message, label=False) }}
<div>{{ form.submit(class_='bg-ok') }}</div>
</form>
</div>
</section>
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More