forked from devs/PCv5
Merge branch 'preprod' of gitea.planet-casio.com:devs/PCv5
This commit is contained in:
commit
21b863e0e2
|
@ -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":
|
||||
|
|
|
@ -397,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
|
||||
|
@ -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)
|
||||
|
|
|
@ -84,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)))
|
||||
|
@ -111,6 +115,10 @@ def delete_post(postid):
|
|||
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()
|
||||
|
||||
|
@ -126,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)
|
||||
|
||||
|
@ -149,6 +162,9 @@ def set_post_topcomment(postid):
|
|||
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)
|
||||
|
||||
|
@ -191,8 +207,11 @@ 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)
|
||||
|
||||
|
@ -212,8 +231,10 @@ def lock_thread(postid):
|
|||
db.session.commit()
|
||||
|
||||
if post.thread.locked:
|
||||
flash(f"Le thread a été verrouillé")
|
||||
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é")
|
||||
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
|
||||
|
||||
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
|
|
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
|
|
@ -39,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,6 +1,6 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = f"Administration - Suppression du compte de " + user.name %}
|
||||
{% 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>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% set tabtitle = f"Administration - Édition du compte de " + user.name %}
|
||||
{% 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>
|
||||
|
|
|
@ -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" %}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<head>
|
||||
<title>{{ tabtitle or "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="/forum/actus/projets/2/fin/avancees-de-la-v5" class="button bg-error">Infos sur l'avancée de la v5</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>
|
|
@ -8,11 +8,114 @@
|
|||
|
||||
{% block content %}
|
||||
<section class="home-pinned-content">
|
||||
<div>
|
||||
<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>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
{% import "widgets/editor.html" as widget_editor %}
|
||||
{% import "widgets/tag_selector.html" as widget_tag_selector with context %}
|
||||
|
||||
{% set tabtitle = f"Programmes - Soumettre un programme" %}
|
||||
{% set tabtitle = "Programmes - Soumettre un programme" %}
|
||||
|
||||
{% block title %}
|
||||
<a href="{{ url_for('program_index') }}">Programmes</a> » <h1>Soumettre un programme</h1>
|
||||
|
|
|
@ -32,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 %}
|
||||
|
|
|
@ -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_)
|
||||
|
|
17
config.py
17
config.py
|
@ -1,4 +1,5 @@
|
|||
import os
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
try:
|
||||
|
@ -34,7 +35,7 @@ class FlaskApplicationSettings(object):
|
|||
+ LocalConfig.DB_NAME
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
MAIL_DEFAULT_SENDER = "noreply@v5.planet-casio.com"
|
||||
MAIL_DEFAULT_SENDER = "noreply-v5@planet-casio.com"
|
||||
MAIL_SUPPRESS_SEND = None
|
||||
|
||||
# Only send cookies over HTTPS connections (use only if HTTPS is enabled)
|
||||
|
@ -83,6 +84,20 @@ class DefaultConfig(object):
|
|||
SLOW_REQUEST_THRESHOLD = 0.400 # s
|
||||
# Whether to enable flask-debug-toolbar
|
||||
ENABLE_FLASK_DEBUG_TOOLBAR = False
|
||||
# Tab title prefix. Useful to dissociate local/dev/prod tabs
|
||||
TABTITLE_PREFIX = ""
|
||||
|
||||
@staticmethod
|
||||
def v5logger():
|
||||
""" A fully configured logger for v5 activity logs """
|
||||
logger = logging.getLogger('v5')
|
||||
logger.setLevel(logging.DEBUG)
|
||||
formatter = logging.Formatter('[%(asctime)s] %(levelname)s: %(message)s')
|
||||
handler = logging.FileHandler('v5_activity.log')
|
||||
handler.setLevel(logging.DEBUG)
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
return logger
|
||||
|
||||
|
||||
class V5Config(LocalConfig, DefaultConfig):
|
||||
|
|
Loading…
Reference in New Issue