Compare commits

...

52 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
Darks b9becbf21f
moderation: fixed moving a post to another topic 2023-06-06 19:52:46 +02:00
Darks 9de0f9f823
Merge branch 'preprod' into 'master' 2022-04-26 23:49:11 +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
103 changed files with 685 additions and 6795 deletions

7
.gitignore vendored
View File

@ -22,6 +22,9 @@ test.*
# Autosaves
*.dia~
## Logging files
*.log
## Deployment files
@ -39,6 +42,10 @@ local_config.py
wiki/
## JavaScript submodules buld files
# Emoji picker
app/static/scripts/emoji-picker-element/
## Personal folder

View File

@ -9,6 +9,7 @@ from config import FlaskApplicationSettings, V5Config
app = Flask(__name__)
app.config.from_object(FlaskApplicationSettings)
app.v5logger = V5Config.v5logger()
# Check security of secret
if FlaskApplicationSettings.SECRET_KEY == "a-random-secret-key":

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
-

View File

@ -28,6 +28,12 @@ class Comment(Post):
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.

View File

@ -29,6 +29,12 @@ class Post(db.Model):
'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

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

View File

@ -212,7 +212,7 @@ class Member(User):
def priv(self, priv):
"""Check whether the member has the specified privilege."""
if priv in self.special_privs:
if priv in self.special_privileges():
return True
for g in self.groups:
if priv in g.privs():
@ -220,8 +220,8 @@ class Member(User):
return False
def special_privileges(self):
"""List member's special privileges."""
return sorted(self.special_privs)
"""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."""
@ -263,6 +263,13 @@ 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)
@ -390,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()

View File

@ -1,6 +1,6 @@
# 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, forums, \
attachments, config, members, polls, login_as

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

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

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

@ -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))):
@ -54,6 +55,11 @@ 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=str(c.id)))

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,7 +25,7 @@ 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
if not current_user.can_edit_post(p):
@ -83,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)))
@ -104,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()
@ -125,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)
@ -142,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)
@ -155,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)
@ -166,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
@ -188,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,6 +4,7 @@ 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
@ -19,7 +20,7 @@ def program_view(page):
else:
form = AnonymousCommentForm()
if form.validate_on_submit() and (
if form.validate_on_submit() and not p.thread.locked and (
V5Config.ENABLE_GUEST_POST or current_user.is_authenticated):
# Manage author
@ -41,6 +42,10 @@ def program_view(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}")
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)))

View File

@ -5,6 +5,7 @@ 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
@ -54,6 +55,10 @@ def program_submit():
current_user.update_trophies('new-program')
flash('Le programme a bien été soumis', 'ok')
return redirect(url_for('program_index'))
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

@ -163,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 @@
../../../submodules/v5shoutbox/style.css

View File

@ -218,4 +218,8 @@ hr.signature {
border-radius: calc(4.5em);
user-select: none;
cursor: default;
}
.locked {
text-align: center;
font-style: italic;
}

View File

@ -161,6 +161,7 @@ button, .button, input[type="button"], input[type="submit"] {
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

@ -253,3 +253,9 @@ hr.signature {
user-select: none;
cursor: default;
}
/* Thread locked state */
.locked {
text-align: center;
font-style: italic;
}

File diff suppressed because one or more lines are too long

View File

@ -1,972 +0,0 @@
function assertNonEmptyString (str) {
if (typeof str !== 'string' || !str) {
throw new Error('expected a non-empty string, got: ' + str)
}
}
function assertNumber (number) {
if (typeof number !== 'number') {
throw new Error('expected a number, got: ' + number)
}
}
const DB_VERSION_CURRENT = 1;
const DB_VERSION_INITIAL = 1;
const STORE_EMOJI = 'emoji';
const STORE_KEYVALUE = 'keyvalue';
const STORE_FAVORITES = 'favorites';
const FIELD_TOKENS = 'tokens';
const INDEX_TOKENS = 'tokens';
const FIELD_UNICODE = 'unicode';
const INDEX_COUNT = 'count';
const FIELD_GROUP = 'group';
const FIELD_ORDER = 'order';
const INDEX_GROUP_AND_ORDER = 'group-order';
const KEY_ETAG = 'eTag';
const KEY_URL = 'url';
const KEY_PREFERRED_SKINTONE = 'skinTone';
const MODE_READONLY = 'readonly';
const MODE_READWRITE = 'readwrite';
const INDEX_SKIN_UNICODE = 'skinUnicodes';
const FIELD_SKIN_UNICODE = 'skinUnicodes';
const DEFAULT_DATA_SOURCE = 'https://cdn.jsdelivr.net/npm/emoji-picker-element-data@^1/en/emojibase/data.json';
const DEFAULT_LOCALE = 'en';
// like lodash's uniqBy but much smaller
function uniqBy (arr, func) {
const set = new Set();
const res = [];
for (const item of arr) {
const key = func(item);
if (!set.has(key)) {
set.add(key);
res.push(item);
}
}
return res
}
function uniqEmoji (emojis) {
return uniqBy(emojis, _ => _.unicode)
}
function initialMigration (db) {
function createObjectStore (name, keyPath, indexes) {
const store = keyPath
? db.createObjectStore(name, { keyPath })
: db.createObjectStore(name);
if (indexes) {
for (const [indexName, [keyPath, multiEntry]] of Object.entries(indexes)) {
store.createIndex(indexName, keyPath, { multiEntry });
}
}
return store
}
createObjectStore(STORE_KEYVALUE);
createObjectStore(STORE_EMOJI, /* keyPath */ FIELD_UNICODE, {
[INDEX_TOKENS]: [FIELD_TOKENS, /* multiEntry */ true],
[INDEX_GROUP_AND_ORDER]: [[FIELD_GROUP, FIELD_ORDER]],
[INDEX_SKIN_UNICODE]: [FIELD_SKIN_UNICODE, /* multiEntry */ true]
});
createObjectStore(STORE_FAVORITES, undefined, {
[INDEX_COUNT]: ['']
});
}
const openIndexedDBRequests = {};
const databaseCache = {};
const onCloseListeners = {};
function handleOpenOrDeleteReq (resolve, reject, req) {
// These things are almost impossible to test with fakeIndexedDB sadly
/* istanbul ignore next */
req.onerror = () => reject(req.error);
/* istanbul ignore next */
req.onblocked = () => reject(new Error('IDB blocked'));
req.onsuccess = () => resolve(req.result);
}
async function createDatabase (dbName) {
const db = await new Promise((resolve, reject) => {
const req = indexedDB.open(dbName, DB_VERSION_CURRENT);
openIndexedDBRequests[dbName] = req;
req.onupgradeneeded = e => {
// Technically there is only one version, so we don't need this `if` check
// But if an old version of the JS is in another browser tab
// and it gets upgraded in the future and we have a new DB version, well...
// better safe than sorry.
/* istanbul ignore else */
if (e.oldVersion < DB_VERSION_INITIAL) {
initialMigration(req.result);
}
};
handleOpenOrDeleteReq(resolve, reject, req);
});
// Handle abnormal closes, e.g. "delete database" in chrome dev tools.
// No need for removeEventListener, because once the DB can no longer
// fire "close" events, it will auto-GC.
// Unfortunately cannot test in fakeIndexedDB: https://github.com/dumbmatter/fakeIndexedDB/issues/50
/* istanbul ignore next */
db.onclose = () => closeDatabase(dbName);
return db
}
function openDatabase (dbName) {
if (!databaseCache[dbName]) {
databaseCache[dbName] = createDatabase(dbName);
}
return databaseCache[dbName]
}
function dbPromise (db, storeName, readOnlyOrReadWrite, cb) {
return new Promise((resolve, reject) => {
// Use relaxed durability because neither the emoji data nor the favorites/preferred skin tone
// are really irreplaceable data. IndexedDB is just a cache in this case.
const txn = db.transaction(storeName, readOnlyOrReadWrite, { durability: 'relaxed' });
const store = typeof storeName === 'string'
? txn.objectStore(storeName)
: storeName.map(name => txn.objectStore(name));
let res;
cb(store, txn, (result) => {
res = result;
});
txn.oncomplete = () => resolve(res);
/* istanbul ignore next */
txn.onerror = () => reject(txn.error);
})
}
function closeDatabase (dbName) {
// close any open requests
const req = openIndexedDBRequests[dbName];
const db = req && req.result;
if (db) {
db.close();
const listeners = onCloseListeners[dbName];
/* istanbul ignore else */
if (listeners) {
for (const listener of listeners) {
listener();
}
}
}
delete openIndexedDBRequests[dbName];
delete databaseCache[dbName];
delete onCloseListeners[dbName];
}
function deleteDatabase (dbName) {
return new Promise((resolve, reject) => {
// close any open requests
closeDatabase(dbName);
const req = indexedDB.deleteDatabase(dbName);
handleOpenOrDeleteReq(resolve, reject, req);
})
}
// The "close" event occurs during an abnormal shutdown, e.g. a user clearing their browser data.
// However, it doesn't occur with the normal "close" event, so we handle that separately.
// https://www.w3.org/TR/IndexedDB/#close-a-database-connection
function addOnCloseListener (dbName, listener) {
let listeners = onCloseListeners[dbName];
if (!listeners) {
listeners = onCloseListeners[dbName] = [];
}
listeners.push(listener);
}
// list of emoticons that don't match a simple \W+ regex
// extracted using:
// require('emoji-picker-element-data/en/emojibase/data.json').map(_ => _.emoticon).filter(Boolean).filter(_ => !/^\W+$/.test(_))
const irregularEmoticons = new Set([
':D', 'XD', ":'D", 'O:)',
':X', ':P', ';P', 'XP',
':L', ':Z', ':j', '8D',
'XO', '8)', ':B', ':O',
':S', ":'o", 'Dx', 'X(',
'D:', ':C', '>0)', ':3',
'</3', '<3', '\\M/', ':E',
'8#'
]);
function extractTokens (str) {
return str
.split(/[\s_]+/)
.map(word => {
if (!word.match(/\w/) || irregularEmoticons.has(word)) {
// for pure emoticons like :) or :-), just leave them as-is
return word.toLowerCase()
}
return word
.replace(/[)(:,]/g, '')
.replace(//g, "'")
.toLowerCase()
}).filter(Boolean)
}
const MIN_SEARCH_TEXT_LENGTH = 2;
// This is an extra step in addition to extractTokens(). The difference here is that we expect
// the input to have already been run through extractTokens(). This is useful for cases like
// emoticons, where we don't want to do any tokenization (because it makes no sense to split up
// ">:)" by the colon) but we do want to lowercase it to have consistent search results, so that
// the user can type ':P' or ':p' and still get the same result.
function normalizeTokens (str) {
return str
.filter(Boolean)
.map(_ => _.toLowerCase())
.filter(_ => _.length >= MIN_SEARCH_TEXT_LENGTH)
}
// Transform emoji data for storage in IDB
function transformEmojiData (emojiData) {
const res = emojiData.map(({ annotation, emoticon, group, order, shortcodes, skins, tags, emoji, version }) => {
const tokens = [...new Set(
normalizeTokens([
...(shortcodes || []).map(extractTokens).flat(),
...tags.map(extractTokens).flat(),
...extractTokens(annotation),
emoticon
])
)].sort();
const res = {
annotation,
group,
order,
tags,
tokens,
unicode: emoji,
version
};
if (emoticon) {
res.emoticon = emoticon;
}
if (shortcodes) {
res.shortcodes = shortcodes;
}
if (skins) {
res.skinTones = [];
res.skinUnicodes = [];
res.skinVersions = [];
for (const { tone, emoji, version } of skins) {
res.skinTones.push(tone);
res.skinUnicodes.push(emoji);
res.skinVersions.push(version);
}
}
return res
});
return res
}
// helper functions that help compress the code better
function callStore (store, method, key, cb) {
store[method](key).onsuccess = e => (cb && cb(e.target.result));
}
function getIDB (store, key, cb) {
callStore(store, 'get', key, cb);
}
function getAllIDB (store, key, cb) {
callStore(store, 'getAll', key, cb);
}
function commit (txn) {
/* istanbul ignore else */
if (txn.commit) {
txn.commit();
}
}
// like lodash's minBy
function minBy (array, func) {
let minItem = array[0];
for (let i = 1; i < array.length; i++) {
const item = array[i];
if (func(minItem) > func(item)) {
minItem = item;
}
}
return minItem
}
// return an array of results representing all items that are found in each one of the arrays
function findCommonMembers (arrays, uniqByFunc) {
const shortestArray = minBy(arrays, _ => _.length);
const results = [];
for (const item of shortestArray) {
// if this item is included in every array in the intermediate results, add it to the final results
if (!arrays.some(array => array.findIndex(_ => uniqByFunc(_) === uniqByFunc(item)) === -1)) {
results.push(item);
}
}
return results
}
async function isEmpty (db) {
return !(await get(db, STORE_KEYVALUE, KEY_URL))
}
async function hasData (db, url, eTag) {
const [oldETag, oldUrl] = await Promise.all([KEY_ETAG, KEY_URL]
.map(key => get(db, STORE_KEYVALUE, key)));
return (oldETag === eTag && oldUrl === url)
}
async function doFullDatabaseScanForSingleResult (db, predicate) {
// This batching algorithm is just a perf improvement over a basic
// cursor. The BATCH_SIZE is an estimate of what would give the best
// perf for doing a full DB scan (worst case).
//
// Mini-benchmark for determining the best batch size:
//
// PERF=1 yarn build:rollup && yarn test:adhoc
//
// (async () => {
// performance.mark('start')
// await $('emoji-picker').database.getEmojiByShortcode('doesnotexist')
// performance.measure('total', 'start')
// console.log(performance.getEntriesByName('total').slice(-1)[0].duration)
// })()
const BATCH_SIZE = 50; // Typically around 150ms for 6x slowdown in Chrome for above benchmark
return dbPromise(db, STORE_EMOJI, MODE_READONLY, (emojiStore, txn, cb) => {
let lastKey;
const processNextBatch = () => {
emojiStore.getAll(lastKey && IDBKeyRange.lowerBound(lastKey, true), BATCH_SIZE).onsuccess = e => {
const results = e.target.result;
for (const result of results) {
lastKey = result.unicode;
if (predicate(result)) {
return cb(result)
}
}
if (results.length < BATCH_SIZE) {
return cb()
}
processNextBatch();
};
};
processNextBatch();
})
}
async function loadData (db, emojiData, url, eTag) {
try {
const transformedData = transformEmojiData(emojiData);
await dbPromise(db, [STORE_EMOJI, STORE_KEYVALUE], MODE_READWRITE, ([emojiStore, metaStore], txn) => {
let oldETag;
let oldUrl;
let todo = 0;
function checkFetched () {
if (++todo === 2) { // 2 requests made
onFetched();
}
}
function onFetched () {
if (oldETag === eTag && oldUrl === url) {
// check again within the transaction to guard against concurrency, e.g. multiple browser tabs
return
}
// delete old data
emojiStore.clear();
// insert new data
for (const data of transformedData) {
emojiStore.put(data);
}
metaStore.put(eTag, KEY_ETAG);
metaStore.put(url, KEY_URL);
commit(txn);
}
getIDB(metaStore, KEY_ETAG, result => {
oldETag = result;
checkFetched();
});
getIDB(metaStore, KEY_URL, result => {
oldUrl = result;
checkFetched();
});
});
} finally {
}
}
async function getEmojiByGroup (db, group) {
return dbPromise(db, STORE_EMOJI, MODE_READONLY, (emojiStore, txn, cb) => {
const range = IDBKeyRange.bound([group, 0], [group + 1, 0], false, true);
getAllIDB(emojiStore.index(INDEX_GROUP_AND_ORDER), range, cb);
})
}
async function getEmojiBySearchQuery (db, query) {
const tokens = normalizeTokens(extractTokens(query));
if (!tokens.length) {
return []
}
return dbPromise(db, STORE_EMOJI, MODE_READONLY, (emojiStore, txn, cb) => {
// get all results that contain all tokens (i.e. an AND query)
const intermediateResults = [];
const checkDone = () => {
if (intermediateResults.length === tokens.length) {
onDone();
}
};
const onDone = () => {
const results = findCommonMembers(intermediateResults, _ => _.unicode);
cb(results.sort((a, b) => a.order < b.order ? -1 : 1));
};
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
const range = i === tokens.length - 1
? IDBKeyRange.bound(token, token + '\uffff', false, true) // treat last token as a prefix search
: IDBKeyRange.only(token); // treat all other tokens as an exact match
getAllIDB(emojiStore.index(INDEX_TOKENS), range, result => {
intermediateResults.push(result);
checkDone();
});
}
})
}
// This could have been implemented as an IDB index on shortcodes, but it seemed wasteful to do that
// when we can already query by tokens and this will give us what we're looking for 99.9% of the time
async function getEmojiByShortcode (db, shortcode) {
const emojis = await getEmojiBySearchQuery(db, shortcode);
// In very rare cases (e.g. the shortcode "v" as in "v for victory"), we cannot search because
// there are no usable tokens (too short in this case). In that case, we have to do an inefficient
// full-database scan, which I believe is an acceptable tradeoff for not having to have an extra
// index on shortcodes.
if (!emojis.length) {
const predicate = _ => ((_.shortcodes || []).includes(shortcode.toLowerCase()));
return (await doFullDatabaseScanForSingleResult(db, predicate)) || null
}
return emojis.filter(_ => {
const lowerShortcodes = (_.shortcodes || []).map(_ => _.toLowerCase());
return lowerShortcodes.includes(shortcode.toLowerCase())
})[0] || null
}
async function getEmojiByUnicode (db, unicode) {
return dbPromise(db, STORE_EMOJI, MODE_READONLY, (emojiStore, txn, cb) => (
getIDB(emojiStore, unicode, result => {
if (result) {
return cb(result)
}
getIDB(emojiStore.index(INDEX_SKIN_UNICODE), unicode, result => cb(result || null));
})
))
}
function get (db, storeName, key) {
return dbPromise(db, storeName, MODE_READONLY, (store, txn, cb) => (
getIDB(store, key, cb)
))
}
function set (db, storeName, key, value) {
return dbPromise(db, storeName, MODE_READWRITE, (store, txn) => {
store.put(value, key);
commit(txn);
})
}
function incrementFavoriteEmojiCount (db, unicode) {
return dbPromise(db, STORE_FAVORITES, MODE_READWRITE, (store, txn) => (
getIDB(store, unicode, result => {
store.put((result || 0) + 1, unicode);
commit(txn);
})
))
}
function getTopFavoriteEmoji (db, customEmojiIndex, limit) {
if (limit === 0) {
return []
}
return dbPromise(db, [STORE_FAVORITES, STORE_EMOJI], MODE_READONLY, ([favoritesStore, emojiStore], txn, cb) => {
const results = [];
favoritesStore.index(INDEX_COUNT).openCursor(undefined, 'prev').onsuccess = e => {
const cursor = e.target.result;
if (!cursor) { // no more results
return cb(results)
}
function addResult (result) {
results.push(result);
if (results.length === limit) {
return cb(results) // done, reached the limit
}
cursor.continue();
}
const unicodeOrName = cursor.primaryKey;
const custom = customEmojiIndex.byName(unicodeOrName);
if (custom) {
return addResult(custom)
}
// This could be done in parallel (i.e. make the cursor and the get()s parallelized),
// but my testing suggests it's not actually faster.
getIDB(emojiStore, unicodeOrName, emoji => {
if (emoji) {
return addResult(emoji)
}
// emoji not found somehow, ignore (may happen if custom emoji change)
cursor.continue();
});
};
})
}
// trie data structure for prefix searches
// loosely based on https://github.com/nolanlawson/substring-trie
const CODA_MARKER = ''; // marks the end of the string
function trie (arr, itemToTokens) {
const map = new Map();
for (const item of arr) {
const tokens = itemToTokens(item);
for (const token of tokens) {
let currentMap = map;
for (let i = 0; i < token.length; i++) {
const char = token.charAt(i);
let nextMap = currentMap.get(char);
if (!nextMap) {
nextMap = new Map();
currentMap.set(char, nextMap);
}
currentMap = nextMap;
}
let valuesAtCoda = currentMap.get(CODA_MARKER);
if (!valuesAtCoda) {
valuesAtCoda = [];
currentMap.set(CODA_MARKER, valuesAtCoda);
}
valuesAtCoda.push(item);
}
}
const search = (query, exact) => {
let currentMap = map;
for (let i = 0; i < query.length; i++) {
const char = query.charAt(i);
const nextMap = currentMap.get(char);
if (nextMap) {
currentMap = nextMap;
} else {
return []
}
}
if (exact) {
const results = currentMap.get(CODA_MARKER);
return results || []
}
const results = [];
// traverse
const queue = [currentMap];
while (queue.length) {
const currentMap = queue.shift();
const entriesSortedByKey = [...currentMap.entries()].sort((a, b) => a[0] < b[0] ? -1 : 1);
for (const [key, value] of entriesSortedByKey) {
if (key === CODA_MARKER) { // CODA_MARKER always comes first; it's the empty string
results.push(...value);
} else {
queue.push(value);
}
}
}
return results
};
return search
}
const requiredKeys$1 = [
'name',
'url'
];
function assertCustomEmojis (customEmojis) {
const isArray = customEmojis && Array.isArray(customEmojis);
const firstItemIsFaulty = isArray &&
customEmojis.length &&
(!customEmojis[0] || requiredKeys$1.some(key => !(key in customEmojis[0])));
if (!isArray || firstItemIsFaulty) {
throw new Error('Custom emojis are in the wrong format')
}
}
function customEmojiIndex (customEmojis) {
assertCustomEmojis(customEmojis);
const sortByName = (a, b) => a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1;
//
// all()
//
const all = customEmojis.sort(sortByName);
//
// search()
//
const emojiToTokens = emoji => (
[...new Set((emoji.shortcodes || []).map(shortcode => extractTokens(shortcode)).flat())]
);
const searchTrie = trie(customEmojis, emojiToTokens);
const searchByExactMatch = _ => searchTrie(_, true);
const searchByPrefix = _ => searchTrie(_, false);
// Search by query for custom emoji. Similar to how we do this in IDB, the last token
// is treated as a prefix search, but every other one is treated as an exact match.
// Then we AND the results together
const search = query => {
const tokens = extractTokens(query);
const intermediateResults = tokens.map((token, i) => (
(i < tokens.length - 1 ? searchByExactMatch : searchByPrefix)(token)
));
return findCommonMembers(intermediateResults, _ => _.name).sort(sortByName)
};
//
// byShortcode, byName
//
const shortcodeToEmoji = new Map();
const nameToEmoji = new Map();
for (const customEmoji of customEmojis) {
nameToEmoji.set(customEmoji.name.toLowerCase(), customEmoji);
for (const shortcode of (customEmoji.shortcodes || [])) {
shortcodeToEmoji.set(shortcode.toLowerCase(), customEmoji);
}
}
const byShortcode = shortcode => shortcodeToEmoji.get(shortcode.toLowerCase());
const byName = name => nameToEmoji.get(name.toLowerCase());
return {
all,
search,
byShortcode,
byName
}
}
// remove some internal implementation details, i.e. the "tokens" array on the emoji object
// essentially, convert the emoji from the version stored in IDB to the version used in-memory
function cleanEmoji (emoji) {
if (!emoji) {
return emoji
}
delete emoji.tokens;
if (emoji.skinTones) {
const len = emoji.skinTones.length;
emoji.skins = Array(len);
for (let i = 0; i < len; i++) {
emoji.skins[i] = {
tone: emoji.skinTones[i],
unicode: emoji.skinUnicodes[i],
version: emoji.skinVersions[i]
};
}
delete emoji.skinTones;
delete emoji.skinUnicodes;
delete emoji.skinVersions;
}
return emoji
}
function warnETag (eTag) {
if (!eTag) {
console.warn('emoji-picker-element is more efficient if the dataSource server exposes an ETag header.');
}
}
const requiredKeys = [
'annotation',
'emoji',
'group',
'order',
'tags',
'version'
];
function assertEmojiData (emojiData) {
if (!emojiData ||
!Array.isArray(emojiData) ||
!emojiData[0] ||
(typeof emojiData[0] !== 'object') ||
requiredKeys.some(key => (!(key in emojiData[0])))) {
throw new Error('Emoji data is in the wrong format')
}
}
function assertStatus (response, dataSource) {
if (Math.floor(response.status / 100) !== 2) {
throw new Error('Failed to fetch: ' + dataSource + ': ' + response.status)
}
}
async function getETag (dataSource) {
const response = await fetch(dataSource, { method: 'HEAD' });
assertStatus(response, dataSource);
const eTag = response.headers.get('etag');
warnETag(eTag);
return eTag
}
async function getETagAndData (dataSource) {
const response = await fetch(dataSource);
assertStatus(response, dataSource);
const eTag = response.headers.get('etag');
warnETag(eTag);
const emojiData = await response.json();
assertEmojiData(emojiData);
return [eTag, emojiData]
}
// TODO: including these in blob-util.ts causes typedoc to generate docs for them,
/**
* Convert an `ArrayBuffer` to a binary string.
*
* Example:
*
* ```js
* var myString = blobUtil.arrayBufferToBinaryString(arrayBuff)
* ```
*
* @param buffer - array buffer
* @returns binary string
*/
function arrayBufferToBinaryString(buffer) {
var binary = '';
var bytes = new Uint8Array(buffer);
var length = bytes.byteLength;
var i = -1;
while (++i < length) {
binary += String.fromCharCode(bytes[i]);
}
return binary;
}
/**
* Convert a binary string to an `ArrayBuffer`.
*
* ```js
* var myBuffer = blobUtil.binaryStringToArrayBuffer(binaryString)
* ```
*
* @param binary - binary string
* @returns array buffer
*/
function binaryStringToArrayBuffer(binary) {
var length = binary.length;
var buf = new ArrayBuffer(length);
var arr = new Uint8Array(buf);
var i = -1;
while (++i < length) {
arr[i] = binary.charCodeAt(i);
}
return buf;
}
// generate a checksum based on the stringified JSON
async function jsonChecksum (object) {
const inString = JSON.stringify(object);
const inBuffer = binaryStringToArrayBuffer(inString);
// this does not need to be cryptographically secure, SHA-1 is fine
const outBuffer = await crypto.subtle.digest('SHA-1', inBuffer);
const outBinString = arrayBufferToBinaryString(outBuffer);
const res = btoa(outBinString);
return res
}
async function checkForUpdates (db, dataSource) {
// just do a simple HEAD request first to see if the eTags match
let emojiData;
let eTag = await getETag(dataSource);
if (!eTag) { // work around lack of ETag/Access-Control-Expose-Headers
const eTagAndData = await getETagAndData(dataSource);
eTag = eTagAndData[0];
emojiData = eTagAndData[1];
if (!eTag) {
eTag = await jsonChecksum(emojiData);
}
}
if (await hasData(db, dataSource, eTag)) ; else {
if (!emojiData) {
const eTagAndData = await getETagAndData(dataSource);
emojiData = eTagAndData[1];
}
await loadData(db, emojiData, dataSource, eTag);
}
}
async function loadDataForFirstTime (db, dataSource) {
let [eTag, emojiData] = await getETagAndData(dataSource);
if (!eTag) {
// Handle lack of support for ETag or Access-Control-Expose-Headers
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers#Browser_compatibility
eTag = await jsonChecksum(emojiData);
}
await loadData(db, emojiData, dataSource, eTag);
}
class Database {
constructor ({ dataSource = DEFAULT_DATA_SOURCE, locale = DEFAULT_LOCALE, customEmoji = [] } = {}) {
this.dataSource = dataSource;
this.locale = locale;
this._dbName = `emoji-picker-element-${this.locale}`;
this._db = undefined;
this._lazyUpdate = undefined;
this._custom = customEmojiIndex(customEmoji);
this._clear = this._clear.bind(this);
this._ready = this._init();
}
async _init () {
const db = this._db = await openDatabase(this._dbName);
addOnCloseListener(this._dbName, this._clear);
const dataSource = this.dataSource;
const empty = await isEmpty(db);
if (empty) {
await loadDataForFirstTime(db, dataSource);
} else { // offline-first - do an update asynchronously
this._lazyUpdate = checkForUpdates(db, dataSource);
}
}
async ready () {
const checkReady = async () => {
if (!this._ready) {
this._ready = this._init();
}
return this._ready
};
await checkReady();
// There's a possibility of a race condition where the element gets added, removed, and then added again
// with a particular timing, which would set the _db to undefined.
// We *could* do a while loop here, but that seems excessive and could lead to an infinite loop.
if (!this._db) {
await checkReady();
}
}
async getEmojiByGroup (group) {
assertNumber(group);
await this.ready();
return uniqEmoji(await getEmojiByGroup(this._db, group)).map(cleanEmoji)
}
async getEmojiBySearchQuery (query) {
assertNonEmptyString(query);
await this.ready();
const customs = this._custom.search(query);
const natives = uniqEmoji(await getEmojiBySearchQuery(this._db, query)).map(cleanEmoji);
return [
...customs,
...natives
]
}
async getEmojiByShortcode (shortcode) {
assertNonEmptyString(shortcode);
await this.ready();
const custom = this._custom.byShortcode(shortcode);
if (custom) {
return custom
}
return cleanEmoji(await getEmojiByShortcode(this._db, shortcode))
}
async getEmojiByUnicodeOrName (unicodeOrName) {
assertNonEmptyString(unicodeOrName);
await this.ready();
const custom = this._custom.byName(unicodeOrName);
if (custom) {
return custom
}
return cleanEmoji(await getEmojiByUnicode(this._db, unicodeOrName))
}
async getPreferredSkinTone () {
await this.ready();
return (await get(this._db, STORE_KEYVALUE, KEY_PREFERRED_SKINTONE)) || 0
}
async setPreferredSkinTone (skinTone) {
assertNumber(skinTone);
await this.ready();
return set(this._db, STORE_KEYVALUE, KEY_PREFERRED_SKINTONE, skinTone)
}
async incrementFavoriteEmojiCount (unicodeOrName) {
assertNonEmptyString(unicodeOrName);
await this.ready();
return incrementFavoriteEmojiCount(this._db, unicodeOrName)
}
async getTopFavoriteEmoji (limit) {
assertNumber(limit);
await this.ready();
return (await getTopFavoriteEmoji(this._db, this._custom, limit)).map(cleanEmoji)
}
set customEmoji (customEmojis) {
this._custom = customEmojiIndex(customEmojis);
}
get customEmoji () {
return this._custom.all
}
async _shutdown () {
await this.ready(); // reopen if we've already been closed/deleted
try {
await this._lazyUpdate; // allow any lazy updates to process before closing/deleting
} catch (err) { /* ignore network errors (offline-first) */ }
}
// clear references to IDB, e.g. during a close event
_clear () {
// We don't need to call removeEventListener or remove the manual "close" listeners.
// The memory leak tests prove this is unnecessary. It's because:
// 1) IDBDatabases that can no longer fire "close" automatically have listeners GCed
// 2) we clear the manual close listeners in databaseLifecycle.js.
this._db = this._ready = this._lazyUpdate = undefined;
}
async close () {
await this._shutdown();
await closeDatabase(this._dbName);
}
async delete () {
await this._shutdown();
await deleteDatabase(this._dbName);
}
}
export { Database as default };

View File

@ -1,34 +0,0 @@
export default {
categoriesLabel: 'الفئات',
emojiUnsupportedMessage: 'متصفحك لا يدعم رموز المشاعر الملونة.',
favoritesLabel: 'المفضلة',
loadingMessage: 'جارٍ التحميل…',
networkErrorMessage: 'تعذر تحميل رمز مشاعر.',
regionLabel: 'منتقي رموز المشاعر',
searchDescription: 'عندما تكون نتائج البحث متاحة، اضغط السهم لأعلى أو لأسفل للتحديد واضغط enter للاختيار.',
searchLabel: 'بحث',
searchResultsLabel: 'نتائج البحث',
skinToneDescription: 'عند توسيع النتائج، اضغط السهم لأعلى أو لأسفل للتحديد واضغط enter للاختيار.',
skinToneLabel: 'اختر درجة لون البشرة (حاليًا {skinTone})',
skinTonesLabel: 'درجات لون البشرة',
skinTones: [
'افتراضي',
'فاتح',
'فاتح متوسط',
'متوسط',
'داكن متوسط',
'داكن'
],
categories: {
custom: 'مخصص',
'smileys-emotion': 'الوجوه الضاحكة ورموز المشاعر',
'people-body': 'الأشخاص والجسد',
'animals-nature': 'الحيوانات والطبيعة',
'food-drink': 'الطعام والشراب',
'travel-places': 'السفر والأماكن',
activities: 'الأنشطة',
objects: 'الأشياء',
symbols: 'الرموز',
flags: 'الأعلام'
}
}

View File

@ -1,34 +0,0 @@
export default {
categoriesLabel: 'Kategorien',
emojiUnsupportedMessage: 'Dein Browser unterstützt keine farbigen Emojis.',
favoritesLabel: 'Favoriten',
loadingMessage: 'Wird geladen…',
networkErrorMessage: 'Konnte Emoji nicht laden.',
regionLabel: 'Emoji auswählen',
searchDescription: 'Wenn Suchergebnisse verfügbar sind, wähle sie mit Pfeil rauf und runter, dann Eingabetaste, aus.',
searchLabel: 'Suchen',
searchResultsLabel: 'Suchergebnisse',
skinToneDescription: 'Wenn angezeigt, nutze Pfeiltasten rauf und runter zum Auswählen, Eingabe zum Akzeptieren.',
skinToneLabel: 'Wähle einen Hautton (aktuell {skinTone})',
skinTonesLabel: 'Hauttöne',
skinTones: [
'Standard',
'Hell',
'Mittel-hell',
'Mittel',
'Mittel-dunkel',
'Dunkel'
],
categories: {
custom: 'Benutzerdefiniert',
'smileys-emotion': 'Smileys und Emoticons',
'people-body': 'Menschen und Körper',
'animals-nature': 'Tiere und Natur',
'food-drink': 'Essen und Trinken',
'travel-places': 'Reisen und Orte',
activities: 'Aktivitäten',
objects: 'Objekte',
symbols: 'Symbole',
flags: 'Flaggen'
}
}

View File

@ -1,34 +0,0 @@
export default {
categoriesLabel: 'Categories',
emojiUnsupportedMessage: 'Your browser does not support color emoji.',
favoritesLabel: 'Favorites',
loadingMessage: 'Loading…',
networkErrorMessage: 'Could not load emoji.',
regionLabel: 'Emoji picker',
searchDescription: 'When search results are available, press up or down to select and enter to choose.',
searchLabel: 'Search',
searchResultsLabel: 'Search results',
skinToneDescription: 'When expanded, press up or down to select and enter to choose.',
skinToneLabel: 'Choose a skin tone (currently {skinTone})',
skinTonesLabel: 'Skin tones',
skinTones: [
'Default',
'Light',
'Medium-Light',
'Medium',
'Medium-Dark',
'Dark'
],
categories: {
custom: 'Custom',
'smileys-emotion': 'Smileys and emoticons',
'people-body': 'People and body',
'animals-nature': 'Animals and nature',
'food-drink': 'Food and drink',
'travel-places': 'Travel and places',
activities: 'Activities',
objects: 'Objects',
symbols: 'Symbols',
flags: 'Flags'
}
}

View File

@ -1,34 +0,0 @@
export default {
categoriesLabel: 'Categorías',
emojiUnsupportedMessage: 'El navegador no admite emojis de color.',
favoritesLabel: 'Favoritos',
loadingMessage: 'Cargando…',
networkErrorMessage: 'No se pudo cargar el emoji.',
regionLabel: 'Selector de emojis',
searchDescription: 'Cuando estén disponibles los resultados, pulsa la tecla hacia arriba o hacia abajo para seleccionar y la tecla intro para elegir.',
searchLabel: 'Buscar',
searchResultsLabel: 'Resultados de búsqueda',
skinToneDescription: 'Cuando se abran las opciones, pulsa la tecla hacia arriba o hacia abajo para seleccionar y la tecla intro para elegir.',
skinToneLabel: 'Elige un tono de piel ({skinTone} es el actual)',
skinTonesLabel: 'Tonos de piel',
skinTones: [
'Predeterminado',
'Claro',
'Claro medio',
'Medio',
'Oscuro medio',
'Oscuro'
],
categories: {
custom: 'Personalizado',
'smileys-emotion': 'Emojis y emoticones',
'people-body': 'Personas y partes del cuerpo',
'animals-nature': 'Animales y naturaleza',
'food-drink': 'Comida y bebida',
'travel-places': 'Viajes y lugares',
activities: 'Actividades',
objects: 'Objetos',
symbols: 'Símbolos',
flags: 'Banderas'
}
}

View File

@ -1,34 +0,0 @@
export default {
categoriesLabel: 'Catégories',
emojiUnsupportedMessage: 'Votre navigateur ne soutient pas les emojis en couleur.',
favoritesLabel: 'Favoris',
loadingMessage: 'Chargement en cours…',
networkErrorMessage: 'Impossible de charger les emojis.',
regionLabel: 'Choisir un emoji',
searchDescription: 'Quand les résultats sont disponisbles, appuyez la fleche vers le haut ou le bas et la touche entrée pour choisir.',
searchLabel: 'Rechercher',
searchResultsLabel: 'Résultats',
skinToneDescription: 'Quand disponible, appuyez la fleche vers le haut ou le bas et la touch entrée pour choisir.',
skinToneLabel: 'Choisir une couleur de peau (actuellement {skinTone})',
skinTonesLabel: 'Couleurs de peau',
skinTones: [
'Défaut',
'Clair',
'Moyennement clair',
'Moyen',
'Moyennement sombre',
'Sombre'
],
categories: {
custom: 'Customisé',
'smileys-emotion': 'Les smileyes et les émoticônes',
'people-body': 'Les gens et le corps',
'animals-nature': 'Les animaux et la nature',
'food-drink': 'La nourriture et les boissons',
'travel-places': 'Les voyages et les endroits',
activities: 'Les activités',
objects: 'Les objets',
symbols: 'Les symbols',
flags: 'Les drapeaux'
}
}

View File

@ -1,34 +0,0 @@
export default {
categoriesLabel: 'श्रेणियाँ',
emojiUnsupportedMessage: 'आपका ब्राउज़र कलर इमोजी का समर्थन नहीं करता।',
favoritesLabel: 'पसंदीदा',
loadingMessage: 'लोड हो रहा है...',
networkErrorMessage: 'इमोजी लोड नहीं हो सके।',
regionLabel: 'इमोजी चुननेवाला',
searchDescription: 'जब खोज परिणाम उपलब्ध हों तो चयन करने के लिए ऊपर या नीचे दबाएं और चुनने के लिए एंटर दबाएं।',
searchLabel: 'खोज',
searchResultsLabel: 'खोज के परिणाम',
skinToneDescription: 'जब विस्तृत किया जाता है तो चयन करने के लिए ऊपर या नीचे दबाएं और चुनने के लिए एंटर दबाएं।',
skinToneLabel: 'त्वचा का रंग चुनें (वर्तमान में {skinTone})',
skinTonesLabel: 'त्वचा के रंग',
skinTones: [
'डिफॉल्ट',
'हल्का',
'मध्यम हल्का',
'मध्यम',
'मध्यम गहरा',
'गहरा'
],
categories: {
custom: 'कस्टम',
'smileys-emotion': 'स्माइली और इमोटिकॉन्स',
'people-body': 'लोग और शरीर',
'animals-nature': 'पशु और प्रकृति',
'food-drink': 'खाद्य और पेय',
'travel-places': 'यात्रा और स्थान',
activities: 'गतिविधियां',
objects: 'वस्तुएं',
symbols: 'प्रतीक',
flags: 'झंडे'
}
}

View File

@ -1,34 +0,0 @@
export default {
categoriesLabel: 'Kategori',
emojiUnsupportedMessage: 'Browser Anda tidak mendukung emoji warna.',
favoritesLabel: 'Favorit',
loadingMessage: 'Memuat...',
networkErrorMessage: 'Tidak dapat memuat emoji.',
regionLabel: 'Pemilih emoji',
searchDescription: 'Ketika hasil pencarian tersedia, tekan atas atau bawah untuk menyeleksi dan enter untuk memilih.',
searchLabel: 'Cari',
searchResultsLabel: 'Hasil Pencarian',
skinToneDescription: 'Saat diperluas tekan atas atau bawah untuk menyeleksi dan enter untuk memilih.',
skinToneLabel: 'Pilih warna skin (saat ini {skinTone})',
skinTonesLabel: 'Warna skin',
skinTones: [
'Default',
'Light',
'Medium light',
'Medium',
'Medium dark',
'Dark'
],
categories: {
custom: 'Kustom',
'smileys-emotion': 'Smiley dan emoticon',
'people-body': 'Orang dan bagian tubuh',
'animals-nature': 'Hewan dan tumbuhan',
'food-drink': 'Makanan dan minuman',
'travel-places': 'Rekreasi dan tempat',
activities: 'Aktivitas',
objects: 'Objek',
symbols: 'Simbol',
flags: 'Bendera'
}
}

View File

@ -1,34 +0,0 @@
export default {
categoriesLabel: 'Categorie',
emojiUnsupportedMessage: 'Il tuo browser non supporta le emoji colorate.',
favoritesLabel: 'Preferiti',
loadingMessage: 'Caricamento...',
networkErrorMessage: 'Impossibile caricare le emoji.',
regionLabel: 'Selezione emoji',
searchDescription: 'Quando i risultati della ricerca sono disponibili, premi su o giù per selezionare e invio per scegliere.',
searchLabel: 'Cerca',
searchResultsLabel: 'Risultati di ricerca',
skinToneDescription: 'Quando espanso, premi su o giù per selezionare e invio per scegliere.',
skinToneLabel: 'Scegli una tonalità della pelle (corrente {skinTone})',
skinTonesLabel: 'Tonalità della pelle',
skinTones: [
'Predefinita',
'Chiara',
'Medio-Chiara',
'Media',
'Medio-Scura',
'Scura'
],
categories: {
custom: 'Personalizzata',
'smileys-emotion': 'Faccine ed emozioni',
'people-body': 'Persone e corpi',
'animals-nature': 'Animali e natura',
'food-drink': 'Cibi e bevande',
'travel-places': 'Viaggi e luoghi',
activities: 'Attività',
objects: 'Oggetti',
symbols: 'Simboli',
flags: 'Bandiere'
}
}

View File

@ -1,34 +0,0 @@
export default {
categoriesLabel: 'Kategori',
emojiUnsupportedMessage: 'Penyemak imbas anda tidak menyokong emoji warna.',
favoritesLabel: 'Kegemaran',
loadingMessage: 'Memuat…',
networkErrorMessage: 'Tidak dapat memuatkan emoji.',
regionLabel: 'Pemilih emoji',
searchDescription: 'Apabila hasil carian tersedia, tekan atas atau bawah untuk memilih dan tekan masukkan untuk memilih.',
searchLabel: 'Cari',
searchResultsLabel: 'Hasil carian',
skinToneDescription: 'Apabila dikembangkan, tekan atas atau bawah untuk memilih dan tekan masukkan untuk memilih.',
skinToneLabel: 'Pilih warna kulit (pada masa ini {skinTone})',
skinTonesLabel: 'Warna kulit',
skinTones: [
'Lalai',
'Cerah',
'Kuning langsat',
'Sederhana cerah',
'Sawo matang',
'Gelap'
],
categories: {
custom: 'Tersuai',
'smileys-emotion': 'Smiley dan emotikon',
'people-body': 'Orang dan badan',
'animals-nature': 'Haiwan dan alam semula jadi',
'food-drink': 'Makanan dan minuman',
'travel-places': 'Perjalanan dan tempat',
activities: 'Aktiviti',
objects: 'Objek',
symbols: 'Simbol',
flags: 'Bendera'
}
}

View File

@ -1,34 +0,0 @@
export default {
categoriesLabel: 'Categorieën',
emojiUnsupportedMessage: 'Uw browser ondersteunt geen kleurenemoji.',
favoritesLabel: 'Favorieten',
loadingMessage: 'Bezig met laden…',
networkErrorMessage: 'Kan emoji niet laden.',
regionLabel: 'Emoji-kiezer',
searchDescription: 'Als er zoekresultaten beschikbaar zijn, drukt u op omhoog of omlaag om te selecteren en op enter om te kiezen.',
searchLabel: 'Zoeken',
searchResultsLabel: 'Zoekresultaten',
skinToneDescription: 'Wanneer uitgevouwen, druk omhoog of omlaag om te selecteren en enter om te kiezen.',
skinToneLabel: 'Kies een huidskleur (momenteel {skinTone})',
skinTonesLabel: 'Huidskleuren',
skinTones: [
'Standaard',
'Licht',
'Medium-Licht',
'Medium',
'Middeldonker',
'Donker'
],
categories: {
custom: 'Aangepast',
'smileys-emotion': 'Smileys en emoticons',
'people-body': 'Mensen en lichaam',
'animals-nature': 'Dieren en natuur',
'food-drink': 'Eten en drinken',
'travel-places': 'Reizen en plaatsen',
activities: 'Activiteiten',
objects: 'Voorwerpen',
symbols: 'Symbolen',
flags: 'Vlaggen'
}
}

View File

@ -1,34 +0,0 @@
export default {
categoriesLabel: 'Kategorie',
emojiUnsupportedMessage: 'Twoja przeglądarka nie wspiera kolorowych emotikon.',
favoritesLabel: 'Ulubione',
loadingMessage: 'Ładuję…',
networkErrorMessage: 'Nie można załadować emoji.',
regionLabel: 'Selektor emoji',
searchDescription: 'Kiedy wyniki wyszukiwania będą dostępne, wciśnij góra lub dół aby wybrać oraz enter aby zatwierdzić wybór.',
searchLabel: 'Wyszukaj',
searchResultsLabel: 'Wyniki wyszukiwania',
skinToneDescription: 'Po rozwinięciu wciśnij góra lub dół aby wybrać oraz enter aby zatwierdzić wybór.',
skinToneLabel: 'Wybierz odcień skóry (aktualnie {skinTone})',
skinTonesLabel: 'Odcienie skóry',
skinTones: [
'Domyślna',
'Jasna',
'Średnio-jasna',
'Średnia',
'Średnio-ciemna',
'Ciemna'
],
categories: {
custom: 'Własne',
'smileys-emotion': 'Uśmiechy',
'people-body': 'Ludzie',
'animals-nature': 'Zwierzęta i natura',
'food-drink': 'Żywność i napoje',
'travel-places': 'Podróże i miejsca',
activities: 'Aktywności',
objects: 'Obiekty',
symbols: 'Symbole',
flags: 'Flagi'
}
}

View File

@ -1,34 +0,0 @@
export default {
categoriesLabel: 'Categorias',
emojiUnsupportedMessage: 'Seu navegador não suporta emojis coloridos.',
favoritesLabel: 'Favoritos',
loadingMessage: 'Carregando…',
networkErrorMessage: 'Não foi possível carregar o emoji.',
regionLabel: 'Seletor de emoji',
searchDescription: 'Quando os resultados da pesquisa estiverem disponíveis, pressione para cima ou para baixo para selecionar e “enter” para escolher.',
searchLabel: 'Procurar',
searchResultsLabel: 'Resultados da pesquisa',
skinToneDescription: 'Quando expandido, pressione para cima ou para baixo para selecionar e “enter” para escolher.',
skinToneLabel: 'Escolha um tom de pele (atualmente {skinTone})',
skinTonesLabel: 'Tons de pele',
skinTones: [
'Padrão',
'Claro',
'Claro médio',
'Médio',
'Escuro médio',
'Escuro'
],
categories: {
custom: 'Personalizar',
'smileys-emotion': 'Carinhas e emoticons',
'people-body': 'Pessoas e corpo',
'animals-nature': 'Animais e natureza',
'food-drink': 'Alimentos e bebidas',
'travel-places': 'Viagem e lugares',
activities: 'Atividades',
objects: 'Objetos',
symbols: 'Símbolos',
flags: 'Bandeiras'
}
}

View File

@ -1,34 +0,0 @@
export default {
categoriesLabel: 'Categorias',
emojiUnsupportedMessage: 'O seu browser não suporta emojis.',
favoritesLabel: 'Favoritos',
loadingMessage: 'A Carregar…',
networkErrorMessage: 'Não foi possível carregar o emoji.',
regionLabel: 'Emoji picker',
searchDescription: 'Quando os resultados da pesquisa estiverem disponíveis, pressione para cima ou para baixo para selecionar e digite para escolher.',
searchLabel: 'Procurar',
searchResultsLabel: 'Resultados da procura',
skinToneDescription: 'Quando expandido, pressione para cima ou para baixo para selecionar e digite para escolher.',
skinToneLabel: 'Escolha um tom de pele (atual {skinTone})',
skinTonesLabel: 'Tons de pele',
skinTones: [
'Pré-definido',
'Claro',
'Médio-Claro',
'Médio',
'Médio-Escuro',
'Escuro'
],
categories: {
custom: 'Personalizados',
'smileys-emotion': 'Smileys e emoticons',
'people-body': 'Pessoas e corpo',
'animals-nature': 'Animais e natureza',
'food-drink': 'Comida e bebida',
'travel-places': 'Viagens e locais',
activities: 'Atividades',
objects: 'Objetos',
symbols: 'Símbolos',
flags: 'Bandeiras'
}
}

View File

@ -1,34 +0,0 @@
export default {
categoriesLabel: 'Категории',
emojiUnsupportedMessage: 'Ваш браузер не поддерживает цветные эмодзи.',
favoritesLabel: 'Избранное',
loadingMessage: 'Загрузка…',
networkErrorMessage: 'Не удалось загрузить эмодзи. Попробуйте перезагрузить страницу.',
regionLabel: 'Выберите эмодзи',
searchDescription: 'Когда результаты поиска станут доступны, выберите их с помощью стрелок вверх и вниз, затем нажмите для подтверждения.',
searchLabel: 'Искать',
searchResultsLabel: 'Результаты поиска',
skinToneDescription: 'При отображении используйте клавиши со стрелками вверх и вниз для выбора, нажмите для подтверждения.',
skinToneLabel: 'Выберите оттенок кожи (текущий {skinTone})',
skinTonesLabel: 'Оттенки кожи',
skinTones: [
'Стандартный',
'Светлый',
'Средне-светлый',
'Средний',
'Средне-темный',
'Темный'
],
categories: {
custom: 'Пользовательский',
'smileys-emotion': 'Смайлики и Эмотиконы',
'people-body': 'Люди и Тела',
'animals-nature': 'Животные и Природа',
'food-drink': 'Еда и Напитки',
'travel-places': 'Путешествия и Места',
activities: 'Виды деятельности',
objects: 'Объекты',
symbols: 'Символы',
flags: 'Флаги'
}
}

View File

@ -1,34 +0,0 @@
export default {
categoriesLabel: 'Kategoriler',
emojiUnsupportedMessage: 'Tarayıcınız renkli emojiyi desteklemiyor.',
favoritesLabel: 'Favoriler',
loadingMessage: 'Yükleniyor…',
networkErrorMessage: 'Emoji yüklenemedi.',
regionLabel: 'Emoji seçici',
searchDescription: 'Arama sonuçları mevcut olduğunda seçmek için yukarı veya aşağı basın ve seçmek için girin.',
searchLabel: 'Arama',
searchResultsLabel: 'Arama sonuçları',
skinToneDescription: 'Genişletildiğinde seçmek için yukarı veya aşağı basın ve seçmek için girin.',
skinToneLabel: 'Cilt tonu seçin (şu anda {skinTone})',
skinTonesLabel: 'Cilt tonları',
skinTones: [
'Varsayılan',
'Işık',
'Orta ışık',
'Orta',
'Orta koyu',
'Karanlık'
],
categories: {
custom: 'Gelenek',
'smileys-emotion': 'Suratlar ve ifadeler',
'people-body': 'İnsanlar ve vücut',
'animals-nature': 'Hayvanlar ve doğa',
'food-drink': 'Yiyecek ve içecek',
'travel-places': 'Seyahat ve yerler',
activities: 'Aktiviteler',
objects: 'Nesneler',
symbols: 'Semboller',
flags: 'Bayraklar'
}
}

View File

@ -1,27 +0,0 @@
export default {
categoriesLabel: '类别',
emojiUnsupportedMessage: '您的浏览器不支持彩色表情符号。',
favoritesLabel: '收藏夹',
loadingMessage: '正在加载…',
networkErrorMessage: '无法加载表情符号。',
regionLabel: '表情符号选择器',
searchDescription: '当搜索结果可用时,按向上或向下选择并输入选择。',
searchLabel: '搜索',
searchResultsLabel: '搜索结果',
skinToneDescription: '展开时,按向上或向下键进行选择,按回车键进行选择。',
skinToneLabel: '选择肤色(当前为 {skinTone}',
skinTonesLabel: '肤色',
skinTones: ['默认', '明亮', '微亮', '中等', '微暗', '暗'],
categories: {
custom: '自定义',
'smileys-emotion': '笑脸和表情',
'people-body': '人物和身体',
'animals-nature': '动物与自然',
'food-drink': '食品饮料',
'travel-places': '旅行和地方',
activities: '活动',
objects: '物体',
symbols: '符号',
flags: '旗帜'
}
}

View File

@ -1,3 +0,0 @@
import Picker from './picker.js'
import Database from './database.js'
export { Picker, Database }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

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>

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

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

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

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

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

View File

@ -1,7 +1,7 @@
<!DOCTYPE html>
<html lang="fr-FR">
{% include "base/head.html" %}
{% include "base/head.html" with context %}
<body>
<div><a class="skip-to-content-link" href="#main-content">Aller directement au contenu</a></div>
{% include "base/navbar.html" %}
@ -14,8 +14,8 @@
{% include "base/flash.html" %}
<div id="main-content"></div>
{% block content %}
<div id="main-content"></div>
{% endblock %}
{% include "base/footer.html" %}

View File

@ -4,5 +4,11 @@
{% if current_user.is_authenticated and current_user.priv('misc.dev-infos') %}
<p>Page générée en {{ "%.3f" % g.request_time() }} secondes.</p>
{% endif %}
<<<<<<< HEAD
<<<<<<< HEAD
<p>Ceci est un environnement de test. Tout contenu peut être supprimé sans avertissement préalable.</p>
=======
>>>>>>> e15005a... Ajout des stats sur la durée de chargement
=======
>>>>>>> e15005a427f95829bbbad8f0d625ab9cb0c30e69
</footer>

View File

@ -1,5 +1,5 @@
<head>
<title>Planète Casio</title>
<title>{{ V5Config.TABTITLE_PREFIX + (tabtitle or "Planète Casio") }}</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">

View File

@ -9,7 +9,7 @@
</form>
<div id="spotlight">
<a href="/" class="button bg-error">Infos sur la maintenance</a>
<a href="/forum/projets/2/fin/avancees-de-la-v5" class="button bg-error">Infos sur l'avancée de la v5</a>
</div>
{% if current_user.is_authenticated and current_user.priv('misc.dev-infos') %}

View File

@ -1,4 +1,6 @@
{% for s in scripts %}
<script type="text/javascript" src={{url_for('static', filename=s)}}></script>
{% endfor %}
<script type="module" src={{url_for('static', filename='scripts/emoji-picker-element/index.js')}}></script>
{% for m in modules %}
<script type="module" src={{url_for('static', filename=m)}}></script>
{% endfor %}

10
app/templates/chat.html Normal file
View File

@ -0,0 +1,10 @@
{% set tabtitle = "Shoutbox" %}
<!DOCTYPE html>
<html lang="fr-FR">
{% include "base/head.html" with context %}
<body>
{% include "widgets/v5shoutbox.html" %}
{% include "base/scripts.html" %}
</body>
</html>

View File

@ -1,5 +1,7 @@
{% extends "base/base.html" %}
{% set tabtitle = "Erreur 403 - Accès non autorisé" %}
{% block title %}
<h1>403 - Accès non autorisé</h1>
{% endblock %}

View File

@ -1,5 +1,7 @@
{% extends "base/base.html" %}
{% set tabtitle = "Erreur 404 - Page non trouvée" %}
{% block title %}
<h1>404 - Page non trouvée</h1>
{% endblock %}

View File

@ -2,6 +2,8 @@
{% import "widgets/editor.html" as widget_editor %}
{% import "widgets/user.html" as widget_user %}
{% set tabtitle = "Forum - Édition de commentaire" %}
{% block title %}
<a href='/forum'>Forum de Planète Casio</a> » Édition de commentaire</h1>
{% endblock %}

View File

@ -4,6 +4,8 @@
{% import "widgets/editor.html" as widget_editor %}
{% import "widgets/user.html" as widget_user %}
{% set tabtitle = t.forum.name + " - Édition du sujet: " + t.title %}
{% block title %}
<a href='/forum'>Forum de Planète Casio</a> » <a href="{{ url_for('forum_page', f=t.forum) }}">{{ t.forum.name }}</a> » <h1>Édition de sujet</h1>
{% endblock %}

View File

@ -2,6 +2,8 @@
{% import "widgets/editor.html" as widget_editor %}
{% import "widgets/pagination.html" as widget_pagination with context %}
{% set tabtitle = "Forum - " + f.name %}
{% block title %}
<a href='/forum'>Forum de Planète Casio</a> » <h1>{{ f.name }}</h1>
{% endblock %}

View File

@ -1,5 +1,7 @@
{% extends "base/base.html" %}
{% set tabtitle = "Forum de Planète Casio" %}
{% block title %}
<h1>Forum de Planète Casio</h1>
{% endblock %}

View File

@ -5,6 +5,8 @@
{% import "widgets/pagination.html" as widget_pagination with context %}
{% import "widgets/attachments.html" as widget_attachments %}
{% set tabtitle = t.forum.name + " - " + t.title %}
{% block title %}
<a href='/forum'>Forum de Planète Casio</a> » <a href="{{ url_for('forum_page', f=t.forum) }}">{{ t.forum.name }}</a> » <h1>{{ t.title }}</h1>
{% endblock %}
@ -36,7 +38,9 @@
</div>
{% endif %}
{% if V5Config.ENABLE_GUEST_POST
{% if t.thread.locked %}
<div class="locked">Les commentaires sont verrouillés</div>
{% elif V5Config.ENABLE_GUEST_POST
or (current_user.is_authenticated and current_user.can_post_in_forum(t.forum)) %}
<div class=form>
<h3>Commenter le sujet</h3>

View File

@ -1,20 +1,121 @@
{% extends "base/base.html" %}
{% set tabtitle = "Planète Casio" %}
{% block title %}
<h1>Planète Casio</h1>
{% endblock %}
{% block content %}
<section class="home-pinned-content">
<div>
<p><b>Site temporaire de Planète Casio</b></p>
<p>Le site habituel de Planète Casio est indisponible en raison de problèmes techniques avec l'hébergement. Vous êtes sur la prochaine version du site (v5) qui est en développement sur un serveur séparé.</p>
<div class="home-banner">
<img src="https://www.planet-casio.com/storage/staff/CPC30-banner.png" />
</div>
<div class="home-welcome">
<h1>Bienvenue sur Planète Casio !</h1>
<p>Planète Casio est la communauté française de référence pour toutes les calculatrices Casio.
Apprenez à utiliser votre machine, téléchargez et partagez des programmes, ou initiez-vous à l'informatique sur le forum.
Ou bien venez développer des jeux avec nous pour passer le temps !</p>
<div>
<h2>Les calculatrices</h2>
<ul>
<li>Tout sur sa Casio</li>
<li>Graph 25+E II</li>
<li>Graph 35+E II</li>
<li>Graph 90+E</li>
<li>Classpad 400+E</li>
<li>Comparer les calculatrices CASIO</li>
</ul>
</div>
<div>
<h2>La communauté</h2>
<ul>
<li>Sinscrire ou se connecter</li>
<li>Index du forum</li>
<li>Nos jeux outils et cours</li>
</ul>
</div>
<div>
<h2>Programmer</h2>
<ul>
<li>Apprendre à programmer</li>
<li>Articles et astuces</li>
</ul>
</div>
</div>
<div class="home-news">
<h1>Actualitées</h1>
<ul>
<li>Inscription : dans le menu "Compte" à gauche (les comptes seront ultimement reconnectés à la version originale du site)</li>
<li>Le forum est fonctionnel, les programmes arrivent sous peu.</li>
<li>Pour toute demande particulière, vous pouvez envoyer un email à <code>contact (at) planet-casio (dot) com</code>.</li>
<li>
<a href="#"><img src="https://www.planet-casio.com/images/staff/tdm9_collision2.jpg"/></a>
<div>
<h3><a href="#">Les collisions — partie 2</a></h3>
<p class="date"><i>Publié par <a href="#">Shadow15510</a> le <time>09/06/2023 09:10</time></i></p>
La deuxième partie du tutoriel du mercredi sur les collisions est maintenant disponible en vidéo.
</div>
</li>
<li>
<a href="#"><img src="https://www.planet-casio.com/images/staff/CPC30-image-thumb-results.png"/></a>
<div>
<h3><a href="#">Résultats du CPC #30 — Les profondeurs !</a>
</h3>
<p class="date"><i>Publié par <a href="#">Lephenixnoir</a> le <time>08/06/2023 22:10</time></i></p>
Les programmes sont profonds et avec un peu de chance les tests aussi ! </div>
</li>
<li>
<a href="#"><img src="https://www.planet-casio.com/images/staff/tituya-thumb.png"/></a>
<div>
<h3><a href="#">Un second renardministrateur !</a>
</h3>
<p class="date"><i>Publié par <a href="#">Lephenixnoir</a> le <time>07/06/2023 22:55</time></i></p>
Vive la renardocratie ! </div>
</li>
<li>
<a href="#"><img src="https://www.planet-casio.com/images/staff/massy2.jpg"/></a>
<div>
<h3><a href="#">Visite chez Casio France à Massy, musée inclus</a>
</h3>
<p class="date"><i>Publié par <a href="#">Critor</a> le <time>05/06/2023 11:51</time></i></p>
Compte-rendu de notre visite au sein même des locaux de Casio France à Massy en mai 2023, passage par leur musée privé inclus. </div>
</li>
<li>
<a href="#"><img src="https://www.planet-casio.com/images/staff/CPC30-image-title.jpg"/></a>
<div>
<h3><a href="#">Le CPC#30 - Les Profondeurs ... C'est désormais terminé ... Bravo à tous les participants.</a>
</h3>
<p class="date"><i>Publié par <a href="#">Slyvtt</a> le <time>03/06/2023 21:36</time></i></p>
Aujourd'hui sonne la fin du CPC#30 avec à la clef 6 participations pour vous donner du fun. Graphs Monochromes et Couleurs sont à la fête avec des programmes en Basic et des Addins. </div>
</li>
<li>
<a href="#"><img src="https://www.planet-casio.com/images/staff/tdm9_miniature.jpg"/></a>
<div>
<h3><a href="#">Les Tutos du Mercredi débarquent sur Youtube !</a>
</h3>
<p class="date"><i>Publié par <a href="#">Shadow15510</a> le <time>29/05/2023 14:15</time></i></p>
</div>
</li>
<li>
<a href="#"><img src="https://www.planet-casio.com/images/staff/CPC30-image-thumb.jpg"/></a>
<div>
<h3><a href="#">Le CPC #30 - Les profondeurs !</a>
</h3>
<p class="date"><i>Publié par <a href="#">Lephenixnoir</a> le <time>27/05/2023 18:00</time></i></p>
Le concours CPC revient en force et c'est le moment de se plonger (métaphoriquement <i>et</i> littéralement !) dans le game design fin et la programmation sportive. Une semaine pour les programmer tous, et dans les profondeurs les li— oups ! </div>
</li>
<li><p><i><a href="https://www.planet-casio.com/Fr/forums/partie6-.html">Voir toutes les news</a></i></p></li>
</ul>
</div>
<div class="home-shoutbox" style="border: 1px solid #737373; padding: 20px">
<div id="v5shoutbox">
Ici yaura la shoutbox (plus tard)
</div>
</div>
<div class="home-projects">
<h1>Projets du moment</h1>
<ul>
<li>La v5, bien sûr</li>
<li>Chaos drop</li>
<li>Un easter egg</li>
</ul>
</div>
</section>

View File

@ -1,6 +1,8 @@
{% extends "base/base.html" %}
{% import "widgets/poll.html" as poll_widget with context %}
{% set tabtitle = "Supprimer un sondage" %}
{% block title %}
<h1>Supprimer un sondage</h1>
{% endblock %}

View File

@ -2,6 +2,8 @@
{% import "widgets/editor.html" as widget_editor %}
{% import "widgets/user.html" as widget_user %}
{% set tabtitle = "Déplacer un commentaire" %}
{% block title %}
<a href='/forum'>Forum de Planète Casio</a> » Édition de commentaire</h1>
{% endblock %}

View File

@ -1,5 +1,7 @@
{% extends "base/base.html" %}
{% set tabtitle = "Programmes de Planète Casio" %}
{% block title %}
<h1>Programmes de Planète Casio</h1>
{% endblock %}

View File

@ -5,9 +5,10 @@
{% import "widgets/pagination.html" as widget_pagination with context %}
{% import "widgets/attachments.html" as widget_attachments %}
{% set tabtitle = "Programmes - " + p.name %}
{% block title %}
<h1>Programme: {{ p.name }}</h1>
<a href="{{ url_for('program_index') }}">Programmes</a> » <h1>{{ p.name }}</h1>
{% endblock %}
{% block content %}
@ -60,7 +61,10 @@
{{ widget_pagination.paginate(comments, 'program_view', p) }}
{% if V5Config.ENABLE_GUEST_POST or current_user.is_authenticated %}
{% if p.thread.locked %}
<div class="locked">Les commentaires sont verrouillés</div>
{% elif V5Config.ENABLE_GUEST_POST or current_user.is_authenticated %}
<div class=form>
<h3>Commenter le programme</h3>
<form action="" method="post" enctype="multipart/form-data">

View File

@ -2,8 +2,10 @@
{% import "widgets/editor.html" as widget_editor %}
{% import "widgets/tag_selector.html" as widget_tag_selector with context %}
{% set tabtitle = "Programmes - Soumettre un programme" %}
{% block title %}
<h1>Programmes de Planète Casio</h1>
<a href="{{ url_for('program_index') }}">Programmes</a> » <h1>Soumettre un programme</h1>
{% endblock %}
{% block content %}

View File

@ -1,5 +1,7 @@
{% extends "base/base.html" %}
{% set tabtitle = "Recherche avancée" %}
{% block content %}
<section class="form">
<h1>Recherche avancée</h1>

View File

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

View File

@ -12,6 +12,7 @@
{% set can_punish = auth and current_user.can_punish_post(post) %}
{% set can_topcomm = auth and current_user.can_set_topcomment(post) %}
{% set can_move = auth and current_user.can_edit_post(post) and post.type == "comment" %}
{% set can_lock = auth and current_user.can_lock_thread(post) %}
{% if post.type == "topic" %}
{% set suffix = " le sujet" %}
@ -19,7 +20,7 @@
{% set suffix = " le programme" %}
{% endif %}
{% if can_edit or can_move or can_delete or can_punish or can_topcomm %}
{% if can_edit or can_move or can_delete or can_punish or can_topcomm or can_lock %}
<details>
<summary><b></b></summary>
<div class='context-menu'>
@ -31,7 +32,7 @@
<a href="{{ url_for('move_post', postid=post.id) }}">Déplacer</a>
{% endif %}
{% if can_punish %}
{% if can_punish and post.author.type == "member"%}
<a href="{{ url_for('delete_post', postid=post.id, penalty=False, csrf_token=csrf_token()) }}" onclick="return confirm('Le post sera supprimé.')">Supprimer{{ suffix }} (normal)</a>
<a href="{{ url_for('delete_post', postid=post.id, penalty=True, csrf_token=csrf_token()) }}" onclick="return confirm('Le post sera supprimé avec pénalité d\'XP.')">Supprimer{{ suffix }} (pénalité)</a>
{% elif can_delete %}
@ -41,6 +42,11 @@
{% if can_topcomm %}
<a href="{{ url_for('set_post_topcomment', postid=post.id, csrf_token=csrf_token()) }}">Utiliser comme en-tête</a>
{% endif %}
{% if can_lock %}
{% set prefix = "Déverrouiller" if post.thread.locked else "Verrouiller" %}
<a href="{{ url_for('lock_thread', postid=post.id, csrf_token=csrf_token()) }}">{{ prefix }}{{ suffix }}</a>
{% endif %}
</div>
</details>
{% endif %}

View File

@ -0,0 +1 @@
../../../submodules/v5shoutbox/widget.html

View File

@ -1,7 +1,7 @@
# Tags suitable for rendering markdown
markdown_tags = [
"h1", "h2", "h3", "h4", "h5", "h6",
"b", "i", "strong", "em", "tt",
"b", "i", "strong", "em", "tt", "ins", "del",
"p", "br",
"span", "div", "blockquote", "code", "pre", "hr",
"ul", "ol", "li", "dd", "dt",

View File

@ -7,6 +7,7 @@ from markdown.extensions.toc import TocExtension, slugify_unicode
from bleach import clean
from app.utils.bleach_allowlist import markdown_tags, markdown_attrs
from app.utils.markdown_extensions.del_ins import DelInsExtension
from app.utils.markdown_extensions.pclinks import PCLinkExtension
from app.utils.markdown_extensions.hardbreaks import HardBreakExtension
from app.utils.markdown_extensions.escape_html import EscapeHtmlExtension
@ -34,6 +35,7 @@ def md(text, prefix=None):
# 'nl2br',
'sane_lists',
'tables',
DelInsExtension(),
CodeHiliteExtension(linenums=True, use_pygments=True),
EscapeHtmlExtension(),
FootnoteExtension(UNIQUE_IDS=True),

View File

@ -36,4 +36,4 @@ def say(msg, channels = ["#general"]):
def new_topic(topic):
""" Example wrapper for glados.say """
say(f"Le topic {BOLD}{topic.title}{BOLD} a été créé")
say(f"Nouveau topic de {topic.author.name}: {BOLD}{topic.title}{BOLD}")

View File

@ -16,18 +16,24 @@ def get_member(username):
return None
def edit(user, fields):
def edit(old_username, new_member):
""" Edit a user. Fields is {'name': ['value'], …} """
old_username = normalize(old_username)
conn = ldap.initialize("ldap://localhost")
# TODO: do this
# Connect as root
# conn.simple_bind_s(f'cn=ldap-root,{V5Config.LDAP_ENV}',
# V5Config.LDAP_PASSWORD)
# old_value = {"userPassword": ["my_old_password"]}
# new_value = {"userPassword": ["my_new_password"]}
conn.simple_bind_s(f'cn=ldap-root,{V5Config.LDAP_ROOT}',
V5Config.LDAP_PASSWORD)
# Create values fields
old_dn = f'cn={old_username},{V5Config.LDAP_ENV},{V5Config.LDAP_ROOT}'
new_dn = f'cn={new_member.norm}'
new_values = [
(ldap.MOD_REPLACE, 'sn', [new_member.norm.encode('utf-8')]),
(ldap.MOD_REPLACE, 'displayName', [new_member.name.encode('utf-8')]),
(ldap.MOD_REPLACE, 'mail', [new_member.email.encode('utf-8')]),
]
# modlist = modifyModlist(old_value, new_value)
# conn.modify_s(dn, modlist)
conn.modify_s(old_dn, new_values)
conn.rename_s(old_dn, new_dn)
def set_email(user, email):

View File

@ -0,0 +1,12 @@
from markdown.extensions import Extension
from markdown.inlinepatterns import SimpleTagPattern
class DelInsExtension(Extension):
def extendMarkdown(self, md):
DEL_RE = r'(~~)(.*?)~~'
del_tag = SimpleTagPattern(DEL_RE, 'del')
md.inlinePatterns.register(del_tag, 'del', 55)
INS_RE = r'(__)(.*?)__'
ins_tag = SimpleTagPattern(INS_RE, 'ins')
md.inlinePatterns.register(ins_tag, 'ins', 55)

View File

@ -1,7 +1,7 @@
from flask import render_template
from flask_login import current_user
def render(*args, styles=[], scripts=[], **kwargs):
def render(*args, styles=[], scripts=[], modules=[], **kwargs):
# Pour jouer sur les feuilles de style ou les scripts :
# render('page.html', styles=['-css/form.css', '+css/admin/forms.css'])
@ -29,6 +29,9 @@ def render(*args, styles=[], scripts=[], **kwargs):
'scripts/tag_selector.js',
'scripts/editor.js',
]
modules_ = [
'scripts/emoji-picker-element/index.js',
]
# Apply theme from user settings
theme = current_user.theme if current_user.is_authenticated else ''
@ -56,4 +59,10 @@ def render(*args, styles=[], scripts=[], **kwargs):
if s[0] == '+':
scripts_.append(s[1:])
return render_template(*args, **kwargs, styles=styles_, scripts=scripts_)
for m in modules:
if m[0] == '-':
modules_.remove(m[1:])
if m[0] == '+':
modules_.append(m[1:])
return render_template(*args, **kwargs, styles=styles_, scripts=scripts_, modules=modules_)

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