Compare commits
161 Commits
api/admin/
...
master
Author | SHA1 | Date |
---|---|---|
Darks | 21b863e0e2 | |
Darks | e92792c8d6 | |
Darks | 20524d28c3 | |
Eragon | 0d9ca65238 | |
Darks | 402e6699aa | |
Darks | 7e64a70eec | |
Darks | 920724718f | |
Lephenixnoir | d6ff6eb77f | |
IniKiwi | c5df575af3 | |
IniKiwi | f93443310b | |
Darks | f6aefffc3c | |
Lephe | c8f2d73bc2 | |
Lephe | d531106c78 | |
Eragon | 43381bf493 | |
Darks | 62341cf9d9 | |
Eragon | c88c993ee3 | |
Eragon | ed8550f291 | |
Eragon | f163d15066 | |
Darks | 14e81bdfb5 | |
Eragon | 4231b3084e | |
Darks | 9902719328 | |
Darks | 358a5fec9d | |
Darks | 8721a7be69 | |
Darks | 12483e70e4 | |
Eragon | e5dafb68e5 | |
Darks | fabbb130b6 | |
Darks | 2530581095 | |
Darks | f06f14e814 | |
Eragon | 5a6d000be6 | |
Darks | 876cae2b69 | |
Darks | b892d9ae68 | |
Darks | 6238f72d6d | |
Darks | fccd0e5b84 | |
Darks | a5b2933727 | |
Darks | 6519cf4a6a | |
Darks | c31cca6314 | |
Darks | 0865ae0e67 | |
Darks | 798f5d203e | |
Darks | 3f8f8ab225 | |
Eragon | 4eb4145846 | |
Darks | 6d1d6a1b2e | |
Darks | 1a63544183 | |
Darks | 6817b79680 | |
Darks | 3c671da85c | |
Eragon | 7c076fea79 | |
Darks | 65828ffbdd | |
Eragon | 7aa93f15ea | |
Eragon | 57644c4378 | |
Eragon | be0d531b00 | |
Darks | b9becbf21f | |
Darks | e7d28570c7 | |
Lephe | 85830f1893 | |
Eragon | 6cc066b4d6 | |
Eragon | 168b77c8de | |
Eragon | 8fbec9ed87 | |
Eragon | 44609f2f96 | |
Eragon | f15186d4f8 | |
Eragon | ed1a534aa6 | |
Eragon | 2b5485677e | |
Eragon | b09ffaf97d | |
Eragon | 55836ebf19 | |
Darks | 228f613f09 | |
Darks | 65fd5c8188 | |
Eragon | 7f53b6c6c2 | |
Eragon | 37fba5d93b | |
Eragon | b93415819b | |
Darks | 3848b3dedd | |
Darks | 28ac88f5bd | |
Darks | 6fe04b0a6c | |
Darks | 8cd862078b | |
Darks | 2089398773 | |
Eragon | 112d06e3d6 | |
Eragon | f3b89716b7 | |
Eragon | 5e368ac08b | |
Eragon | 546b32c22b | |
Eragon | 2da20720bb | |
Eragon | e2283b7675 | |
Eragon | 5eaf1cc207 | |
Eragon | 95166b2da3 | |
Eragon | b132eed1c7 | |
Eragon | cd84ccf4e6 | |
Eragon | 28935b2ae8 | |
Eragon | 6799f7477b | |
Eragon | fcf42d4bb5 | |
Eragon | b062d9fa64 | |
Eragon | 371dee1f7a | |
Eragon | 1f1a06b02d | |
Eragon | 46106d69c9 | |
Eragon | 0d00b4dfb6 | |
Eragon | bcbab7033d | |
Lephe | 07bd7075d6 | |
Lephe | d6a3faa161 | |
Lephe | 786f940f21 | |
Lephe | 4322790ca1 | |
Lephe | 896e799b97 | |
Lephe | e54b01efe0 | |
Lephe | 15ce72b72d | |
Lephe | 02520f6b2d | |
Eragon | 8bec7120c5 | |
Eragon | 0a0ad4d558 | |
Eragon | 490ab2714c | |
Eragon | a01b74f3e8 | |
Eragon | 5bb581f4f3 | |
Eragon | 5fb06732ff | |
Eragon | 277ec535e7 | |
Eragon | d0126e7aba | |
Lephe | 2b9ab64f6e | |
Lephe | 760c2f20b2 | |
Lephe | 417fc05d29 | |
Lephe | 8ff21c615d | |
Lephe | db0e42d285 | |
Lephe | c74abf3fcc | |
Lephe | f4b9110ce2 | |
Lephe | 85323e896d | |
Lephe | c26861527b | |
Lephe | 6756838882 | |
Lephe | 84066eaca3 | |
Lephe | b047ed97af | |
Lephe | 0e1b434f7d | |
Lephe | 011ea3d2a6 | |
Lephe | 13ce27b682 | |
Lephe | 8393cf1933 | |
Darks | b7dc2ebbf2 | |
Lephe | 38c4f274a0 | |
Lephe | 7d9e897ae9 | |
Lephe | 1040d57506 | |
Darks | f64e3a2c39 | |
Lephe | 8f620c6150 | |
Lephe | 5a87d29c7f | |
Lephe | a3ed633791 | |
Darks | 9de0f9f823 | |
Darks | eb5ce1bd5c | |
Darks | faf5bd184d | |
Lephe | 262c5f22c8 | |
Darks | 3e399fb4c4 | |
Darks | 7b66e1ec20 | |
Darks | fe8e2f0265 | |
Lephe | db5e613f7e | |
Lephe | 8098642d4b | |
Darks | 8eee0ad236 | |
Darks | 13b1d29e42 | |
Darks | 17f5e82a2a | |
Darks | 2119329997 | |
Darks | 09c7f63b55 | |
Lephe | f53032fc88 | |
Lephe | 610fe6f1fd | |
Lephe | 48d6c1c03c | |
Lephe | e9c1f04f42 | |
Lephe | 39748667ee | |
Lephe | 19d90c6845 | |
Lephe | df07c905ab | |
Darks | dda7cce5d5 | |
Eragon | 2b2a5cc0d1 | |
Lephe | d964641e1f | |
Lephe | 9c78aca5ad | |
Lephe | 19586f9087 | |
Eragon | 875524ccb6 | |
Eldeberen | 41eaaa4c30 | |
Darks | ad1042865b | |
Darks | 2dd7863e89 | |
Darks | e15005a427 |
|
@ -19,6 +19,11 @@ Pipfile
|
|||
Pipfile.lock
|
||||
# Tests files
|
||||
test.*
|
||||
# Autosaves
|
||||
*.dia~
|
||||
|
||||
## Logging files
|
||||
*.log
|
||||
|
||||
|
||||
## Deployment files
|
||||
|
@ -37,6 +42,10 @@ local_config.py
|
|||
|
||||
wiki/
|
||||
|
||||
## JavaScript submodules buld files
|
||||
|
||||
# Emoji picker
|
||||
app/static/scripts/emoji-picker-element/
|
||||
|
||||
## Personal folder
|
||||
|
||||
|
|
57
README.md
57
README.md
|
@ -1,15 +1,66 @@
|
|||
# Planète Casio v5
|
||||
|
||||
## Présentation
|
||||
|
||||
La v4 se fait vieille, écrite en PHP 5 a l'origine elle a pu être mise a jour
|
||||
vers PHP 7.
|
||||
Mais le site n'est plus a jour, ne répond plus aux attentes de la communauté
|
||||
et on ne peut pas le modifier sans un gros travail.
|
||||
|
||||
Pour répondre a tout ces problèmes nous avons décidé de faire une nouvelle
|
||||
version du site, la v5.
|
||||
Écrite en Python avec Flask elle doit répondre aux nouvelles attentes de la
|
||||
communauté.
|
||||
La v5 est donc un logiciel libre, vous pouvez tous participer a sa création.
|
||||
Vous pouvez dès maintenant tester la version de pré-production du site
|
||||
[ici](https://v5.planet-casio.com).
|
||||
|
||||
## Des images
|
||||
|
||||
La page d’accueil, un peu vide pour le moment.
|
||||
![La page d’accueil, un peu vide pour le moment](demo/index.png)
|
||||
L'index des forums.
|
||||
![L'index des forums](demo/forum.png)
|
||||
L'index des topics de discussion, aussi connu sous l'abus des essais de DS.
|
||||
![L'index des topics de discussion](demo/index_discussions.png)
|
||||
Un topic au hasard, et voila le thème sombre.
|
||||
![Un topic, et le thème sombre sombre](demo/topic_dark.png)
|
||||
La barre de menu.
|
||||
![La barre de menu](demo/barre_laterale.png)
|
||||
Un profil.
|
||||
![Le profil d'Eragon](demo/profil.png)
|
||||
Les paramètres utilisateurs.
|
||||
![Et les paramètres utilisateurs](demo/parametres.png)
|
||||
El la version mobile de la v5.
|
||||
![La v5 est même pensé pour les téléphones](demo/mobile.png)
|
||||
|
||||
## Contribuer
|
||||
|
||||
Tu veux aider ?
|
||||
Tu peut nous aider en allant sur [la démo](https://v5.planet-casio.com/) et
|
||||
en cherchant des problèmes.
|
||||
Tu peut aussi venir apporter ton avis dans les réflexions pour l'avancé du site.
|
||||
Et si tu sait coder en python, nous serons heureux de t'accueillir parmi les
|
||||
développeurs de la v5.
|
||||
|
||||
## Quelques liens utiles
|
||||
|
||||
[Le wiki du développement](https://gitea.planet-casio.com/devs/PCv5/wiki/00-Home)
|
||||
[Le topic sur la v4](https://www.planet-casio.com/Fr/forums/topic13736-1-planete-casio-v5.html)
|
||||
[La RFC des notifications](https://www.planet-casio.com/Fr/forums/topic15828-1-rfc-v5-systeme-de-notifications.html)
|
||||
|
||||
## Code de conduite
|
||||
|
||||
Don't be an asshole.
|
||||
Respectez les règles de Planète Casio.
|
||||
(cf [La charte d'utilisation du forum](https://www.planet-casio.com/Fr/forums/topic12618-1-charte-dutilisation-du-forum-cuf.html))
|
||||
|
||||
## Style de code
|
||||
|
||||
* On respecte la PEP8. Je sais c'est relou d'indenter avec des espaces, mais au moins le reste est consistant.
|
||||
* La seule exception concerne la longueur des lignes. Merci d'essayer de respecter les 79 colonnes, mais dans certains cas c'est plus crade de revenir à la ligne, donc blc.
|
||||
* Je conseille d'utiliser Flake8 qui permet de vérifier les erreurs de syntaxe, de style, etc. en live.
|
||||
* On essaye d'écrire des commits en anglais
|
||||
|
||||
### License
|
||||
### Licence
|
||||
|
||||
Le code de Planète Casio v5 est sous license GPLv3+. Voyez [`LICENSE`](LICENSE).
|
||||
Le code de Planète Casio v5 est sous licence GPLv3+. Voyez [`LICENSE`](LICENSE).
|
||||
|
|
|
@ -23,4 +23,10 @@ python-psycopg2
|
|||
python-pillow
|
||||
python-pyyaml
|
||||
python-slugify
|
||||
flask-crontab
|
||||
```
|
||||
|
||||
Optionnel:
|
||||
```
|
||||
python-flask-debugtoolbar (Disponible dans l'AUR)
|
||||
```
|
||||
|
|
|
@ -4,19 +4,22 @@ from flask_migrate import Migrate
|
|||
from flask_login import LoginManager
|
||||
from flask_mail import Mail
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
from config import Config
|
||||
from flask_crontab import Crontab
|
||||
from config import FlaskApplicationSettings, V5Config
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(Config)
|
||||
app.config.from_object(FlaskApplicationSettings)
|
||||
app.v5logger = V5Config.v5logger()
|
||||
|
||||
# Check security of secret
|
||||
if Config.SECRET_KEY == "a-random-secret-key":
|
||||
if FlaskApplicationSettings.SECRET_KEY == "a-random-secret-key":
|
||||
raise Exception("Please use a strong secret key!")
|
||||
|
||||
db = SQLAlchemy(app)
|
||||
migrate = Migrate(app, db)
|
||||
mail = Mail(app)
|
||||
csrf = CSRFProtect(app)
|
||||
crontab = Crontab(app)
|
||||
|
||||
login = LoginManager(app)
|
||||
login.login_view = 'login'
|
||||
|
@ -27,6 +30,7 @@ login.login_message = "Veuillez vous authentifier avant de continuer."
|
|||
from app.utils.converters import *
|
||||
app.url_map.converters['forum'] = ForumConverter
|
||||
app.url_map.converters['topicpage'] = TopicPageConverter
|
||||
app.url_map.converters['programpage'] = ProgramPageConverter
|
||||
|
||||
# Register routes
|
||||
from app import routes
|
||||
|
@ -36,3 +40,12 @@ from app.utils import filters
|
|||
|
||||
# Register processors
|
||||
from app import processors
|
||||
|
||||
# Register scheduled jobs
|
||||
from app import jobs
|
||||
|
||||
# Enable flask-debug-toolbar if requested
|
||||
if V5Config.ENABLE_FLASK_DEBUG_TOOLBAR:
|
||||
from flask_debugtoolbar import DebugToolbarExtension
|
||||
app.config['DEBUG_TB_PROFILER_ENABLED'] = True
|
||||
toolbar = DebugToolbarExtension(app)
|
||||
|
|
|
@ -25,6 +25,7 @@
|
|||
# delete.accounts
|
||||
# delete.shared-files
|
||||
# move.posts
|
||||
# lock.threads
|
||||
#
|
||||
# Shoutbox:
|
||||
# shoutbox.kick
|
||||
|
@ -58,7 +59,7 @@
|
|||
publish.schedule-posts publish.pin-posts publish.shared-files
|
||||
edit.posts edit.tests edit.accounts edit.trophies
|
||||
delete.posts delete.tests delete.accounts delete.shared-files
|
||||
move.posts
|
||||
move.posts lock.threads
|
||||
shoutbox.kick shoutbox.ban
|
||||
misc.unlimited-pms misc.dev-infos misc.admin-panel
|
||||
misc.no-upload-limits misc.arbitrary-login
|
||||
|
@ -69,7 +70,7 @@
|
|||
privs: forum.access.admin
|
||||
edit.posts edit.tests
|
||||
delete.posts delete.tests
|
||||
move.posts
|
||||
move.posts lock.threads
|
||||
shoutbox.kick shoutbox.ban
|
||||
misc.unlimited-pms misc.no-upload-limits
|
||||
-
|
||||
|
|
|
@ -0,0 +1,108 @@
|
|||
# This is a list of all tags, sorted by category. The category names are used
|
||||
# to name CSS rules and shouldn't be changed directly.
|
||||
|
||||
# The following category groups calculators by common compatibility properties.
|
||||
# Each comment indicates why the group should exist on its own rather than
|
||||
# being merged with another one.
|
||||
calc:
|
||||
# Middle-school level, only basic algorithms; a unique property in this list.
|
||||
fx92:
|
||||
pretty: fx-92 Scientifique Collège+
|
||||
# Some of the most limited Graph models, no add-ins.
|
||||
g25:
|
||||
pretty: Graph 25/25+E/25+EII
|
||||
# The whole series with more Basic constructs than g25, but SH3 for add-ins
|
||||
# We don't separate based on whether an OS update is required (deemed safe)
|
||||
gsh3:
|
||||
pretty: Graph 35+/75/85/95 (SH3)
|
||||
# Same as gsh3, but with SH4 for add-ins; support CasioPython
|
||||
gsh4:
|
||||
pretty: Graph 35+/35+E/75+/75+E (SH4)
|
||||
# Like gsh3, but has Python; also; issues with the display and MonochromLib
|
||||
g35+e2:
|
||||
pretty: Graph 35+E II
|
||||
# Color display, nothing like the previous models
|
||||
cg20:
|
||||
pretty: fx-CG 10/20/Prizm
|
||||
# Like cg20, but has Python, and some incompatibilities on add-in
|
||||
g90+e:
|
||||
pretty: Graph 90+E
|
||||
# Different series entirely; has an SDK for add-ins
|
||||
cp300:
|
||||
pretty: Classpad 300/330
|
||||
# Like cp300, but does not have an SDK
|
||||
cp330+:
|
||||
pretty: Classpad 330+
|
||||
# Color display, entirely new model; no SDK
|
||||
cp400:
|
||||
pretty: Classpad 400/400+E
|
||||
|
||||
lang:
|
||||
basic:
|
||||
pretty: Basic CASIO
|
||||
cbasic:
|
||||
pretty: C.Basic
|
||||
python:
|
||||
pretty: Python
|
||||
c:
|
||||
pretty: C/C++ (add-in)
|
||||
lua:
|
||||
pretty: LuaFX
|
||||
other:
|
||||
pretty: "Langage: autre"
|
||||
|
||||
games:
|
||||
action:
|
||||
pretty: Action
|
||||
adventure:
|
||||
pretty: Aventure
|
||||
fighting:
|
||||
pretty: Combat
|
||||
narrative:
|
||||
pretty: Narratif
|
||||
other:
|
||||
pretty: "Jeu: autre"
|
||||
platform:
|
||||
pretty: Plateforme
|
||||
puzzle:
|
||||
pretty: Puzzle
|
||||
rpg:
|
||||
pretty: RPG
|
||||
rythm:
|
||||
pretty: Rythme
|
||||
shooting:
|
||||
pretty: Tir/FPS
|
||||
simulation:
|
||||
pretty: Simulation
|
||||
sport:
|
||||
pretty: Sport
|
||||
strategy:
|
||||
pretty: Stratégie
|
||||
survival:
|
||||
pretty: Survie
|
||||
|
||||
tools:
|
||||
conversion:
|
||||
pretty: Outil de conversion
|
||||
graphics:
|
||||
pretty: Outil graphique
|
||||
science:
|
||||
pretty: Outil scientifique
|
||||
programming:
|
||||
pretty: Outil pour programmer
|
||||
other:
|
||||
pretty: "Outil: autre"
|
||||
|
||||
courses:
|
||||
math:
|
||||
pretty: Maths
|
||||
physics:
|
||||
pretty: Physique
|
||||
engineering:
|
||||
pretty: SI/Électronique
|
||||
economics:
|
||||
pretty: Économie
|
||||
informatics:
|
||||
pretty: Informatique
|
||||
other:
|
||||
pretty: "Cours: autre"
|
|
@ -1,6 +1,7 @@
|
|||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, PasswordField, BooleanField, TextAreaField, SubmitField, DecimalField, SelectField, RadioField
|
||||
from wtforms.fields.html5 import DateField, EmailField
|
||||
from wtforms.fields.datetime import DateField
|
||||
from wtforms.fields.simple import EmailField
|
||||
from wtforms.validators import InputRequired, Optional, Email, EqualTo
|
||||
from flask_wtf.file import FileField # Cuz' wtforms' FileField is shitty
|
||||
import app.utils.validators as vd
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, SubmitField, TextAreaField, SelectField, \
|
||||
BooleanField
|
||||
from wtforms.fields.html5 import DateTimeField
|
||||
from wtforms.fields.datetime import DateTimeField
|
||||
from wtforms.validators import InputRequired, Optional
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, SubmitField, TextAreaField, MultipleFileField
|
||||
from wtforms.validators import InputRequired, Length
|
||||
import app.utils.validators as vf
|
||||
from app.utils.antibot_field import AntibotField
|
||||
from app.utils.tag_field import TagListField
|
||||
from app.forms.forum import CommentForm
|
||||
|
||||
class ProgramCreationForm(CommentForm):
|
||||
name = StringField('Nom du programme',
|
||||
validators=[InputRequired(), Length(min=3, max=64)])
|
||||
|
||||
tags = TagListField('Liste de tags')
|
||||
|
||||
submit = SubmitField('Soumettre le programme')
|
|
@ -1,6 +1,6 @@
|
|||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, SubmitField
|
||||
from wtforms.fields.html5 import DateField
|
||||
from wtforms.fields.datetime import DateField
|
||||
from wtforms.validators import InputRequired, Optional
|
||||
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
from app.jobs.update_progrank import update_progrank
|
|
@ -0,0 +1,11 @@
|
|||
from app import db, crontab
|
||||
from app.models.program import Program
|
||||
from datetime import datetime
|
||||
|
||||
@crontab.job(minute="0", hour="4")
|
||||
def update_progrank():
|
||||
for p in Program.query.all():
|
||||
p.progrank = 0
|
||||
p.progrank_date = datetime.now()
|
||||
db.session.merge(p)
|
||||
db.session.commit()
|
|
@ -4,3 +4,5 @@ from app.models.forum import Forum
|
|||
from app.models.topic import Topic
|
||||
from app.models.notification import Notification
|
||||
from app.models.program import Program
|
||||
from app.models.tag import Tag
|
||||
from app.models.event import Event
|
||||
|
|
|
@ -1,20 +1,25 @@
|
|||
from werkzeug.utils import secure_filename
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import backref
|
||||
from app import db
|
||||
from app.utils.filesize import filesize
|
||||
from config import V5Config
|
||||
import os
|
||||
import uuid
|
||||
|
||||
|
||||
class Attachment(db.Model):
|
||||
__tablename__ = 'attachment'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
|
||||
# Original name of the file
|
||||
name = db.Column(db.Unicode(64))
|
||||
|
||||
# The comment linked with
|
||||
comment_id = db.Column(db.Integer, db.ForeignKey('comment.id'), nullable=False)
|
||||
comment = db.relationship('Comment', backref=backref('attachments'))
|
||||
comment_id = db.Column(db.Integer, db.ForeignKey('comment.id'),
|
||||
nullable=False, index=True)
|
||||
comment = db.relationship('Comment', back_populates='attachments',
|
||||
foreign_keys=comment_id)
|
||||
|
||||
# The size of the file
|
||||
size = db.Column(db.Integer)
|
||||
|
@ -23,11 +28,11 @@ class Attachment(db.Model):
|
|||
@property
|
||||
def path(self):
|
||||
return os.path.join(V5Config.DATA_FOLDER, "attachments",
|
||||
f"{self.id:05}", self.name)
|
||||
f"{self.id}", self.name)
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return f"/fichiers/{self.id:05}/{self.name}"
|
||||
return f"/fichiers/{self.id}/{self.name}"
|
||||
|
||||
|
||||
def __init__(self, file, comment):
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
from app import db
|
||||
from app.models.post import Post
|
||||
from app.models.attachment import Attachment
|
||||
from sqlalchemy.orm import backref
|
||||
|
||||
|
||||
|
@ -20,12 +21,19 @@ class Comment(Post):
|
|||
backref=backref('comments', lazy='dynamic'),
|
||||
foreign_keys=thread_id)
|
||||
|
||||
# attachments (relation from Attachment)
|
||||
attachments = db.relationship('Attachment', back_populates='comment',
|
||||
lazy='joined')
|
||||
|
||||
@property
|
||||
def is_top_comment(self):
|
||||
return self.id == self.thread.top_comment_id
|
||||
|
||||
@property
|
||||
def is_metacontent(self):
|
||||
"""Whether if this post is metacontent (topic, program) or actual content"""
|
||||
|
||||
return False
|
||||
|
||||
def __init__(self, author, text, thread):
|
||||
"""
|
||||
Create a new Comment in a thread.
|
||||
|
@ -53,5 +61,17 @@ class Comment(Post):
|
|||
db.session.commit()
|
||||
db.session.delete(self)
|
||||
|
||||
def create_attachments(self, multiple_file_field_data):
|
||||
"""Create attachements from a form's MultipleFileField.data."""
|
||||
attachments = []
|
||||
for file in multiple_file_field_data:
|
||||
if file.filename != "":
|
||||
a = Attachment(file, self)
|
||||
attachments.append((a, file))
|
||||
db.session.add(a)
|
||||
db.session.commit()
|
||||
for a, file in attachments:
|
||||
a.set_file(file)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Comment: #{self.id}>'
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
from app import db
|
||||
|
||||
class Event(db.Model):
|
||||
__tablename__ = 'event'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Pretty event name, eg. "CPC #28"
|
||||
name = db.Column(db.Unicode(128))
|
||||
|
||||
# Main topic, used to automatically insert links
|
||||
main_topic = db.Column(db.Integer, db.ForeignKey('topic.id'))
|
|
@ -11,7 +11,8 @@ class Notification(db.Model):
|
|||
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_id = db.Column(db.Integer, db.ForeignKey('member.id'),
|
||||
nullable=False, index=True)
|
||||
owner = db.relationship('Member', backref='notifications',
|
||||
foreign_keys=owner_id)
|
||||
|
||||
|
|
|
@ -103,7 +103,7 @@ class PollAnswer(db.Model):
|
|||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Poll
|
||||
poll_id = db.Column(db.Integer, db.ForeignKey('poll.id'))
|
||||
poll_id = db.Column(db.Integer, db.ForeignKey('poll.id'), index=True)
|
||||
poll = db.relationship('Poll', backref=backref('answers'),
|
||||
foreign_keys=poll_id)
|
||||
|
||||
|
|
|
@ -10,21 +10,31 @@ class Post(db.Model):
|
|||
# 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))
|
||||
type = db.Column(db.String(20), index=True)
|
||||
|
||||
# Creation and edition date
|
||||
date_created = db.Column(db.DateTime)
|
||||
date_modified = db.Column(db.DateTime)
|
||||
date_modified = db.Column(db.DateTime, index=True)
|
||||
|
||||
# Post author
|
||||
author_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
author_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False,
|
||||
index=True)
|
||||
author = db.relationship('User', backref="posts", foreign_keys=author_id)
|
||||
|
||||
# Tags, for programs and tutorials
|
||||
tags = db.relationship('Tag', back_populates='post', lazy='joined')
|
||||
|
||||
__mapper_args__ = {
|
||||
'polymorphic_identity': __tablename__,
|
||||
'polymorphic_on': type
|
||||
}
|
||||
|
||||
@property
|
||||
def is_metacontent(self):
|
||||
"""Whether if this post is metacontent (topic, program) or actual content"""
|
||||
|
||||
return True
|
||||
|
||||
def __init__(self, author):
|
||||
"""
|
||||
Create a new Post.
|
||||
|
|
|
@ -16,16 +16,18 @@ class SpecialPrivilege(db.Model):
|
|||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Member that is granted the privilege
|
||||
mid = db.Column(db.Integer, db.ForeignKey('member.id'), index=True)
|
||||
member_id = db.Column(db.Integer, db.ForeignKey('member.id'), index=True)
|
||||
member = db.relationship('Member', back_populates="special_privs",
|
||||
foreign_keys=member_id)
|
||||
# Privilege name
|
||||
priv = db.Column(db.String(64))
|
||||
|
||||
def __init__(self, member, priv):
|
||||
self.mid = member.id
|
||||
self.member = member
|
||||
self.priv = priv
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Privilege: {self.priv} of member #{self.mid}>'
|
||||
return f'<Privilege: {self.priv} of member #{self.member_id}>'
|
||||
|
||||
|
||||
# Group: User group, corresponds to a community role and a set of privileges
|
||||
|
@ -43,7 +45,9 @@ class Group(db.Model):
|
|||
description = db.Column(db.UnicodeText)
|
||||
# List of members (lambda delays evaluation)
|
||||
members = db.relationship('Member', secondary=lambda: GroupMember,
|
||||
back_populates='groups')
|
||||
back_populates='groups', lazy='joined')
|
||||
# List of privileges
|
||||
privileges = db.relationship('GroupPrivilege', back_populates='group')
|
||||
|
||||
def __init__(self, name, css, descr):
|
||||
self.name = name
|
||||
|
@ -57,7 +61,7 @@ class Group(db.Model):
|
|||
* Group privileges
|
||||
"""
|
||||
|
||||
for gp in GroupPrivilege.query.filter_by(gid=self.id).all():
|
||||
for gp in self.privileges:
|
||||
db.session.delete(gp)
|
||||
db.session.commit()
|
||||
|
||||
|
@ -65,8 +69,7 @@ class Group(db.Model):
|
|||
db.session.commit()
|
||||
|
||||
def privs(self):
|
||||
gps = GroupPrivilege.query.filter_by(gid=self.id).all()
|
||||
return sorted(gp.priv for gp in gps)
|
||||
return sorted(gp.priv for gp in self.privileges)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Group: {self.name}>'
|
||||
|
@ -77,15 +80,17 @@ GroupMember = db.Table('group_member', db.Model.metadata,
|
|||
db.Column('gid', db.Integer, db.ForeignKey('group.id')),
|
||||
db.Column('uid', db.Integer, db.ForeignKey('member.id')))
|
||||
|
||||
|
||||
# Many-to-many relationship for privileges granted to groups
|
||||
# GroupPrivilege: A list of privileges for groups, materialized as a table
|
||||
class GroupPrivilege(db.Model):
|
||||
__tablename__ = 'group_privilege'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
gid = db.Column(db.Integer, db.ForeignKey('group.id'))
|
||||
group_id = db.Column(db.Integer, db.ForeignKey('group.id'))
|
||||
group = db.relationship('Group', back_populates='privileges',
|
||||
foreign_keys=group_id)
|
||||
|
||||
priv = db.Column(db.String(64))
|
||||
|
||||
def __init__(self, group, priv):
|
||||
self.gid = group.id
|
||||
self.group = group
|
||||
self.priv = priv
|
||||
|
|
|
@ -9,30 +9,48 @@ class Program(Post):
|
|||
id = db.Column(db.Integer, db.ForeignKey('post.id'), primary_key=True)
|
||||
|
||||
# Program name
|
||||
title = db.Column(db.Unicode(128))
|
||||
name = db.Column(db.Unicode(128))
|
||||
# Author, when different from the poster
|
||||
real_author = db.Column(db.Unicode(128))
|
||||
# Version
|
||||
version = db.Column(db.Unicode(64))
|
||||
# Approximate size as indicated by poster
|
||||
size = db.Column(db.Unicode(64))
|
||||
# License identifier
|
||||
license = db.Column(db.String(32))
|
||||
|
||||
# TODO: Category (games/utilities/lessons)
|
||||
# TODO: Tags
|
||||
# TODO: Compatible calculator models
|
||||
# Label de qualité
|
||||
label = db.Column(db.Boolean, nullable=False, server_default="FALSE")
|
||||
# Event for which the program was posted
|
||||
event = db.Column(db.Integer, db.ForeignKey('event.id'), nullable=True)
|
||||
|
||||
# TODO: Number of downloads
|
||||
|
||||
# Thread with the program description (top comment) and comments
|
||||
thread_id = db.Column(db.Integer,db.ForeignKey('thread.id'),nullable=False)
|
||||
thread = db.relationship('Thread', foreign_keys=thread_id,
|
||||
back_populates='owner_program')
|
||||
|
||||
# TODO: Number of views, statistics, attached files, etc
|
||||
# Progrank, and last date of progrank update
|
||||
progrank = db.Column(db.Integer)
|
||||
progrank_date = db.Column(db.DateTime)
|
||||
|
||||
def __init__(self, author, title, thread):
|
||||
# Implicit attributes:
|
||||
# * tags (inherited from Post)
|
||||
# * attachements (available at thread.top_comment.attachments)
|
||||
|
||||
def __init__(self, author, name, thread):
|
||||
"""
|
||||
Create a Program.
|
||||
|
||||
Arguments:
|
||||
author -- post author (User, though only Members can post)
|
||||
title -- program title (unicode string)
|
||||
name -- program name (unicode string)
|
||||
thread -- discussion thread attached to the topic
|
||||
"""
|
||||
|
||||
Post.__init__(self, author)
|
||||
self.title = title
|
||||
self.name = name
|
||||
self.thread = thread
|
||||
|
||||
@staticmethod
|
||||
|
@ -44,4 +62,4 @@ class Program(Post):
|
|||
db.session.delete(self)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Program: #{self.id} "{self.title}">'
|
||||
return f'<Program: #{self.id} "{self.name}">'
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
from app import db
|
||||
|
||||
class TagInformation(db.Model):
|
||||
"""Detailed information about tags, by dot-string tag identifier."""
|
||||
|
||||
__tablename__ = 'tag_information'
|
||||
# The ID is the dot-string of the tag (eg. "calc.g35+e2")
|
||||
id = db.Column(db.String(64), primary_key=True)
|
||||
|
||||
# List of uses. Note how we load tag information along individual tags, but
|
||||
# we don't load uses unless the field is accessed.
|
||||
uses = db.relationship('Tag', back_populates='tag', lazy='dynamic')
|
||||
|
||||
# Pretty name
|
||||
pretty = db.Column(db.String(64))
|
||||
|
||||
# ... any other static information about tags
|
||||
|
||||
def __init__(self, id):
|
||||
self.id = id
|
||||
|
||||
def category(self):
|
||||
return self.id.split(".", 1)[0]
|
||||
|
||||
@staticmethod
|
||||
def all_tags():
|
||||
all_tags = {}
|
||||
for ti in TagInformation.query.all():
|
||||
ctgy = ti.category()
|
||||
if ctgy not in all_tags:
|
||||
all_tags[ctgy] = []
|
||||
all_tags[ctgy].append(ti)
|
||||
return all_tags
|
||||
|
||||
class Tag(db.Model):
|
||||
"""Association between a Post and a dot-string tag identifier."""
|
||||
|
||||
__tablename__ = 'tag'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Tagged post
|
||||
post_id = db.Column(db.Integer, db.ForeignKey('post.id'), index=True)
|
||||
post = db.relationship('Post', back_populates='tags', foreign_keys=post_id)
|
||||
# Tag name. Note how we always load the information along the tag, but not
|
||||
# the other way around.
|
||||
tag_id = db.Column(db.String(64), db.ForeignKey('tag_information.id'),
|
||||
index=True)
|
||||
tag = db.relationship('TagInformation', back_populates='uses',
|
||||
foreign_keys=tag_id, lazy='joined')
|
||||
|
||||
def __init__(self, post, tag):
|
||||
self.post = post
|
||||
|
||||
if isinstance(tag, str):
|
||||
tag = TagInformation.query.filter_by(id=tag).one()
|
||||
self.tag = tag
|
|
@ -18,6 +18,9 @@ class Thread(db.Model):
|
|||
owner_topic = db.relationship('Topic')
|
||||
owner_program = db.relationship('Program')
|
||||
|
||||
# Whether the thread is locked
|
||||
locked = db.Column(db.Boolean, default=False)
|
||||
|
||||
# Other fields populated automatically through relations:
|
||||
# <comments> The list of comments (of type Comment)
|
||||
|
||||
|
@ -53,6 +56,13 @@ class Thread(db.Model):
|
|||
return self.owner_program[0]
|
||||
return None
|
||||
|
||||
def is_default_accessible(self):
|
||||
if self.owner_program != []:
|
||||
return True
|
||||
if self.owner_topic != []:
|
||||
return self.owner_topic[0].forum.is_default_accessible()
|
||||
return False
|
||||
|
||||
def delete(self):
|
||||
"""Recursively delete thread and all associated contents."""
|
||||
# Remove reference to top comment
|
||||
|
|
|
@ -23,7 +23,8 @@ class Topic(Post):
|
|||
title = db.Column(db.Unicode(128))
|
||||
|
||||
# Parent forum
|
||||
forum_id = db.Column(db.Integer, db.ForeignKey('forum.id'), nullable=False)
|
||||
forum_id = db.Column(db.Integer, db.ForeignKey('forum.id'), nullable=False,
|
||||
index=True)
|
||||
forum = db.relationship('Forum',
|
||||
backref=backref('topics', lazy='dynamic'), foreign_keys=forum_id)
|
||||
|
||||
|
|
|
@ -59,4 +59,4 @@ class Title(Trophy):
|
|||
# Many-to-many relation for users earning trophies
|
||||
TrophyMember = db.Table('trophy_member', db.Model.metadata,
|
||||
db.Column('tid', db.Integer, db.ForeignKey('trophy.id')),
|
||||
db.Column('uid', db.Integer, db.ForeignKey('member.id')))
|
||||
db.Column('uid', db.Integer, db.ForeignKey('member.id'), index=True))
|
||||
|
|
|
@ -1,19 +1,26 @@
|
|||
from datetime import date
|
||||
from flask import url_for
|
||||
from flask_login import UserMixin
|
||||
from sqlalchemy import func as SQLfunc
|
||||
from os.path import isfile
|
||||
from PIL import Image
|
||||
import werkzeug.security
|
||||
|
||||
from app import app, db
|
||||
from app.models.priv import SpecialPrivilege, Group, GroupMember, \
|
||||
GroupPrivilege
|
||||
from app.models.trophy import Trophy, TrophyMember, Title
|
||||
from app.models.notification import Notification
|
||||
from app.models.post import Post
|
||||
from app.models.comment import Comment
|
||||
from app.models.topic import Topic
|
||||
from app.models.program import Program
|
||||
|
||||
import app.utils.unicode_names as unicode_names
|
||||
import app.utils.ldap as ldap
|
||||
from app.utils.unicode_names import normalize
|
||||
from config import V5Config
|
||||
|
||||
import werkzeug.security
|
||||
from os.path import isfile
|
||||
from datetime import date
|
||||
from PIL import Image
|
||||
import math
|
||||
import app
|
||||
import os
|
||||
|
@ -83,19 +90,9 @@ class Member(User):
|
|||
xp = db.Column(db.Integer)
|
||||
register_date = db.Column(db.Date, default=date.today)
|
||||
|
||||
avatar_id = db.Column(db.Integer, default=0)
|
||||
@property
|
||||
def avatar(self):
|
||||
return f'{self.id}_{self.avatar_id}.png'
|
||||
|
||||
@property
|
||||
def level(self):
|
||||
level = math.asinh(self.xp / 1000) * (100 / math.asinh(10))
|
||||
return int(level), int(level * 100) % 100
|
||||
|
||||
# Groups and related privileges
|
||||
groups = db.relationship('Group', secondary=GroupMember,
|
||||
back_populates='members')
|
||||
back_populates='members', lazy='joined')
|
||||
|
||||
# Personal information, all optional
|
||||
bio = db.Column(db.UnicodeText)
|
||||
|
@ -109,18 +106,47 @@ class Member(User):
|
|||
# Settings
|
||||
newsletter = db.Column(db.Boolean, default=False)
|
||||
theme = db.Column(db.Unicode(32))
|
||||
avatar_id = db.Column(db.Integer, default=0)
|
||||
|
||||
# Relations
|
||||
trophies = db.relationship('Trophy', secondary=TrophyMember,
|
||||
back_populates='owners')
|
||||
topics = db.relationship('Topic')
|
||||
programs = db.relationship('Program')
|
||||
comments = db.relationship('Comment')
|
||||
|
||||
# Specially-offered privileges (use self.special_privileges())
|
||||
special_privs = db.relationship('SpecialPrivilege',
|
||||
back_populates='member', lazy='joined')
|
||||
|
||||
|
||||
# Other fields populated automatically through relations:
|
||||
# <notifications> List of unseen notifications (of type Notification)
|
||||
# <polls> Polls created by the member (of class Poll)
|
||||
|
||||
# Access to polymorphic posts
|
||||
# TODO: Check that the query uses the double index on Post.{author_id,type}
|
||||
def comments(self):
|
||||
return db.session.query(Comment).filter(Post.author_id==self.id).all()
|
||||
def topics(self):
|
||||
return db.session.query(Topic).filter(Post.author_id==self.id).all()
|
||||
def programs(self):
|
||||
return db.session.query(Program).filter(Post.author_id==self.id).all()
|
||||
|
||||
@property
|
||||
def avatar_filename(self):
|
||||
return f'{self.id}_{self.avatar_id}.png'
|
||||
|
||||
@property
|
||||
def avatar_url(self):
|
||||
if self.avatar_id == 0:
|
||||
return url_for('static', filename='images/default_avatar.png')
|
||||
else:
|
||||
return url_for('avatar',filename=self.avatar_filename)
|
||||
|
||||
@property
|
||||
def level(self):
|
||||
level = math.asinh(self.xp / 1000) * (100 / math.asinh(10))
|
||||
return int(level), int(level * 100) % 100
|
||||
|
||||
|
||||
def __init__(self, name, email, password):
|
||||
"""Register a new user."""
|
||||
self.name = name
|
||||
|
@ -129,7 +155,7 @@ class Member(User):
|
|||
self.email_confirmed = not V5Config.ENABLE_EMAIL_CONFIRMATION
|
||||
if not V5Config.USE_LDAP:
|
||||
self.set_password(password)
|
||||
# Workflow with LDAP enabled is User → Postgresql → LDAP → set password
|
||||
# Workflow with LDAP enabled is User → PostgreSQL → LDAP → set password
|
||||
self.xp = 0
|
||||
|
||||
self.theme = 'default_theme'
|
||||
|
@ -149,23 +175,23 @@ class Member(User):
|
|||
Transfers all the posts to another user. This is generally used to
|
||||
transfer ownership to a newly-created Guest before deleting an account.
|
||||
"""
|
||||
for t in self.topics:
|
||||
for t in self.topics():
|
||||
t.author = other
|
||||
db.session.add(t)
|
||||
for p in self.programs:
|
||||
for p in self.programs():
|
||||
p.author = other
|
||||
db.session.add(p)
|
||||
for c in self.comments:
|
||||
for c in self.comments():
|
||||
c.author = other
|
||||
db.session.add(c)
|
||||
|
||||
def delete_posts(self):
|
||||
"""Deletes the user's posts."""
|
||||
for t in self.topics:
|
||||
for t in self.topics():
|
||||
t.delete()
|
||||
for p in self.programs:
|
||||
for p in self.programs():
|
||||
p.delete()
|
||||
for c in self.comments:
|
||||
for c in self.comments():
|
||||
c.delete()
|
||||
|
||||
def delete(self):
|
||||
|
@ -173,7 +199,7 @@ class Member(User):
|
|||
Deletes the user, but not the posts; use either transfer_posts() or
|
||||
delete_posts() before calling this.
|
||||
"""
|
||||
for sp in SpecialPrivilege.query.filter_by(mid=self.id).all():
|
||||
for sp in self.special_privs:
|
||||
db.session.delete(sp)
|
||||
|
||||
self.trophies = []
|
||||
|
@ -186,17 +212,16 @@ class Member(User):
|
|||
|
||||
def priv(self, priv):
|
||||
"""Check whether the member has the specified privilege."""
|
||||
if SpecialPrivilege.query.filter_by(mid=self.id, priv=priv).first():
|
||||
if priv in self.special_privileges():
|
||||
return True
|
||||
return db.session.query(Group, GroupPrivilege).filter(
|
||||
Group.id.in_([g.id for g in self.groups]),
|
||||
GroupPrivilege.gid==Group.id,
|
||||
GroupPrivilege.priv==priv).first() is not None
|
||||
for g in self.groups:
|
||||
if priv in g.privs():
|
||||
return True
|
||||
return False
|
||||
|
||||
def special_privileges(self):
|
||||
"""List member's special privileges."""
|
||||
sp = SpecialPrivilege.query.filter_by(mid=self.id).all()
|
||||
return sorted(row.priv for row in sp)
|
||||
"""List member's special privileges as list of strings."""
|
||||
return sorted([p.priv for p in self.special_privs])
|
||||
|
||||
def can_access_forum(self, forum):
|
||||
"""Whether this member can read the forum's contents."""
|
||||
|
@ -238,6 +263,17 @@ class Member(User):
|
|||
post = comment.thread.owner_post
|
||||
return self.can_edit_post(post) and (comment.author == post.author)
|
||||
|
||||
def can_lock_thread(self, post):
|
||||
"""Whether this member can lock the thread associated with the post"""
|
||||
print(post.id, post.is_metacontent)
|
||||
if not post.is_metacontent:
|
||||
return False
|
||||
return self.priv("lock.threads")
|
||||
|
||||
def can_access_file(self, file):
|
||||
"""Whether this member can access the file."""
|
||||
return self.can_access_post(file.comment)
|
||||
|
||||
def update(self, **data):
|
||||
"""
|
||||
Update all or part of the user's metadata. The [data] dictionary
|
||||
|
@ -296,20 +332,22 @@ class Member(User):
|
|||
|
||||
def set_avatar(self, avatar):
|
||||
# Save old avatar filepath
|
||||
old_avatar = os.path.join(V5Config.DATA_FOLDER, "avatars", self.avatar)
|
||||
old_avatar = os.path.join(V5Config.DATA_FOLDER, "avatars",
|
||||
self.avatar_filename)
|
||||
# Resize & convert image
|
||||
size = 128, 128
|
||||
im = Image.open(avatar)
|
||||
im.thumbnail(size, Image.ANTIALIAS)
|
||||
im.thumbnail((128, 128), 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(os.path.join(V5Config.DATA_FOLDER, "avatars", self.avatar),
|
||||
'PNG')
|
||||
im.save(os.path.join(V5Config.DATA_FOLDER, "avatars",
|
||||
self.avatar_filename), 'PNG')
|
||||
# If nothing has failed, remove old one (allow failure to regularize
|
||||
# exceptional situations like missing avatar or folder migration)
|
||||
try:
|
||||
|
@ -359,8 +397,7 @@ class Member(User):
|
|||
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)
|
||||
n = Notification(self, message, href=href)
|
||||
db.session.add(n)
|
||||
db.session.commit()
|
||||
|
||||
|
@ -450,7 +487,7 @@ class Member(User):
|
|||
progress(levels, post_count)
|
||||
|
||||
if context in ["new-program", None]:
|
||||
program_count = len(self.programs)
|
||||
program_count = len(self.programs())
|
||||
|
||||
levels = {
|
||||
5: "Programmeur du dimanche",
|
||||
|
@ -537,8 +574,7 @@ class Member(User):
|
|||
# TODO: Trophy "actif"
|
||||
|
||||
if context in ["on-profile-update", None]:
|
||||
if isfile(os.path.join(
|
||||
V5Config.DATA_FOLDER, "avatars", self.avatar)):
|
||||
if self.avatar_id != 0:
|
||||
self.add_trophy("Artiste")
|
||||
else:
|
||||
self.del_trophy("Artiste")
|
||||
|
|
|
@ -16,13 +16,17 @@ def menu_processor():
|
|||
main_forum = Forum.query.filter_by(parent=None).first()
|
||||
|
||||
# Constructing last active topics
|
||||
raw = db.session.execute( """SELECT topic.id FROM topic
|
||||
rows = 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 20;""")
|
||||
last_active_topics = [Topic.query.get(id) for id in raw]
|
||||
ids = [row[0] for row in rows]
|
||||
# Somewhat inelegant, but much better than loading individually
|
||||
recent_topics = db.session.query(Topic).filter(Topic.id.in_(ids)).all()
|
||||
recent_topics = sorted(recent_topics, key=lambda t: ids.index(t.id))
|
||||
|
||||
|
||||
# Filter the topics the user can view and limit to 10
|
||||
if current_user.is_authenticated:
|
||||
|
@ -30,7 +34,22 @@ def menu_processor():
|
|||
else:
|
||||
f = lambda t: t.forum.is_default_accessible()
|
||||
|
||||
last_active_topics = list(filter(f, last_active_topics))[:10]
|
||||
recent_topics = list(filter(f, recent_topics))[:10]
|
||||
|
||||
# Constructing last news
|
||||
rows = db.session.execute( """SELECT topic.id FROM topic
|
||||
INNER JOIN forum ON topic.forum_id = forum.id
|
||||
INNER JOIN comment ON topic.thread_id = comment.thread_id
|
||||
INNER JOIN post ON post.id = comment.id
|
||||
WHERE forum.url LIKE '/actus%'
|
||||
GROUP BY topic.id
|
||||
ORDER BY MIN(post.date_created) DESC
|
||||
LIMIT 10;
|
||||
""")
|
||||
ids = [row[0] for row in rows]
|
||||
recent_news = db.session.query(Topic).filter(Topic.id.in_(ids)).all()
|
||||
recent_news = sorted(recent_news, key=lambda t: ids.index(t.id))
|
||||
|
||||
return dict(login_form=login_form, search_form=search_form,
|
||||
main_forum=main_forum, last_active_topics=last_active_topics)
|
||||
main_forum=main_forum, last_active_topics=recent_topics,
|
||||
last_news=recent_news)
|
||||
|
|
|
@ -3,15 +3,16 @@ from flask import url_for
|
|||
from config import V5Config
|
||||
from slugify import slugify
|
||||
from app.utils.login_as import is_vandal
|
||||
from app.models.tag import TagInformation
|
||||
|
||||
@app.context_processor
|
||||
def utilities_processor():
|
||||
""" Add some utilities to render context """
|
||||
return dict(
|
||||
len=len,
|
||||
# enumerate=enumerate,
|
||||
_url_for=lambda route, args, **other: url_for(route, **args, **other),
|
||||
V5Config=V5Config,
|
||||
slugify=slugify,
|
||||
is_vandal=is_vandal
|
||||
is_vandal=is_vandal,
|
||||
db_all_tags=TagInformation.all_tags,
|
||||
)
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
# Register routes here
|
||||
|
||||
from app.routes import index, search, users, tools, development
|
||||
from app.routes import index, search, users, tools, development, chat
|
||||
from app.routes.account import login, account, notification, polls
|
||||
from app.routes.admin import index, groups, account, trophies, forums, \
|
||||
from app.routes.admin import index, groups, account, forums, \
|
||||
attachments, config, members, polls, login_as
|
||||
from app.routes.forum import index, topic
|
||||
from app.routes.polls import vote, delete
|
||||
from app.routes.posts import edit
|
||||
from app.routes.programs import index
|
||||
from app.routes.programs import index, submit, program
|
||||
from app.routes.api import markdown
|
||||
|
||||
try:
|
||||
|
|
|
@ -8,6 +8,7 @@ from app.models.trophy import Title
|
|||
from app.utils.render import render
|
||||
from app.utils.send_mail import send_validation_mail, send_reset_password_mail
|
||||
from app.utils.priv_required import guest_only
|
||||
from app.utils.glados import say, BOLD
|
||||
import app.utils.ldap as ldap
|
||||
import app.utils.validators as vd
|
||||
from itsdangerous import URLSafeTimedSerializer
|
||||
|
@ -30,6 +31,7 @@ def edit_account():
|
|||
|
||||
if form.submit.data:
|
||||
if form.is_submitted() and form.validate(extra_validators=extra_vd):
|
||||
old_username = current_user.norm
|
||||
current_user.update(
|
||||
avatar=form.avatar.data or None,
|
||||
email=form.email.data or None,
|
||||
|
@ -41,10 +43,13 @@ def edit_account():
|
|||
newsletter=form.newsletter.data,
|
||||
theme=form.theme.data
|
||||
)
|
||||
ldap.edit(old_username, current_user)
|
||||
current_user.update(password=form.password.data or None)
|
||||
db.session.merge(current_user)
|
||||
db.session.commit()
|
||||
current_user.update_trophies("on-profile-update")
|
||||
flash('Modifications effectuées', 'ok')
|
||||
app.v5logger.info(f"<{current_user.name}> has edited their account")
|
||||
return redirect(request.url)
|
||||
else:
|
||||
flash('Erreur lors de la modification', 'error')
|
||||
|
@ -62,6 +67,7 @@ def ask_reset_password():
|
|||
m = Member.query.filter_by(email=form.email.data).first()
|
||||
if m is not None:
|
||||
send_reset_password_mail(m.name, m.email)
|
||||
app.v5logger.info(f"<{m.name}> has asked a password reset token")
|
||||
flash('Un email a été envoyé à l\'adresse renseignée', 'ok')
|
||||
return redirect(url_for('login'))
|
||||
elif request.method == "POST":
|
||||
|
@ -87,6 +93,7 @@ def reset_password(token):
|
|||
db.session.merge(m)
|
||||
db.session.commit()
|
||||
flash('Modifications effectuées', 'ok')
|
||||
app.v5logger.info(f"<{m.name}> has reset their password")
|
||||
return redirect(url_for('login'))
|
||||
else:
|
||||
flash('Erreur lors de la modification', 'error')
|
||||
|
@ -102,6 +109,7 @@ def delete_account():
|
|||
|
||||
if del_form.submit.data:
|
||||
if del_form.validate_on_submit():
|
||||
name = current_user.name
|
||||
if del_form.transfer.data:
|
||||
guest = Guest(current_user.generate_guest_name())
|
||||
db.session.add(guest)
|
||||
|
@ -112,10 +120,14 @@ def delete_account():
|
|||
current_user.delete_posts()
|
||||
db.session.commit()
|
||||
|
||||
if (V5Config.USE_LDAP):
|
||||
ldap.delete_member(current_user)
|
||||
|
||||
current_user.delete()
|
||||
logout_user()
|
||||
db.session.commit()
|
||||
flash('Compte supprimé', 'ok')
|
||||
app.v5logger.info(f"<{name}> has deleted their account ({'with' if del_form.transfer.data else 'without'} guest transfer)")
|
||||
return redirect(url_for('index'))
|
||||
else:
|
||||
flash('Erreur lors de la suppression du compte', 'error')
|
||||
|
@ -141,6 +153,7 @@ def register():
|
|||
|
||||
# Email validation message
|
||||
send_validation_mail(member.name, member.email)
|
||||
app.v5logger.info(f"<{member.name}> registered")
|
||||
|
||||
return redirect(url_for('validation') + "?email=" + form.email.data)
|
||||
return render('account/register.html', title='Register',
|
||||
|
@ -178,4 +191,8 @@ def activate_account(token):
|
|||
db.session.commit()
|
||||
|
||||
flash("L'email a bien été confirmé", "ok")
|
||||
app.v5logger.info(f"<{m.name}> has activated their account")
|
||||
say(f"Un nouveau membre s’est inscrit ! Il s’agit de {BOLD}{m.name}{BOLD}.")
|
||||
say(url_for('user', username=m.name, _external=True))
|
||||
|
||||
return redirect(url_for('login'))
|
||||
|
|
|
@ -49,6 +49,7 @@ def login():
|
|||
login_user(member, remember=form.remember_me.data,
|
||||
duration=datetime.timedelta(days=7))
|
||||
member.update_trophies("on-login")
|
||||
app.v5logger.info(f"<{member.name}> has logged in")
|
||||
|
||||
# Redirect safely (https://huit.re/open-redirect)
|
||||
def is_safe_url(target):
|
||||
|
@ -71,8 +72,10 @@ def login():
|
|||
@login_required
|
||||
@check_csrf
|
||||
def logout():
|
||||
name = current_user.name
|
||||
logout_user()
|
||||
flash('Déconnexion réussie', 'info')
|
||||
app.v5logger.info(f"<{name}> has logged out")
|
||||
if request.referrer:
|
||||
return redirect(request.referrer)
|
||||
return redirect(url_for('index'))
|
||||
|
|
|
@ -28,5 +28,6 @@ def account_polls():
|
|||
db.session.commit()
|
||||
|
||||
flash(f"Le sondage {p.id} a été créé", "info")
|
||||
app.v5logger.info(f"<{current_user.name}> has created the form #{p.id}")
|
||||
|
||||
return render("account/polls.html", polls=polls, form=form)
|
||||
|
|
|
@ -9,6 +9,7 @@ from app.forms.account import AdminUpdateAccountForm, AdminDeleteAccountForm, \
|
|||
AdminAccountEditTrophyForm, AdminAccountEditGroupForm
|
||||
from app.utils.render import render
|
||||
from app.utils.notify import notify
|
||||
from app.utils import ldap as ldap
|
||||
from app import app, db
|
||||
from config import V5Config
|
||||
|
||||
|
@ -50,12 +51,12 @@ def adm_edit_account(user_id):
|
|||
# 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')
|
||||
old_username = user.norm
|
||||
user.update(
|
||||
avatar=form.avatar.data or None,
|
||||
name=form.username.data or None,
|
||||
email=form.email.data or None,
|
||||
email_confirmed=form.email_confirmed.data,
|
||||
password=form.password.data or None,
|
||||
birthday=form.birthday.data,
|
||||
signature=form.signature.data,
|
||||
title=form.title.data,
|
||||
|
@ -63,11 +64,14 @@ def adm_edit_account(user_id):
|
|||
newsletter=form.newsletter.data,
|
||||
xp=form.xp.data or None,
|
||||
)
|
||||
ldap.edit(old_username, user)
|
||||
user.update(password=form.password.data or None)
|
||||
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')
|
||||
app.v5logger.info(f"[admin] <{current_user.name}> has edited <{user.name}>'s data")
|
||||
return redirect(request.url)
|
||||
else:
|
||||
flash('Erreur lors de la modification', 'error')
|
||||
|
@ -85,6 +89,7 @@ def adm_edit_account(user_id):
|
|||
db.session.merge(user)
|
||||
db.session.commit()
|
||||
flash('Modifications effectuées', 'ok')
|
||||
app.v5logger.info(f"[admin] <{current_user.name}> has edited <{user.name}>'s trophies")
|
||||
return redirect(request.url)
|
||||
else:
|
||||
flash("Erreur lors de la modification des trophées", 'error')
|
||||
|
@ -102,6 +107,7 @@ def adm_edit_account(user_id):
|
|||
db.session.merge(user)
|
||||
db.session.commit()
|
||||
flash('Modifications effectuées', 'ok')
|
||||
app.v5logger.info(f"[admin] <{current_user.name}> has edited <{user.name}>'s groups")
|
||||
return redirect(request.url)
|
||||
else:
|
||||
flash("Erreur lors de la modification des groupes", 'error')
|
||||
|
@ -128,9 +134,9 @@ def adm_delete_account(user_id):
|
|||
|
||||
# TODO: Number of comments by *other* members which will be deleted
|
||||
stats = {
|
||||
'comments': len(user.comments),
|
||||
'topics': len(user.topics),
|
||||
'programs': len(user.programs),
|
||||
'comments': len(user.comments()),
|
||||
'topics': len(user.topics()),
|
||||
'programs': len(user.programs()),
|
||||
'groups': len(user.groups),
|
||||
'privs': len(user.special_privileges()),
|
||||
}
|
||||
|
@ -148,9 +154,13 @@ def adm_delete_account(user_id):
|
|||
user.delete_posts()
|
||||
db.session.commit()
|
||||
|
||||
if (V5Config.USE_LDAP):
|
||||
ldap.delete_member(user)
|
||||
|
||||
user.delete()
|
||||
db.session.commit()
|
||||
flash('Compte supprimé', 'ok')
|
||||
app.v5logger.info(f"[admin] <{current_user.name}> has deleted <{user.name}> account")
|
||||
return redirect(url_for('adm'))
|
||||
else:
|
||||
flash('Erreur lors de la suppression du compte', 'error')
|
||||
|
|
|
@ -15,7 +15,7 @@ def adm_groups():
|
|||
# Users with either groups or special privileges
|
||||
users_groups = Member.query.join(GroupMember)
|
||||
users_special = Member.query \
|
||||
.join(SpecialPrivilege, Member.id == SpecialPrivilege.mid)
|
||||
.join(SpecialPrivilege, Member.id == SpecialPrivilege.member_id)
|
||||
users = users_groups.union(users_special)
|
||||
users = sorted(users, key = lambda x: x.name)
|
||||
|
||||
|
|
|
@ -42,6 +42,7 @@ def adm_login_as():
|
|||
# Create a safe token to flee when needed
|
||||
s = Serializer(app.config["SECRET_KEY"])
|
||||
vandal_token = s.dumps(current_user.id)
|
||||
vandal_name = current_user.name
|
||||
|
||||
# Login and display some messages
|
||||
login_user(user)
|
||||
|
@ -51,9 +52,11 @@ def adm_login_as():
|
|||
else:
|
||||
flash(f"Connecté en tant que {user.name}")
|
||||
|
||||
app.v5logger.info(f"[admin] <{vandal_name}> has logged in as <{user.name}>")
|
||||
|
||||
# Return the response
|
||||
resp = make_response(redirect(url_for('index')))
|
||||
resp.set_cookie('vandale', vandal_token)
|
||||
resp.set_cookie('vandale', vandal_token, path='/')
|
||||
return resp
|
||||
|
||||
# Else return form
|
||||
|
@ -76,13 +79,22 @@ def adm_logout_as():
|
|||
abort(403)
|
||||
|
||||
user = Member.query.get(id)
|
||||
|
||||
# Send a notification to vandalized user
|
||||
current_user.notify(f"{user.name} a accédé à ce compte à des fins de modération",
|
||||
url_for('user', username=user.name))
|
||||
|
||||
# Switch back to admin
|
||||
victim_name = current_user.name
|
||||
logout_user()
|
||||
login_user(user)
|
||||
|
||||
app.v5logger.info(f"[admin] <{user.name}> has logged out from <{victim_name}>'s account")
|
||||
|
||||
if request.referrer:
|
||||
resp = make_response(redirect(request.referrer))
|
||||
else:
|
||||
resp = make_response(redirect(url_for('index')))
|
||||
|
||||
resp.set_cookie('vandale', '', expires=0)
|
||||
resp.set_cookie('vandale', '', expires=0, path='/')
|
||||
return resp
|
||||
|
|
|
@ -4,7 +4,7 @@ from app.utils.render import render
|
|||
from app.models.poll import Poll
|
||||
|
||||
@app.route('/admin/sondages', methods=['GET'])
|
||||
@priv_required('access-admin-panel')
|
||||
@priv_required('misc.admin-panel')
|
||||
def adm_polls():
|
||||
polls = Poll.query.order_by(Poll.end.desc()).all()
|
||||
|
||||
|
|
|
@ -1,77 +0,0 @@
|
|||
from flask import request, flash, redirect, url_for
|
||||
from app.utils.priv_required import priv_required
|
||||
from app.models.trophy import Trophy, Title
|
||||
from app.forms.trophy import TrophyForm, DeleteTrophyForm
|
||||
from app.utils.render import render
|
||||
from app import app, db
|
||||
|
||||
|
||||
@app.route('/admin/trophees', methods=['GET', 'POST'])
|
||||
@priv_required('misc.admin-panel', 'edit.trophies')
|
||||
def adm_trophies():
|
||||
form = TrophyForm()
|
||||
if request.method == "POST":
|
||||
if form.validate_on_submit():
|
||||
is_title = form.title.data
|
||||
if is_title:
|
||||
trophy = Title(form.name.data, form.desc.data,
|
||||
form.hidden.data, form.css.data)
|
||||
else:
|
||||
trophy = Trophy(form.name.data, form.desc.data,
|
||||
form.hidden.data)
|
||||
db.session.add(trophy)
|
||||
db.session.commit()
|
||||
flash(f'Nouveau {["trophée", "titre"][is_title]} ajouté', 'ok')
|
||||
else:
|
||||
flash('Erreur lors de la création du trophée', 'error')
|
||||
|
||||
trophies = Trophy.query.all()
|
||||
return render('admin/trophies.html', trophies=trophies,
|
||||
form=form)
|
||||
|
||||
|
||||
@app.route('/admin/trophees/<trophy_id>/editer', methods=['GET', 'POST'])
|
||||
@priv_required('misc.admin-panel', 'edit.trophies')
|
||||
def adm_edit_trophy(trophy_id):
|
||||
trophy = Trophy.query.filter_by(id=trophy_id).first_or_404()
|
||||
|
||||
form = TrophyForm()
|
||||
if request.method == "POST":
|
||||
if form.validate_on_submit():
|
||||
is_title = form.title.data != ""
|
||||
if is_title:
|
||||
trophy.name = form.name.data
|
||||
trophy.description = form.desc.data
|
||||
trophy.title = form.title.data
|
||||
trophy.hidden = form.hidden.data
|
||||
trophy.css = form.css.data
|
||||
else:
|
||||
trophy.name = form.name.data
|
||||
trophy.description = form.desc.data
|
||||
trophy.hidden = form.hidden.data
|
||||
db.session.merge(trophy)
|
||||
db.session.commit()
|
||||
flash(f'{["Trophée", "Titre"][is_title]} modifié', 'ok')
|
||||
return redirect(url_for('adm_trophies'))
|
||||
else:
|
||||
flash('Erreur lors de la création du trophée', 'error')
|
||||
return render('admin/edit_trophy.html', trophy=trophy, form=form)
|
||||
|
||||
|
||||
@app.route('/admin/trophees/<trophy_id>/supprimer', methods=['GET', 'POST'])
|
||||
@priv_required('misc.admin-panel', 'edit.trophies')
|
||||
def adm_delete_trophy(trophy_id):
|
||||
trophy = Trophy.query.filter_by(id=trophy_id).first_or_404()
|
||||
|
||||
# TODO: Add an overview of what will be deleted.
|
||||
del_form = DeleteTrophyForm()
|
||||
if request.method == "POST":
|
||||
if del_form.validate_on_submit():
|
||||
trophy.delete()
|
||||
db.session.commit()
|
||||
flash('Trophée supprimé', 'ok')
|
||||
return redirect(url_for('adm_trophies'))
|
||||
else:
|
||||
flash('Erreur lors de la suppression du trophée', 'error')
|
||||
del_form.delete.data = False # Force to tick to delete the trophy
|
||||
return render('admin/delete_trophy.html', trophy=trophy, del_form=del_form)
|
|
@ -2,9 +2,11 @@ from app import app
|
|||
from app.utils.filters.markdown import md
|
||||
from flask import request, abort
|
||||
from werkzeug.exceptions import BadRequestKeyError
|
||||
from app import csrf
|
||||
|
||||
class API():
|
||||
@app.route("/api/markdown", methods=["POST"])
|
||||
@csrf.exempt
|
||||
def api_markdown():
|
||||
try:
|
||||
markdown = request.get_json()['text']
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
from app import app
|
||||
from app.utils.render import render
|
||||
from flask import send_file, url_for
|
||||
|
||||
@app.route('/chat')
|
||||
def chat():
|
||||
return render('chat.html',
|
||||
styles=[
|
||||
'+css/v5shoutbox.css'],
|
||||
scripts=[
|
||||
'-scripts/trigger_menu.js',
|
||||
'-scripts/editor.js'])
|
||||
|
||||
@app.route('/v5shoutbox.js')
|
||||
def v5shoutbox_js():
|
||||
return send_file('static/scripts/v5shoutbox.js')
|
|
@ -11,9 +11,7 @@ import os
|
|||
def avatar(filename):
|
||||
filename = secure_filename(filename) # No h4ckers allowed
|
||||
filepath = os.path.join(V5Config.DATA_FOLDER, "avatars", filename)
|
||||
if os.path.isfile(filepath):
|
||||
return send_file(filepath)
|
||||
return redirect(url_for('static', filename='images/default_avatar.png'))
|
||||
return send_file(filepath)
|
||||
|
||||
@app.route('/fichiers/<path>/<name>')
|
||||
def attachment(path, name):
|
||||
|
|
|
@ -4,6 +4,7 @@ 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.utils.glados import say, BOLD
|
||||
from app.forms.forum import TopicCreationForm, AnonymousTopicCreationForm
|
||||
from app.models.forum import Forum
|
||||
from app.models.topic import Topic
|
||||
|
@ -73,6 +74,11 @@ def forum_page(f, page=1):
|
|||
current_user.update_trophies('new-post')
|
||||
|
||||
flash('Le sujet a bien été créé', 'ok')
|
||||
app.v5logger.info(f"<{t.author.name}> has created the topic #{t.id}")
|
||||
if f.is_default_accessible():
|
||||
say(f"Nouveau topic de {author.name} : {BOLD}{t.title}{BOLD}")
|
||||
say(url_for('forum_topic', f=f, page=(t, 1), _external=True))
|
||||
|
||||
return redirect(url_for('forum_topic', f=f, page=(t,1)))
|
||||
|
||||
# Paginate topic pages
|
||||
|
|
|
@ -5,6 +5,7 @@ from sqlalchemy import desc
|
|||
from app import app, db
|
||||
from config import V5Config
|
||||
from app.utils.render import render
|
||||
from app.utils.glados import say, BOLD
|
||||
from app.forms.forum import CommentForm, AnonymousCommentForm
|
||||
from app.models.thread import Thread
|
||||
from app.models.comment import Comment
|
||||
|
@ -31,7 +32,7 @@ def forum_topic(f, page):
|
|||
else:
|
||||
form = AnonymousCommentForm()
|
||||
|
||||
if form.validate_on_submit() and (
|
||||
if form.validate_on_submit() and not t.thread.locked and (
|
||||
V5Config.ENABLE_GUEST_POST or \
|
||||
(current_user.is_authenticated and current_user.can_post_in_forum(f))):
|
||||
|
||||
|
@ -46,17 +47,7 @@ def forum_topic(f, page):
|
|||
c = Comment(author, form.message.data, t.thread)
|
||||
db.session.add(c)
|
||||
db.session.commit()
|
||||
|
||||
# Manage files
|
||||
attachments = []
|
||||
for file in form.attachments.data:
|
||||
if file.filename != "":
|
||||
a = Attachment(file, c)
|
||||
attachments.append((a, file))
|
||||
db.session.add(a)
|
||||
db.session.commit()
|
||||
for a, file in attachments:
|
||||
a.set_file(file)
|
||||
c.create_attachments(form.attachments.data)
|
||||
|
||||
# Update member's xp and trophies
|
||||
if current_user.is_authenticated:
|
||||
|
@ -64,9 +55,14 @@ def forum_topic(f, page):
|
|||
current_user.update_trophies('new-post')
|
||||
|
||||
flash('Message envoyé', 'ok')
|
||||
app.v5logger.info(f"<{c.author.name}> has posted a the comment #{c.id}")
|
||||
if f.is_default_accessible():
|
||||
say(f"Nouveau commentaire de {author.name} sur le topic : {BOLD}{t.title}{BOLD}")
|
||||
say(url_for('forum_topic', f=f, page=(t, "fin"), _anchor=str(c.id), _external=True))
|
||||
|
||||
# Redirect to empty the form
|
||||
return redirect(url_for('forum_topic', f=f, page=(t, "fin"),
|
||||
_anchor=c.id))
|
||||
_anchor=str(c.id)))
|
||||
|
||||
# Update views
|
||||
t.views += 1
|
||||
|
|
|
@ -5,7 +5,7 @@ from app.utils.render import render
|
|||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return render('index.html')
|
||||
return render('index.html', styles=["+css/homepage.css"])
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
|
|
|
@ -34,7 +34,8 @@ def poll_vote(poll_id):
|
|||
db.session.add(answer)
|
||||
db.session.commit()
|
||||
|
||||
flash('Le vote a été pris en compte', 'info')
|
||||
flash('Le vote a été pris en compte', 'ok')
|
||||
app.v5logger.info(f"<{current_user.name}> has voted on the poll #{poll.id}")
|
||||
|
||||
if request.referrer:
|
||||
return redirect(request.referrer)
|
||||
|
|
|
@ -9,6 +9,7 @@ from app.models.topic import Topic
|
|||
from app.models.user import Member
|
||||
from app.utils.render import render
|
||||
from app.utils.check_csrf import check_csrf
|
||||
from app.utils.priv_required import priv_required
|
||||
from app.forms.forum import CommentEditForm, AnonymousCommentEditForm, TopicEditForm
|
||||
from app.forms.post import MovePost, SearchThread
|
||||
from wtforms import BooleanField
|
||||
|
@ -24,9 +25,9 @@ def edit_post(postid):
|
|||
referrer = urlparse(request.args.get('r', default = '/', type = str)).path
|
||||
print(referrer)
|
||||
|
||||
p = Post.query.filter_by(id=postid).first_or_404()
|
||||
p = Post.query.get_or_404(postid)
|
||||
|
||||
# Check permissions. TODO: Allow guests to edit their posts?
|
||||
# Check permissions
|
||||
if not current_user.can_edit_post(p):
|
||||
abort(403)
|
||||
|
||||
|
@ -68,6 +69,7 @@ def edit_post(postid):
|
|||
attachments.append((a, file))
|
||||
db.session.add(a)
|
||||
|
||||
comment.touch()
|
||||
db.session.add(comment)
|
||||
|
||||
if isinstance(p, Topic):
|
||||
|
@ -82,6 +84,10 @@ def edit_post(postid):
|
|||
for a, file in attachments:
|
||||
a.set_file(file)
|
||||
|
||||
flash('Modifications enregistrées', 'ok')
|
||||
admin_msg = "[admin] " if current_user != p.author else ""
|
||||
app.v5logger.info(f"{admin_msg}<{current_user.name}> has edited the post #{p.id}")
|
||||
|
||||
# Determine topic URL now, in case forum was changed
|
||||
if isinstance(p, Topic):
|
||||
return redirect(url_for('forum_topic', f=p.forum, page=(p,1)))
|
||||
|
@ -103,12 +109,16 @@ def edit_post(postid):
|
|||
@check_csrf
|
||||
def delete_post(postid):
|
||||
next_page = request.referrer
|
||||
p = Post.query.filter_by(id=postid).first_or_404()
|
||||
p = Post.query.get_or_404(postid)
|
||||
xp = -1
|
||||
|
||||
if not current_user.can_delete_post(p):
|
||||
abort(403)
|
||||
|
||||
# Is a penalty deletion
|
||||
is_penalty = request.args.get('penalty') == 'True' \
|
||||
and current_user.priv('delete.posts')
|
||||
|
||||
# Users who need to have their trophies updated
|
||||
authors = set()
|
||||
|
||||
|
@ -124,16 +134,21 @@ def delete_post(postid):
|
|||
authors.add(comment.author)
|
||||
|
||||
if isinstance(p.author, Member):
|
||||
factor = 3 if request.args.get('penalty') == 'True' else 1
|
||||
factor = 3 if is_penalty else 1
|
||||
p.author.add_xp(xp * factor)
|
||||
db.session.merge(p.author)
|
||||
authors.add(p.author)
|
||||
|
||||
admin_msg = "[admin] " if current_user != p.author else ""
|
||||
p.delete()
|
||||
db.session.commit()
|
||||
|
||||
for author in authors:
|
||||
author.update_trophies("new-post")
|
||||
|
||||
flash("Le contenu a été supprimé", 'ok')
|
||||
penalty_msg = " (with penalty)" if is_penalty else ""
|
||||
app.v5logger.info(f"{admin_msg}<{current_user.name}> has deleted the post #{p.id}{penalty_msg}")
|
||||
|
||||
return redirect(next_page)
|
||||
|
||||
|
@ -141,12 +156,15 @@ def delete_post(postid):
|
|||
@login_required
|
||||
@check_csrf
|
||||
def set_post_topcomment(postid):
|
||||
comment = Post.query.filter_by(id=postid).first_or_404()
|
||||
comment = Post.query.get_or_404(postid)
|
||||
|
||||
if current_user.can_set_topcomment(comment):
|
||||
comment.thread.top_comment = comment
|
||||
db.session.add(comment.thread)
|
||||
db.session.commit()
|
||||
flash("Le post a été défini comme nouvel en-tête", 'ok')
|
||||
admin_msg = "[admin] " if current_user != comment.author else ""
|
||||
app.v5logger.info(f"{admin_msg}<{current_user.name}> has set a new top comment on thread #{comment.thread.id}")
|
||||
|
||||
return redirect(request.referrer)
|
||||
|
||||
|
@ -154,7 +172,7 @@ def set_post_topcomment(postid):
|
|||
@app.route('/post/deplacer/<int:postid>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def move_post(postid):
|
||||
comment = Post.query.filter_by(id=postid).first_or_404()
|
||||
comment = Post.query.get_or_404(postid)
|
||||
|
||||
if not current_user.can_edit_post(comment):
|
||||
abort(403)
|
||||
|
@ -165,7 +183,9 @@ def move_post(postid):
|
|||
|
||||
move_form = MovePost(prefix="move_")
|
||||
search_form = SearchThread(prefix="thread_")
|
||||
keyword = search_form.name.data if search_form.validate_on_submit() else ""
|
||||
|
||||
# There is a bug with validate_on_submit
|
||||
keyword = search_form.name.data if search_form.search.data else ""
|
||||
|
||||
# Get 10 last corresponding threads
|
||||
# TODO: add support for every MainPost
|
||||
|
@ -187,7 +207,34 @@ def move_post(postid):
|
|||
comment.thread = thread
|
||||
db.session.add(comment)
|
||||
db.session.commit()
|
||||
flash("Le topic a été déplacé", 'ok')
|
||||
admin_msg = "[admin] " if current_user != comment.author else ""
|
||||
app.v5logger.info(f"{admin_msg}<{current_user.name}> has moved the comment #{comment.id} to thread #{thread.id}")
|
||||
return redirect(url_for('forum_topic', f=t.forum, page=(t,1)))
|
||||
|
||||
|
||||
return render('post/move_post.html', comment=comment,
|
||||
search_form=search_form, move_form=move_form)
|
||||
|
||||
@app.route('/post/verrouiller/<int:postid>', methods=['GET'])
|
||||
@priv_required("lock.threads")
|
||||
@check_csrf
|
||||
def lock_thread(postid):
|
||||
post = Post.query.get_or_404(postid)
|
||||
|
||||
if not post.is_metacontent:
|
||||
flash("Vous ne pouvez pas verrouiller ce contenu (n'est pas de type metacontenu)", 'error')
|
||||
abort(403)
|
||||
|
||||
post.thread.locked = not post.thread.locked
|
||||
|
||||
db.session.add(post.thread)
|
||||
db.session.commit()
|
||||
|
||||
if post.thread.locked:
|
||||
flash(f"Le thread a été verrouillé", 'ok')
|
||||
app.v5logger.info(f"[admin] <{current_user.name}> has locked the thread #{post.thread.id}")
|
||||
else:
|
||||
flash(f"Le thread a été déverrouillé", 'ok')
|
||||
app.v5logger.info(f"[admin] <{current_user.name}> has unlocked the thread #{post.thread.id}")
|
||||
|
||||
return redirect(request.referrer)
|
|
@ -4,5 +4,5 @@ from app.utils.render import render
|
|||
|
||||
@app.route('/programmes')
|
||||
def program_index():
|
||||
programs = Program.query.all()
|
||||
return render('/programs/index.html')
|
||||
programs = Program.query.order_by(Program.date_created.desc()).all()
|
||||
return render('/programs/index.html', programs=programs)
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
from app import app, db
|
||||
from app.models.user import Guest
|
||||
from app.models.program import Program
|
||||
from app.models.comment import Comment
|
||||
from app.models.thread import Thread
|
||||
from app.utils.render import render
|
||||
from app.utils.glados import say, BOLD
|
||||
from app.forms.forum import CommentForm, AnonymousCommentForm
|
||||
from config import V5Config
|
||||
|
||||
from flask_login import current_user
|
||||
from flask import redirect, url_for, flash
|
||||
|
||||
@app.route('/programmes/<programpage:page>', methods=['GET','POST'])
|
||||
def program_view(page):
|
||||
p, page = page
|
||||
|
||||
if current_user.is_authenticated:
|
||||
form = CommentForm()
|
||||
else:
|
||||
form = AnonymousCommentForm()
|
||||
|
||||
if form.validate_on_submit() and not p.thread.locked and (
|
||||
V5Config.ENABLE_GUEST_POST or current_user.is_authenticated):
|
||||
|
||||
# Manage author
|
||||
if current_user.is_authenticated:
|
||||
author = current_user
|
||||
else:
|
||||
author = Guest(form.pseudo.data)
|
||||
db.session.add(author)
|
||||
|
||||
# Create comment
|
||||
c = Comment(author, form.message.data, p.thread)
|
||||
db.session.add(c)
|
||||
db.session.commit()
|
||||
c.create_attachments(form.attachments.data)
|
||||
|
||||
# Update member's xp and trophies
|
||||
if current_user.is_authenticated:
|
||||
current_user.add_xp(1)
|
||||
current_user.update_trophies('new-post')
|
||||
|
||||
flash('Message envoyé', 'ok')
|
||||
app.v5logger.info(f"<{c.author.name}> has posted a the comment #{c.id}")
|
||||
say(f"Nouveau commentaire de {author.name} sur le programme : {BOLD}{p.name}{BOLD}")
|
||||
say(url_for('program_view', page=(p, "fin"), _anchor=str(c.id), _external=True))
|
||||
|
||||
# Redirect to empty the form
|
||||
return redirect(url_for('program_view', page=(p, "fin"), _anchor=str(c.id)))
|
||||
|
||||
if page == -1:
|
||||
page = (p.thread.comments.count() - 1) // Thread.COMMENTS_PER_PAGE + 1
|
||||
|
||||
comments = p.thread.comments.order_by(Comment.date_created.asc()) \
|
||||
.paginate(page, Thread.COMMENTS_PER_PAGE, True)
|
||||
|
||||
return render('/programs/program.html', p=p, form=form, comments=comments)
|
|
@ -0,0 +1,64 @@
|
|||
from app import app, db
|
||||
from app.models.program import Program
|
||||
from app.models.thread import Thread
|
||||
from app.models.comment import Comment
|
||||
from app.models.tag import Tag
|
||||
from app.models.attachment import Attachment
|
||||
from app.utils.render import render
|
||||
from app.utils.glados import say, BOLD
|
||||
from app.forms.programs import ProgramCreationForm
|
||||
|
||||
from flask_login import login_required, current_user
|
||||
from flask import redirect, url_for, flash
|
||||
|
||||
@app.route('/programmes/soumettre', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def program_submit():
|
||||
|
||||
form = ProgramCreationForm()
|
||||
if form.validate_on_submit():
|
||||
# First create a new thread
|
||||
# TODO: Reuse a thread when performing topic promotion
|
||||
th = Thread()
|
||||
db.session.add(th)
|
||||
db.session.commit()
|
||||
|
||||
# Create its top comment
|
||||
c = Comment(current_user, form.message.data, th)
|
||||
db.session.add(c)
|
||||
db.session.commit()
|
||||
th.set_top_comment(c)
|
||||
db.session.merge(th)
|
||||
|
||||
# Then build the actual program
|
||||
p = Program(current_user, form.name.data, th)
|
||||
db.session.add(p)
|
||||
db.session.commit()
|
||||
|
||||
# Add tags
|
||||
for tag in form.tags.selected_tags():
|
||||
db.session.add(Tag(p, tag))
|
||||
db.session.commit()
|
||||
|
||||
# Manage files
|
||||
attachments = []
|
||||
for file in form.attachments.data:
|
||||
if file.filename != "":
|
||||
a = Attachment(file, c)
|
||||
attachments.append((a, file))
|
||||
db.session.add(a)
|
||||
db.session.commit()
|
||||
for a, file in attachments:
|
||||
a.set_file(file)
|
||||
|
||||
current_user.add_xp(20)
|
||||
current_user.update_trophies('new-program')
|
||||
|
||||
flash('Le programme a bien été soumis', 'ok')
|
||||
app.v5logger.info(f"<{p.author.name}> has submitted the program #{c.id}")
|
||||
say(f"Nouveau programme de {current_user.name} : {BOLD}{p.name}{BOLD}")
|
||||
say(url_for('program_view', page=(p, 1), _external=True))
|
||||
|
||||
return redirect(url_for('program_view', page=(p, 1)))
|
||||
|
||||
return render('/programs/submit.html', form=form)
|
|
@ -0,0 +1,3 @@
|
|||
#flDebug * {
|
||||
overflow: auto !important;
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
.editor .btn-group {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.editor .btn-group #filler {
|
||||
flex-grow: 1;
|
||||
}
|
||||
.editor .btn-group button {
|
||||
padding: 6px;
|
||||
background-color: var(--background);
|
||||
}
|
||||
.editor .btn-group button:hover {
|
||||
background: var(--background-hover);
|
||||
}
|
||||
.editor .btn-group button > svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.editor .btn-group button > svg > path,
|
||||
.editor .btn-group button > svg > rect {
|
||||
fill: var(--icons);
|
||||
}
|
||||
.editor .btn-group button,
|
||||
.editor .btn-group .separator {
|
||||
margin: 0 8px 8px 0;
|
||||
height: 32px;
|
||||
position: relative;
|
||||
}
|
||||
.editor .btn-group > a {
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
.editor .btn-group .separator {
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
border: 1px solid var(--text);
|
||||
color: transparent;
|
||||
text-indent: -10px;
|
||||
}
|
||||
.editor textarea {
|
||||
min-height: 15rem;
|
||||
}
|
||||
.editor #editor_content_preview {
|
||||
padding: 10px;
|
||||
margin-top: 5px;
|
||||
border: var(--border);
|
||||
background-color: rgba(0,0,0,0.2);
|
||||
}
|
||||
.editor .modal {
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
width: auto;
|
||||
min-width: min-content;
|
||||
text-align: left;
|
||||
right: inherit;
|
||||
background: var(--background-hover);
|
||||
border: var(--border);
|
||||
color: var(--text);
|
||||
padding: .2rem;
|
||||
top: 2.3rem;
|
||||
z-index: 100;
|
||||
list-style-position: initial;
|
||||
list-style-type: none;
|
||||
}
|
||||
.editor .modal > div {
|
||||
margin: 0.8rem;
|
||||
margin-top: 0.4rem;
|
||||
margin-bottom: 1rem;
|
||||
min-width: 30vw;
|
||||
}
|
||||
.editor .modal > div label {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.editor .modal a.editor-emoji-close-btn {
|
||||
display: inline-block;
|
||||
margin: 0.3rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
@media screen and (max-width:849px) {
|
||||
.editor .modal {
|
||||
width: 80vw;
|
||||
position: fixed;
|
||||
left: 50vw;
|
||||
transform: translateX(-50%);
|
||||
top: 50vh;
|
||||
}
|
||||
}
|
|
@ -8,7 +8,7 @@
|
|||
.form form label + .desc {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 80%;
|
||||
opacity: .75;
|
||||
opacity: .65;
|
||||
}
|
||||
.form form .avatar {
|
||||
width: 128px;
|
||||
|
@ -23,6 +23,7 @@
|
|||
.form input[type='date'],
|
||||
.form input[type='password'],
|
||||
.form input[type='search'],
|
||||
.form input[type='url'],
|
||||
.form textarea,
|
||||
.form select {
|
||||
display: block;
|
||||
|
@ -38,6 +39,7 @@
|
|||
.form input[type='date']:focus,
|
||||
.form input[type='password']:focus,
|
||||
.form input[type='search']:focus,
|
||||
.form input[type='url']:focus,
|
||||
.form textarea:focus,
|
||||
.form select:focus {
|
||||
border-color: var(--border-focused);
|
||||
|
@ -48,6 +50,7 @@
|
|||
.form input[type='date']:focus-within,
|
||||
.form input[type='password']:focus-within,
|
||||
.form input[type='search']:focus-within,
|
||||
.form input[type='url']:focus-within,
|
||||
.form textarea:focus-within,
|
||||
.form select:focus-within {
|
||||
outline: none;
|
||||
|
@ -84,18 +87,27 @@
|
|||
.form progress.entropy.high::-webkit-progress-bar {
|
||||
background: var(--ok);
|
||||
}
|
||||
.form hr {
|
||||
height: 3px;
|
||||
border: var(--hr-border);
|
||||
border-width: 1px 0;
|
||||
margin: 24px 0;
|
||||
}
|
||||
.form .msgerror {
|
||||
color: var(--error);
|
||||
font-weight: 400;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.form .abfield {
|
||||
.form input[type='email'].abfield {
|
||||
display: none;
|
||||
}
|
||||
form .dynamic-tag-selector {
|
||||
display: none;
|
||||
}
|
||||
form .dynamic-tag-selector input[type="text"] {
|
||||
display: none;
|
||||
}
|
||||
form .dynamic-tag-selector .tag {
|
||||
cursor: pointer;
|
||||
}
|
||||
form .dynamic-tag-selector .tags-selected {
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
form .dynamic-tag-selector .tags-selected .tag {
|
||||
display: none;
|
||||
}
|
||||
.form.filter {
|
||||
|
@ -125,4 +137,4 @@
|
|||
background: rgba(0,0,0,.05);
|
||||
padding: 1px 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -45,6 +45,15 @@ a:focus {
|
|||
text-decoration: underline;
|
||||
outline: none;
|
||||
}
|
||||
img.pixelated {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
hr {
|
||||
height: 3px;
|
||||
border: var(--hr-border);
|
||||
border-width: 1px 0;
|
||||
margin: 24px 0;
|
||||
}
|
||||
section p {
|
||||
line-height: 20px;
|
||||
word-wrap: anywhere;
|
||||
|
@ -69,6 +78,13 @@ section h2 {
|
|||
color: var(--text-light);
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
section blockquote {
|
||||
margin: 0 0 10px 0;
|
||||
border-left: 3px solid var(--border);
|
||||
background: var(--background);
|
||||
padding-left: 15px;
|
||||
}
|
||||
button,
|
||||
.button,
|
||||
input[type="button"],
|
||||
input[type="submit"] {
|
||||
|
@ -79,9 +95,11 @@ input[type="submit"] {
|
|||
font-weight: 400;
|
||||
border: 0;
|
||||
}
|
||||
button:hover,
|
||||
.button:hover,
|
||||
input[type="button"]:hover,
|
||||
input[type="submit"]:hover,
|
||||
button:focus,
|
||||
.button:focus,
|
||||
input[type="button"]:focus,
|
||||
input[type="submit"]:focus {
|
||||
|
@ -117,6 +135,24 @@ input[type="submit"]:focus {
|
|||
.bg-warn:active {
|
||||
background: var(--warn-active);
|
||||
}
|
||||
.align-left {
|
||||
text-align: left;
|
||||
}
|
||||
.align-center {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
.align-right {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
}
|
||||
.float-left {
|
||||
float: left;
|
||||
}
|
||||
.float-right {
|
||||
float: right;
|
||||
}
|
||||
.skip-to-content-link {
|
||||
height: 30px;
|
||||
left: 50%;
|
||||
|
@ -127,6 +163,7 @@ input[type="submit"]:focus {
|
|||
background: var(--links);
|
||||
color: var(--warn-text);
|
||||
border-radius: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.skip-to-content-link:focus {
|
||||
transform: translateY(0%);
|
||||
|
|
|
@ -1,135 +1,100 @@
|
|||
.home-title {
|
||||
margin: 20px 0;
|
||||
padding: 10px 5%;
|
||||
background: #bf1c11;
|
||||
box-shadow: 0 2px 2px rgba(0,0,0,.3);
|
||||
border-top: 10px solid #ab170c;
|
||||
}
|
||||
.home-title h1 {
|
||||
margin-top: 0;
|
||||
color: #ffffff;
|
||||
border-color: #ffffff;
|
||||
}
|
||||
.home-title p {
|
||||
margin-bottom: 0;
|
||||
text-align: justify;
|
||||
color: #ffffff;
|
||||
}
|
||||
.home-title a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.home-pinned-content > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.home-pinned-content h2 {
|
||||
display: block;
|
||||
margin: 5px 0;
|
||||
font-size: 18px;
|
||||
font-family: NotoSans;
|
||||
font-weight: 200;
|
||||
line-height: 20px;
|
||||
}
|
||||
.home-pinned-content a {
|
||||
display: block;
|
||||
}
|
||||
.home-pinned-content a:hover img,
|
||||
.home-pinned-content a:focus img {
|
||||
filter: blur(3px);
|
||||
}
|
||||
.home-pinned-content a:hover div,
|
||||
.home-pinned-content a:focus div {
|
||||
padding: 200px 5% 10px 5%;
|
||||
background-image: linear-gradient(180deg,transparent 0,rgba(0,0,0,.7)40px,rgba(0,0,0,.8));
|
||||
}
|
||||
.home-pinned-content img {
|
||||
width: 100%;
|
||||
filter: blur(0px);
|
||||
}
|
||||
.home-pinned-content article {
|
||||
flex-grow: 1;
|
||||
margin: 0 1px;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
max-width: 250px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.home-pinned-content article div {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
z-index: 3;
|
||||
.home-pinned-content {
|
||||
width: 90%;
|
||||
margin: 0;
|
||||
padding: 30px 5% 10px 5%;
|
||||
color: #ffffff;
|
||||
text-shadow: 1px 1px 0 rgba(0,0,0,.6);
|
||||
background-image: linear-gradient(180deg,transparent 0,rgba(0,0,0,.7)40px,rgba(0,0,0,.8));
|
||||
display: grid;
|
||||
grid-template-areas: 'banner news''welcome news''shout news''projects projects';
|
||||
grid-template-rows: auto auto minmax(200px,1fr)auto;
|
||||
grid-template-columns: 4fr 3fr;
|
||||
}
|
||||
.home-articles {
|
||||
.home-pinned-content > * {
|
||||
margin: 10px 20px;
|
||||
}
|
||||
.home-pinned-content > * h1 {
|
||||
font-size: 18px;
|
||||
}
|
||||
@media screen and (max-width:1449px) {
|
||||
.home-pinned-content {
|
||||
width: 97%;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width:1199px) {
|
||||
.home-pinned-content {
|
||||
width: 100%;
|
||||
grid-template-areas: 'welcome''banner''news''shout''projects';
|
||||
grid-template-rows: auto;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.home-banner {
|
||||
grid-area: banner;
|
||||
text-align: center;
|
||||
}
|
||||
.home-banner img {
|
||||
max-width: 100%;
|
||||
}
|
||||
.home-welcome {
|
||||
grid-area: welcome;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.home-articles > div {
|
||||
.home-welcome h1 {
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.home-welcome ul {
|
||||
padding-left: 20px;
|
||||
}
|
||||
.home-welcome div {
|
||||
flex-grow: 1;
|
||||
max-width: 48%;
|
||||
}
|
||||
.home-articles h1 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.home-articles h1 a {
|
||||
padding: 0;
|
||||
font-family: NotoSans;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: #234d5f;
|
||||
}
|
||||
.home-articles h1 a:hover,
|
||||
.home-articles h1 a:focus {
|
||||
padding-right: 10px;
|
||||
}
|
||||
.home-articles p {
|
||||
.home-welcome h2 {
|
||||
margin: 5px 0;
|
||||
text-align: justify;
|
||||
color: #808080;
|
||||
}
|
||||
.home-articles article {
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
.home-news {
|
||||
grid-area: news;
|
||||
}
|
||||
.home-news ul {
|
||||
padding: 0;
|
||||
}
|
||||
.home-news li {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(0,0,0,.2);
|
||||
flex-wrap: nowrap;
|
||||
padding: 10px 0;
|
||||
border-bottom: var(--hr-border);
|
||||
}
|
||||
.home-articles article > img {
|
||||
float: left;
|
||||
margin-right: 10px;
|
||||
flex-shrink: 0;
|
||||
.home-news li > a {
|
||||
align-self: baseline;
|
||||
}
|
||||
.home-articles article > img.screeshot {
|
||||
width: 128px;
|
||||
height: 64px;
|
||||
.home-news li img {
|
||||
max-width: 100px;
|
||||
max-height: 100px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.home-articles article > div {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
.home-articles article h3 {
|
||||
.home-news li h3 {
|
||||
margin: 0;
|
||||
color: #424242;
|
||||
font-weight: normal;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
font-family: Cantarell;
|
||||
}
|
||||
.home-articles article a:hover,
|
||||
.home-articles article a:focus {
|
||||
text-decoration: underline;
|
||||
.home-news li .date {
|
||||
margin: 4px 0 10px 0;
|
||||
}
|
||||
.home-articles .metadata {
|
||||
margin: 0;
|
||||
color: #22292c;
|
||||
.home-news li div {
|
||||
font-size: 13px;
|
||||
line-height: 150%;
|
||||
}
|
||||
.home-articles .metadata a {
|
||||
color: #22292c;
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
@media screen and (max-width:499px) {
|
||||
.home-news li {
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
.home-shoutbox {
|
||||
grid-area: shout;
|
||||
}
|
||||
.home-projects {
|
||||
grid-area: projects;
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
#program-banner {
|
||||
background: navy;
|
||||
height: 144px;
|
||||
margin: 0 0 32px 0;
|
||||
}
|
||||
section .program-infos {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
section .program-infos span.progrank {
|
||||
border-width: 0 0 1px 0;
|
||||
border-color: var(--color);
|
||||
border-style: dotted;
|
||||
}
|
||||
section .program-infos > div {
|
||||
flex-shrink: 0;
|
||||
margin: 0 8px;
|
||||
}
|
||||
section .program-infos div.program-tags {
|
||||
flex-shrink: 1;
|
||||
}
|
|
@ -1,71 +0,0 @@
|
|||
/* SimpleMDE overwrite that allows us to customize from themes */
|
||||
|
||||
div.editor-toolbar {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
div.editor-toolbar > a {
|
||||
color: var(--text) !important;
|
||||
}
|
||||
div.editor-toolbar > a.active,
|
||||
div.editor-toolbar > a:hover {
|
||||
background: var(--background-light);
|
||||
border-color: var(--background-light);
|
||||
}
|
||||
|
||||
div.editor-toolbar > i.separator {
|
||||
border-right-color: transparent;
|
||||
border-left-color: var(--separator);
|
||||
}
|
||||
|
||||
div.editor-toolbar.disabled-for-preview a:not(.no-disable) {
|
||||
background: none;
|
||||
color: var(--text-disabled) !important;
|
||||
}
|
||||
div.editor-toolbar.disabled-for-preview > i.separator {
|
||||
border-left-color: var(--text-disabled);
|
||||
}
|
||||
|
||||
div.CodeMirror,
|
||||
div.editor-preview {
|
||||
background: var(--background);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
div.editor-preview {
|
||||
background: var(--background-preview);
|
||||
}
|
||||
|
||||
div.editor-preview table th,
|
||||
div.editor-preview-side table th,
|
||||
div.editor-preview table td,
|
||||
div.editor-preview-side table td {
|
||||
border: inherit;
|
||||
padding: inherit;
|
||||
}
|
||||
|
||||
div.editor-preview table.codehilitetable pre,
|
||||
div.editor-preview-side table.codehilitetable pre {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
div.CodeMirror .CodeMirror-selected,
|
||||
div.CodeMirror .CodeMirror-selectedtext {
|
||||
background: var(--background-light);
|
||||
}
|
||||
div.CodeMirror .CodeMirror-focused .CodeMirror-selected,
|
||||
div.CodeMirror .CodeMirror-focused .CodeMirror-selectedtext,
|
||||
div.CodeMirror .CodeMirror-line::selection,
|
||||
div.CodeMirror .CodeMirror-line > span::selection,
|
||||
div.CodeMirror .CodeMirror-line > span > span::selection {
|
||||
background: var(--background-light);
|
||||
}
|
||||
div.CodeMirror .CodeMirror-line::-moz-selection,
|
||||
div.CodeMirror .CodeMirror-line > span::-moz-selection,
|
||||
div.CodeMirror .CodeMirror-line > span > span::-moz-selection {
|
||||
background: var(--background-light);
|
||||
}
|
||||
|
||||
div.CodeMirror-cursor {
|
||||
border-color: var(--text);
|
||||
}
|
File diff suppressed because one or more lines are too long
|
@ -43,10 +43,12 @@
|
|||
}
|
||||
|
||||
.editor button {
|
||||
--background: #ffffff;
|
||||
--text: #000000;
|
||||
--border: 1px solid rgba(0, 0, 0, 0);
|
||||
--border-focused: 1px solid rgba(0, 0, 0, .5);
|
||||
--background: #1d2326;
|
||||
--text: #ffffff;
|
||||
--background-hover: #262c2f;
|
||||
}
|
||||
.editor svg {
|
||||
--icons: #eeeeee;
|
||||
}
|
||||
|
||||
#light-menu {
|
||||
|
@ -128,6 +130,11 @@ table.codehilitetable {
|
|||
--background: #263238;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
--border: rgba(255, 255, 255, .3);
|
||||
--background: transparent;
|
||||
}
|
||||
|
||||
div.editor-toolbar, div.CodeMirror {
|
||||
--border: #404040;
|
||||
--background-light: #404040;
|
||||
|
@ -135,3 +142,36 @@ div.editor-toolbar, div.CodeMirror {
|
|||
--separator: #404040;
|
||||
--text-disabled: #262c2f;
|
||||
}
|
||||
|
||||
.dl-button {
|
||||
--link: #149641;
|
||||
--link-text: #ffffff;
|
||||
--link-active: #0f7331;
|
||||
--meta: rgba(255, 255, 255, .15);
|
||||
--meta-text: #ffffff;
|
||||
}
|
||||
|
||||
.gallery, .gallery-js {
|
||||
--border: rgba(255, 255, 255, 0.8);
|
||||
--selected: rgba(255, 0, 0, 1.0);
|
||||
}
|
||||
|
||||
.tag {
|
||||
--background: #22292c;
|
||||
--color: white;
|
||||
}
|
||||
.tag.tag-calc {
|
||||
--background: #917e1a;
|
||||
}
|
||||
.tag.tag-lang {
|
||||
--background: #4a8033;
|
||||
}
|
||||
.tag.tag-games {
|
||||
--background: #488695;
|
||||
}
|
||||
.tag.tag-tools {
|
||||
--background: #70538a;
|
||||
}
|
||||
.tag.tag-courses {
|
||||
--background: #884646;
|
||||
}
|
||||
|
|
|
@ -38,10 +38,9 @@
|
|||
}
|
||||
|
||||
.editor button {
|
||||
--background: #ffffff;
|
||||
--background: #eee;
|
||||
--text: #030303;
|
||||
--border: 1px solid rgba(0, 0, 0, 0);
|
||||
--border-focused: 1px solid rgba(0, 0, 0, .5);
|
||||
--background-hover: #ddd;
|
||||
}
|
||||
|
||||
#light-menu {
|
||||
|
@ -146,6 +145,11 @@ table.thread.topcomment {
|
|||
border: 1px solid #c0c0c0;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
--border: rgba(236, 36, 36, .7);
|
||||
--background: transparent;
|
||||
}
|
||||
|
||||
div.editor-toolbar {
|
||||
--border: #aaa2a2;
|
||||
--background-light: #c0c0c0;
|
||||
|
@ -172,3 +176,22 @@ div.pagination {
|
|||
font-size: 14px;
|
||||
margin: 13px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
--background: #e0e0e0;
|
||||
}
|
||||
.tag.tag-calc {
|
||||
--background: #f0ca81;
|
||||
}
|
||||
.tag.tag-lang {
|
||||
--background: #aad796;
|
||||
}
|
||||
.tag.tag-games {
|
||||
--background: #a7ccd5;
|
||||
}
|
||||
.tag.tag-tools {
|
||||
--background: #c6aae1;
|
||||
}
|
||||
.tag.tag-courses {
|
||||
--background: #f0a0a0;
|
||||
}
|
||||
|
|
|
@ -1,26 +1,26 @@
|
|||
/* Some colors, variables etc. to be used as theme */
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--text: #000000;
|
||||
--text-light: #101010;
|
||||
--background: #fff;
|
||||
--text: #000;
|
||||
--text-light: #111;
|
||||
|
||||
--links: #c61a1a;
|
||||
|
||||
--ok: #149641;
|
||||
--ok-text: #ffffff;
|
||||
--ok-text: #fff;
|
||||
--ok-active: #0f7331;
|
||||
|
||||
--warn: #f59f25;
|
||||
--warn-text: #ffffff;
|
||||
--warn-text: #fff;
|
||||
--warn-active: #ea9720;
|
||||
|
||||
--error: #d23a2f;
|
||||
--error-text: #ffffff;
|
||||
--error-text: #fff;
|
||||
--error-active: #b32a20;
|
||||
|
||||
--info: #2e7aec;
|
||||
--info-text: #ffffff;
|
||||
--info-text: #fff;
|
||||
--info-active: #215ab0;
|
||||
|
||||
--hr-border: 1px solid #d8d8d8;
|
||||
|
@ -33,23 +33,27 @@ table tr:nth-child(odd) {
|
|||
--background: rgba(0, 0, 0, .1);
|
||||
}
|
||||
table th {
|
||||
--background: #e0e0e0;
|
||||
--border: #d0d0d0;
|
||||
--background: #eee;
|
||||
--border: #ddd;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
--border: rgba(0, 0, 0, .3);
|
||||
--background: transparent;
|
||||
}
|
||||
|
||||
.form {
|
||||
--background: #ffffff;
|
||||
--text: #000000;
|
||||
--background: #fff;
|
||||
--text: #000;
|
||||
--border: 1px solid #c8c8c8;
|
||||
--border-focused: #7cade0;
|
||||
--shadow-focused: rgba(87, 143, 228, 0.5);
|
||||
}
|
||||
|
||||
.editor button {
|
||||
--background: #ffffff;
|
||||
--text: #000000;
|
||||
--border: 1px solid rgba(0, 0, 0, 0);
|
||||
--border-focused: 1px solid rgba(0, 0, 0, .5);
|
||||
--background: #eee;
|
||||
--text: #000;
|
||||
--background-hover: #ddd;
|
||||
}
|
||||
|
||||
#light-menu {
|
||||
|
@ -82,13 +86,13 @@ header {
|
|||
|
||||
footer {
|
||||
--background: #ffffff;
|
||||
--text: #a0a0a0;
|
||||
--border: #d0d0d0;
|
||||
--text: #aaa;
|
||||
--border: #ddd;
|
||||
}
|
||||
|
||||
.flash {
|
||||
--background: #ffffff;
|
||||
--text: #212121;
|
||||
--background: #fff;
|
||||
--text: #222;
|
||||
--shadow: 0 1px 12px rgba(0, 0, 0, 0.3);
|
||||
|
||||
/* Uncomment to inherit :root values
|
||||
|
@ -98,34 +102,62 @@ footer {
|
|||
--info: #2e7aec; */
|
||||
|
||||
--btn-bg: rgba(0, 0, 0, 0);
|
||||
--btn-text: #000000;
|
||||
--btn-text: #000;
|
||||
--btn-bg-active: rgba(0, 0, 0, .15);
|
||||
}
|
||||
|
||||
.profile-xp {
|
||||
--background: #e0e0e0;
|
||||
--border: 1px solid #c0c0c0;
|
||||
--background-xp: #f85555;
|
||||
--background-xp-100: #d03333;
|
||||
--border-xp: 1px solid #d03333;
|
||||
--background: #eee;
|
||||
--border: 1px solid #ccc;
|
||||
--background-xp: #f55;
|
||||
--background-xp-100: #d33;
|
||||
--border-xp: 1px solid #d33;
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
--background: #ffffff;
|
||||
--shadow: 0 0 12px -9px #000000;
|
||||
--border: #d0d0d0;
|
||||
--background-light: #f0f0f0;
|
||||
--background: #fff;
|
||||
--shadow: 0 0 12px -9px #000;
|
||||
--border: #ddd;
|
||||
--background-light: #fff;
|
||||
}
|
||||
|
||||
div.editor-toolbar, div.CodeMirror {
|
||||
--border: #c0c0c0;
|
||||
--background-light: #d9d9d9;
|
||||
--background-preview: #f4f4f6;
|
||||
--separator: #a0a0a0;
|
||||
--text-disabled: #c0c0c0;
|
||||
.editor svg {
|
||||
--icons: #000;
|
||||
}
|
||||
|
||||
.dl-button {
|
||||
--link: #149641;
|
||||
--link-text: #ffffff;
|
||||
--link-active: #0f7331;
|
||||
--meta: rgba(0, 0, 0, .15);
|
||||
--meta-text: #000000;
|
||||
}
|
||||
|
||||
.gallery, .gallery-js {
|
||||
--border: rgba(0, 0, 0, 0.5);
|
||||
--selected: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
|
||||
/* Extra style on top of the Pygments style */
|
||||
table.codehilitetable td.linenos {
|
||||
color: #808080;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.tag {
|
||||
--background: #e0e0e0;
|
||||
}
|
||||
.tag.tag-calc {
|
||||
--background: #f0ca81;
|
||||
}
|
||||
.tag.tag-lang {
|
||||
--background: #aad796;
|
||||
}
|
||||
.tag.tag-games {
|
||||
--background: #a7ccd5;
|
||||
}
|
||||
.tag.tag-tools {
|
||||
--background: #c6aae1;
|
||||
}
|
||||
.tag.tag-courses {
|
||||
--background: #f0a0a0;
|
||||
}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
../../../submodules/v5shoutbox/style.css
|
|
@ -86,6 +86,9 @@
|
|||
height: 64px;
|
||||
}
|
||||
}
|
||||
hr.signature {
|
||||
opacity: 0.2;
|
||||
}
|
||||
.trophies {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
@ -125,6 +128,98 @@
|
|||
.trophy span {
|
||||
font-size: 80%;
|
||||
}
|
||||
hr.signature {
|
||||
opacity: 0.2;
|
||||
.dl-button {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
margin: 3px 5px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.dl-button a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5px 15px;
|
||||
font-size: 110%;
|
||||
background: var(--link);
|
||||
color: var(--link-text);
|
||||
}
|
||||
.dl-button a:hover,
|
||||
.dl-button a:focus,
|
||||
.dl-button a:active {
|
||||
background: var(--link-active);
|
||||
text-decoration: none;
|
||||
}
|
||||
.dl-button span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5px 8px;
|
||||
background: var(--meta);
|
||||
color: var(--meta-text);
|
||||
font-size: 90%;
|
||||
}
|
||||
.gallery {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin: auto;
|
||||
}
|
||||
.gallery * {
|
||||
margin: 3px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.gallery-js {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
margin: auto;
|
||||
padding: 15px;
|
||||
height: 180px;
|
||||
}
|
||||
.gallery-js img,
|
||||
.gallery-js video {
|
||||
height: 100%;
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
}
|
||||
.gallery-js img:not(:first-child),
|
||||
.gallery-js video:not(:first-child) {
|
||||
margin-left: 15px;
|
||||
}
|
||||
.gallery-js img.selected,
|
||||
.gallery-js video.selected {
|
||||
box-shadow: 0 0 7.5px var(--selected);
|
||||
}
|
||||
@media screen and (max-width:1199px) {
|
||||
.gallery-js {
|
||||
height: 150px;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width:499px) {
|
||||
.gallery-js {
|
||||
height: 130px;
|
||||
}
|
||||
}
|
||||
.gallery-spot {
|
||||
justify-content: center;
|
||||
margin: 10px auto;
|
||||
}
|
||||
.gallery-spot * {
|
||||
cursor: pointer;
|
||||
}
|
||||
.tag {
|
||||
display: inline-block;
|
||||
background: var(--background);
|
||||
color: var(--color);
|
||||
padding: 4px 12px;
|
||||
margin: 4px 0;
|
||||
border-radius: 8px;
|
||||
border-radius: calc(4.5em);
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
.locked {
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
/* Some styles to enhance debugger */
|
||||
#flDebug * {
|
||||
overflow: auto !important;
|
||||
}
|
|
@ -0,0 +1,105 @@
|
|||
@import "vars";
|
||||
|
||||
.editor {
|
||||
.btn-group {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
|
||||
#filler {
|
||||
flex-grow: 1;
|
||||
}
|
||||
button {
|
||||
/* This centers the 20x20 SVG in the button */
|
||||
padding: 6px;
|
||||
background-color: var(--background);
|
||||
|
||||
&:hover {
|
||||
background: var(--background-hover);
|
||||
}
|
||||
|
||||
& > svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
|
||||
& > path, & > rect {
|
||||
fill: var(--icons);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button, .separator {
|
||||
margin: 0 8px 8px 0;
|
||||
height: 32px;
|
||||
|
||||
position: relative;
|
||||
}
|
||||
& > a {
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.separator {
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
border: 1px solid var(--text);
|
||||
color: transparent;
|
||||
text-indent: -10px;
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 15rem;
|
||||
}
|
||||
|
||||
#editor_content_preview {
|
||||
padding: 10px;
|
||||
margin-top: 5px;
|
||||
border: var(--border);
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
width: auto;
|
||||
min-width: min-content;
|
||||
text-align: left;
|
||||
right: inherit;
|
||||
|
||||
@media screen and (max-width: @tiny) {
|
||||
width: 80vw;
|
||||
position: fixed;
|
||||
left: 50vw;
|
||||
transform: translateX(-50%);
|
||||
top: 50vh;
|
||||
}
|
||||
|
||||
background: var(--background-hover);
|
||||
border: var(--border);
|
||||
color: var(--text);
|
||||
padding: .2rem;
|
||||
top: 2.3rem;
|
||||
z-index: 100;
|
||||
list-style-position: initial;
|
||||
list-style-type: none;
|
||||
|
||||
& > div {
|
||||
margin: 0.8rem;
|
||||
margin-top: 0.4rem;
|
||||
margin-bottom: 1rem;
|
||||
min-width: 30vw;
|
||||
|
||||
label {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
a.editor-emoji-close-btn {
|
||||
display: inline-block;
|
||||
margin: 0.3rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
& + .desc {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 80%;
|
||||
opacity: .75;
|
||||
opacity: .65;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -32,6 +32,7 @@
|
|||
input[type='date'],
|
||||
input[type='password'],
|
||||
input[type='search'],
|
||||
input[type='url'],
|
||||
textarea,
|
||||
select {
|
||||
display: block;
|
||||
|
@ -95,13 +96,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
height: 3px;
|
||||
border: var(--hr-border);
|
||||
border-width: 1px 0;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.msgerror {
|
||||
color: var(--error);
|
||||
font-weight: 400;
|
||||
|
@ -109,12 +103,35 @@
|
|||
}
|
||||
|
||||
/* anti-bots field */
|
||||
.abfield {
|
||||
input[type='email'].abfield {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Interactive tag selector */
|
||||
|
||||
form .dynamic-tag-selector {
|
||||
display: none;
|
||||
|
||||
input[type="text"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tag {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tags-selected {
|
||||
margin: 0 0 4px 0;
|
||||
|
||||
.tag {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Interactive filter forms */
|
||||
|
||||
.form.filter {
|
||||
|
|
|
@ -40,6 +40,17 @@ a {
|
|||
}
|
||||
}
|
||||
|
||||
img.pixelated {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
hr {
|
||||
height: 3px;
|
||||
border: var(--hr-border);
|
||||
border-width: 1px 0;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
section {
|
||||
p {
|
||||
line-height: 20px;
|
||||
|
@ -66,10 +77,17 @@ section {
|
|||
color: var(--text-light);
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 0 0 10px 0;
|
||||
border-left: 3px solid var(--border);
|
||||
background: var(--background);
|
||||
padding-left: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.button, input[type="button"], input[type="submit"] {
|
||||
button, .button, input[type="button"], input[type="submit"] {
|
||||
padding: 6px 10px; border-radius: 2px;
|
||||
cursor: pointer;
|
||||
font-family: 'DejaVu Sans', sans-serif; font-weight: 400;
|
||||
|
@ -113,6 +131,25 @@ section {
|
|||
}
|
||||
}
|
||||
|
||||
.align-left {
|
||||
text-align: left;
|
||||
}
|
||||
.align-center {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
.align-right {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
}
|
||||
.float-left {
|
||||
float: left;
|
||||
}
|
||||
.float-right {
|
||||
float: right;
|
||||
}
|
||||
|
||||
|
||||
.skip-to-content-link {
|
||||
height: 30px;
|
||||
|
@ -124,6 +161,7 @@ section {
|
|||
background: var(--links);
|
||||
color: var(--warn-text);
|
||||
border-radius: 1px;
|
||||
overflow: hidden;
|
||||
|
||||
&:focus {
|
||||
transform: translateY(0%);
|
||||
|
|
|
@ -1,140 +1,131 @@
|
|||
/*
|
||||
home-title
|
||||
*/
|
||||
@import "vars";
|
||||
|
||||
.home-title {
|
||||
margin: 20px 0; padding: 10px 5%;
|
||||
background: #bf1c11; box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
|
||||
border-top: 10px solid #ab170c;
|
||||
.home-pinned-content {
|
||||
width: 90%;
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'banner news'
|
||||
'welcome news'
|
||||
'shout news'
|
||||
'projects projects';
|
||||
grid-template-rows: auto auto minmax(200px, 1fr) auto;
|
||||
grid-template-columns: 4fr 3fr;
|
||||
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
color: #ffffff; border-color: #ffffff;
|
||||
@media screen and (max-width: @normal) {
|
||||
width: 97%;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 0; text-align: justify;
|
||||
color: #ffffff;
|
||||
@media screen and (max-width: @small) {
|
||||
width: 100%;
|
||||
grid-template-areas:
|
||||
'welcome'
|
||||
'banner'
|
||||
'news'
|
||||
'shout'
|
||||
'projects';
|
||||
grid-template-rows: auto;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit; text-decoration: underline;
|
||||
& > * {
|
||||
//border: 1px solid red;
|
||||
margin: 10px 20px;
|
||||
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.home-banner {
|
||||
grid-area: banner;
|
||||
text-align: center;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
pinned-content
|
||||
*/
|
||||
.home-welcome {
|
||||
grid-area: welcome;
|
||||
|
||||
.home-pinned-content {
|
||||
& > div {
|
||||
display: flex; justify-content: space-between;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
|
||||
justify-content: center;
|
||||
|
||||
h1 {
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
div {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
h2 {
|
||||
display: block; margin: 5px 0;
|
||||
font-size: 18px; font-family: NotoSans; font-weight: 200;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
a {
|
||||
display: block;
|
||||
|
||||
&:hover, &:focus {
|
||||
img {
|
||||
filter: blur(3px);
|
||||
}
|
||||
div {
|
||||
padding: 200px 5% 10px 5%;
|
||||
background-image: linear-gradient(180deg,transparent 0,rgba(0,0,0,.7) 40px,rgba(0,0,0,.8));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%; filter: blur(0px);
|
||||
}
|
||||
|
||||
article {
|
||||
flex-grow: 1; margin: 0 1px; padding: 0;
|
||||
position: relative;
|
||||
max-width: 250px; overflow: hidden;
|
||||
|
||||
div {
|
||||
position: absolute; bottom: 0; z-index: 3;
|
||||
width: 90%; margin: 0;
|
||||
padding: 30px 5% 10px 5%;
|
||||
color: #ffffff; text-shadow: 1px 1px 0 rgba(0,0,0,.6);
|
||||
background-image: linear-gradient(180deg,transparent 0,rgba(0,0,0,.7) 40px,rgba(0,0,0,.8));
|
||||
}
|
||||
margin: 5px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.home-news {
|
||||
grid-area: news;
|
||||
|
||||
/*
|
||||
home-articles
|
||||
*/
|
||||
|
||||
.home-articles {
|
||||
display: flex; justify-content: space-between;
|
||||
|
||||
& > div {
|
||||
flex-grow: 1; max-width: 48%;
|
||||
ul {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
li {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
padding: 10px 0;
|
||||
|
||||
a {
|
||||
padding: 0;
|
||||
font-family: NotoSans; font-size: 16px;
|
||||
font-weight: 400; color: /*#015078*/ /*#bf1c11*/ #234d5f;
|
||||
border-bottom: var(--hr-border);
|
||||
|
||||
&:hover, &:focus {
|
||||
padding-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 5px 0;
|
||||
text-align: justify;
|
||||
color: #808080;
|
||||
}
|
||||
|
||||
article {
|
||||
padding: 10px; margin: 10px 0; display: flex; align-items: center;
|
||||
background: #ffffff; border: 1px solid rgba(0, 0, 0, .2);
|
||||
|
||||
& > img {
|
||||
float: left; margin-right: 10px; flex-shrink: 0;
|
||||
|
||||
&.screeshot {
|
||||
width: 128px; height: 64px;
|
||||
}
|
||||
@media screen and (max-width: @micro) {
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
& > div {
|
||||
flex-shrink: 1;
|
||||
& > a {
|
||||
align-self: baseline;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100px;
|
||||
max-height: 100px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #424242; font-weight: normal;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
font-family: Cantarell;
|
||||
}
|
||||
|
||||
a:hover, a:focus {
|
||||
text-decoration: underline;
|
||||
.date {
|
||||
margin: 4px 0 10px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.metadata {
|
||||
margin: 0;
|
||||
color: #22292c;
|
||||
|
||||
a {
|
||||
color: #22292c; font-weight: 400; font-style: italic;
|
||||
|
||||
div {
|
||||
font-size: 13px;
|
||||
line-height: 150%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.home-shoutbox {
|
||||
grid-area: shout;
|
||||
}
|
||||
|
||||
.home-projects {
|
||||
grid-area: projects;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
#program-banner {
|
||||
background: navy; /* debugging */
|
||||
height: 144px;
|
||||
margin: 0 0 32px 0;
|
||||
}
|
||||
|
||||
section .program-infos {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
|
||||
span.progrank {
|
||||
border-width: 0 0 1px 0;
|
||||
border-color: var(--color); /* use text color */
|
||||
border-style: dotted;
|
||||
}
|
||||
|
||||
& > div {
|
||||
flex-shrink: 0;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
div.program-tags {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
}
|
|
@ -109,6 +109,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
hr.signature {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
|
||||
/* Trophies */
|
||||
.trophies {
|
||||
display: flex;
|
||||
|
@ -157,6 +162,100 @@
|
|||
}
|
||||
}
|
||||
|
||||
hr.signature {
|
||||
opacity: 0.2;
|
||||
|
||||
/* Download button */
|
||||
.dl-button {
|
||||
display: inline-flex; flex-direction: row; align-items: stretch;
|
||||
|
||||
border-radius: 5px; overflow: hidden;
|
||||
margin: 3px 5px; vertical-align: middle;
|
||||
|
||||
a {
|
||||
display:flex; align-items:center;
|
||||
padding: 5px 15px;
|
||||
|
||||
font-size: 110%;
|
||||
background: var(--link); color: var(--link-text);
|
||||
|
||||
&:hover, &:focus, &:active {
|
||||
background: var(--link-active);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
display: flex; align-items:center;
|
||||
padding: 5px 8px;
|
||||
background: var(--meta); color: var(--meta-text);
|
||||
font-size: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Gallery without Javascript */
|
||||
.gallery {
|
||||
display: flex; flex-wrap: wrap;
|
||||
justify-content: center; margin: auto;
|
||||
|
||||
* {
|
||||
margin: 3px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
/* Gallery with Javascript */
|
||||
.gallery-js {
|
||||
@padding: 15px;
|
||||
display: flex; overflow-x: auto; overflow-y: hidden;
|
||||
margin: auto; padding: @padding;
|
||||
height: 150px + 2 * @padding;
|
||||
|
||||
@media screen and (max-width: @small) {
|
||||
height: 120px + 2 * @padding;
|
||||
}
|
||||
@media screen and (max-width: @micro) {
|
||||
height: 100px + 2 * @padding;
|
||||
}
|
||||
|
||||
img, video {
|
||||
height: 100%;
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer; //box-sizing: content-box;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-left: @padding;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
box-shadow: 0 0 @padding/2 var(--selected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-spot {
|
||||
justify-content: center;
|
||||
margin: 10px auto;
|
||||
|
||||
* {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tags */
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
background: var(--background);
|
||||
color: var(--color);
|
||||
padding: 4px 12px;
|
||||
margin: 4px 0;
|
||||
border-radius: 8px;
|
||||
border-radius: calc(0.5em + 4px);
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Thread locked state */
|
||||
.locked {
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
}
|
||||
|
|
|
@ -1,113 +1,426 @@
|
|||
/* 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': '...',
|
||||
}
|
||||
/* Locate the editor associated to an edition event.
|
||||
event: Global event emitted by one of the editor buttons
|
||||
Returns [the div.editor, the button, the textarea] */
|
||||
function editor_event_source(event)
|
||||
{
|
||||
let button = undefined;
|
||||
let editor = undefined;
|
||||
|
||||
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);
|
||||
}
|
||||
/* Grab the button and the parent editor block. The onclick event itself
|
||||
usually reports the SVG in the button as the source */
|
||||
let node = event.target || event.srcElement;
|
||||
while (node != document.body) {
|
||||
if (node.tagName == "BUTTON" && !button) {
|
||||
button = node;
|
||||
}
|
||||
if (node.classList.contains("editor") && !editor) {
|
||||
editor = node;
|
||||
// Hack to use keybinds
|
||||
if (!button) {
|
||||
button = node.firstElementChild.firstElementChild
|
||||
}
|
||||
break;
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
if (!button || !editor) return;
|
||||
|
||||
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;
|
||||
}
|
||||
const ta = editor.querySelector(".editor textarea");
|
||||
return [editor, button, ta];
|
||||
}
|
||||
|
||||
function pre(type, str, multiline) {
|
||||
/* Replace the range [start:end) with the new contents, and returns the new
|
||||
interval [start:end) (ie. the range where the contents are now located). */
|
||||
function editor_replace_range(textarea, start, end, contents)
|
||||
{
|
||||
ta.value = ta.value.substring(0, start)
|
||||
+ contents
|
||||
+ ta.value.substring(end);
|
||||
|
||||
return [start, start + contents.length];
|
||||
}
|
||||
|
||||
/* Event handler that inserts specified tokens around the selection.
|
||||
after token is the same as before if not specified */
|
||||
function editor_insert_around(event, before="", after=null)
|
||||
{
|
||||
const [editor, button, ta] = editor_event_source(event);
|
||||
ta.focus();
|
||||
let indexStart = ta.selectionStart;
|
||||
let indexEnd = ta.selectionEnd;
|
||||
|
||||
if (after === null) {
|
||||
after = before;
|
||||
}
|
||||
|
||||
let [start, end] = editor_replace_range(ta, indexStart, indexEnd,
|
||||
before + ta.value.substring(indexStart, indexEnd) + after);
|
||||
|
||||
/* Restore selection */
|
||||
if (indexStart != indexEnd) {
|
||||
ta.selectionStart = start;
|
||||
ta.selectionEnd = end;
|
||||
}
|
||||
else {
|
||||
ta.selectionStart = ta.selectionEnd = start + before.length;
|
||||
}
|
||||
|
||||
preview();
|
||||
}
|
||||
|
||||
/* Event handler that modifies each line within the selection through a
|
||||
generic function. */
|
||||
function editor_act_on_lines(event, fn)
|
||||
{
|
||||
const [editor, button, ta] = editor_event_source(event);
|
||||
ta.focus();
|
||||
let indexStart = ta.selectionStart;
|
||||
let indexEnd = ta.selectionEnd;
|
||||
|
||||
let firstLineIndex = ta.value.substring(0, indexStart).lastIndexOf('\n');
|
||||
if (firstLineIndex < 0)
|
||||
firstLineIndex = 0;
|
||||
else
|
||||
firstLineIndex += 1;
|
||||
|
||||
let lastLineIndex = ta.value.substring(indexEnd).indexOf('\n');
|
||||
if (lastLineIndex < 0)
|
||||
lastLineIndex = ta.value.length;
|
||||
else
|
||||
lastLineIndex += indexEnd;
|
||||
|
||||
let lines = ta.value.substring(firstLineIndex, lastLineIndex).split('\n');
|
||||
|
||||
for(let i = 0; i < lines.length; i++)
|
||||
lines[i] = fn(lines[i], i);
|
||||
|
||||
let [start, end] = editor_replace_range(ta, firstLineIndex, lastLineIndex,
|
||||
lines.join('\n'));
|
||||
|
||||
ta.selectionStart = start;
|
||||
ta.selectionEnd = end;
|
||||
|
||||
preview();
|
||||
}
|
||||
|
||||
function editor_clear_modals(event, close = true)
|
||||
{
|
||||
// Stop the propagation of the event
|
||||
event.stopPropagation()
|
||||
|
||||
// Reset all modal inputs
|
||||
document.getElementById('media-alt-input').value = '';
|
||||
document.getElementById('media-link-input').value = '';
|
||||
document.getElementById('link-desc-input').value = '';
|
||||
document.getElementById('link-link-input').value = '';
|
||||
const media_type = document.getElementsByName("media-type");
|
||||
for(i = 0; i < media_type.length; i++) {
|
||||
media_type[i].checked = false;
|
||||
}
|
||||
|
||||
// Close all modal if requested
|
||||
if (!close) { return }
|
||||
const modals = document.getElementsByClassName('modal');
|
||||
for (const i of modals) {i.style.display = 'none'};
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
/* End-user functions */
|
||||
function editor_inline(event, type, enable_preview = true)
|
||||
{
|
||||
tokens = {
|
||||
bold: "**",
|
||||
italic: "*",
|
||||
underline: "__",
|
||||
strike: "~~",
|
||||
inlinecode: "`",
|
||||
};
|
||||
|
||||
if (type in tokens) {
|
||||
editor_insert_around(event, tokens[type]);
|
||||
}
|
||||
|
||||
if (enable_preview) {
|
||||
preview();
|
||||
}
|
||||
}
|
||||
|
||||
function editor_display_link_modal(event) {
|
||||
const [editor, button, ta] = editor_event_source(event);
|
||||
let indexStart = ta.selectionStart;
|
||||
let indexEnd = ta.selectionEnd;
|
||||
let selection = ta.value.substring(indexStart, indexEnd);
|
||||
|
||||
// Tab insert some spaces
|
||||
// Ctrl+Enter send the form
|
||||
ta = document.querySelector(".editor textarea");
|
||||
// Assuming it's a link
|
||||
if (selection.match(/^https?:\/\/\S+/)) {
|
||||
event.currentTarget.querySelector("#link-link-input").value = selection;
|
||||
}
|
||||
// Or text
|
||||
else if (selection != "") {
|
||||
event.currentTarget.querySelector("#link-desc-input").value = selection;
|
||||
}
|
||||
|
||||
editor_display_child_modal(event);
|
||||
}
|
||||
|
||||
function editor_insert_link(event, link_id, text_id, media = false)
|
||||
{
|
||||
const [editor, button, ta] = editor_event_source(event);
|
||||
ta.focus();
|
||||
let indexStart = ta.selectionStart;
|
||||
let indexEnd = ta.selectionEnd;
|
||||
|
||||
const link = document.getElementById(link_id).value;
|
||||
const text = document.getElementById(text_id).value;
|
||||
let media_type = "";
|
||||
|
||||
const media_selector = document.getElementsByName("media-type");
|
||||
for(i = 0; i < media_selector.length; i++) {
|
||||
if (media_selector[i].checked) {
|
||||
media_type = `{type=${media_selector[i].value}}`;
|
||||
}
|
||||
}
|
||||
|
||||
editor_clear_modals(event);
|
||||
|
||||
let [start, end] = editor_replace_range(ta, indexStart, indexEnd,
|
||||
`${media ? "!" : ""}[${text.length === 0 ? ta.value.substring(indexStart, indexEnd) : text}](${link})${media ? media_type : ""}`);
|
||||
|
||||
/* Restore selection */
|
||||
if (indexStart != indexEnd) {
|
||||
ta.selectionStart = start;
|
||||
ta.selectionEnd = end;
|
||||
}
|
||||
else {
|
||||
ta.selectionStart = ta.selectionEnd = start + 1;
|
||||
}
|
||||
|
||||
preview();
|
||||
}
|
||||
|
||||
function editor_title(event, level, diff)
|
||||
{
|
||||
editor_act_on_lines(event, function(line, _) {
|
||||
/* Strip all the initial # (and count them) */
|
||||
let count = 0;
|
||||
while(count < line.length && line[count] == '#') count++;
|
||||
|
||||
let contents_index = count;
|
||||
if (count < line.length && line[count] == ' ') contents_index++;
|
||||
let contents = line.slice(contents_index);
|
||||
|
||||
if (level > 0 || count == 1 && diff == -1) {
|
||||
/* Remove the title if the corresponding level is re-requested */
|
||||
if (count == level || count == 1 && diff == -1)
|
||||
return contents;
|
||||
/* Otherwise, add it */
|
||||
else
|
||||
return '#'.repeat(level) + ' ' + contents;
|
||||
}
|
||||
else if (count > 0) {
|
||||
/* Apply the difference */
|
||||
let new_level = Math.max(1, Math.min(6, count + diff));
|
||||
return '#'.repeat(new_level) + ' ' + contents;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
}
|
||||
|
||||
function editor_quote(event)
|
||||
{
|
||||
editor_act_on_lines(event, function(line, _) {
|
||||
/* Strip all the initial > (and count them) */
|
||||
let count = 0;
|
||||
while(count < line.length && line[count] == '>') count++;
|
||||
|
||||
let contents_index = count;
|
||||
if (count < line.length && line[count] == ' ') contents_index++;
|
||||
let contents = line.slice(contents_index);
|
||||
|
||||
/* Apply the difference */
|
||||
return '>'.repeat(count + 1) + ' ' + contents;
|
||||
});
|
||||
}
|
||||
|
||||
function editor_bullet_list(event)
|
||||
{
|
||||
editor_act_on_lines(event, function(line, _) {
|
||||
let ident_match = line.match(/^[\t]+/m) ?? [''];
|
||||
let ident = ident_match[0];
|
||||
let count = ident.length;
|
||||
|
||||
const contents = line.slice(count);
|
||||
if ((count < line.length || count == 0) && line[count] != '-') return '- ' + contents;
|
||||
|
||||
return ident + "\t" + contents;
|
||||
});
|
||||
}
|
||||
|
||||
function editor_numbered_list(event)
|
||||
{
|
||||
editor_act_on_lines(event, function(line, number) {
|
||||
let ident_match = line.match(/^[\t]+/m) ?? [''];
|
||||
let ident = ident_match[0];
|
||||
let count = ident.length;
|
||||
|
||||
const contents = line.slice(count);
|
||||
if ((count < line.length || count == 0) && isNaN(line[count])) return `${number + 1}. ` + contents;
|
||||
|
||||
return ident + "\t" + contents;
|
||||
});
|
||||
}
|
||||
|
||||
function editor_table(event) {
|
||||
let table = `| Column 1 | Column 2 | Column 3 |
|
||||
| -------- | -------- | -------- |
|
||||
| Text | Text | Text |`;
|
||||
|
||||
editor_insert_around(event, "", table);
|
||||
}
|
||||
|
||||
function editor_separator(event) {
|
||||
editor_insert_around(event, "", "\n---\n");
|
||||
}
|
||||
|
||||
function editor_display_child_modal(event) {
|
||||
editor_clear_modals(event);
|
||||
event.currentTarget.children[1].style = {'display': 'block'};
|
||||
}
|
||||
|
||||
const DISABLE_PREVIEW_ICON = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-eye-slash" viewBox="0 0 16 16"><path d="M13.359 11.238C15.06 9.72 16 8 16 8s-3-5.5-8-5.5a7.028 7.028 0 0 0-2.79.588l.77.771A5.944 5.944 0 0 1 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.134 13.134 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755-.165.165-.337.328-.517.486l.708.709z"/><path d="M11.297 9.176a3.5 3.5 0 0 0-4.474-4.474l.823.823a2.5 2.5 0 0 1 2.829 2.829l.822.822zm-2.943 1.299.822.822a3.5 3.5 0 0 1-4.474-4.474l.823.823a2.5 2.5 0 0 0 2.829 2.829z"/><path d="M3.35 5.47c-.18.16-.353.322-.518.487A13.134 13.134 0 0 0 1.172 8l.195.288c.335.48.83 1.12 1.465 1.755C4.121 11.332 5.881 12.5 8 12.5c.716 0 1.39-.133 2.02-.36l.77.772A7.029 7.029 0 0 1 8 13.5C3 13.5 0 8 0 8s.939-1.721 2.641-3.238l.708.709zm10.296 8.884-12-12 .708-.708 12 12-.708.708z"/></svg>';
|
||||
const ENABLE_PREVIEW_ICON = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-eye" viewBox="0 0 16 16"><path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z"/><path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z"/></svg>';
|
||||
|
||||
function toggle_auto_preview() {
|
||||
let auto_preview;
|
||||
if (document.cookie.split(";").some((item) => item.trim().startsWith("auto-preview="))) {
|
||||
auto_preview = document.cookie.split(";").some((item) => item.includes("auto-preview=true"));
|
||||
} else {
|
||||
auto_preview = true;
|
||||
}
|
||||
document.cookie = `auto-preview=${!auto_preview}; max-age=31536000; SameSite=Strict; Secure`
|
||||
if (!auto_preview) {
|
||||
document.getElementById("toggle_preview").title = "Désactiver la prévisualisation";
|
||||
document.getElementById("toggle_preview").innerHTML = DISABLE_PREVIEW_ICON;
|
||||
document.getElementById("manual_preview").style = "display: none";
|
||||
} else {
|
||||
document.getElementById("toggle_preview").title = "Activer la prévisualisation";
|
||||
document.getElementById("toggle_preview").innerHTML = ENABLE_PREVIEW_ICON;
|
||||
document.getElementById("manual_preview").style = "display: block";
|
||||
}
|
||||
}
|
||||
|
||||
/* This request the server to get a complete render of the current text in the textarea */
|
||||
function preview(manual=false) {
|
||||
// If auto-preview is disabled and the preview is not manually requested by the user
|
||||
if (document.cookie.split(";").some((item) => item.includes("auto-preview=false")) && !manual) {
|
||||
return;
|
||||
}
|
||||
const previewArea = document.querySelector("#editor_content_preview");
|
||||
|
||||
const textarea = document.querySelector(".editor textarea");
|
||||
const payload = {text: ta.value};
|
||||
|
||||
const headers = new Headers();
|
||||
headers.append("Content-Type", "application/json");
|
||||
|
||||
const params = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
headers
|
||||
};
|
||||
|
||||
fetch("/api/markdown", params).then(
|
||||
(response) => {
|
||||
response.text().then(
|
||||
(text) => {
|
||||
previewArea.innerHTML = text;
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (document.cookie.split(";").some((item) => item.trim().startsWith("auto-preview="))) {
|
||||
if (document.cookie.split(";").some((item) => item.includes("auto-preview=false"))) {
|
||||
document.getElementById("toggle_preview").title = "Activer la prévisualisation";
|
||||
document.getElementById("toggle_preview").innerHTML = ENABLE_PREVIEW_ICON;
|
||||
document.getElementById("manual_preview").style = "display: block";
|
||||
}
|
||||
}
|
||||
|
||||
let previewTimeout = null;
|
||||
let ta = document.querySelector(".editor textarea");
|
||||
ta.addEventListener('keydown', function(e) {
|
||||
var keyCode = e.keyCode || e.which;
|
||||
if (keyCode == 9) {
|
||||
e.preventDefault();
|
||||
// Tab insert some spaces
|
||||
let keyCode = e.keyCode || e.which;
|
||||
if (keyCode == 9) {
|
||||
// TODO Add one tab to selected text without replacing it
|
||||
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();
|
||||
}
|
||||
}
|
||||
let start = e.target.selectionStart;
|
||||
let 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;
|
||||
}
|
||||
|
||||
/*
|
||||
* Keybindings for buttons. The default action of the keybinding is prevented.
|
||||
* Ctrl+B adds bold
|
||||
* Ctrl+I adds italic
|
||||
* Ctrl+U adds underline
|
||||
* Ctrl+S adds strikethrough
|
||||
* Ctrl+H adds Header +1
|
||||
* Ctrl+Enter send the form
|
||||
*/
|
||||
if (e.ctrlKey) {
|
||||
switch (keyCode) {
|
||||
case 13:
|
||||
let t = e.target;
|
||||
while(! (t instanceof HTMLFormElement)) {
|
||||
t = t.parentNode;
|
||||
}
|
||||
try {
|
||||
t.submit();
|
||||
} catch(exception) {
|
||||
t.submit.click();
|
||||
}
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 66: // B
|
||||
editor_inline(e, "bold", false);
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 72: // H
|
||||
editor_title(e, 0, +1);
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 73: // I
|
||||
editor_inline(e, "italic", false);
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 83: // S
|
||||
editor_inline(e, "strike", false);
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 85: // U
|
||||
editor_inline(e, "underline", false);
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Set a timeout for refreshing the preview
|
||||
if (previewTimeout != null) {
|
||||
clearTimeout(previewTimeout);
|
||||
}
|
||||
previewTimeout = setTimeout(preview, 3000);
|
||||
});
|
||||
|
||||
document.querySelector('emoji-picker').addEventListener('emoji-click', event => {
|
||||
editor_clear_modals(event);
|
||||
editor_insert_around(event, "", event.detail.unicode)
|
||||
|
||||
preview();
|
||||
});
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
function entropy(password) {
|
||||
var chars = [
|
||||
let chars = [
|
||||
"abcdefghijklmnopqrstuvwxyz",
|
||||
"ABCDFEGHIJKLMNOPQRSTUVWXYZ",
|
||||
"0123456789",
|
||||
" !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~§", // OWASP special chars
|
||||
"áàâéèêíìîóòôúùûç"
|
||||
"áàâéèêíìîóòôúùûçÁÀÂÉÈÊÍÌÎÓÒÔÚÙÛǵ²³" // French layout special chars
|
||||
];
|
||||
|
||||
used = new Set();
|
||||
|
@ -19,9 +19,9 @@ function entropy(password) {
|
|||
}
|
||||
|
||||
function update_entropy(ev) {
|
||||
var i = document.querySelector(".entropy").previousElementSibling;
|
||||
var p = document.querySelector(".entropy");
|
||||
var e = entropy(i.value);
|
||||
let i = document.querySelector(".entropy").previousElementSibling;
|
||||
let p = document.querySelector(".entropy");
|
||||
let e = entropy(i.value);
|
||||
|
||||
p.classList.remove('low');
|
||||
p.classList.remove('medium');
|
||||
|
|
|
@ -8,7 +8,7 @@ const patterns = [
|
|||
|
||||
function* lex(str) {
|
||||
while(str = str.trim()) {
|
||||
var t = T.ERR, best = undefined;
|
||||
let t = T.ERR, best = undefined;
|
||||
|
||||
for(const i in patterns) {
|
||||
const m = str.match(patterns[i]);
|
||||
|
@ -86,7 +86,7 @@ class Parser {
|
|||
return e;
|
||||
}
|
||||
|
||||
var e = {
|
||||
let e = {
|
||||
type: "Atom",
|
||||
field: this.expect(T.NAME),
|
||||
op: this.expect(T.COMP),
|
||||
|
@ -124,8 +124,8 @@ function filter_update(input) {
|
|||
const th = t.querySelectorAll("tr:first-child > th");
|
||||
|
||||
/* Generate the names of fields from the header */
|
||||
var fields = {};
|
||||
for(var i = 0; i < th.length; i++) {
|
||||
let fields = {};
|
||||
for(let i = 0; i < th.length; i++) {
|
||||
const name = th[i].dataset.filter;
|
||||
if(name) fields[name] = i;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
document.querySelectorAll(".gallery").forEach(item => {
|
||||
// Switch to gallery-js stylesheet
|
||||
item.className = "gallery-js";
|
||||
|
||||
// Create the spotlight container
|
||||
let spot = document.createElement('div');
|
||||
spot.className = "gallery-spot";
|
||||
spot.style.display = "none";
|
||||
spot.appendChild(item.firstElementChild.cloneNode(true));
|
||||
item.after(spot);
|
||||
|
||||
// Add some logic
|
||||
// item.addEventListener("click", function(e) {
|
||||
// console.log(e.target);
|
||||
// console.log(e.currentTarget);
|
||||
// // Select the clicked media
|
||||
// Array.from(item.children).forEach(child => {
|
||||
// child.classList.remove('selected');
|
||||
// });
|
||||
// e.target.classList.add('selected');
|
||||
//
|
||||
// // Display the current
|
||||
// e.currentTarget.nextElementSibling.querySelector('div').innerHTML = e.target.outerHTML;
|
||||
// });
|
||||
});
|
||||
|
||||
document.querySelectorAll(".gallery-js > *").forEach(item => {
|
||||
item.addEventListener("click", function(e) {
|
||||
console.log(e.target);
|
||||
// Manage selected media
|
||||
if(e.target.classList.contains('selected')) {
|
||||
e.target.classList.remove('selected');
|
||||
} else {
|
||||
e.target.classList.add('selected');
|
||||
}
|
||||
Array.from(e.target.parentElement.children).forEach(el => {
|
||||
if(el != e.target) el.classList.remove('selected');
|
||||
});
|
||||
|
||||
// Change content of spotlight
|
||||
let spot = e.target.parentElement.nextElementSibling;
|
||||
spot.replaceChild(e.target.cloneNode(true), spot.firstElementChild);
|
||||
// Open spotlight media in a new tab
|
||||
spot.firstElementChild.addEventListener("click", function(e) {
|
||||
window.open(spot.firstElementChild.src, "_blank");
|
||||
});
|
||||
|
||||
// Display the spotlight
|
||||
if(e.target.classList.contains('selected')) {
|
||||
spot.style.display = "flex";
|
||||
} else {
|
||||
spot.style.display = "none";
|
||||
}
|
||||
});
|
||||
});
|
|
@ -1,13 +1,13 @@
|
|||
function setCookie(name, value) {
|
||||
var end = new Date();
|
||||
let end = new Date();
|
||||
end.setTime( end.getTime() + 3600 * 1000 );
|
||||
var str=name+"="+escape(value)+"; expires="+end.toGMTString()+"; path=/; Secure; SameSite=lax";
|
||||
let str=name+"="+escape(value)+"; expires="+end.toGMTString()+"; path=/; Secure; SameSite=lax";
|
||||
document.cookie = str;
|
||||
}
|
||||
function getCookie(name) {
|
||||
var debut = document.cookie.indexOf(name);
|
||||
let debut = document.cookie.indexOf(name);
|
||||
if( debut == -1 ) return null;
|
||||
var end = document.cookie.indexOf( ";", debut+name.length+1 );
|
||||
let end = document.cookie.indexOf( ";", debut+name.length+1 );
|
||||
if( end == -1 ) end = document.cookie.length;
|
||||
return unescape( document.cookie.substring( debut+name.length+1, end ) );
|
||||
}
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -1,11 +1,11 @@
|
|||
/* Smartphone patch for menu */
|
||||
/* It don't work if links haven't any href attribute */
|
||||
|
||||
var w = Math.max(document.documentElement.clientWidth, window.innerWidth || 0)
|
||||
let w = Math.max(document.documentElement.clientWidth, window.innerWidth || 0)
|
||||
|
||||
if(w < 700) {
|
||||
var buttons = document.getElementById('light-menu').getElementsByTagName('li');
|
||||
for(var i = 0; i < buttons.length; i++) {
|
||||
let buttons = document.getElementById('light-menu').getElementsByTagName('li');
|
||||
for(let i = 0; i < buttons.length; i++) {
|
||||
buttons[i].getElementsByTagName('a')[0].setAttribute('href', '#');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
function tag_selector_find(node) {
|
||||
while(node != document.body) {
|
||||
if(node.classList.contains("dynamic-tag-selector"))
|
||||
return node;
|
||||
node = node.parentNode;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function tag_selector_get(ts) {
|
||||
return ts.querySelector("input").value
|
||||
.split(",")
|
||||
.map(str => str.trim())
|
||||
.filter(str => str !== "");
|
||||
}
|
||||
|
||||
function tag_selector_set(ts, values) {
|
||||
ts.querySelector("input").value = values.join(", ");
|
||||
tag_selector_update(ts);
|
||||
}
|
||||
|
||||
function tag_selector_update(ts) {
|
||||
if(ts === undefined) return;
|
||||
const input_names = tag_selector_get(ts);
|
||||
|
||||
/* Update visibility of selected tags */
|
||||
ts.querySelectorAll(".tags-selected .tag[data-name]").forEach(tag => {
|
||||
const visible = input_names.includes(tag.dataset.name);
|
||||
tag.style.display = visible ? "inline-block" : "none";
|
||||
});
|
||||
|
||||
/* Update visibility of pool tags */
|
||||
ts.querySelectorAll(".tags-pool .tag[data-name]").forEach(tag => {
|
||||
const visible = !input_names.includes(tag.dataset.name);
|
||||
tag.style.display = visible ? "inline-block" : "none";
|
||||
});
|
||||
}
|
||||
|
||||
function tag_selector_add(ts, id) {
|
||||
if(ts === undefined) return;
|
||||
|
||||
let tags = tag_selector_get(ts);
|
||||
if(!tags.includes(id))
|
||||
tags.push(id);
|
||||
|
||||
tag_selector_set(ts, tags);
|
||||
}
|
||||
|
||||
function tag_selector_remove(ts, id) {
|
||||
if(ts === undefined) return;
|
||||
|
||||
let tags = tag_selector_get(ts);
|
||||
tags = tags.filter(str => str !== id);
|
||||
|
||||
tag_selector_set(ts, tags);
|
||||
}
|
||||
|
||||
document.querySelectorAll(".dynamic-tag-selector").forEach(ts => {
|
||||
ts.style.display = "block";
|
||||
tag_selector_update(ts);
|
||||
});
|
|
@ -1,24 +1,24 @@
|
|||
/* Trigger actions for the menu */
|
||||
|
||||
/* Initialization */
|
||||
var b = document.querySelectorAll('#light-menu a');
|
||||
for(var i = 1; i < b.length; i++) {
|
||||
let b = document.querySelectorAll('#light-menu a');
|
||||
for(let 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) {
|
||||
var display = function(element) {
|
||||
let trigger_menu = function(active) {
|
||||
let display = function(element) {
|
||||
element.classList.add('opened');
|
||||
}
|
||||
var hide = function(element) {
|
||||
let hide = function(element) {
|
||||
element.classList.remove('opened');
|
||||
}
|
||||
|
||||
var menu = document.querySelector('#menu');
|
||||
var buttons = document.querySelectorAll('#light-menu li');
|
||||
var menus = document.querySelectorAll('#menu > div');
|
||||
let menu = document.querySelector('#menu');
|
||||
let buttons = document.querySelectorAll('#light-menu li');
|
||||
let menus = document.querySelectorAll('#menu > div');
|
||||
|
||||
if(active == -1 || buttons[active].classList.contains('opened')) {
|
||||
hide(menu);
|
||||
|
@ -39,14 +39,14 @@ var trigger_menu = function(active) {
|
|||
}
|
||||
}
|
||||
|
||||
var mouse_trigger = function(event) {
|
||||
var menu = document.querySelector('#menu');
|
||||
var buttons = document.querySelectorAll('#light-menu li');
|
||||
let mouse_trigger = function(event) {
|
||||
let menu = document.querySelector('#menu');
|
||||
let buttons = document.querySelectorAll('#light-menu li');
|
||||
|
||||
if(!menu.contains(event.target)) {
|
||||
var active = -1;
|
||||
let active = -1;
|
||||
|
||||
for(i = 0; i < buttons.length; i++) {
|
||||
for(let i = 0; i < buttons.length; i++) {
|
||||
if(buttons[i].contains(event.target))
|
||||
active = i;
|
||||
buttons[i].querySelector('a').blur();
|
||||
|
@ -56,12 +56,12 @@ var mouse_trigger = function(event) {
|
|||
}
|
||||
}
|
||||
|
||||
var keyboard_trigger = function(event) {
|
||||
var menu = document.getElementById('menu');
|
||||
var buttons = document.querySelectorAll('#light-menu li');
|
||||
let keyboard_trigger = function(event) {
|
||||
let menu = document.getElementById('menu');
|
||||
let buttons = document.querySelectorAll('#light-menu li');
|
||||
|
||||
if(event.keyCode == 13) {
|
||||
for(var i = 0; i < buttons.length; i++) {
|
||||
for(let i = 0; i < buttons.length; i++) {
|
||||
if(buttons[i].querySelector('a').getAttribute('f') == 'true') {
|
||||
trigger_menu(i);
|
||||
}
|
||||
|
@ -69,5 +69,5 @@ var keyboard_trigger = function(event) {
|
|||
}
|
||||
}
|
||||
|
||||
document.onclick = mouse_trigger;
|
||||
document.onkeypress = keyboard_trigger;
|
||||
document.addEventListener("click", mouse_trigger);
|
||||
document.addEventListener("keydown", keyboard_trigger);
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
../../../submodules/v5shoutbox/v5shoutbox.js
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Gestion du compte" %}
|
||||
|
||||
{% block title %}
|
||||
<h1>Gestion du compte</h1>
|
||||
{% endblock %}
|
||||
|
@ -13,7 +15,7 @@
|
|||
<div>
|
||||
{{ form.avatar.label }}
|
||||
<div>
|
||||
<img class="avatar" src="{{ url_for('avatar', filename=current_user.avatar) }}" meta="{{ current_user.avatar }}" />
|
||||
<img class="avatar" src="{{ current_user.avatar_url }}" />
|
||||
{{ form.avatar }}
|
||||
</div>
|
||||
{% for error in form.avatar.errors %}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Réinitialiser le mot de passe" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="form" style="width:40%;">
|
||||
<h1>Réinitialiser le mot de passe</h1>
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Suppression du compte" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="form">
|
||||
<h1>Suppression du compte</h2>
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Connexion" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="form" style="width:40%;">
|
||||
<h1>Connexion</h1>
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Notifications" %}
|
||||
|
||||
{% block title %}
|
||||
<h1>Notifications</h1>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
{% extends "base/base.html" %}
|
||||
{% import "widgets/poll.html" as poll_widget with context %}
|
||||
|
||||
{% set tabtitle = "Gestion des sondages" %}
|
||||
|
||||
{% block title %}
|
||||
<h1>Gestion des sondages</h1>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Inscription" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="form" style="width:40%;">
|
||||
<h1>Inscription</h1>
|
||||
|
@ -37,7 +39,7 @@
|
|||
{% endfor %}
|
||||
</div>
|
||||
<div>
|
||||
{{ form.guidelines.label }}
|
||||
<label for="guidelines">J'accepte les <a href="#">CGU</a></label>
|
||||
{{ form.guidelines() }}
|
||||
{% for error in form.guidelines.errors %}
|
||||
<span class="msgerror">{{ error }}</span>
|
||||
|
@ -46,7 +48,7 @@
|
|||
<div>
|
||||
{{ form.newsletter.label }}
|
||||
{{ form.newsletter() }}
|
||||
<div style="font-size:80%;color:rgba(0,0,0,.5)">{{ form.newsletter.description }}</div>
|
||||
<div class="desc">{{ form.newsletter.description }}</div>
|
||||
{% for error in form.newsletter.errors %}
|
||||
<span class="msgerror">{{ error }}</span>
|
||||
{% endfor %}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Réinitialiser le mot de passe" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="form" style="width:40%;">
|
||||
<h1>Réinitialiser le mot de passe</h1>
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
{% extends "base/base.html" %}
|
||||
{% import "widgets/user.html" as widget_member %}
|
||||
|
||||
{% set tabtitle = "Profil de " + member.name %}
|
||||
|
||||
{% block title %}
|
||||
<h1>Profil de {{ member.name }}</h1>
|
||||
{% endblock %}
|
||||
|
@ -68,7 +70,7 @@
|
|||
<th>Forum</th>
|
||||
<th>Création</th>
|
||||
</tr>
|
||||
{% for t in member.topics %}
|
||||
{% for t in member.topics() %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('forum_topic', f=t.forum, page=(t, 1)) }}">{{ t.title }}</a></td>
|
||||
<td><a href="{{ url_for('forum_page', f=t.forum) }}">{{ t.forum.name }}</a></td>
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Inscription réussie" %}
|
||||
|
||||
{% block content %}
|
||||
<section>
|
||||
<div>
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Administration - Pièces-jointes" %}
|
||||
|
||||
{% block title %}
|
||||
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Pièces jointes</h1>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Administration - Configuration du site" %}
|
||||
|
||||
{% block title %}
|
||||
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Configuration du site</h1>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Administration - Suppression du compte de " + user.name %}
|
||||
|
||||
{% block title %}
|
||||
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Suppression du compte de '{{ user.name }}'</h1>
|
||||
{% endblock %}
|
||||
|
@ -11,7 +13,7 @@
|
|||
<ul>
|
||||
<li>{{ stats.comments }} commentaire{{ stats.comments | pluralize }}</li>
|
||||
<li>{{ stats.topics }} topic{{ stats.topics | pluralize }}</li>
|
||||
<li>{{ stats.programs }} topic{{ stats.programs | pluralize }}</li>
|
||||
<li>{{ stats.programs }} programme{{ stats.programs | pluralize }}</li>
|
||||
</ul>
|
||||
<p>Les propriétés suivantes seront supprimées :</p>
|
||||
<ul>
|
||||
|
|
|
@ -1,28 +0,0 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% block title %}
|
||||
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <a href={{ url_for('adm_trophies') }}>Titres et trophées</a> » <h1>Suppression du trophée '{{ trophy.name }}'</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="form">
|
||||
<h2>Confirmer la suppression du trophée</h2>
|
||||
<p>Le trophée '{{ trophy.name }}' que vous allez supprimer est lié à :</p>
|
||||
<ul>
|
||||
<li>{{ trophy.owners | length }} membre{{ trophy.owners|length|pluralize }}</li>
|
||||
</ul>
|
||||
|
||||
<form action="{{ url_for('adm_delete_trophy', trophy_id=trophy.id) }}" method=post>
|
||||
{{ del_form.hidden_tag() }}
|
||||
<div>
|
||||
{{ del_form.delete.label }}
|
||||
{{ del_form.delete(checked=False) }}
|
||||
<div style="font-size: 80%; color: gray">{{ del_form.delete.description }}</div>
|
||||
{% for error in del_form.delete.errors %}
|
||||
<span class=msgerror>{{ error }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div>{{ del_form.submit(class_="bg-error") }}</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
|
@ -1,7 +1,9 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Administration - Édition du compte de " + user.name %}
|
||||
|
||||
{% block title %}
|
||||
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Édition du compte de {{ user.name }}</h1>
|
||||
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Édition du compte de {{ user.name }}</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
@ -14,7 +16,7 @@
|
|||
<div>
|
||||
{{ form.avatar.label }}
|
||||
<div>
|
||||
<img class="avatar" src="{{ url_for('avatar', filename=user.avatar) }}" meta="{{ user.avatar }}" />
|
||||
<img class="avatar" src="{{ user.avatar_url }}" />
|
||||
{{ form.avatar }}
|
||||
</div>
|
||||
{% for error in form.avatar.errors %}
|
||||
|
|
|
@ -1,58 +0,0 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% block title %}
|
||||
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <a href={{ url_for('adm_trophies') }}>Titres et trophées</a> » <h1>Édition du trophée '{{ trophy.name }}'</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="form">
|
||||
<form action="{{ url_for('adm_edit_trophy', trophy_id=trophy.id) }}" method="post" enctype="multipart/form-data">
|
||||
{{ form.hidden_tag() }}
|
||||
<h2>Éditer le trophée</h2>
|
||||
|
||||
<div>
|
||||
<img src="{{ url_for('static', filename='images/trophies/'+slugify(trophy.name))+'.png' }}" style="vertical-align: middle; margin-right: 8px">
|
||||
<b>{{ trophy.name }}</b>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{ form.name.label }}
|
||||
{{ form.name(value=trophy.name) }}
|
||||
{% for error in form.name.errors %}
|
||||
<span class="msgerror">{{ error }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div>
|
||||
{{ form.desc.label }}
|
||||
{{ form.desc(value=trophy.description) }}
|
||||
{% for error in form.desc.errors %}
|
||||
<span class="msgerror">{{ error }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div>
|
||||
{{ form.hidden.label }}
|
||||
{{ form.hidden(checked=trophy.hidden) }}
|
||||
<div class=desc>{{ form.hidden.description }}</div>
|
||||
{% for error in form.hidden.errors %}
|
||||
<span class="msgerror">{{ error }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div>
|
||||
{{ form.title.label }}
|
||||
{{ form.title() }}
|
||||
<div class=desc>{{ form.title.description }}</div>
|
||||
{% for error in form.title.errors %}
|
||||
<span class="msgerror">{{ error }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div>
|
||||
{{ form.css.label }}
|
||||
<div class=desc>{{ form.css.description }}</div>
|
||||
{{ form.css(value=trophy.css) }}
|
||||
{% for error in form.css.errors %}
|
||||
<span class="msgerror">{{ error }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div>{{ form.submit(class_="bg-ok") }}</div>
|
||||
</section>
|
||||
{% endblock %}
|
|
@ -16,6 +16,8 @@
|
|||
{% endfor %}
|
||||
{% endmacro %}
|
||||
|
||||
{% set tabtitle = "Administration - Forums" %}
|
||||
|
||||
{% block title %}
|
||||
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Forums</h1>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Administration - Groupes et privilèges" %}
|
||||
|
||||
{% block title %}
|
||||
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Groupes et privilèges</h1>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Panneau d’administration" %}
|
||||
|
||||
{% block title %}
|
||||
<h1>Panneau d'administration</h1>
|
||||
{% endblock %}
|
||||
|
@ -10,7 +12,6 @@
|
|||
<ul>
|
||||
<li><a href="{{ url_for('adm_groups') }}">Groupes et privilèges</a></li>
|
||||
<li><a href="{{ url_for('adm_members') }}">Liste des membres</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>
|
||||
<li><a href="{{ url_for('adm_polls') }}">Sondages</a></li>
|
||||
<li><a href="{{ url_for('adm_attachments') }}">Pièces-jointes</a></li>
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Administration - Vandaliser un compte" %}
|
||||
|
||||
{% block title %}
|
||||
<h1>Vandaliser un compte</h1>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Administration - Liste des membres" %}
|
||||
|
||||
{% block title %}
|
||||
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Liste des membres</h1>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
{% extends "base/base.html" %}
|
||||
{% import "widgets/poll.html" as poll_widget with context %}
|
||||
|
||||
{% set tabtitle = "Administration - Gestion des sondages" %}
|
||||
|
||||
{% block title %}
|
||||
<h1>Gestion des sondages</h1>
|
||||
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Gestion des sondages</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue