Compare commits

...

161 Commits

Author SHA1 Message Date
Darks 21b863e0e2
Merge branch 'preprod' of gitea.planet-casio.com:devs/PCv5 2023-07-18 21:31:46 +02:00
Darks e92792c8d6
submodules: moved to PCv5-extra 2023-07-18 21:29:17 +02:00
Darks 20524d28c3
scripts: add modules to render helper 2023-07-15 20:36:44 +02:00
Eragon 0d9ca65238
config: Change default sender for mails 2023-07-06 15:35:59 +02:00
Darks 402e6699aa
fixed 'avancées de la v5' button 2023-07-04 21:45:35 +02:00
Darks 7e64a70eec Merge pull request 'Ajout de la page d’accueil en préprod' (#141) from landing_page into dev
Reviewed-on: https://gitea.planet-casio.com/devs/PCv5/pulls/141
2023-07-04 21:35:58 +02:00
Darks 920724718f
homepage: good enough for preview release 2023-07-04 21:30:16 +02:00
Lephenixnoir d6ff6eb77f Merge pull request 'post: unique delete button for guest posts' (#140) from IniKiwi/PCv5:guest-delete-button-2 into dev
Reviewed-on: https://gitea.planet-casio.com/devs/PCv5/pulls/140
2023-07-01 12:42:51 +02:00
IniKiwi c5df575af3
post: fix duplicate code 2023-07-01 12:39:46 +02:00
IniKiwi f93443310b
post: unique delete button for guest posts 2023-07-01 12:24:53 +02:00
Darks f6aefffc3c
homepage: still WIP, but better 2023-06-27 23:28:32 +02:00
Lephe c8f2d73bc2
shoutbox: add standalone shoutbox at /chat 2023-06-27 22:35:42 +02:00
Lephe d531106c78
meta: add shoutbox submodule 2023-06-27 22:08:27 +02:00
Eragon 43381bf493
homepage: Fix grid style 2023-06-25 01:57:39 +02:00
Darks 62341cf9d9
landing page: WIP 2023-06-23 23:41:46 +02:00
Eragon c88c993ee3
makefile: copy all scripts for emoji picker 2023-06-21 00:16:45 +02:00
Eragon ed8550f291
makefile: Mkdir folders for emoji JS 2023-06-21 00:04:38 +02:00
Eragon f163d15066
ldap: Update user informations in LDAP when edited from PCv5 2023-06-20 22:40:51 +02:00
Darks 14e81bdfb5
registration: fix link to CGU 2023-06-20 22:22:43 +02:00
Eragon 4231b3084e
member: Delete members from LDAP on account deletion 2023-06-20 20:08:41 +02:00
Darks 9902719328
Merge branch 'glados_say' of gitea.planet-casio.com:devs/PCv5 into dev 2023-06-20 19:39:49 +02:00
Darks 358a5fec9d
notifications: fixed notifications 2023-06-20 19:38:04 +02:00
Darks 8721a7be69 Merge pull request 'logging: add some logging for v5 events' (#136) from logging into dev
Reviewed-on: https://gitea.planet-casio.com/devs/PCv5/pulls/136
2023-06-20 19:11:41 +02:00
Darks 12483e70e4
logging: add some logging for v5 events 2023-06-13 23:32:58 +02:00
Eragon e5dafb68e5
submodule: Add emoji picker build command to Makefile 2023-06-13 10:52:53 +02:00
Darks fabbb130b6
Merge branch 'dev' of gitea.planet-casio.com:devs/PCv5 into glados_say 2023-06-12 20:09:02 +02:00
Darks 2530581095
glados: updated announces 2023-06-12 20:04:20 +02:00
Darks f06f14e814
templates: fix a template tabtitle 2023-06-12 19:21:26 +02:00
Eragon 5a6d000be6
submodule: Move picker element to submodule 2023-06-12 15:12:14 +02:00
Darks 876cae2b69
glados: add some 'say' messages 2023-06-11 23:05:03 +02:00
Darks b892d9ae68
tabtitles: add configuration entry to set a prefix on tabtitles 2023-06-07 22:06:56 +02:00
Darks 6238f72d6d
tabtitles: jinja set directive does not support f-strings 2023-06-07 21:59:07 +02:00
Darks fccd0e5b84
admin: fixed priv name on polls route 2023-06-07 21:55:40 +02:00
Darks a5b2933727
Merge branch 'preprod' of gitea.planet-casio.com:devs/PCv5 2023-06-07 21:37:12 +02:00
Darks 6519cf4a6a
markdown: add ins (underline) and del (strikethrough) tags 2023-06-07 21:33:06 +02:00
Darks c31cca6314
privs: fixed #127 2023-06-06 23:07:14 +02:00
Darks 0865ae0e67
erge branch 'dev' of gitea.planet-casio.com:devs/PCv5 into dev 2023-06-06 22:44:23 +02:00
Darks 798f5d203e
admin: fixed sort of special privs 2023-06-06 22:44:12 +02:00
Darks 3f8f8ab225
templates: added tabtitles to all relevant templates 2023-06-06 22:43:35 +02:00
Eragon 4eb4145846
template: Change the link/button to get to the topic about the current state of PCv5 2023-06-06 21:56:02 +02:00
Darks 6d1d6a1b2e Merge pull request 'moderation/lockthread' (#126) from moderation/lockthread into dev
Reviewed-on: https://gitea.planet-casio.com/devs/PCv5/pulls/126
2023-06-06 21:41:17 +02:00
Darks 1a63544183
moderation: added some css on locked message 2023-06-06 21:38:50 +02:00
Darks 6817b79680
Merge branch 'dev' of gitea.planet-casio.com:devs/PCv5 into moderation/lockthread 2023-06-06 21:35:33 +02:00
Darks 3c671da85c
moderation: added locking capability to topics and programs 2023-06-06 21:35:29 +02:00
Eragon 7c076fea79
accueil: Update the welcome message. 2023-06-06 21:11:59 +02:00
Darks 65828ffbdd
moderation: fixed moving a post to another topic 2023-06-06 21:01:58 +02:00
Eragon 7aa93f15ea
editor: Count lines for numbered list 2023-06-06 21:00:59 +02:00
Eragon 57644c4378
editor: fix modal not closing when openning an other one 2023-06-06 20:57:05 +02:00
Eragon be0d531b00
style: Add blockquote style 2023-06-06 20:14:07 +02:00
Darks b9becbf21f
moderation: fixed moving a post to another topic 2023-06-06 19:52:46 +02:00
Darks e7d28570c7 programs: fix index table template
A column was missing in the header
2023-06-02 15:37:26 +02:00
Lephe 85830f1893
home: incident information 2023-05-20 21:17:29 +02:00
Eragon 6cc066b4d6
editor: Self-host the emoji-picker custom element. 2023-05-17 09:56:22 +02:00
Eragon 168b77c8de
scripts: Fix variable declaration 2023-05-16 23:09:30 +02:00
Eragon 8fbec9ed87
editor: Fix input being cleared on click in link modal 2023-05-16 22:23:08 +02:00
Eragon 44609f2f96
editor: Toggle automatic preview and add manual preview button 2023-05-16 13:50:47 +02:00
Eragon f15186d4f8
editor: Prevent default browser action for keybinds when textarea is foccused 2023-05-15 16:17:28 +02:00
Eragon ed1a534aa6
editor: Add keybinds 2023-05-15 16:17:28 +02:00
Eragon 2b5485677e
editor: tests with emoji-picker-element for emoji picker
Note: we will need to self-host it if we keep it moving forward. This is
just a test, this commit should be reverted if not used in the end.
2023-05-15 16:17:28 +02:00
Eragon b09ffaf97d
editor: Fix color variable on button separator 2023-05-15 16:17:24 +02:00
Eragon 55836ebf19
Merge branch 'improve-entropy' into dev 2023-05-15 15:19:58 +02:00
Darks 228f613f09
Added more french special chars to list 2023-05-15 15:17:03 +02:00
Darks 65fd5c8188
Added more french special chars to list 2023-05-15 15:17:00 +02:00
Eragon 7f53b6c6c2
debug: Fix debugToolbar redirect catch by disabling it.
The debug toolbar catch redirects by default but it breaks redirection to
specific anchor in topics and programs.
`/forum/discussion/2/fin/rtnldj#30` redirect is broken with the catch
redirect.
But redirections to `/forum/discussion/2/fin/rtnldj` are fine.
Disabling this feature is enough for it to work. Feel free to re-enable
it yourself, but be aware that it will break parts of the application.
2023-05-15 13:04:27 +02:00
Eragon 37fba5d93b
dependencies: Python 3.11 URLlib is now typed and require explicit conversion from int to str 2023-05-15 13:02:52 +02:00
Eragon b93415819b
editor: Fix image link `!` position 2022-12-18 00:36:33 +01:00
Darks 3848b3dedd
Add 'update-all' capability to master script 2022-12-18 00:32:14 +01:00
Darks 28ac88f5bd Merge pull request 'Remplacer simpleMDE par un éditeur maison' (#110) from new_editor into dev
Reviewed-on: https://gitea.planet-casio.com/devs/PCv5/pulls/110
2022-12-18 00:08:45 +01:00
Darks 6fe04b0a6c
Improved the editor 2022-12-17 23:28:06 +01:00
Darks 8cd862078b
Rebase new_editor from dev 2022-12-17 22:58:22 +01:00
Darks 2089398773
Update to follow Pillow evolution 2022-12-17 22:11:36 +01:00
Eragon 112d06e3d6
program: Fix submission, login required and file upload 2022-12-15 19:04:30 +01:00
Eragon f3b89716b7
editor: remove unwanted console.log 2022-12-15 18:49:51 +01:00
Eragon 5e368ac08b
editor: Add support for type= custom extension on media 2022-12-15 14:07:18 +01:00
Eragon 546b32c22b
editor: Multiples bugfixes from Lephe's review
Refresh preview 3 sec after last keypress
Refresh preview on button usage
Move cursor after the --- line when using the button
Replace margin for padding in the preview css
Add a slight background shade on the preview
2022-12-15 12:05:58 +01:00
Eragon 2da20720bb
editor: Dirty CSS hack for small screen devices 2022-12-14 11:22:14 +01:00
Eragon e2283b7675
editor: CSS and JS cleaning 2022-12-14 11:22:14 +01:00
Eragon 5eaf1cc207
editor: Link and Images buttons works, still need some CSS and cleaning 2022-12-14 11:22:14 +01:00
Eragon 95166b2da3
editor: WIP: Add modal to add links 2022-12-14 11:22:14 +01:00
Eragon b132eed1c7
editor: Insert table (Same as easy-mde) 2022-12-14 11:22:13 +01:00
Eragon cd84ccf4e6
editor: Insert separator on a new line 2022-12-14 11:22:13 +01:00
Eragon 28935b2ae8
editor: The numbered list button have the same usage as the old one 2022-12-14 11:22:13 +01:00
Eragon 6799f7477b
editor: I promise, this is progress 2022-12-14 11:22:13 +01:00
Eragon fcf42d4bb5
editor: Add title to all buttons 2022-12-14 11:22:13 +01:00
Eragon b062d9fa64
editor: Fix bullet list creation if the line is empty 2022-12-14 11:22:13 +01:00
Eragon 371dee1f7a
editor: Completely remove title using the title dec button 2022-12-14 11:22:13 +01:00
Eragon 1f1a06b02d
editor: Bullet list, using tabs (mixing tabs and space doesn't work) 2022-12-14 11:22:13 +01:00
Eragon 46106d69c9
Editor: Add border to preview 2022-12-14 11:22:12 +01:00
Eragon 0d00b4dfb6
Editor: Add inline code and quotes 2022-12-14 11:22:12 +01:00
Eragon bcbab7033d
editor: Add preview 2022-12-14 11:22:12 +01:00
Lephe 07bd7075d6
editor: add placeholder help link 2022-12-14 11:22:12 +01:00
Lephe d6a3faa161
markdown: make header slugs unique in every thread 2022-12-14 11:22:12 +01:00
Lephe 786f940f21
editor: adjust button spacing for small screens 2022-12-14 11:22:12 +01:00
Lephe 4322790ca1
editor: implement heading insertion and level changes 2022-12-14 11:22:12 +01:00
Lephe 896e799b97
editor: smaller icons 2022-12-14 11:22:12 +01:00
Lephe e54b01efe0
editor: basic markup insertion around selection 2022-12-14 11:22:11 +01:00
Lephe 15ce72b72d
editor: fix variable shadowing in let causing use before declaration 2022-12-14 11:22:11 +01:00
Lephe 02520f6b2d
editor: improve layout and style of buttons 2022-12-14 11:22:11 +01:00
Eragon 8bec7120c5
editor: Set textarea minimum height 2022-12-14 11:22:11 +01:00
Eragon 0a0ad4d558
editor: Add input type button to fix HTML problems 2022-12-14 11:22:11 +01:00
Eragon 490ab2714c
Start to add editor script 2022-12-14 11:22:09 +01:00
Eragon a01b74f3e8
Change themes for better contrast on buttons 2022-12-14 11:21:17 +01:00
Eragon 5bb581f4f3
Delete scripts 2022-12-14 11:21:17 +01:00
Eragon 5fb06732ff
Remove old & add buttons for new editor 2022-12-14 11:21:14 +01:00
Eragon 277ec535e7
templates: Add slash at end of hardcoded urls 2022-11-15 16:19:32 +01:00
Eragon d0126e7aba
meta: Update REQUIREMENTS.md according to package updates in AUR 2022-11-15 16:10:53 +01:00
Lephe 2b9ab64f6e
routes: fix constant 404s due to new werkzeug handling of / 2022-11-15 11:03:36 +01:00
Lephe 760c2f20b2
programs: a reasonable start for the program page (#93) 2022-06-16 17:37:39 +01:00
Lephe 417fc05d29
program: add metadata and a basic model for events (#114)
This commit adds most of the optional metadata for programs. The event
related to the program's publication is an actual relationship to an
Event model. The idea is to expand on that model in the future to
include:

- A link to the event's main topic
- List of programs published in the event
- Possibly, a list of all related topics (announcement, start, results,
  etc) all sharing a common 1-line header so they are linked together
- This would be used for event-related trophies
- And possibly for an event calendar
2022-06-16 17:00:59 +01:00
Lephe 8ff21c615d
program: add infrastructure for the progrank job (#114)
* Add an automatic job every day at 4 AM to recompute the progrank of
  every program. Currently everyone gets progrank 0.

[MIGRATION] This commit contains a new version of the schema.

[SETUP]
* Install flask-crontab (with pip)
* Run `flask crontab add` to register the jobs
2022-06-15 11:27:29 +01:00
Lephe db0e42d285
programs: add tag input and display (#114)
* Add a TagListField which automatically validates its input against the
  TagInformation database, and has a richer .selected_tags() method
* Add a dynamic tag input widget, available through a macro (*import
  with context*), that supports both JS and non-JS input
* Add a TagInformation.all_tags() function
* Add colored tag display to all themes
* Fix a bug causing programs to have no names
* Add tags: games.action, games.narrative, courses.informatics

[MASTER] Run the 'update-tags' command of master.py.
2022-06-14 23:19:41 +01:00
Lephe c74abf3fcc
post: add a tagging system, with a common base set of tags
Adds the tagging system, with 3 types of tags:
* Calculator models grouped by compatibility classes
* Programming languages
* Game, tools, and course categories

[MIGRATION] This commit contains a new version of the schema.
[BREAKS] This commit breaks existing tag assignments.
[MASTER] Run the 'update-tags' command of master.py.
2022-06-12 18:26:47 +01:00
Lephe f4b9110ce2
master: fix group update 2022-05-26 23:01:02 +01:00
Lephe 85323e896d
forum: fix edit timestamp recording + display
On the preproduction server there are messages that have an edit
timestamp some 20 ns after their creation, for some reason.
2022-05-26 21:24:50 +01:00
Lephe c26861527b
admin: remove trophy edition interface (#82)
It was decided to keep using the master script to update them.
2022-05-26 20:16:29 +01:00
Lephe 6756838882
forum: factor attachment creation code 2022-05-26 20:08:16 +01:00
Lephe 84066eaca3
css: fix antibot field being visible 2022-05-26 20:07:50 +01:00
Lephe b047ed97af
programs: program creation + view + comments
This is very much a work in progress, but the main ideas are here.

[MIGRATION] This commit contains a new version of the schema.
2022-05-19 20:34:46 +01:00
Lephe 0e1b434f7d
program: fix tag assignment 2022-05-19 19:12:09 +01:00
Lephe 011ea3d2a6
programs: add the model for tags
[MIGRATION] This commit contains a new version of the schema.
2022-05-19 18:51:05 +01:00
Lephe 13ce27b682
perf: avoid N+1 query in recent topics and news 2022-05-12 21:53:26 +01:00
Lephe 8393cf1933
perf: eagerly load auxiliary data 2022-05-12 20:45:31 +01:00
Darks b7dc2ebbf2
hook: add reminder to build css before commiting when needed 2022-05-12 21:39:10 +02:00
Lephe 38c4f274a0
perf: optimize away more privilege requests
[MIGRATION] This commit contains a new version of the schema.
2022-05-12 20:00:24 +01:00
Lephe 7d9e897ae9
perf: optimize away special privilege requests by lazy loading 2022-05-12 19:24:17 +01:00
Lephe 1040d57506
css: fully recompile LESS files 2022-05-12 19:24:17 +01:00
Darks f64e3a2c39
debugger: add some style to enhance it 2022-05-12 20:07:28 +02:00
Lephe 8f620c6150
meta: add optional setting for flask-debug-toolbar
It provides profiling information and an overview of SQL requests while
in development.
2022-05-05 20:33:46 +01:00
Lephe 5a87d29c7f
account: make default avatar selection less hacky 2022-05-05 20:33:45 +01:00
Lephe a3ed633791
config: update and slight improvements
* Rename Config → FlaskApplicationSettings so we know exactly what we're
  talking about
* Clarify that LocalConfig overrides both V5Config and Flask settings
* Only give defaults that are needed in LocalConfig and remove old
  settings that are no longer used
2022-05-05 20:33:45 +01:00
Darks 9de0f9f823
Merge branch 'preprod' into 'master' 2022-04-26 23:49:11 +02:00
Darks eb5ce1bd5c
attachement: switch to uuid + check permission in dl widget (#109)
Also added is_default_accessible() to Thread class as its owner may be a 
Topic with forum access restrictions or public main content (like 
Program)

[MIGRATION] This commit contains a new version of the schema. /!\ This 
migration breaks all attachments
2022-04-26 23:29:11 +02:00
Darks faf5bd184d
navbar: properly generate links to recent topics 2022-04-26 20:40:56 +02:00
Lephe 262c5f22c8
navbar: fix links to news forums under "Actualités" 2022-04-26 15:15:30 +01:00
Darks 3e399fb4c4
gallery: second prototype, evolving into a beta 2022-04-26 01:38:33 +02:00
Darks 7b66e1ec20
gallery: rebase from dev 2022-04-26 00:29:07 +02:00
Darks fe8e2f0265
gallery: second prototype
Don't look for the first one, it's trapped in a parallel universe.
2022-04-26 00:27:43 +02:00
Lephe db5e613f7e
model: use methods to access a user's typed posts (#104) 2022-04-25 17:05:17 +01:00
Lephe 8098642d4b
model: add an index on Post.type
This is useful to quickly browse a list of polymorphic Posts for topics,
programs, etc. The main application is from Member.posts, since
polymorphic collection seems both difficult and edgy.

[MIGRATION] This commit contains a new version of the schema.
2022-04-25 17:04:08 +01:00
Darks 8eee0ad236
pclinks: fixed a typo 2022-04-24 23:33:18 +02:00
Darks 13b1d29e42
theme: add support for download widget to FK's dark theme 2022-04-24 22:55:11 +02:00
Darks 17f5e82a2a
pclinks: switched to <> as delimiters (#108)
And some other enhancements
2022-04-24 17:50:46 +02:00
Darks 2119329997
widgets: add '[[f: 123]]' pclink widget 2022-04-24 17:24:47 +02:00
Darks 09c7f63b55
menu.js: fixed bad design 2022-04-24 14:06:44 +02:00
Lephe f53032fc88
markdown: add an extension for image/video galleries
This will be used on program pages. Currently there is no check that
list elements are images and videos.
2022-04-21 22:07:49 +01:00
Lephe 610fe6f1fd
markdown: allow videos with size and positioning
Same options as for images, except for [pixelated]. Supported sources
are standard videos and YouTube, and there is basic auto-detection which
avoids the need to set the [video] attribute.
2022-04-21 20:43:50 +01:00
Lephe 48d6c1c03c
markdown: allow positioning attributes on images
New attributes
  * left, center, right: Exactly what you expect
  * float-left, float-right: Also just what you expect

Currently there is no way to force a clear.
2022-04-21 20:03:22 +01:00
Lephe e9c1f04f42
markdown: add a MediaExtension that allows attributes on images
Supported attributes:
  * size=<WIDTH>x<HEIGHT>, both being optional
  * pixelated

In the near future it will also support audio files and videos.
2022-04-21 19:31:18 +01:00
Lephe 39748667ee
model: add a number of missing indexes
See #107.

[MIGRATION] This commit contains a new version of the schema.
2022-04-21 17:58:59 +01:00
Lephe 19d90c6845
meta: actually useful diagram 2022-04-14 21:07:21 +01:00
Lephe df07c905ab
meta: update model diagram 2022-04-14 20:12:04 +01:00
Darks dda7cce5d5
Updated WTForms imports 2022-04-14 20:01:45 +02:00
Eragon 2b2a5cc0d1
Small typo fix 2021-12-16 10:24:06 +01:00
Lephe d964641e1f
forum: fix antibot field being visible (#51) 2021-10-03 17:34:30 +02:00
Lephe 9c78aca5ad
(minor improvement on threads' div.info) 2021-10-03 17:34:30 +02:00
Lephe 19586f9087
fix incorrect date display omitting years 2021-10-03 17:34:30 +02:00
Eragon 875524ccb6
#98 New readme with pictures and better description 2021-10-02 11:57:20 +02:00
Eldeberen 41eaaa4c30
Merge branch 'preprod' on master 2021-02-23 00:15:29 +01:00
Darks ad1042865b
Merge branch 'master' of gitea.planet-casio.com:devs/PCv5 2020-07-23 20:25:41 +02:00
Darks 2dd7863e89
Rebase master from preprod 2020-07-23 20:25:00 +02:00
Darks e15005a427
Ajout des stats sur la durée de chargement 2019-08-20 18:04:10 +02:00
178 changed files with 3787 additions and 1065 deletions

9
.gitignore vendored
View File

@ -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

View File

@ -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 daccueil, un peu vide pour le moment.
![La page daccueil, 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).

View File

@ -23,4 +23,10 @@ python-psycopg2
python-pillow
python-pyyaml
python-slugify
flask-crontab
```
Optionnel:
```
python-flask-debugtoolbar (Disponible dans l'AUR)
```

View File

@ -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)

View File

@ -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
-

108
app/data/tags.yaml Normal file
View File

@ -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"

View File

@ -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

View File

@ -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

15
app/forms/programs.py Normal file
View File

@ -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')

View File

@ -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

1
app/jobs/__init__.py Normal file
View File

@ -0,0 +1 @@
from app.jobs.update_progrank import update_progrank

View File

@ -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()

View File

@ -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

View File

@ -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):

View File

@ -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}>'

11
app/models/event.py Normal file
View File

@ -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'))

View File

@ -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)

View File

@ -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)

View File

@ -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.

View File

@ -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

View File

@ -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}">'

56
app/models/tag.py Normal file
View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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))

View File

@ -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")

View File

@ -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)

View File

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

View File

@ -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:

View File

@ -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 sest inscrit ! Il sagit de {BOLD}{m.name}{BOLD}.")
say(url_for('user', username=m.name, _external=True))
return redirect(url_for('login'))

View File

@ -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'))

View File

@ -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)

View File

@ -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')

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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']

16
app/routes/chat.py Normal file
View File

@ -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')

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -0,0 +1,3 @@
#flDebug * {
overflow: auto !important;
}

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

@ -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;
}
}

View File

@ -8,7 +8,7 @@
.form form label + .desc {
margin: 0 0 4px 0;
font-size: 80%;
opacity: .75;
opacity: .65;
}
.form form .avatar {
width: 128px;
@ -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;
}
}

View File

@ -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%);

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -0,0 +1 @@
../../../submodules/v5shoutbox/style.css

View File

@ -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;
}

View File

@ -0,0 +1,4 @@
/* Some styles to enhance debugger */
#flDebug * {
overflow: auto !important;
}

105
app/static/less/editor.less Normal file
View File

@ -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;
}
}
}

View File

@ -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 {

View File

@ -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%);

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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();
});

View File

@ -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');

View File

@ -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;
}

View File

@ -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";
}
});
});

View File

@ -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

View File

@ -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', '#');
}
}
}

View File

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

View File

@ -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);

View File

@ -0,0 +1 @@
../../../submodules/v5shoutbox/v5shoutbox.js

View File

@ -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 %}

View File

@ -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>

View File

@ -1,5 +1,7 @@
{% extends "base/base.html" %}
{% set tabtitle = "Suppression du compte" %}
{% block content %}
<section class="form">
<h1>Suppression du compte</h2>

View File

@ -1,5 +1,7 @@
{% extends "base/base.html" %}
{% set tabtitle = "Connexion" %}
{% block content %}
<section class="form" style="width:40%;">
<h1>Connexion</h1>

View File

@ -1,5 +1,7 @@
{% extends "base/base.html" %}
{% set tabtitle = "Notifications" %}
{% block title %}
<h1>Notifications</h1>
{% endblock %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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>

View File

@ -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>

View File

@ -1,5 +1,7 @@
{% extends "base/base.html" %}
{% set tabtitle = "Inscription réussie" %}
{% block content %}
<section>
<div>

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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>

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -1,5 +1,7 @@
{% extends "base/base.html" %}
{% set tabtitle = "Panneau dadministration" %}
{% 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>

View File

@ -1,5 +1,7 @@
{% extends "base/base.html" %}
{% set tabtitle = "Administration - Vandaliser un compte" %}
{% block title %}
<h1>Vandaliser un compte</h1>
{% endblock %}

View File

@ -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 %}

View File

@ -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