Merge branch 'preprod' of gitea.planet-casio.com:devs/PCv5
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)
|
||||
}
|
||||
}
|
||||