Compare commits
52 Commits
new_editor
...
master
Author | SHA1 | Date |
---|---|---|
Darks | 21b863e0e2 | |
Darks | e92792c8d6 | |
Darks | 20524d28c3 | |
Eragon | 0d9ca65238 | |
Darks | 402e6699aa | |
Darks | 7e64a70eec | |
Darks | 920724718f | |
Lephenixnoir | d6ff6eb77f | |
IniKiwi | c5df575af3 | |
IniKiwi | f93443310b | |
Darks | f6aefffc3c | |
Lephe | c8f2d73bc2 | |
Lephe | d531106c78 | |
Eragon | 43381bf493 | |
Darks | 62341cf9d9 | |
Eragon | c88c993ee3 | |
Eragon | ed8550f291 | |
Eragon | f163d15066 | |
Darks | 14e81bdfb5 | |
Eragon | 4231b3084e | |
Darks | 9902719328 | |
Darks | 358a5fec9d | |
Darks | 8721a7be69 | |
Darks | 12483e70e4 | |
Eragon | e5dafb68e5 | |
Darks | fabbb130b6 | |
Darks | 2530581095 | |
Darks | f06f14e814 | |
Eragon | 5a6d000be6 | |
Darks | 876cae2b69 | |
Darks | b892d9ae68 | |
Darks | 6238f72d6d | |
Darks | fccd0e5b84 | |
Darks | a5b2933727 | |
Darks | 6519cf4a6a | |
Darks | c31cca6314 | |
Darks | 0865ae0e67 | |
Darks | 798f5d203e | |
Darks | 3f8f8ab225 | |
Eragon | 4eb4145846 | |
Darks | 6d1d6a1b2e | |
Darks | 1a63544183 | |
Darks | 6817b79680 | |
Darks | 3c671da85c | |
Eragon | 7c076fea79 | |
Darks | 65828ffbdd | |
Darks | b9becbf21f | |
Darks | 9de0f9f823 | |
Eldeberen | 41eaaa4c30 | |
Darks | ad1042865b | |
Darks | 2dd7863e89 | |
Darks | e15005a427 |
|
@ -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
|
||||
|
||||
|
|
|
@ -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":
|
||||
|
|
|
@ -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
|
||||
-
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -8,6 +8,7 @@ from app.models.trophy import Title
|
|||
from app.utils.render import render
|
||||
from app.utils.send_mail import send_validation_mail, send_reset_password_mail
|
||||
from app.utils.priv_required import guest_only
|
||||
from app.utils.glados import say, BOLD
|
||||
import app.utils.ldap as ldap
|
||||
import app.utils.validators as vd
|
||||
from itsdangerous import URLSafeTimedSerializer
|
||||
|
@ -30,6 +31,7 @@ def edit_account():
|
|||
|
||||
if form.submit.data:
|
||||
if form.is_submitted() and form.validate(extra_validators=extra_vd):
|
||||
old_username = current_user.norm
|
||||
current_user.update(
|
||||
avatar=form.avatar.data or None,
|
||||
email=form.email.data or None,
|
||||
|
@ -41,10 +43,13 @@ def edit_account():
|
|||
newsletter=form.newsletter.data,
|
||||
theme=form.theme.data
|
||||
)
|
||||
ldap.edit(old_username, current_user)
|
||||
current_user.update(password=form.password.data or None)
|
||||
db.session.merge(current_user)
|
||||
db.session.commit()
|
||||
current_user.update_trophies("on-profile-update")
|
||||
flash('Modifications effectuées', 'ok')
|
||||
app.v5logger.info(f"<{current_user.name}> has edited their account")
|
||||
return redirect(request.url)
|
||||
else:
|
||||
flash('Erreur lors de la modification', 'error')
|
||||
|
@ -62,6 +67,7 @@ def ask_reset_password():
|
|||
m = Member.query.filter_by(email=form.email.data).first()
|
||||
if m is not None:
|
||||
send_reset_password_mail(m.name, m.email)
|
||||
app.v5logger.info(f"<{m.name}> has asked a password reset token")
|
||||
flash('Un email a été envoyé à l\'adresse renseignée', 'ok')
|
||||
return redirect(url_for('login'))
|
||||
elif request.method == "POST":
|
||||
|
@ -87,6 +93,7 @@ def reset_password(token):
|
|||
db.session.merge(m)
|
||||
db.session.commit()
|
||||
flash('Modifications effectuées', 'ok')
|
||||
app.v5logger.info(f"<{m.name}> has reset their password")
|
||||
return redirect(url_for('login'))
|
||||
else:
|
||||
flash('Erreur lors de la modification', 'error')
|
||||
|
@ -102,6 +109,7 @@ def delete_account():
|
|||
|
||||
if del_form.submit.data:
|
||||
if del_form.validate_on_submit():
|
||||
name = current_user.name
|
||||
if del_form.transfer.data:
|
||||
guest = Guest(current_user.generate_guest_name())
|
||||
db.session.add(guest)
|
||||
|
@ -112,10 +120,14 @@ def delete_account():
|
|||
current_user.delete_posts()
|
||||
db.session.commit()
|
||||
|
||||
if (V5Config.USE_LDAP):
|
||||
ldap.delete_member(current_user)
|
||||
|
||||
current_user.delete()
|
||||
logout_user()
|
||||
db.session.commit()
|
||||
flash('Compte supprimé', 'ok')
|
||||
app.v5logger.info(f"<{name}> has deleted their account ({'with' if del_form.transfer.data else 'without'} guest transfer)")
|
||||
return redirect(url_for('index'))
|
||||
else:
|
||||
flash('Erreur lors de la suppression du compte', 'error')
|
||||
|
@ -141,6 +153,7 @@ def register():
|
|||
|
||||
# Email validation message
|
||||
send_validation_mail(member.name, member.email)
|
||||
app.v5logger.info(f"<{member.name}> registered")
|
||||
|
||||
return redirect(url_for('validation') + "?email=" + form.email.data)
|
||||
return render('account/register.html', title='Register',
|
||||
|
@ -178,4 +191,8 @@ def activate_account(token):
|
|||
db.session.commit()
|
||||
|
||||
flash("L'email a bien été confirmé", "ok")
|
||||
app.v5logger.info(f"<{m.name}> has activated their account")
|
||||
say(f"Un nouveau membre s’est inscrit ! Il s’agit de {BOLD}{m.name}{BOLD}.")
|
||||
say(url_for('user', username=m.name, _external=True))
|
||||
|
||||
return redirect(url_for('login'))
|
||||
|
|
|
@ -49,6 +49,7 @@ def login():
|
|||
login_user(member, remember=form.remember_me.data,
|
||||
duration=datetime.timedelta(days=7))
|
||||
member.update_trophies("on-login")
|
||||
app.v5logger.info(f"<{member.name}> has logged in")
|
||||
|
||||
# Redirect safely (https://huit.re/open-redirect)
|
||||
def is_safe_url(target):
|
||||
|
@ -71,8 +72,10 @@ def login():
|
|||
@login_required
|
||||
@check_csrf
|
||||
def logout():
|
||||
name = current_user.name
|
||||
logout_user()
|
||||
flash('Déconnexion réussie', 'info')
|
||||
app.v5logger.info(f"<{name}> has logged out")
|
||||
if request.referrer:
|
||||
return redirect(request.referrer)
|
||||
return redirect(url_for('index'))
|
||||
|
|
|
@ -28,5 +28,6 @@ def account_polls():
|
|||
db.session.commit()
|
||||
|
||||
flash(f"Le sondage {p.id} a été créé", "info")
|
||||
app.v5logger.info(f"<{current_user.name}> has created the form #{p.id}")
|
||||
|
||||
return render("account/polls.html", polls=polls, form=form)
|
||||
|
|
|
@ -9,6 +9,7 @@ from app.forms.account import AdminUpdateAccountForm, AdminDeleteAccountForm, \
|
|||
AdminAccountEditTrophyForm, AdminAccountEditGroupForm
|
||||
from app.utils.render import render
|
||||
from app.utils.notify import notify
|
||||
from app.utils import ldap as ldap
|
||||
from app import app, db
|
||||
from config import V5Config
|
||||
|
||||
|
@ -50,12 +51,12 @@ def adm_edit_account(user_id):
|
|||
# You cannot user vd.name_available because name will always be
|
||||
# invalid! Maybe you can add another validator with arguments
|
||||
raise Exception(f'{newname} is not available')
|
||||
old_username = user.norm
|
||||
user.update(
|
||||
avatar=form.avatar.data or None,
|
||||
name=form.username.data or None,
|
||||
email=form.email.data or None,
|
||||
email_confirmed=form.email_confirmed.data,
|
||||
password=form.password.data or None,
|
||||
birthday=form.birthday.data,
|
||||
signature=form.signature.data,
|
||||
title=form.title.data,
|
||||
|
@ -63,11 +64,14 @@ def adm_edit_account(user_id):
|
|||
newsletter=form.newsletter.data,
|
||||
xp=form.xp.data or None,
|
||||
)
|
||||
ldap.edit(old_username, user)
|
||||
user.update(password=form.password.data or None)
|
||||
db.session.merge(user)
|
||||
db.session.commit()
|
||||
# TODO: send an email to member saying his account has been modified
|
||||
user.notify(f"Vos informations personnelles ont été modifiées par {current_user.name}.")
|
||||
flash('Modifications effectuées', 'ok')
|
||||
app.v5logger.info(f"[admin] <{current_user.name}> has edited <{user.name}>'s data")
|
||||
return redirect(request.url)
|
||||
else:
|
||||
flash('Erreur lors de la modification', 'error')
|
||||
|
@ -85,6 +89,7 @@ def adm_edit_account(user_id):
|
|||
db.session.merge(user)
|
||||
db.session.commit()
|
||||
flash('Modifications effectuées', 'ok')
|
||||
app.v5logger.info(f"[admin] <{current_user.name}> has edited <{user.name}>'s trophies")
|
||||
return redirect(request.url)
|
||||
else:
|
||||
flash("Erreur lors de la modification des trophées", 'error')
|
||||
|
@ -102,6 +107,7 @@ def adm_edit_account(user_id):
|
|||
db.session.merge(user)
|
||||
db.session.commit()
|
||||
flash('Modifications effectuées', 'ok')
|
||||
app.v5logger.info(f"[admin] <{current_user.name}> has edited <{user.name}>'s groups")
|
||||
return redirect(request.url)
|
||||
else:
|
||||
flash("Erreur lors de la modification des groupes", 'error')
|
||||
|
@ -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')
|
||||
|
|
|
@ -42,6 +42,7 @@ def adm_login_as():
|
|||
# Create a safe token to flee when needed
|
||||
s = Serializer(app.config["SECRET_KEY"])
|
||||
vandal_token = s.dumps(current_user.id)
|
||||
vandal_name = current_user.name
|
||||
|
||||
# Login and display some messages
|
||||
login_user(user)
|
||||
|
@ -51,9 +52,11 @@ def adm_login_as():
|
|||
else:
|
||||
flash(f"Connecté en tant que {user.name}")
|
||||
|
||||
app.v5logger.info(f"[admin] <{vandal_name}> has logged in as <{user.name}>")
|
||||
|
||||
# Return the response
|
||||
resp = make_response(redirect(url_for('index')))
|
||||
resp.set_cookie('vandale', vandal_token)
|
||||
resp.set_cookie('vandale', vandal_token, path='/')
|
||||
return resp
|
||||
|
||||
# Else return form
|
||||
|
@ -76,13 +79,22 @@ def adm_logout_as():
|
|||
abort(403)
|
||||
|
||||
user = Member.query.get(id)
|
||||
|
||||
# Send a notification to vandalized user
|
||||
current_user.notify(f"{user.name} a accédé à ce compte à des fins de modération",
|
||||
url_for('user', username=user.name))
|
||||
|
||||
# Switch back to admin
|
||||
victim_name = current_user.name
|
||||
logout_user()
|
||||
login_user(user)
|
||||
|
||||
app.v5logger.info(f"[admin] <{user.name}> has logged out from <{victim_name}>'s account")
|
||||
|
||||
if request.referrer:
|
||||
resp = make_response(redirect(request.referrer))
|
||||
else:
|
||||
resp = make_response(redirect(url_for('index')))
|
||||
|
||||
resp.set_cookie('vandale', '', expires=0)
|
||||
resp.set_cookie('vandale', '', expires=0, path='/')
|
||||
return resp
|
||||
|
|
|
@ -4,7 +4,7 @@ from app.utils.render import render
|
|||
from app.models.poll import Poll
|
||||
|
||||
@app.route('/admin/sondages', methods=['GET'])
|
||||
@priv_required('access-admin-panel')
|
||||
@priv_required('misc.admin-panel')
|
||||
def adm_polls():
|
||||
polls = Poll.query.order_by(Poll.end.desc()).all()
|
||||
|
||||
|
|
|
@ -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')
|
|
@ -4,6 +4,7 @@ from flask import request, redirect, url_for, abort, flash
|
|||
from app import app, db
|
||||
from config import V5Config
|
||||
from app.utils.render import render
|
||||
from app.utils.glados import say, BOLD
|
||||
from app.forms.forum import TopicCreationForm, AnonymousTopicCreationForm
|
||||
from app.models.forum import Forum
|
||||
from app.models.topic import Topic
|
||||
|
@ -73,6 +74,11 @@ def forum_page(f, page=1):
|
|||
current_user.update_trophies('new-post')
|
||||
|
||||
flash('Le sujet a bien été créé', 'ok')
|
||||
app.v5logger.info(f"<{t.author.name}> has created the topic #{t.id}")
|
||||
if f.is_default_accessible():
|
||||
say(f"Nouveau topic de {author.name} : {BOLD}{t.title}{BOLD}")
|
||||
say(url_for('forum_topic', f=f, page=(t, 1), _external=True))
|
||||
|
||||
return redirect(url_for('forum_topic', f=f, page=(t,1)))
|
||||
|
||||
# Paginate topic pages
|
||||
|
|
|
@ -5,6 +5,7 @@ from sqlalchemy import desc
|
|||
from app import app, db
|
||||
from config import V5Config
|
||||
from app.utils.render import render
|
||||
from app.utils.glados import say, BOLD
|
||||
from app.forms.forum import CommentForm, AnonymousCommentForm
|
||||
from app.models.thread import Thread
|
||||
from app.models.comment import Comment
|
||||
|
@ -31,7 +32,7 @@ def forum_topic(f, page):
|
|||
else:
|
||||
form = AnonymousCommentForm()
|
||||
|
||||
if form.validate_on_submit() and (
|
||||
if form.validate_on_submit() and not t.thread.locked and (
|
||||
V5Config.ENABLE_GUEST_POST or \
|
||||
(current_user.is_authenticated and current_user.can_post_in_forum(f))):
|
||||
|
||||
|
@ -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)))
|
||||
|
|
|
@ -5,7 +5,7 @@ from app.utils.render import render
|
|||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return render('index.html')
|
||||
return render('index.html', styles=["+css/homepage.css"])
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
|
|
|
@ -34,7 +34,8 @@ def poll_vote(poll_id):
|
|||
db.session.add(answer)
|
||||
db.session.commit()
|
||||
|
||||
flash('Le vote a été pris en compte', 'info')
|
||||
flash('Le vote a été pris en compte', 'ok')
|
||||
app.v5logger.info(f"<{current_user.name}> has voted on the poll #{poll.id}")
|
||||
|
||||
if request.referrer:
|
||||
return redirect(request.referrer)
|
||||
|
|
|
@ -9,6 +9,7 @@ from app.models.topic import Topic
|
|||
from app.models.user import Member
|
||||
from app.utils.render import render
|
||||
from app.utils.check_csrf import check_csrf
|
||||
from app.utils.priv_required import priv_required
|
||||
from app.forms.forum import CommentEditForm, AnonymousCommentEditForm, TopicEditForm
|
||||
from app.forms.post import MovePost, SearchThread
|
||||
from wtforms import BooleanField
|
||||
|
@ -24,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)
|
|
@ -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)))
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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%);
|
||||
|
|
|
@ -1,135 +1,100 @@
|
|||
.home-title {
|
||||
margin: 20px 0;
|
||||
padding: 10px 5%;
|
||||
background: #bf1c11;
|
||||
box-shadow: 0 2px 2px rgba(0,0,0,.3);
|
||||
border-top: 10px solid #ab170c;
|
||||
}
|
||||
.home-title h1 {
|
||||
margin-top: 0;
|
||||
color: #ffffff;
|
||||
border-color: #ffffff;
|
||||
}
|
||||
.home-title p {
|
||||
margin-bottom: 0;
|
||||
text-align: justify;
|
||||
color: #ffffff;
|
||||
}
|
||||
.home-title a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.home-pinned-content > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.home-pinned-content h2 {
|
||||
display: block;
|
||||
margin: 5px 0;
|
||||
font-size: 18px;
|
||||
font-family: NotoSans;
|
||||
font-weight: 200;
|
||||
line-height: 20px;
|
||||
}
|
||||
.home-pinned-content a {
|
||||
display: block;
|
||||
}
|
||||
.home-pinned-content a:hover img,
|
||||
.home-pinned-content a:focus img {
|
||||
filter: blur(3px);
|
||||
}
|
||||
.home-pinned-content a:hover div,
|
||||
.home-pinned-content a:focus div {
|
||||
padding: 200px 5% 10px 5%;
|
||||
background-image: linear-gradient(180deg,transparent 0,rgba(0,0,0,.7)40px,rgba(0,0,0,.8));
|
||||
}
|
||||
.home-pinned-content img {
|
||||
width: 100%;
|
||||
filter: blur(0px);
|
||||
}
|
||||
.home-pinned-content article {
|
||||
flex-grow: 1;
|
||||
margin: 0 1px;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
max-width: 250px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.home-pinned-content article div {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
z-index: 3;
|
||||
.home-pinned-content {
|
||||
width: 90%;
|
||||
margin: 0;
|
||||
padding: 30px 5% 10px 5%;
|
||||
color: #ffffff;
|
||||
text-shadow: 1px 1px 0 rgba(0,0,0,.6);
|
||||
background-image: linear-gradient(180deg,transparent 0,rgba(0,0,0,.7)40px,rgba(0,0,0,.8));
|
||||
display: grid;
|
||||
grid-template-areas: 'banner news''welcome news''shout news''projects projects';
|
||||
grid-template-rows: auto auto minmax(200px,1fr)auto;
|
||||
grid-template-columns: 4fr 3fr;
|
||||
}
|
||||
.home-articles {
|
||||
.home-pinned-content > * {
|
||||
margin: 10px 20px;
|
||||
}
|
||||
.home-pinned-content > * h1 {
|
||||
font-size: 18px;
|
||||
}
|
||||
@media screen and (max-width:1449px) {
|
||||
.home-pinned-content {
|
||||
width: 97%;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width:1199px) {
|
||||
.home-pinned-content {
|
||||
width: 100%;
|
||||
grid-template-areas: 'welcome''banner''news''shout''projects';
|
||||
grid-template-rows: auto;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.home-banner {
|
||||
grid-area: banner;
|
||||
text-align: center;
|
||||
}
|
||||
.home-banner img {
|
||||
max-width: 100%;
|
||||
}
|
||||
.home-welcome {
|
||||
grid-area: welcome;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.home-articles > div {
|
||||
.home-welcome h1 {
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.home-welcome ul {
|
||||
padding-left: 20px;
|
||||
}
|
||||
.home-welcome div {
|
||||
flex-grow: 1;
|
||||
max-width: 48%;
|
||||
}
|
||||
.home-articles h1 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.home-articles h1 a {
|
||||
padding: 0;
|
||||
font-family: NotoSans;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: #234d5f;
|
||||
}
|
||||
.home-articles h1 a:hover,
|
||||
.home-articles h1 a:focus {
|
||||
padding-right: 10px;
|
||||
}
|
||||
.home-articles p {
|
||||
.home-welcome h2 {
|
||||
margin: 5px 0;
|
||||
text-align: justify;
|
||||
color: #808080;
|
||||
}
|
||||
.home-articles article {
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
.home-news {
|
||||
grid-area: news;
|
||||
}
|
||||
.home-news ul {
|
||||
padding: 0;
|
||||
}
|
||||
.home-news li {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(0,0,0,.2);
|
||||
flex-wrap: nowrap;
|
||||
padding: 10px 0;
|
||||
border-bottom: var(--hr-border);
|
||||
}
|
||||
.home-articles article > img {
|
||||
float: left;
|
||||
margin-right: 10px;
|
||||
flex-shrink: 0;
|
||||
.home-news li > a {
|
||||
align-self: baseline;
|
||||
}
|
||||
.home-articles article > img.screeshot {
|
||||
width: 128px;
|
||||
height: 64px;
|
||||
.home-news li img {
|
||||
max-width: 100px;
|
||||
max-height: 100px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.home-articles article > div {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
.home-articles article h3 {
|
||||
.home-news li h3 {
|
||||
margin: 0;
|
||||
color: #424242;
|
||||
font-weight: normal;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
font-family: Cantarell;
|
||||
}
|
||||
.home-articles article a:hover,
|
||||
.home-articles article a:focus {
|
||||
text-decoration: underline;
|
||||
.home-news li .date {
|
||||
margin: 4px 0 10px 0;
|
||||
}
|
||||
.home-articles .metadata {
|
||||
margin: 0;
|
||||
color: #22292c;
|
||||
.home-news li div {
|
||||
font-size: 13px;
|
||||
line-height: 150%;
|
||||
}
|
||||
.home-articles .metadata a {
|
||||
color: #22292c;
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
@media screen and (max-width:499px) {
|
||||
.home-news li {
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
.home-shoutbox {
|
||||
grid-area: shout;
|
||||
}
|
||||
.home-projects {
|
||||
grid-area: projects;
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
../../../submodules/v5shoutbox/style.css
|
|
@ -218,4 +218,8 @@ hr.signature {
|
|||
border-radius: calc(4.5em);
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
.locked {
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
}
|
|
@ -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%);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
@ -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 };
|
|
@ -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: 'الأعلام'
|
||||
}
|
||||
}
|
|
@ -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'
|
||||
}
|
||||
}
|
|
@ -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'
|
||||
}
|
||||
}
|
|
@ -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'
|
||||
}
|
||||
}
|
|
@ -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'
|
||||
}
|
||||
}
|
|
@ -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: 'झंडे'
|
||||
}
|
||||
}
|
|
@ -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'
|
||||
}
|
||||
}
|
|
@ -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'
|
||||
}
|
||||
}
|
|
@ -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'
|
||||
}
|
||||
}
|
|
@ -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'
|
||||
}
|
||||
}
|
|
@ -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'
|
||||
}
|
||||
}
|
|
@ -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'
|
||||
}
|
||||
}
|
|
@ -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'
|
||||
}
|
||||
}
|
|
@ -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: 'Флаги'
|
||||
}
|
||||
}
|
|
@ -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'
|
||||
}
|
||||
}
|
|
@ -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: '旗帜'
|
||||
}
|
||||
}
|
|
@ -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
|
@ -0,0 +1 @@
|
|||
../../../submodules/v5shoutbox/v5shoutbox.js
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Gestion du compte" %}
|
||||
|
||||
{% block title %}
|
||||
<h1>Gestion du compte</h1>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Réinitialiser le mot de passe" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="form" style="width:40%;">
|
||||
<h1>Réinitialiser le mot de passe</h1>
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Suppression du compte" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="form">
|
||||
<h1>Suppression du compte</h2>
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Connexion" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="form" style="width:40%;">
|
||||
<h1>Connexion</h1>
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Notifications" %}
|
||||
|
||||
{% block title %}
|
||||
<h1>Notifications</h1>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
{% extends "base/base.html" %}
|
||||
{% import "widgets/poll.html" as poll_widget with context %}
|
||||
|
||||
{% set tabtitle = "Gestion des sondages" %}
|
||||
|
||||
{% block title %}
|
||||
<h1>Gestion des sondages</h1>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Inscription" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="form" style="width:40%;">
|
||||
<h1>Inscription</h1>
|
||||
|
@ -37,7 +39,7 @@
|
|||
{% endfor %}
|
||||
</div>
|
||||
<div>
|
||||
{{ form.guidelines.label }}
|
||||
<label for="guidelines">J'accepte les <a href="#">CGU</a></label>
|
||||
{{ form.guidelines() }}
|
||||
{% for error in form.guidelines.errors %}
|
||||
<span class="msgerror">{{ error }}</span>
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Réinitialiser le mot de passe" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="form" style="width:40%;">
|
||||
<h1>Réinitialiser le mot de passe</h1>
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
{% extends "base/base.html" %}
|
||||
{% import "widgets/user.html" as widget_member %}
|
||||
|
||||
{% set tabtitle = "Profil de " + member.name %}
|
||||
|
||||
{% block title %}
|
||||
<h1>Profil de {{ member.name }}</h1>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Inscription réussie" %}
|
||||
|
||||
{% block content %}
|
||||
<section>
|
||||
<div>
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Administration - Pièces-jointes" %}
|
||||
|
||||
{% block title %}
|
||||
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Pièces jointes</h1>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Administration - Configuration du site" %}
|
||||
|
||||
{% block title %}
|
||||
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Configuration du site</h1>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Administration - Suppression du compte de " + user.name %}
|
||||
|
||||
{% block title %}
|
||||
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Suppression du compte de '{{ user.name }}'</h1>
|
||||
{% endblock %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -16,6 +16,8 @@
|
|||
{% endfor %}
|
||||
{% endmacro %}
|
||||
|
||||
{% set tabtitle = "Administration - Forums" %}
|
||||
|
||||
{% block title %}
|
||||
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Forums</h1>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Administration - Groupes et privilèges" %}
|
||||
|
||||
{% block title %}
|
||||
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Groupes et privilèges</h1>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Panneau d’administration" %}
|
||||
|
||||
{% block title %}
|
||||
<h1>Panneau d'administration</h1>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Administration - Vandaliser un compte" %}
|
||||
|
||||
{% block title %}
|
||||
<h1>Vandaliser un compte</h1>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Administration - Liste des membres" %}
|
||||
|
||||
{% block title %}
|
||||
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Liste des membres</h1>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
{% extends "base/base.html" %}
|
||||
{% import "widgets/poll.html" as poll_widget with context %}
|
||||
|
||||
{% set tabtitle = "Administration - Gestion des sondages" %}
|
||||
|
||||
{% block title %}
|
||||
<h1>Gestion des sondages</h1>
|
||||
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Gestion des sondages</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
|
|
@ -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" %}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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') %}
|
||||
|
|
|
@ -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 %}
|
|
@ -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>
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>S’inscrire 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 y’aura 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>
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Recherche avancée" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="form">
|
||||
<h1>Recherche avancée</h1>
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = "Outils" %}
|
||||
|
||||
{% block title %}
|
||||
<h1>Outils</h1>
|
||||
{% endblock %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
../../../submodules/v5shoutbox/widget.html
|
|
@ -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",
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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}")
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
|
@ -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
Loading…
Reference in New Issue