Merge branch 'preprod' of gitea.planet-casio.com:devs/PCv5

This commit is contained in:
Darks 2023-06-07 21:37:12 +02:00
commit a5b2933727
Signed by: Darks
GPG Key ID: 7515644268BE1433
152 changed files with 9039 additions and 840 deletions

View File

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

View File

@ -4,19 +4,21 @@ from flask_migrate import Migrate
from flask_login import LoginManager
from flask_mail import Mail
from flask_wtf.csrf import CSRFProtect
from config import Config
from flask_crontab import Crontab
from config import FlaskApplicationSettings, V5Config
app = Flask(__name__)
app.config.from_object(Config)
app.config.from_object(FlaskApplicationSettings)
# Check security of secret
if Config.SECRET_KEY == "a-random-secret-key":
if FlaskApplicationSettings.SECRET_KEY == "a-random-secret-key":
raise Exception("Please use a strong secret key!")
db = SQLAlchemy(app)
migrate = Migrate(app, db)
mail = Mail(app)
csrf = CSRFProtect(app)
crontab = Crontab(app)
login = LoginManager(app)
login.login_view = 'login'
@ -27,6 +29,7 @@ login.login_message = "Veuillez vous authentifier avant de continuer."
from app.utils.converters import *
app.url_map.converters['forum'] = ForumConverter
app.url_map.converters['topicpage'] = TopicPageConverter
app.url_map.converters['programpage'] = ProgramPageConverter
# Register routes
from app import routes
@ -36,3 +39,12 @@ from app.utils import filters
# Register processors
from app import processors
# Register scheduled jobs
from app import jobs
# Enable flask-debug-toolbar if requested
if V5Config.ENABLE_FLASK_DEBUG_TOOLBAR:
from flask_debugtoolbar import DebugToolbarExtension
app.config['DEBUG_TB_PROFILER_ENABLED'] = True
toolbar = DebugToolbarExtension(app)

View File

@ -25,6 +25,7 @@
# delete.accounts
# delete.shared-files
# move.posts
# lock.threads
#
# Shoutbox:
# shoutbox.kick
@ -58,7 +59,7 @@
publish.schedule-posts publish.pin-posts publish.shared-files
edit.posts edit.tests edit.accounts edit.trophies
delete.posts delete.tests delete.accounts delete.shared-files
move.posts
move.posts lock.threads
shoutbox.kick shoutbox.ban
misc.unlimited-pms misc.dev-infos misc.admin-panel
misc.no-upload-limits misc.arbitrary-login
@ -69,7 +70,7 @@
privs: forum.access.admin
edit.posts edit.tests
delete.posts delete.tests
move.posts
move.posts lock.threads
shoutbox.kick shoutbox.ban
misc.unlimited-pms misc.no-upload-limits
-

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

@ -0,0 +1,108 @@
# This is a list of all tags, sorted by category. The category names are used
# to name CSS rules and shouldn't be changed directly.
# The following category groups calculators by common compatibility properties.
# Each comment indicates why the group should exist on its own rather than
# being merged with another one.
calc:
# Middle-school level, only basic algorithms; a unique property in this list.
fx92:
pretty: fx-92 Scientifique Collège+
# Some of the most limited Graph models, no add-ins.
g25:
pretty: Graph 25/25+E/25+EII
# The whole series with more Basic constructs than g25, but SH3 for add-ins
# We don't separate based on whether an OS update is required (deemed safe)
gsh3:
pretty: Graph 35+/75/85/95 (SH3)
# Same as gsh3, but with SH4 for add-ins; support CasioPython
gsh4:
pretty: Graph 35+/35+E/75+/75+E (SH4)
# Like gsh3, but has Python; also; issues with the display and MonochromLib
g35+e2:
pretty: Graph 35+E II
# Color display, nothing like the previous models
cg20:
pretty: fx-CG 10/20/Prizm
# Like cg20, but has Python, and some incompatibilities on add-in
g90+e:
pretty: Graph 90+E
# Different series entirely; has an SDK for add-ins
cp300:
pretty: Classpad 300/330
# Like cp300, but does not have an SDK
cp330+:
pretty: Classpad 330+
# Color display, entirely new model; no SDK
cp400:
pretty: Classpad 400/400+E
lang:
basic:
pretty: Basic CASIO
cbasic:
pretty: C.Basic
python:
pretty: Python
c:
pretty: C/C++ (add-in)
lua:
pretty: LuaFX
other:
pretty: "Langage: autre"
games:
action:
pretty: Action
adventure:
pretty: Aventure
fighting:
pretty: Combat
narrative:
pretty: Narratif
other:
pretty: "Jeu: autre"
platform:
pretty: Plateforme
puzzle:
pretty: Puzzle
rpg:
pretty: RPG
rythm:
pretty: Rythme
shooting:
pretty: Tir/FPS
simulation:
pretty: Simulation
sport:
pretty: Sport
strategy:
pretty: Stratégie
survival:
pretty: Survie
tools:
conversion:
pretty: Outil de conversion
graphics:
pretty: Outil graphique
science:
pretty: Outil scientifique
programming:
pretty: Outil pour programmer
other:
pretty: "Outil: autre"
courses:
math:
pretty: Maths
physics:
pretty: Physique
engineering:
pretty: SI/Électronique
economics:
pretty: Économie
informatics:
pretty: Informatique
other:
pretty: "Cours: autre"

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

@ -0,0 +1,15 @@
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, TextAreaField, MultipleFileField
from wtforms.validators import InputRequired, Length
import app.utils.validators as vf
from app.utils.antibot_field import AntibotField
from app.utils.tag_field import TagListField
from app.forms.forum import CommentForm
class ProgramCreationForm(CommentForm):
name = StringField('Nom du programme',
validators=[InputRequired(), Length(min=3, max=64)])
tags = TagListField('Liste de tags')
submit = SubmitField('Soumettre le programme')

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

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

View File

@ -0,0 +1,11 @@
from app import db, crontab
from app.models.program import Program
from datetime import datetime
@crontab.job(minute="0", hour="4")
def update_progrank():
for p in Program.query.all():
p.progrank = 0
p.progrank_date = datetime.now()
db.session.merge(p)
db.session.commit()

View File

@ -4,3 +4,5 @@ from app.models.forum import Forum
from app.models.topic import Topic
from app.models.notification import Notification
from app.models.program import Program
from app.models.tag import Tag
from app.models.event import Event

View File

@ -1,13 +1,16 @@
from werkzeug.utils import secure_filename
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import backref
from app import db
from app.utils.filesize import filesize
from config import V5Config
import os
import uuid
class Attachment(db.Model):
__tablename__ = 'attachment'
id = db.Column(db.Integer, primary_key=True)
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Original name of the file
name = db.Column(db.Unicode(64))
@ -15,7 +18,8 @@ class Attachment(db.Model):
# The comment linked with
comment_id = db.Column(db.Integer, db.ForeignKey('comment.id'),
nullable=False, index=True)
comment = db.relationship('Comment', backref=backref('attachments'))
comment = db.relationship('Comment', back_populates='attachments',
foreign_keys=comment_id)
# The size of the file
size = db.Column(db.Integer)
@ -24,11 +28,11 @@ class Attachment(db.Model):
@property
def path(self):
return os.path.join(V5Config.DATA_FOLDER, "attachments",
f"{self.id:05}", self.name)
f"{self.id}", self.name)
@property
def url(self):
return f"/fichiers/{self.id:05}/{self.name}"
return f"/fichiers/{self.id}/{self.name}"
def __init__(self, file, comment):

View File

@ -1,5 +1,6 @@
from app import db
from app.models.post import Post
from app.models.attachment import Attachment
from sqlalchemy.orm import backref
@ -20,12 +21,19 @@ class Comment(Post):
backref=backref('comments', lazy='dynamic'),
foreign_keys=thread_id)
# attachments (relation from Attachment)
attachments = db.relationship('Attachment', back_populates='comment',
lazy='joined')
@property
def is_top_comment(self):
return self.id == self.thread.top_comment_id
@property
def is_metacontent(self):
"""Whether if this post is metacontent (topic, program) or actual content"""
return False
def __init__(self, author, text, thread):
"""
Create a new Comment in a thread.
@ -53,5 +61,17 @@ class Comment(Post):
db.session.commit()
db.session.delete(self)
def create_attachments(self, multiple_file_field_data):
"""Create attachements from a form's MultipleFileField.data."""
attachments = []
for file in multiple_file_field_data:
if file.filename != "":
a = Attachment(file, self)
attachments.append((a, file))
db.session.add(a)
db.session.commit()
for a, file in attachments:
a.set_file(file)
def __repr__(self):
return f'<Comment: #{self.id}>'

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

@ -0,0 +1,11 @@
from app import db
class Event(db.Model):
__tablename__ = 'event'
id = db.Column(db.Integer, primary_key=True)
# Pretty event name, eg. "CPC #28"
name = db.Column(db.Unicode(128))
# Main topic, used to automatically insert links
main_topic = db.Column(db.Integer, db.ForeignKey('topic.id'))

View File

@ -21,11 +21,20 @@ class Post(db.Model):
index=True)
author = db.relationship('User', backref="posts", foreign_keys=author_id)
# Tags, for programs and tutorials
tags = db.relationship('Tag', back_populates='post', lazy='joined')
__mapper_args__ = {
'polymorphic_identity': __tablename__,
'polymorphic_on': type
}
@property
def is_metacontent(self):
"""Whether if this post is metacontent (topic, program) or actual content"""
return True
def __init__(self, author):
"""
Create a new Post.

View File

@ -16,16 +16,18 @@ class SpecialPrivilege(db.Model):
id = db.Column(db.Integer, primary_key=True)
# Member that is granted the privilege
mid = db.Column(db.Integer, db.ForeignKey('member.id'), index=True)
member_id = db.Column(db.Integer, db.ForeignKey('member.id'), index=True)
member = db.relationship('Member', back_populates="special_privs",
foreign_keys=member_id)
# Privilege name
priv = db.Column(db.String(64))
def __init__(self, member, priv):
self.mid = member.id
self.member = member
self.priv = priv
def __repr__(self):
return f'<Privilege: {self.priv} of member #{self.mid}>'
return f'<Privilege: {self.priv} of member #{self.member_id}>'
# Group: User group, corresponds to a community role and a set of privileges
@ -43,7 +45,9 @@ class Group(db.Model):
description = db.Column(db.UnicodeText)
# List of members (lambda delays evaluation)
members = db.relationship('Member', secondary=lambda: GroupMember,
back_populates='groups')
back_populates='groups', lazy='joined')
# List of privileges
privileges = db.relationship('GroupPrivilege', back_populates='group')
def __init__(self, name, css, descr):
self.name = name
@ -57,7 +61,7 @@ class Group(db.Model):
* Group privileges
"""
for gp in GroupPrivilege.query.filter_by(gid=self.id).all():
for gp in self.privileges:
db.session.delete(gp)
db.session.commit()
@ -65,8 +69,7 @@ class Group(db.Model):
db.session.commit()
def privs(self):
gps = GroupPrivilege.query.filter_by(gid=self.id).all()
return sorted(gp.priv for gp in gps)
return sorted(gp.priv for gp in self.privileges)
def __repr__(self):
return f'<Group: {self.name}>'
@ -77,15 +80,17 @@ GroupMember = db.Table('group_member', db.Model.metadata,
db.Column('gid', db.Integer, db.ForeignKey('group.id')),
db.Column('uid', db.Integer, db.ForeignKey('member.id')))
# Many-to-many relationship for privileges granted to groups
# GroupPrivilege: A list of privileges for groups, materialized as a table
class GroupPrivilege(db.Model):
__tablename__ = 'group_privilege'
id = db.Column(db.Integer, primary_key=True)
gid = db.Column(db.Integer, db.ForeignKey('group.id'))
group_id = db.Column(db.Integer, db.ForeignKey('group.id'))
group = db.relationship('Group', back_populates='privileges',
foreign_keys=group_id)
priv = db.Column(db.String(64))
def __init__(self, group, priv):
self.gid = group.id
self.group = group
self.priv = priv

View File

@ -9,30 +9,48 @@ class Program(Post):
id = db.Column(db.Integer, db.ForeignKey('post.id'), primary_key=True)
# Program name
title = db.Column(db.Unicode(128))
name = db.Column(db.Unicode(128))
# Author, when different from the poster
real_author = db.Column(db.Unicode(128))
# Version
version = db.Column(db.Unicode(64))
# Approximate size as indicated by poster
size = db.Column(db.Unicode(64))
# License identifier
license = db.Column(db.String(32))
# TODO: Category (games/utilities/lessons)
# TODO: Tags
# TODO: Compatible calculator models
# Label de qualité
label = db.Column(db.Boolean, nullable=False, server_default="FALSE")
# Event for which the program was posted
event = db.Column(db.Integer, db.ForeignKey('event.id'), nullable=True)
# TODO: Number of downloads
# Thread with the program description (top comment) and comments
thread_id = db.Column(db.Integer,db.ForeignKey('thread.id'),nullable=False)
thread = db.relationship('Thread', foreign_keys=thread_id,
back_populates='owner_program')
# TODO: Number of views, statistics, attached files, etc
# Progrank, and last date of progrank update
progrank = db.Column(db.Integer)
progrank_date = db.Column(db.DateTime)
def __init__(self, author, title, thread):
# Implicit attributes:
# * tags (inherited from Post)
# * attachements (available at thread.top_comment.attachments)
def __init__(self, author, name, thread):
"""
Create a Program.
Arguments:
author -- post author (User, though only Members can post)
title -- program title (unicode string)
name -- program name (unicode string)
thread -- discussion thread attached to the topic
"""
Post.__init__(self, author)
self.title = title
self.name = name
self.thread = thread
@staticmethod
@ -44,4 +62,4 @@ class Program(Post):
db.session.delete(self)
def __repr__(self):
return f'<Program: #{self.id} "{self.title}">'
return f'<Program: #{self.id} "{self.name}">'

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

@ -0,0 +1,56 @@
from app import db
class TagInformation(db.Model):
"""Detailed information about tags, by dot-string tag identifier."""
__tablename__ = 'tag_information'
# The ID is the dot-string of the tag (eg. "calc.g35+e2")
id = db.Column(db.String(64), primary_key=True)
# List of uses. Note how we load tag information along individual tags, but
# we don't load uses unless the field is accessed.
uses = db.relationship('Tag', back_populates='tag', lazy='dynamic')
# Pretty name
pretty = db.Column(db.String(64))
# ... any other static information about tags
def __init__(self, id):
self.id = id
def category(self):
return self.id.split(".", 1)[0]
@staticmethod
def all_tags():
all_tags = {}
for ti in TagInformation.query.all():
ctgy = ti.category()
if ctgy not in all_tags:
all_tags[ctgy] = []
all_tags[ctgy].append(ti)
return all_tags
class Tag(db.Model):
"""Association between a Post and a dot-string tag identifier."""
__tablename__ = 'tag'
id = db.Column(db.Integer, primary_key=True)
# Tagged post
post_id = db.Column(db.Integer, db.ForeignKey('post.id'), index=True)
post = db.relationship('Post', back_populates='tags', foreign_keys=post_id)
# Tag name. Note how we always load the information along the tag, but not
# the other way around.
tag_id = db.Column(db.String(64), db.ForeignKey('tag_information.id'),
index=True)
tag = db.relationship('TagInformation', back_populates='uses',
foreign_keys=tag_id, lazy='joined')
def __init__(self, post, tag):
self.post = post
if isinstance(tag, str):
tag = TagInformation.query.filter_by(id=tag).one()
self.tag = tag

View File

@ -18,6 +18,9 @@ class Thread(db.Model):
owner_topic = db.relationship('Topic')
owner_program = db.relationship('Program')
# Whether the thread is locked
locked = db.Column(db.Boolean, default=False)
# Other fields populated automatically through relations:
# <comments> The list of comments (of type Comment)
@ -53,6 +56,13 @@ class Thread(db.Model):
return self.owner_program[0]
return None
def is_default_accessible(self):
if self.owner_program != []:
return True
if self.owner_topic != []:
return self.owner_topic[0].forum.is_default_accessible()
return False
def delete(self):
"""Recursively delete thread and all associated contents."""
# Remove reference to top comment

View File

@ -1,8 +1,7 @@
from datetime import date
from flask import url_for
from flask_login import UserMixin
from sqlalchemy import func as SQLfunc
from os.path import isfile
from PIL import Image
import werkzeug.security
from app import app, db
from app.models.priv import SpecialPrivilege, Group, GroupMember, \
@ -19,7 +18,9 @@ import app.utils.ldap as ldap
from app.utils.unicode_names import normalize
from config import V5Config
import werkzeug.security
from os.path import isfile
from datetime import date
from PIL import Image
import math
import app
import os
@ -89,19 +90,9 @@ class Member(User):
xp = db.Column(db.Integer)
register_date = db.Column(db.Date, default=date.today)
avatar_id = db.Column(db.Integer, default=0)
@property
def avatar(self):
return f'{self.id}_{self.avatar_id}.png'
@property
def level(self):
level = math.asinh(self.xp / 1000) * (100 / math.asinh(10))
return int(level), int(level * 100) % 100
# Groups and related privileges
groups = db.relationship('Group', secondary=GroupMember,
back_populates='members')
back_populates='members', lazy='joined')
# Personal information, all optional
bio = db.Column(db.UnicodeText)
@ -115,11 +106,21 @@ class Member(User):
# Settings
newsletter = db.Column(db.Boolean, default=False)
theme = db.Column(db.Unicode(32))
avatar_id = db.Column(db.Integer, default=0)
# Relations
trophies = db.relationship('Trophy', secondary=TrophyMember,
back_populates='owners')
# Specially-offered privileges (use self.special_privileges())
special_privs = db.relationship('SpecialPrivilege',
back_populates='member', lazy='joined')
# Other fields populated automatically through relations:
# <notifications> List of unseen notifications (of type Notification)
# <polls> Polls created by the member (of class Poll)
# Access to polymorphic posts
# TODO: Check that the query uses the double index on Post.{author_id,type}
def comments(self):
@ -129,9 +130,22 @@ class Member(User):
def programs(self):
return db.session.query(Program).filter(Post.author_id==self.id).all()
# Other fields populated automatically through relations:
# <notifications> List of unseen notifications (of type Notification)
# <polls> Polls created by the member (of class Poll)
@property
def avatar_filename(self):
return f'{self.id}_{self.avatar_id}.png'
@property
def avatar_url(self):
if self.avatar_id == 0:
return url_for('static', filename='images/default_avatar.png')
else:
return url_for('avatar',filename=self.avatar_filename)
@property
def level(self):
level = math.asinh(self.xp / 1000) * (100 / math.asinh(10))
return int(level), int(level * 100) % 100
def __init__(self, name, email, password):
"""Register a new user."""
@ -185,7 +199,7 @@ class Member(User):
Deletes the user, but not the posts; use either transfer_posts() or
delete_posts() before calling this.
"""
for sp in SpecialPrivilege.query.filter_by(mid=self.id).all():
for sp in self.special_privs:
db.session.delete(sp)
self.trophies = []
@ -198,17 +212,16 @@ class Member(User):
def priv(self, priv):
"""Check whether the member has the specified privilege."""
if SpecialPrivilege.query.filter_by(mid=self.id, priv=priv).first():
if priv in self.special_privileges():
return True
return db.session.query(Group, GroupPrivilege).filter(
Group.id.in_([g.id for g in self.groups]),
GroupPrivilege.gid==Group.id,
GroupPrivilege.priv==priv).first() is not None
for g in self.groups:
if priv in g.privs():
return True
return False
def special_privileges(self):
"""List member's special privileges."""
sp = SpecialPrivilege.query.filter_by(mid=self.id).all()
return sorted(row.priv for row in sp)
"""List member's special privileges as list of strings."""
return sorted([p.priv for p in self.special_privs])
def can_access_forum(self, forum):
"""Whether this member can read the forum's contents."""
@ -250,6 +263,17 @@ class Member(User):
post = comment.thread.owner_post
return self.can_edit_post(post) and (comment.author == post.author)
def can_lock_thread(self, post):
"""Whether this member can lock the thread associated with the post"""
print(post.id, post.is_metacontent)
if not post.is_metacontent:
return False
return self.priv("lock.threads")
def can_access_file(self, file):
"""Whether this member can access the file."""
return self.can_access_post(file.comment)
def update(self, **data):
"""
Update all or part of the user's metadata. The [data] dictionary
@ -308,20 +332,22 @@ class Member(User):
def set_avatar(self, avatar):
# Save old avatar filepath
old_avatar = os.path.join(V5Config.DATA_FOLDER, "avatars", self.avatar)
old_avatar = os.path.join(V5Config.DATA_FOLDER, "avatars",
self.avatar_filename)
# Resize & convert image
size = 128, 128
im = Image.open(avatar)
im.thumbnail(size, Image.ANTIALIAS)
im.thumbnail((128, 128), Image.ANTIALIAS)
# Change avatar id
# TODO: verify concurrency behavior
current_id = db.session.query(SQLfunc.max(Member.avatar_id)).first()[0]
self.avatar_id = current_id + 1
db.session.merge(self)
db.session.commit()
# Save the new avatar
im.save(os.path.join(V5Config.DATA_FOLDER, "avatars", self.avatar),
'PNG')
im.save(os.path.join(V5Config.DATA_FOLDER, "avatars",
self.avatar_filename), 'PNG')
# If nothing has failed, remove old one (allow failure to regularize
# exceptional situations like missing avatar or folder migration)
try:
@ -549,8 +575,7 @@ class Member(User):
# TODO: Trophy "actif"
if context in ["on-profile-update", None]:
if isfile(os.path.join(
V5Config.DATA_FOLDER, "avatars", self.avatar)):
if self.avatar_id != 0:
self.add_trophy("Artiste")
else:
self.del_trophy("Artiste")

View File

@ -16,13 +16,17 @@ def menu_processor():
main_forum = Forum.query.filter_by(parent=None).first()
# Constructing last active topics
raw = db.session.execute( """SELECT topic.id FROM topic
rows = db.session.execute( """SELECT topic.id FROM topic
INNER JOIN comment ON topic.thread_id = comment.thread_id
INNER JOIN post ON post.id = comment.id
GROUP BY topic.id
ORDER BY MAX(post.date_created) DESC
LIMIT 20;""")
last_active_topics = [Topic.query.get(id) for id in raw]
ids = [row[0] for row in rows]
# Somewhat inelegant, but much better than loading individually
recent_topics = db.session.query(Topic).filter(Topic.id.in_(ids)).all()
recent_topics = sorted(recent_topics, key=lambda t: ids.index(t.id))
# Filter the topics the user can view and limit to 10
if current_user.is_authenticated:
@ -30,7 +34,22 @@ def menu_processor():
else:
f = lambda t: t.forum.is_default_accessible()
last_active_topics = list(filter(f, last_active_topics))[:10]
recent_topics = list(filter(f, recent_topics))[:10]
# Constructing last news
rows = db.session.execute( """SELECT topic.id FROM topic
INNER JOIN forum ON topic.forum_id = forum.id
INNER JOIN comment ON topic.thread_id = comment.thread_id
INNER JOIN post ON post.id = comment.id
WHERE forum.url LIKE '/actus%'
GROUP BY topic.id
ORDER BY MIN(post.date_created) DESC
LIMIT 10;
""")
ids = [row[0] for row in rows]
recent_news = db.session.query(Topic).filter(Topic.id.in_(ids)).all()
recent_news = sorted(recent_news, key=lambda t: ids.index(t.id))
return dict(login_form=login_form, search_form=search_form,
main_forum=main_forum, last_active_topics=last_active_topics)
main_forum=main_forum, last_active_topics=recent_topics,
last_news=recent_news)

View File

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

View File

@ -2,12 +2,12 @@
from app.routes import index, search, users, tools, development
from app.routes.account import login, account, notification, polls
from app.routes.admin import index, groups, account, trophies, forums, \
from app.routes.admin import index, groups, account, forums, \
attachments, config, members, polls, login_as
from app.routes.forum import index, topic
from app.routes.polls import vote, delete
from app.routes.posts import edit
from app.routes.programs import index
from app.routes.programs import index, submit, program
from app.routes.api import markdown
try:

View File

@ -15,7 +15,7 @@ def adm_groups():
# Users with either groups or special privileges
users_groups = Member.query.join(GroupMember)
users_special = Member.query \
.join(SpecialPrivilege, Member.id == SpecialPrivilege.mid)
.join(SpecialPrivilege, Member.id == SpecialPrivilege.member_id)
users = users_groups.union(users_special)
users = sorted(users, key = lambda x: x.name)

View File

@ -1,77 +0,0 @@
from flask import request, flash, redirect, url_for
from app.utils.priv_required import priv_required
from app.models.trophy import Trophy, Title
from app.forms.trophy import TrophyForm, DeleteTrophyForm
from app.utils.render import render
from app import app, db
@app.route('/admin/trophees', methods=['GET', 'POST'])
@priv_required('misc.admin-panel', 'edit.trophies')
def adm_trophies():
form = TrophyForm()
if request.method == "POST":
if form.validate_on_submit():
is_title = form.title.data
if is_title:
trophy = Title(form.name.data, form.desc.data,
form.hidden.data, form.css.data)
else:
trophy = Trophy(form.name.data, form.desc.data,
form.hidden.data)
db.session.add(trophy)
db.session.commit()
flash(f'Nouveau {["trophée", "titre"][is_title]} ajouté', 'ok')
else:
flash('Erreur lors de la création du trophée', 'error')
trophies = Trophy.query.all()
return render('admin/trophies.html', trophies=trophies,
form=form)
@app.route('/admin/trophees/<trophy_id>/editer', methods=['GET', 'POST'])
@priv_required('misc.admin-panel', 'edit.trophies')
def adm_edit_trophy(trophy_id):
trophy = Trophy.query.filter_by(id=trophy_id).first_or_404()
form = TrophyForm()
if request.method == "POST":
if form.validate_on_submit():
is_title = form.title.data != ""
if is_title:
trophy.name = form.name.data
trophy.description = form.desc.data
trophy.title = form.title.data
trophy.hidden = form.hidden.data
trophy.css = form.css.data
else:
trophy.name = form.name.data
trophy.description = form.desc.data
trophy.hidden = form.hidden.data
db.session.merge(trophy)
db.session.commit()
flash(f'{["Trophée", "Titre"][is_title]} modifié', 'ok')
return redirect(url_for('adm_trophies'))
else:
flash('Erreur lors de la création du trophée', 'error')
return render('admin/edit_trophy.html', trophy=trophy, form=form)
@app.route('/admin/trophees/<trophy_id>/supprimer', methods=['GET', 'POST'])
@priv_required('misc.admin-panel', 'edit.trophies')
def adm_delete_trophy(trophy_id):
trophy = Trophy.query.filter_by(id=trophy_id).first_or_404()
# TODO: Add an overview of what will be deleted.
del_form = DeleteTrophyForm()
if request.method == "POST":
if del_form.validate_on_submit():
trophy.delete()
db.session.commit()
flash('Trophée supprimé', 'ok')
return redirect(url_for('adm_trophies'))
else:
flash('Erreur lors de la suppression du trophée', 'error')
del_form.delete.data = False # Force to tick to delete the trophy
return render('admin/delete_trophy.html', trophy=trophy, del_form=del_form)

View File

@ -2,9 +2,11 @@ from app import app
from app.utils.filters.markdown import md
from flask import request, abort
from werkzeug.exceptions import BadRequestKeyError
from app import csrf
class API():
@app.route("/api/markdown", methods=["POST"])
@csrf.exempt
def api_markdown():
try:
markdown = request.get_json()['text']

View File

@ -11,9 +11,7 @@ import os
def avatar(filename):
filename = secure_filename(filename) # No h4ckers allowed
filepath = os.path.join(V5Config.DATA_FOLDER, "avatars", filename)
if os.path.isfile(filepath):
return send_file(filepath)
return redirect(url_for('static', filename='images/default_avatar.png'))
return send_file(filepath)
@app.route('/fichiers/<path>/<name>')
def attachment(path, name):

View File

@ -31,7 +31,7 @@ def forum_topic(f, page):
else:
form = AnonymousCommentForm()
if form.validate_on_submit() and (
if form.validate_on_submit() and not t.thread.locked and (
V5Config.ENABLE_GUEST_POST or \
(current_user.is_authenticated and current_user.can_post_in_forum(f))):
@ -46,17 +46,7 @@ def forum_topic(f, page):
c = Comment(author, form.message.data, t.thread)
db.session.add(c)
db.session.commit()
# Manage files
attachments = []
for file in form.attachments.data:
if file.filename != "":
a = Attachment(file, c)
attachments.append((a, file))
db.session.add(a)
db.session.commit()
for a, file in attachments:
a.set_file(file)
c.create_attachments(form.attachments.data)
# Update member's xp and trophies
if current_user.is_authenticated:
@ -66,7 +56,7 @@ def forum_topic(f, page):
flash('Message envoyé', 'ok')
# Redirect to empty the form
return redirect(url_for('forum_topic', f=f, page=(t, "fin"),
_anchor=c.id))
_anchor=str(c.id)))
# Update views
t.views += 1

View File

@ -9,6 +9,7 @@ from app.models.topic import Topic
from app.models.user import Member
from app.utils.render import render
from app.utils.check_csrf import check_csrf
from app.utils.priv_required import priv_required
from app.forms.forum import CommentEditForm, AnonymousCommentEditForm, TopicEditForm
from app.forms.post import MovePost, SearchThread
from wtforms import BooleanField
@ -24,9 +25,9 @@ def edit_post(postid):
referrer = urlparse(request.args.get('r', default = '/', type = str)).path
print(referrer)
p = Post.query.filter_by(id=postid).first_or_404()
p = Post.query.get_or_404(postid)
# Check permissions. TODO: Allow guests to edit their posts?
# Check permissions
if not current_user.can_edit_post(p):
abort(403)
@ -68,6 +69,7 @@ def edit_post(postid):
attachments.append((a, file))
db.session.add(a)
comment.touch()
db.session.add(comment)
if isinstance(p, Topic):
@ -103,7 +105,7 @@ def edit_post(postid):
@check_csrf
def delete_post(postid):
next_page = request.referrer
p = Post.query.filter_by(id=postid).first_or_404()
p = Post.query.get_or_404(postid)
xp = -1
if not current_user.can_delete_post(p):
@ -141,7 +143,7 @@ def delete_post(postid):
@login_required
@check_csrf
def set_post_topcomment(postid):
comment = Post.query.filter_by(id=postid).first_or_404()
comment = Post.query.get_or_404(postid)
if current_user.can_set_topcomment(comment):
comment.thread.top_comment = comment
@ -154,7 +156,7 @@ def set_post_topcomment(postid):
@app.route('/post/deplacer/<int:postid>', methods=['GET', 'POST'])
@login_required
def move_post(postid):
comment = Post.query.filter_by(id=postid).first_or_404()
comment = Post.query.get_or_404(postid)
if not current_user.can_edit_post(comment):
abort(403)
@ -165,7 +167,9 @@ def move_post(postid):
move_form = MovePost(prefix="move_")
search_form = SearchThread(prefix="thread_")
keyword = search_form.name.data if search_form.validate_on_submit() else ""
# There is a bug with validate_on_submit
keyword = search_form.name.data if search_form.search.data else ""
# Get 10 last corresponding threads
# TODO: add support for every MainPost
@ -191,3 +195,25 @@ def move_post(postid):
return render('post/move_post.html', comment=comment,
search_form=search_form, move_form=move_form)
@app.route('/post/verrouiller/<int:postid>', methods=['GET'])
@priv_required("lock.threads")
@check_csrf
def lock_thread(postid):
post = Post.query.get_or_404(postid)
if not post.is_metacontent:
flash("Vous ne pouvez pas verrouiller ce contenu (n'est pas de type metacontenu)", 'error')
abort(403)
post.thread.locked = not post.thread.locked
db.session.add(post.thread)
db.session.commit()
if post.thread.locked:
flash(f"Le thread a été verrouillé")
else:
flash(f"Le thread a été déverrouillé")
return redirect(request.referrer)

View File

@ -4,5 +4,5 @@ from app.utils.render import render
@app.route('/programmes')
def program_index():
programs = Program.query.all()
return render('/programs/index.html')
programs = Program.query.order_by(Program.date_created.desc()).all()
return render('/programs/index.html', programs=programs)

View File

@ -0,0 +1,53 @@
from app import app, db
from app.models.user import Guest
from app.models.program import Program
from app.models.comment import Comment
from app.models.thread import Thread
from app.utils.render import render
from app.forms.forum import CommentForm, AnonymousCommentForm
from config import V5Config
from flask_login import current_user
from flask import redirect, url_for, flash
@app.route('/programmes/<programpage:page>', methods=['GET','POST'])
def program_view(page):
p, page = page
if current_user.is_authenticated:
form = CommentForm()
else:
form = AnonymousCommentForm()
if form.validate_on_submit() and not p.thread.locked and (
V5Config.ENABLE_GUEST_POST or current_user.is_authenticated):
# Manage author
if current_user.is_authenticated:
author = current_user
else:
author = Guest(form.pseudo.data)
db.session.add(author)
# Create comment
c = Comment(author, form.message.data, p.thread)
db.session.add(c)
db.session.commit()
c.create_attachments(form.attachments.data)
# Update member's xp and trophies
if current_user.is_authenticated:
current_user.add_xp(1)
current_user.update_trophies('new-post')
flash('Message envoyé', 'ok')
# Redirect to empty the form
return redirect(url_for('program_view', page=(p, "fin"), _anchor=str(c.id)))
if page == -1:
page = (p.thread.comments.count() - 1) // Thread.COMMENTS_PER_PAGE + 1
comments = p.thread.comments.order_by(Comment.date_created.asc()) \
.paginate(page, Thread.COMMENTS_PER_PAGE, True)
return render('/programs/program.html', p=p, form=form, comments=comments)

View File

@ -0,0 +1,59 @@
from app import app, db
from app.models.program import Program
from app.models.thread import Thread
from app.models.comment import Comment
from app.models.tag import Tag
from app.models.attachment import Attachment
from app.utils.render import render
from app.forms.programs import ProgramCreationForm
from flask_login import login_required, current_user
from flask import redirect, url_for, flash
@app.route('/programmes/soumettre', methods=['GET', 'POST'])
@login_required
def program_submit():
form = ProgramCreationForm()
if form.validate_on_submit():
# First create a new thread
# TODO: Reuse a thread when performing topic promotion
th = Thread()
db.session.add(th)
db.session.commit()
# Create its top comment
c = Comment(current_user, form.message.data, th)
db.session.add(c)
db.session.commit()
th.set_top_comment(c)
db.session.merge(th)
# Then build the actual program
p = Program(current_user, form.name.data, th)
db.session.add(p)
db.session.commit()
# Add tags
for tag in form.tags.selected_tags():
db.session.add(Tag(p, tag))
db.session.commit()
# Manage files
attachments = []
for file in form.attachments.data:
if file.filename != "":
a = Attachment(file, c)
attachments.append((a, file))
db.session.add(a)
db.session.commit()
for a, file in attachments:
a.set_file(file)
current_user.add_xp(20)
current_user.update_trophies('new-program')
flash('Le programme a bien été soumis', 'ok')
return redirect(url_for('program_index'))
return render('/programs/submit.html', form=form)

View File

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

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

@ -0,0 +1,88 @@
.editor .btn-group {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
align-items: center;
}
.editor .btn-group #filler {
flex-grow: 1;
}
.editor .btn-group button {
padding: 6px;
background-color: var(--background);
}
.editor .btn-group button:hover {
background: var(--background-hover);
}
.editor .btn-group button > svg {
width: 20px;
height: 20px;
}
.editor .btn-group button > svg > path,
.editor .btn-group button > svg > rect {
fill: var(--icons);
}
.editor .btn-group button,
.editor .btn-group .separator {
margin: 0 8px 8px 0;
height: 32px;
position: relative;
}
.editor .btn-group > a {
margin: 0 0 8px 0;
}
.editor .btn-group .separator {
display: inline-block;
width: 0;
border: 1px solid var(--text);
color: transparent;
text-indent: -10px;
}
.editor textarea {
min-height: 15rem;
}
.editor #editor_content_preview {
padding: 10px;
margin-top: 5px;
border: var(--border);
background-color: rgba(0,0,0,0.2);
}
.editor .modal {
position: absolute;
left: 0px;
width: auto;
min-width: min-content;
text-align: left;
right: inherit;
background: var(--background-hover);
border: var(--border);
color: var(--text);
padding: .2rem;
top: 2.3rem;
z-index: 100;
list-style-position: initial;
list-style-type: none;
}
.editor .modal > div {
margin: 0.8rem;
margin-top: 0.4rem;
margin-bottom: 1rem;
min-width: 30vw;
}
.editor .modal > div label {
margin-top: 0.4rem;
}
.editor .modal a.editor-emoji-close-btn {
display: inline-block;
margin: 0.3rem;
margin-top: 0.5rem;
}
@media screen and (max-width:849px) {
.editor .modal {
width: 80vw;
position: fixed;
left: 50vw;
transform: translateX(-50%);
top: 50vh;
}
}

View File

@ -8,7 +8,7 @@
.form form label + .desc {
margin: 0 0 4px 0;
font-size: 80%;
opacity: .75;
opacity: .65;
}
.form form .avatar {
width: 128px;
@ -23,6 +23,7 @@
.form input[type='date'],
.form input[type='password'],
.form input[type='search'],
.form input[type='url'],
.form textarea,
.form select {
display: block;
@ -38,6 +39,7 @@
.form input[type='date']:focus,
.form input[type='password']:focus,
.form input[type='search']:focus,
.form input[type='url']:focus,
.form textarea:focus,
.form select:focus {
border-color: var(--border-focused);
@ -48,6 +50,7 @@
.form input[type='date']:focus-within,
.form input[type='password']:focus-within,
.form input[type='search']:focus-within,
.form input[type='url']:focus-within,
.form textarea:focus-within,
.form select:focus-within {
outline: none;
@ -84,18 +87,27 @@
.form progress.entropy.high::-webkit-progress-bar {
background: var(--ok);
}
.form hr {
height: 3px;
border: var(--hr-border);
border-width: 1px 0;
margin: 24px 0;
}
.form .msgerror {
color: var(--error);
font-weight: 400;
margin-top: 5px;
}
.form input.abfield[type="email"] {
.form input[type='email'].abfield {
display: none;
}
form .dynamic-tag-selector {
display: none;
}
form .dynamic-tag-selector input[type="text"] {
display: none;
}
form .dynamic-tag-selector .tag {
cursor: pointer;
}
form .dynamic-tag-selector .tags-selected {
margin: 0 0 4px 0;
}
form .dynamic-tag-selector .tags-selected .tag {
display: none;
}
.form.filter {
@ -125,4 +137,4 @@
background: rgba(0,0,0,.05);
padding: 1px 2px;
border-radius: 2px;
}
}

View File

@ -48,6 +48,12 @@ a:focus {
img.pixelated {
image-rendering: pixelated;
}
hr {
height: 3px;
border: var(--hr-border);
border-width: 1px 0;
margin: 24px 0;
}
section p {
line-height: 20px;
word-wrap: anywhere;
@ -72,6 +78,13 @@ section h2 {
color: var(--text-light);
padding-bottom: 2px;
}
section blockquote {
margin: 0 0 10px 0;
border-left: 3px solid var(--border);
background: var(--background);
padding-left: 15px;
}
button,
.button,
input[type="button"],
input[type="submit"] {
@ -82,9 +95,11 @@ input[type="submit"] {
font-weight: 400;
border: 0;
}
button:hover,
.button:hover,
input[type="button"]:hover,
input[type="submit"]:hover,
button:focus,
.button:focus,
input[type="button"]:focus,
input[type="submit"]:focus {

View File

@ -0,0 +1,22 @@
#program-banner {
background: navy;
height: 144px;
margin: 0 0 32px 0;
}
section .program-infos {
display: flex;
width: 100%;
justify-content: space-between;
}
section .program-infos span.progrank {
border-width: 0 0 1px 0;
border-color: var(--color);
border-style: dotted;
}
section .program-infos > div {
flex-shrink: 0;
margin: 0 8px;
}
section .program-infos div.program-tags {
flex-shrink: 1;
}

View File

@ -1,71 +0,0 @@
/* SimpleMDE overwrite that allows us to customize from themes */
div.editor-toolbar {
border-color: var(--border);
}
div.editor-toolbar > a {
color: var(--text) !important;
}
div.editor-toolbar > a.active,
div.editor-toolbar > a:hover {
background: var(--background-light);
border-color: var(--background-light);
}
div.editor-toolbar > i.separator {
border-right-color: transparent;
border-left-color: var(--separator);
}
div.editor-toolbar.disabled-for-preview a:not(.no-disable) {
background: none;
color: var(--text-disabled) !important;
}
div.editor-toolbar.disabled-for-preview > i.separator {
border-left-color: var(--text-disabled);
}
div.CodeMirror,
div.editor-preview {
background: var(--background);
color: var(--text);
border-color: var(--border);
}
div.editor-preview {
background: var(--background-preview);
}
div.editor-preview table th,
div.editor-preview-side table th,
div.editor-preview table td,
div.editor-preview-side table td {
border: inherit;
padding: inherit;
}
div.editor-preview table.codehilitetable pre,
div.editor-preview-side table.codehilitetable pre {
background: transparent;
}
div.CodeMirror .CodeMirror-selected,
div.CodeMirror .CodeMirror-selectedtext {
background: var(--background-light);
}
div.CodeMirror .CodeMirror-focused .CodeMirror-selected,
div.CodeMirror .CodeMirror-focused .CodeMirror-selectedtext,
div.CodeMirror .CodeMirror-line::selection,
div.CodeMirror .CodeMirror-line > span::selection,
div.CodeMirror .CodeMirror-line > span > span::selection {
background: var(--background-light);
}
div.CodeMirror .CodeMirror-line::-moz-selection,
div.CodeMirror .CodeMirror-line > span::-moz-selection,
div.CodeMirror .CodeMirror-line > span > span::-moz-selection {
background: var(--background-light);
}
div.CodeMirror-cursor {
border-color: var(--text);
}

File diff suppressed because one or more lines are too long

View File

@ -111,8 +111,6 @@ table.thread:not(.topcomment) div.info {
table.thread div.info {
text-align: right;
position: relative;
margin-left: 24px;
margin-bottom: 8px;
}
table.thread div.info > * {
display: inline-block;

View File

@ -43,10 +43,12 @@
}
.editor button {
--background: #ffffff;
--text: #000000;
--border: 1px solid rgba(0, 0, 0, 0);
--border-focused: 1px solid rgba(0, 0, 0, .5);
--background: #1d2326;
--text: #ffffff;
--background-hover: #262c2f;
}
.editor svg {
--icons: #eeeeee;
}
#light-menu {
@ -128,6 +130,11 @@ table.codehilitetable {
--background: #263238;
}
blockquote {
--border: rgba(255, 255, 255, .3);
--background: transparent;
}
div.editor-toolbar, div.CodeMirror {
--border: #404040;
--background-light: #404040;
@ -148,3 +155,23 @@ div.editor-toolbar, div.CodeMirror {
--border: rgba(255, 255, 255, 0.8);
--selected: rgba(255, 0, 0, 1.0);
}
.tag {
--background: #22292c;
--color: white;
}
.tag.tag-calc {
--background: #917e1a;
}
.tag.tag-lang {
--background: #4a8033;
}
.tag.tag-games {
--background: #488695;
}
.tag.tag-tools {
--background: #70538a;
}
.tag.tag-courses {
--background: #884646;
}

View File

@ -38,10 +38,9 @@
}
.editor button {
--background: #ffffff;
--background: #eee;
--text: #030303;
--border: 1px solid rgba(0, 0, 0, 0);
--border-focused: 1px solid rgba(0, 0, 0, .5);
--background-hover: #ddd;
}
#light-menu {
@ -146,6 +145,11 @@ table.thread.topcomment {
border: 1px solid #c0c0c0;
}
blockquote {
--border: rgba(236, 36, 36, .7);
--background: transparent;
}
div.editor-toolbar {
--border: #aaa2a2;
--background-light: #c0c0c0;
@ -172,3 +176,22 @@ div.pagination {
font-size: 14px;
margin: 13px;
}
.tag {
--background: #e0e0e0;
}
.tag.tag-calc {
--background: #f0ca81;
}
.tag.tag-lang {
--background: #aad796;
}
.tag.tag-games {
--background: #a7ccd5;
}
.tag.tag-tools {
--background: #c6aae1;
}
.tag.tag-courses {
--background: #f0a0a0;
}

View File

@ -1,26 +1,26 @@
/* Some colors, variables etc. to be used as theme */
:root {
--background: #ffffff;
--text: #000000;
--text-light: #101010;
--background: #fff;
--text: #000;
--text-light: #111;
--links: #c61a1a;
--ok: #149641;
--ok-text: #ffffff;
--ok-text: #fff;
--ok-active: #0f7331;
--warn: #f59f25;
--warn-text: #ffffff;
--warn-text: #fff;
--warn-active: #ea9720;
--error: #d23a2f;
--error-text: #ffffff;
--error-text: #fff;
--error-active: #b32a20;
--info: #2e7aec;
--info-text: #ffffff;
--info-text: #fff;
--info-active: #215ab0;
--hr-border: 1px solid #d8d8d8;
@ -33,23 +33,27 @@ table tr:nth-child(odd) {
--background: rgba(0, 0, 0, .1);
}
table th {
--background: #e0e0e0;
--border: #d0d0d0;
--background: #eee;
--border: #ddd;
}
blockquote {
--border: rgba(0, 0, 0, .3);
--background: transparent;
}
.form {
--background: #ffffff;
--text: #000000;
--background: #fff;
--text: #000;
--border: 1px solid #c8c8c8;
--border-focused: #7cade0;
--shadow-focused: rgba(87, 143, 228, 0.5);
}
.editor button {
--background: #ffffff;
--text: #000000;
--border: 1px solid rgba(0, 0, 0, 0);
--border-focused: 1px solid rgba(0, 0, 0, .5);
--background: #eee;
--text: #000;
--background-hover: #ddd;
}
#light-menu {
@ -82,13 +86,13 @@ header {
footer {
--background: #ffffff;
--text: #a0a0a0;
--border: #d0d0d0;
--text: #aaa;
--border: #ddd;
}
.flash {
--background: #ffffff;
--text: #212121;
--background: #fff;
--text: #222;
--shadow: 0 1px 12px rgba(0, 0, 0, 0.3);
/* Uncomment to inherit :root values
@ -98,31 +102,27 @@ footer {
--info: #2e7aec; */
--btn-bg: rgba(0, 0, 0, 0);
--btn-text: #000000;
--btn-text: #000;
--btn-bg-active: rgba(0, 0, 0, .15);
}
.profile-xp {
--background: #e0e0e0;
--border: 1px solid #c0c0c0;
--background-xp: #f85555;
--background-xp-100: #d03333;
--border-xp: 1px solid #d03333;
--background: #eee;
--border: 1px solid #ccc;
--background-xp: #f55;
--background-xp-100: #d33;
--border-xp: 1px solid #d33;
}
.context-menu {
--background: #ffffff;
--shadow: 0 0 12px -9px #000000;
--border: #d0d0d0;
--background-light: #f0f0f0;
--background: #fff;
--shadow: 0 0 12px -9px #000;
--border: #ddd;
--background-light: #fff;
}
div.editor-toolbar, div.CodeMirror {
--border: #c0c0c0;
--background-light: #d9d9d9;
--background-preview: #f4f4f6;
--separator: #a0a0a0;
--text-disabled: #c0c0c0;
.editor svg {
--icons: #000;
}
.dl-button {
@ -140,5 +140,24 @@ div.editor-toolbar, div.CodeMirror {
/* Extra style on top of the Pygments style */
table.codehilitetable td.linenos {
color: #808080;
color: #888;
}
.tag {
--background: #e0e0e0;
}
.tag.tag-calc {
--background: #f0ca81;
}
.tag.tag-lang {
--background: #aad796;
}
.tag.tag-games {
--background: #a7ccd5;
}
.tag.tag-tools {
--background: #c6aae1;
}
.tag.tag-courses {
--background: #f0a0a0;
}

View File

@ -207,4 +207,19 @@ hr.signature {
}
.gallery-spot * {
cursor: pointer;
}
.tag {
display: inline-block;
background: var(--background);
color: var(--color);
padding: 4px 12px;
margin: 4px 0;
border-radius: 8px;
border-radius: calc(4.5em);
user-select: none;
cursor: default;
}
.locked {
text-align: center;
font-style: italic;
}

View File

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

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

@ -0,0 +1,105 @@
@import "vars";
.editor {
.btn-group {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
align-items: center;
#filler {
flex-grow: 1;
}
button {
/* This centers the 20x20 SVG in the button */
padding: 6px;
background-color: var(--background);
&:hover {
background: var(--background-hover);
}
& > svg {
width: 20px;
height: 20px;
& > path, & > rect {
fill: var(--icons);
}
}
}
button, .separator {
margin: 0 8px 8px 0;
height: 32px;
position: relative;
}
& > a {
margin: 0 0 8px 0;
}
.separator {
display: inline-block;
width: 0;
border: 1px solid var(--text);
color: transparent;
text-indent: -10px;
}
}
textarea {
min-height: 15rem;
}
#editor_content_preview {
padding: 10px;
margin-top: 5px;
border: var(--border);
background-color: rgba(0, 0, 0, 0.2);
}
.modal {
position: absolute;
left: 0px;
width: auto;
min-width: min-content;
text-align: left;
right: inherit;
@media screen and (max-width: @tiny) {
width: 80vw;
position: fixed;
left: 50vw;
transform: translateX(-50%);
top: 50vh;
}
background: var(--background-hover);
border: var(--border);
color: var(--text);
padding: .2rem;
top: 2.3rem;
z-index: 100;
list-style-position: initial;
list-style-type: none;
& > div {
margin: 0.8rem;
margin-top: 0.4rem;
margin-bottom: 1rem;
min-width: 30vw;
label {
margin-top: 0.4rem;
}
}
a.editor-emoji-close-btn {
display: inline-block;
margin: 0.3rem;
margin-top: 0.5rem;
}
}
}

View File

@ -13,7 +13,7 @@
& + .desc {
margin: 0 0 4px 0;
font-size: 80%;
opacity: .75;
opacity: .65;
}
}
@ -32,6 +32,7 @@
input[type='date'],
input[type='password'],
input[type='search'],
input[type='url'],
textarea,
select {
display: block;
@ -95,13 +96,6 @@
}
}
hr {
height: 3px;
border: var(--hr-border);
border-width: 1px 0;
margin: 24px 0;
}
.msgerror {
color: var(--error);
font-weight: 400;
@ -109,12 +103,35 @@
}
/* anti-bots field */
.abfield {
input[type='email'].abfield {
display: none;
}
}
/* Interactive tag selector */
form .dynamic-tag-selector {
display: none;
input[type="text"] {
display: none;
}
.tag {
cursor: pointer;
}
.tags-selected {
margin: 0 0 4px 0;
.tag {
display: none;
}
}
}
/* Interactive filter forms */
.form.filter {

View File

@ -44,6 +44,13 @@ img.pixelated {
image-rendering: pixelated;
}
hr {
height: 3px;
border: var(--hr-border);
border-width: 1px 0;
margin: 24px 0;
}
section {
p {
line-height: 20px;
@ -70,10 +77,17 @@ section {
color: var(--text-light);
padding-bottom: 2px;
}
blockquote {
margin: 0 0 10px 0;
border-left: 3px solid var(--border);
background: var(--background);
padding-left: 15px;
}
}
/* Buttons */
.button, input[type="button"], input[type="submit"] {
button, .button, input[type="button"], input[type="submit"] {
padding: 6px 10px; border-radius: 2px;
cursor: pointer;
font-family: 'DejaVu Sans', sans-serif; font-weight: 400;

View File

@ -0,0 +1,26 @@
#program-banner {
background: navy; /* debugging */
height: 144px;
margin: 0 0 32px 0;
}
section .program-infos {
display: flex;
width: 100%;
justify-content: space-between;
span.progrank {
border-width: 0 0 1px 0;
border-color: var(--color); /* use text color */
border-style: dotted;
}
& > div {
flex-shrink: 0;
margin: 0 8px;
}
div.program-tags {
flex-shrink: 1;
}
}

View File

@ -239,3 +239,23 @@ hr.signature {
cursor: pointer;
}
}
/* Tags */
.tag {
display: inline-block;
background: var(--background);
color: var(--color);
padding: 4px 12px;
margin: 4px 0;
border-radius: 8px;
border-radius: calc(0.5em + 4px);
user-select: none;
cursor: default;
}
/* Thread locked state */
.locked {
text-align: center;
font-style: italic;
}

View File

@ -1,113 +1,426 @@
/* Add callbacks on text formatting buttons */
function edit(e, type) {
function inline(type, str, repeat, insert) {
// Characters used to format inline blocs
// repeat: if true, add one more char to the longest suite found
// insert: insert <insert> between char and str (before and after)
var chars = {
'bold': '*',
'italic': '/',
'underline': '_',
'strikethrough': '~',
'inline-code': '`',
'h1': '===',
'h2': '---',
'h3': '...',
}
/* Locate the editor associated to an edition event.
event: Global event emitted by one of the editor buttons
Returns [the div.editor, the button, the textarea] */
function editor_event_source(event)
{
let button = undefined;
let editor = undefined;
if (repeat) {
// Detect longest suite of similar chars
var n = 1; var tmp = 1;
for(var i = 0; i < str.length; i++) {
if(str[i] == chars[type]) tmp++;
else tmp = 1;
n = (tmp > n) ? tmp : n;
}
return chars[type].repeat(n) + insert + str + insert + chars[type].repeat(n);
}
/* Grab the button and the parent editor block. The onclick event itself
usually reports the SVG in the button as the source */
let node = event.target || event.srcElement;
while (node != document.body) {
if (node.tagName == "BUTTON" && !button) {
button = node;
}
if (node.classList.contains("editor") && !editor) {
editor = node;
// Hack to use keybinds
if (!button) {
button = node.firstElementChild.firstElementChild
}
break;
}
node = node.parentNode;
}
if (!button || !editor) return;
return chars[type] + insert + str + insert + chars[type];
}
function list(type, str) {
switch(type) {
case 'list-bulleted':
return '* ' + str.replaceAll('\n', '\n* ');
break;
case 'list-numbered':
return '1. ' + str;
break;
}
}
var ta = e.parentNode.parentNode.querySelector('textarea');
var start = ta.selectionStart;
var end = ta.selectionEnd;
switch(type) {
case 'bold':
case 'italic':
case 'underline':
case 'strikethrough':
case 'inline-code':
ta.value = ta.value.substring(0, start)
+ inline(type, ta.value.substring(start, end), true, '')
+ ta.value.substring(end);
break;
case 'h1':
case 'h2':
case 'h3':
ta.value = ta.value.substring(0, start)
+ inline(type, ta.value.substring(start, end), false, ' ')
+ ta.value.substring(end);
break;
case 'list-bulleted':
case 'list-numbered':
ta.value = ta.value.substring(0, start)
+ list(type, ta.value.substring(start, end))
+ ta.value.substring(end);
break;
}
const ta = editor.querySelector(".editor textarea");
return [editor, button, ta];
}
function pre(type, str, multiline) {
/* Replace the range [start:end) with the new contents, and returns the new
interval [start:end) (ie. the range where the contents are now located). */
function editor_replace_range(textarea, start, end, contents)
{
ta.value = ta.value.substring(0, start)
+ contents
+ ta.value.substring(end);
return [start, start + contents.length];
}
/* Event handler that inserts specified tokens around the selection.
after token is the same as before if not specified */
function editor_insert_around(event, before="", after=null)
{
const [editor, button, ta] = editor_event_source(event);
ta.focus();
let indexStart = ta.selectionStart;
let indexEnd = ta.selectionEnd;
if (after === null) {
after = before;
}
let [start, end] = editor_replace_range(ta, indexStart, indexEnd,
before + ta.value.substring(indexStart, indexEnd) + after);
/* Restore selection */
if (indexStart != indexEnd) {
ta.selectionStart = start;
ta.selectionEnd = end;
}
else {
ta.selectionStart = ta.selectionEnd = start + before.length;
}
preview();
}
/* Event handler that modifies each line within the selection through a
generic function. */
function editor_act_on_lines(event, fn)
{
const [editor, button, ta] = editor_event_source(event);
ta.focus();
let indexStart = ta.selectionStart;
let indexEnd = ta.selectionEnd;
let firstLineIndex = ta.value.substring(0, indexStart).lastIndexOf('\n');
if (firstLineIndex < 0)
firstLineIndex = 0;
else
firstLineIndex += 1;
let lastLineIndex = ta.value.substring(indexEnd).indexOf('\n');
if (lastLineIndex < 0)
lastLineIndex = ta.value.length;
else
lastLineIndex += indexEnd;
let lines = ta.value.substring(firstLineIndex, lastLineIndex).split('\n');
for(let i = 0; i < lines.length; i++)
lines[i] = fn(lines[i], i);
let [start, end] = editor_replace_range(ta, firstLineIndex, lastLineIndex,
lines.join('\n'));
ta.selectionStart = start;
ta.selectionEnd = end;
preview();
}
function editor_clear_modals(event, close = true)
{
// Stop the propagation of the event
event.stopPropagation()
// Reset all modal inputs
document.getElementById('media-alt-input').value = '';
document.getElementById('media-link-input').value = '';
document.getElementById('link-desc-input').value = '';
document.getElementById('link-link-input').value = '';
const media_type = document.getElementsByName("media-type");
for(i = 0; i < media_type.length; i++) {
media_type[i].checked = false;
}
// Close all modal if requested
if (!close) { return }
const modals = document.getElementsByClassName('modal');
for (const i of modals) {i.style.display = 'none'};
}
function bold(e) {
var ta = e.parentNode.parentNode.querySelector('textarea');
var indexStart = ta.selectionStart;
var indexEnd = ta.selectionEnd;
var txt = ta.value.substring(indexStart, indexEnd);
ta.value += '\n' + inline('bold', txt);
/* End-user functions */
function editor_inline(event, type, enable_preview = true)
{
tokens = {
bold: "**",
italic: "*",
underline: "__",
strike: "~~",
inlinecode: "`",
};
if (type in tokens) {
editor_insert_around(event, tokens[type]);
}
if (enable_preview) {
preview();
}
}
function editor_display_link_modal(event) {
const [editor, button, ta] = editor_event_source(event);
let indexStart = ta.selectionStart;
let indexEnd = ta.selectionEnd;
let selection = ta.value.substring(indexStart, indexEnd);
// Tab insert some spaces
// Ctrl+Enter send the form
ta = document.querySelector(".editor textarea");
// Assuming it's a link
if (selection.match(/^https?:\/\/\S+/)) {
event.currentTarget.querySelector("#link-link-input").value = selection;
}
// Or text
else if (selection != "") {
event.currentTarget.querySelector("#link-desc-input").value = selection;
}
editor_display_child_modal(event);
}
function editor_insert_link(event, link_id, text_id, media = false)
{
const [editor, button, ta] = editor_event_source(event);
ta.focus();
let indexStart = ta.selectionStart;
let indexEnd = ta.selectionEnd;
const link = document.getElementById(link_id).value;
const text = document.getElementById(text_id).value;
let media_type = "";
const media_selector = document.getElementsByName("media-type");
for(i = 0; i < media_selector.length; i++) {
if (media_selector[i].checked) {
media_type = `{type=${media_selector[i].value}}`;
}
}
editor_clear_modals(event);
let [start, end] = editor_replace_range(ta, indexStart, indexEnd,
`${media ? "!" : ""}[${text.length === 0 ? ta.value.substring(indexStart, indexEnd) : text}](${link})${media ? media_type : ""}`);
/* Restore selection */
if (indexStart != indexEnd) {
ta.selectionStart = start;
ta.selectionEnd = end;
}
else {
ta.selectionStart = ta.selectionEnd = start + 1;
}
preview();
}
function editor_title(event, level, diff)
{
editor_act_on_lines(event, function(line, _) {
/* Strip all the initial # (and count them) */
let count = 0;
while(count < line.length && line[count] == '#') count++;
let contents_index = count;
if (count < line.length && line[count] == ' ') contents_index++;
let contents = line.slice(contents_index);
if (level > 0 || count == 1 && diff == -1) {
/* Remove the title if the corresponding level is re-requested */
if (count == level || count == 1 && diff == -1)
return contents;
/* Otherwise, add it */
else
return '#'.repeat(level) + ' ' + contents;
}
else if (count > 0) {
/* Apply the difference */
let new_level = Math.max(1, Math.min(6, count + diff));
return '#'.repeat(new_level) + ' ' + contents;
}
return line;
});
}
function editor_quote(event)
{
editor_act_on_lines(event, function(line, _) {
/* Strip all the initial > (and count them) */
let count = 0;
while(count < line.length && line[count] == '>') count++;
let contents_index = count;
if (count < line.length && line[count] == ' ') contents_index++;
let contents = line.slice(contents_index);
/* Apply the difference */
return '>'.repeat(count + 1) + ' ' + contents;
});
}
function editor_bullet_list(event)
{
editor_act_on_lines(event, function(line, _) {
let ident_match = line.match(/^[\t]+/m) ?? [''];
let ident = ident_match[0];
let count = ident.length;
const contents = line.slice(count);
if ((count < line.length || count == 0) && line[count] != '-') return '- ' + contents;
return ident + "\t" + contents;
});
}
function editor_numbered_list(event)
{
editor_act_on_lines(event, function(line, number) {
let ident_match = line.match(/^[\t]+/m) ?? [''];
let ident = ident_match[0];
let count = ident.length;
const contents = line.slice(count);
if ((count < line.length || count == 0) && isNaN(line[count])) return `${number + 1}. ` + contents;
return ident + "\t" + contents;
});
}
function editor_table(event) {
let table = `| Column 1 | Column 2 | Column 3 |
| -------- | -------- | -------- |
| Text | Text | Text |`;
editor_insert_around(event, "", table);
}
function editor_separator(event) {
editor_insert_around(event, "", "\n---\n");
}
function editor_display_child_modal(event) {
editor_clear_modals(event);
event.currentTarget.children[1].style = {'display': 'block'};
}
const DISABLE_PREVIEW_ICON = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-eye-slash" viewBox="0 0 16 16"><path d="M13.359 11.238C15.06 9.72 16 8 16 8s-3-5.5-8-5.5a7.028 7.028 0 0 0-2.79.588l.77.771A5.944 5.944 0 0 1 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.134 13.134 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755-.165.165-.337.328-.517.486l.708.709z"/><path d="M11.297 9.176a3.5 3.5 0 0 0-4.474-4.474l.823.823a2.5 2.5 0 0 1 2.829 2.829l.822.822zm-2.943 1.299.822.822a3.5 3.5 0 0 1-4.474-4.474l.823.823a2.5 2.5 0 0 0 2.829 2.829z"/><path d="M3.35 5.47c-.18.16-.353.322-.518.487A13.134 13.134 0 0 0 1.172 8l.195.288c.335.48.83 1.12 1.465 1.755C4.121 11.332 5.881 12.5 8 12.5c.716 0 1.39-.133 2.02-.36l.77.772A7.029 7.029 0 0 1 8 13.5C3 13.5 0 8 0 8s.939-1.721 2.641-3.238l.708.709zm10.296 8.884-12-12 .708-.708 12 12-.708.708z"/></svg>';
const ENABLE_PREVIEW_ICON = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-eye" viewBox="0 0 16 16"><path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z"/><path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z"/></svg>';
function toggle_auto_preview() {
let auto_preview;
if (document.cookie.split(";").some((item) => item.trim().startsWith("auto-preview="))) {
auto_preview = document.cookie.split(";").some((item) => item.includes("auto-preview=true"));
} else {
auto_preview = true;
}
document.cookie = `auto-preview=${!auto_preview}; max-age=31536000; SameSite=Strict; Secure`
if (!auto_preview) {
document.getElementById("toggle_preview").title = "Désactiver la prévisualisation";
document.getElementById("toggle_preview").innerHTML = DISABLE_PREVIEW_ICON;
document.getElementById("manual_preview").style = "display: none";
} else {
document.getElementById("toggle_preview").title = "Activer la prévisualisation";
document.getElementById("toggle_preview").innerHTML = ENABLE_PREVIEW_ICON;
document.getElementById("manual_preview").style = "display: block";
}
}
/* This request the server to get a complete render of the current text in the textarea */
function preview(manual=false) {
// If auto-preview is disabled and the preview is not manually requested by the user
if (document.cookie.split(";").some((item) => item.includes("auto-preview=false")) && !manual) {
return;
}
const previewArea = document.querySelector("#editor_content_preview");
const textarea = document.querySelector(".editor textarea");
const payload = {text: ta.value};
const headers = new Headers();
headers.append("Content-Type", "application/json");
const params = {
method: "POST",
body: JSON.stringify(payload),
headers
};
fetch("/api/markdown", params).then(
(response) => {
response.text().then(
(text) => {
previewArea.innerHTML = text;
}
);
});
}
if (document.cookie.split(";").some((item) => item.trim().startsWith("auto-preview="))) {
if (document.cookie.split(";").some((item) => item.includes("auto-preview=false"))) {
document.getElementById("toggle_preview").title = "Activer la prévisualisation";
document.getElementById("toggle_preview").innerHTML = ENABLE_PREVIEW_ICON;
document.getElementById("manual_preview").style = "display: block";
}
}
let previewTimeout = null;
let ta = document.querySelector(".editor textarea");
ta.addEventListener('keydown', function(e) {
var keyCode = e.keyCode || e.which;
if (keyCode == 9) {
e.preventDefault();
// Tab insert some spaces
let keyCode = e.keyCode || e.which;
if (keyCode == 9) {
// TODO Add one tab to selected text without replacing it
e.preventDefault();
var start = e.target.selectionStart;
var end = e.target.selectionEnd;
// set textarea value to: text before caret + tab + text after caret
e.target.value = e.target.value.substring(0, start) + "\t" + e.target.value.substring(end);
e.target.selectionEnd = start + 1;
}
if (e.ctrlKey && keyCode == 13) {
var e = e.target;
while(! (e instanceof HTMLFormElement)) {
e = e.parentNode;
}
try {
e.submit();
} catch(exception) {
e.submit.click();
}
}
let start = e.target.selectionStart;
let end = e.target.selectionEnd;
// set textarea value to: text before caret + tab + text after caret
e.target.value = e.target.value.substring(0, start) + "\t" + e.target.value.substring(end);
e.target.selectionEnd = start + 1;
}
/*
* Keybindings for buttons. The default action of the keybinding is prevented.
* Ctrl+B adds bold
* Ctrl+I adds italic
* Ctrl+U adds underline
* Ctrl+S adds strikethrough
* Ctrl+H adds Header +1
* Ctrl+Enter send the form
*/
if (e.ctrlKey) {
switch (keyCode) {
case 13:
let t = e.target;
while(! (t instanceof HTMLFormElement)) {
t = t.parentNode;
}
try {
t.submit();
} catch(exception) {
t.submit.click();
}
e.preventDefault();
break;
case 66: // B
editor_inline(e, "bold", false);
e.preventDefault();
break;
case 72: // H
editor_title(e, 0, +1);
e.preventDefault();
break;
case 73: // I
editor_inline(e, "italic", false);
e.preventDefault();
break;
case 83: // S
editor_inline(e, "strike", false);
e.preventDefault();
break;
case 85: // U
editor_inline(e, "underline", false);
e.preventDefault();
break;
}
}
// Set a timeout for refreshing the preview
if (previewTimeout != null) {
clearTimeout(previewTimeout);
}
previewTimeout = setTimeout(preview, 3000);
});
document.querySelector('emoji-picker').addEventListener('emoji-click', event => {
editor_clear_modals(event);
editor_insert_around(event, "", event.detail.unicode)
preview();
});

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,10 +1,10 @@
function entropy(password) {
var chars = [
let chars = [
"abcdefghijklmnopqrstuvwxyz",
"ABCDFEGHIJKLMNOPQRSTUVWXYZ",
"0123456789",
" !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~§", // OWASP special chars
"áàâéèêíìîóòôúùûç"
"áàâéèêíìîóòôúùûçÁÀÂÉÈÊÍÌÎÓÒÔÚÙÛǵ²³" // French layout special chars
];
used = new Set();
@ -19,9 +19,9 @@ function entropy(password) {
}
function update_entropy(ev) {
var i = document.querySelector(".entropy").previousElementSibling;
var p = document.querySelector(".entropy");
var e = entropy(i.value);
let i = document.querySelector(".entropy").previousElementSibling;
let p = document.querySelector(".entropy");
let e = entropy(i.value);
p.classList.remove('low');
p.classList.remove('medium');

View File

@ -8,7 +8,7 @@ const patterns = [
function* lex(str) {
while(str = str.trim()) {
var t = T.ERR, best = undefined;
let t = T.ERR, best = undefined;
for(const i in patterns) {
const m = str.match(patterns[i]);
@ -86,7 +86,7 @@ class Parser {
return e;
}
var e = {
let e = {
type: "Atom",
field: this.expect(T.NAME),
op: this.expect(T.COMP),
@ -124,8 +124,8 @@ function filter_update(input) {
const th = t.querySelectorAll("tr:first-child > th");
/* Generate the names of fields from the header */
var fields = {};
for(var i = 0; i < th.length; i++) {
let fields = {};
for(let i = 0; i < th.length; i++) {
const name = th[i].dataset.filter;
if(name) fields[name] = i;
}

View File

@ -1,13 +1,13 @@
function setCookie(name, value) {
var end = new Date();
let end = new Date();
end.setTime( end.getTime() + 3600 * 1000 );
var str=name+"="+escape(value)+"; expires="+end.toGMTString()+"; path=/; Secure; SameSite=lax";
let str=name+"="+escape(value)+"; expires="+end.toGMTString()+"; path=/; Secure; SameSite=lax";
document.cookie = str;
}
function getCookie(name) {
var debut = document.cookie.indexOf(name);
let debut = document.cookie.indexOf(name);
if( debut == -1 ) return null;
var end = document.cookie.indexOf( ";", debut+name.length+1 );
let end = document.cookie.indexOf( ";", debut+name.length+1 );
if( end == -1 ) end = document.cookie.length;
return unescape( document.cookie.substring( debut+name.length+1, end ) );
}

File diff suppressed because one or more lines are too long

View File

@ -1,11 +1,11 @@
/* Smartphone patch for menu */
/* It don't work if links haven't any href attribute */
var w = Math.max(document.documentElement.clientWidth, window.innerWidth || 0)
let w = Math.max(document.documentElement.clientWidth, window.innerWidth || 0)
if(w < 700) {
var buttons = document.getElementById('light-menu').getElementsByTagName('li');
for(var i = 0; i < buttons.length; i++) {
let buttons = document.getElementById('light-menu').getElementsByTagName('li');
for(let i = 0; i < buttons.length; i++) {
buttons[i].getElementsByTagName('a')[0].setAttribute('href', '#');
}
}
}

View File

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

View File

@ -1,24 +1,24 @@
/* Trigger actions for the menu */
/* Initialization */
var b = document.querySelectorAll('#light-menu a');
for(var i = 1; i < b.length; i++) {
let b = document.querySelectorAll('#light-menu a');
for(let i = 1; i < b.length; i++) {
b[i].setAttribute('onfocus', "this.setAttribute('f', 'true');");
b[i].setAttribute('onblur', "this.setAttribute('f', 'false');");
b[i].removeAttribute('href');
}
var trigger_menu = function(active) {
var display = function(element) {
let trigger_menu = function(active) {
let display = function(element) {
element.classList.add('opened');
}
var hide = function(element) {
let hide = function(element) {
element.classList.remove('opened');
}
var menu = document.querySelector('#menu');
var buttons = document.querySelectorAll('#light-menu li');
var menus = document.querySelectorAll('#menu > div');
let menu = document.querySelector('#menu');
let buttons = document.querySelectorAll('#light-menu li');
let menus = document.querySelectorAll('#menu > div');
if(active == -1 || buttons[active].classList.contains('opened')) {
hide(menu);
@ -39,14 +39,14 @@ var trigger_menu = function(active) {
}
}
var mouse_trigger = function(event) {
var menu = document.querySelector('#menu');
var buttons = document.querySelectorAll('#light-menu li');
let mouse_trigger = function(event) {
let menu = document.querySelector('#menu');
let buttons = document.querySelectorAll('#light-menu li');
if(!menu.contains(event.target)) {
var active = -1;
let active = -1;
for(i = 0; i < buttons.length; i++) {
for(let i = 0; i < buttons.length; i++) {
if(buttons[i].contains(event.target))
active = i;
buttons[i].querySelector('a').blur();
@ -56,12 +56,12 @@ var mouse_trigger = function(event) {
}
}
var keyboard_trigger = function(event) {
var menu = document.getElementById('menu');
var buttons = document.querySelectorAll('#light-menu li');
let keyboard_trigger = function(event) {
let menu = document.getElementById('menu');
let buttons = document.querySelectorAll('#light-menu li');
if(event.keyCode == 13) {
for(var i = 0; i < buttons.length; i++) {
for(let i = 0; i < buttons.length; i++) {
if(buttons[i].querySelector('a').getAttribute('f') == 'true') {
trigger_menu(i);
}

View File

@ -1,5 +1,7 @@
{% extends "base/base.html" %}
{% set tabtitle = "Gestion du compte" %}
{% block title %}
<h1>Gestion du compte</h1>
{% endblock %}
@ -13,7 +15,7 @@
<div>
{{ form.avatar.label }}
<div>
<img class="avatar" src="{{ url_for('avatar', filename=current_user.avatar) }}" meta="{{ current_user.avatar }}" />
<img class="avatar" src="{{ current_user.avatar_url }}" />
{{ form.avatar }}
</div>
{% for error in form.avatar.errors %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,7 @@
{% extends "base/base.html" %}
{% set tabtitle = "Inscription" %}
{% block content %}
<section class="form" style="width:40%;">
<h1>Inscription</h1>
@ -46,7 +48,7 @@
<div>
{{ form.newsletter.label }}
{{ form.newsletter() }}
<div style="font-size:80%;color:rgba(0,0,0,.5)">{{ form.newsletter.description }}</div>
<div class="desc">{{ form.newsletter.description }}</div>
{% for error in form.newsletter.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}

View File

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

View File

@ -1,6 +1,8 @@
{% extends "base/base.html" %}
{% import "widgets/user.html" as widget_member %}
{% set tabtitle = "Profil de " + member.name %}
{% block title %}
<h1>Profil de {{ member.name }}</h1>
{% endblock %}

View File

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

View File

@ -1,5 +1,7 @@
{% extends "base/base.html" %}
{% set tabtitle = "Administration - Pièces-jointes" %}
{% block title %}
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Pièces jointes</h1>
{% endblock %}

View File

@ -1,5 +1,7 @@
{% extends "base/base.html" %}
{% set tabtitle = "Administration - Configuration du site" %}
{% block title %}
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Configuration du site</h1>
{% endblock %}

View File

@ -1,5 +1,7 @@
{% extends "base/base.html" %}
{% set tabtitle = f"Administration - Suppression du compte de " + user.name %}
{% block title %}
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Suppression du compte de '{{ user.name }}'</h1>
{% endblock %}

View File

@ -1,28 +0,0 @@
{% extends "base/base.html" %}
{% block title %}
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <a href={{ url_for('adm_trophies') }}>Titres et trophées</a> » <h1>Suppression du trophée '{{ trophy.name }}'</h1>
{% endblock %}
{% block content %}
<section class="form">
<h2>Confirmer la suppression du trophée</h2>
<p>Le trophée '{{ trophy.name }}' que vous allez supprimer est lié à :</p>
<ul>
<li>{{ trophy.owners | length }} membre{{ trophy.owners|length|pluralize }}</li>
</ul>
<form action="{{ url_for('adm_delete_trophy', trophy_id=trophy.id) }}" method=post>
{{ del_form.hidden_tag() }}
<div>
{{ del_form.delete.label }}
{{ del_form.delete(checked=False) }}
<div style="font-size: 80%; color: gray">{{ del_form.delete.description }}</div>
{% for error in del_form.delete.errors %}
<span class=msgerror>{{ error }}</span>
{% endfor %}
</div>
<div>{{ del_form.submit(class_="bg-error") }}</div>
</form>
</section>
{% endblock %}

View File

@ -1,7 +1,9 @@
{% extends "base/base.html" %}
{% set tabtitle = f"Administration - Édition du compte de " + user.name %}
{% block title %}
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Édition du compte de {{ user.name }}</h1>
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Édition du compte de {{ user.name }}</h1>
{% endblock %}
{% block content %}
@ -14,7 +16,7 @@
<div>
{{ form.avatar.label }}
<div>
<img class="avatar" src="{{ url_for('avatar', filename=user.avatar) }}" meta="{{ user.avatar }}" />
<img class="avatar" src="{{ user.avatar_url }}" />
{{ form.avatar }}
</div>
{% for error in form.avatar.errors %}

View File

@ -1,58 +0,0 @@
{% extends "base/base.html" %}
{% block title %}
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <a href={{ url_for('adm_trophies') }}>Titres et trophées</a> » <h1>Édition du trophée '{{ trophy.name }}'</h1>
{% endblock %}
{% block content %}
<section class="form">
<form action="{{ url_for('adm_edit_trophy', trophy_id=trophy.id) }}" method="post" enctype="multipart/form-data">
{{ form.hidden_tag() }}
<h2>Éditer le trophée</h2>
<div>
<img src="{{ url_for('static', filename='images/trophies/'+slugify(trophy.name))+'.png' }}" style="vertical-align: middle; margin-right: 8px">
<b>{{ trophy.name }}</b>
</div>
<div>
{{ form.name.label }}
{{ form.name(value=trophy.name) }}
{% for error in form.name.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ form.desc.label }}
{{ form.desc(value=trophy.description) }}
{% for error in form.desc.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ form.hidden.label }}
{{ form.hidden(checked=trophy.hidden) }}
<div class=desc>{{ form.hidden.description }}</div>
{% for error in form.hidden.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ form.title.label }}
{{ form.title() }}
<div class=desc>{{ form.title.description }}</div>
{% for error in form.title.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ form.css.label }}
<div class=desc>{{ form.css.description }}</div>
{{ form.css(value=trophy.css) }}
{% for error in form.css.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>{{ form.submit(class_="bg-ok") }}</div>
</section>
{% endblock %}

View File

@ -16,6 +16,8 @@
{% endfor %}
{% endmacro %}
{% set tabtitle = "Administration - Forums" %}
{% block title %}
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Forums</h1>
{% endblock %}

View File

@ -1,5 +1,7 @@
{% extends "base/base.html" %}
{% set tabtitle = "Administration - Groupes et privilèges" %}
{% block title %}
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Groupes et privilèges</h1>
{% endblock %}

View File

@ -1,5 +1,7 @@
{% extends "base/base.html" %}
{% set tabtitle = "Panneau dadministration" %}
{% block title %}
<h1>Panneau d'administration</h1>
{% endblock %}
@ -10,7 +12,6 @@
<ul>
<li><a href="{{ url_for('adm_groups') }}">Groupes et privilèges</a></li>
<li><a href="{{ url_for('adm_members') }}">Liste des membres</a></li>
<li><a href="{{ url_for('adm_trophies') }}">Titres et trophées</a></li>
<li><a href="{{ url_for('adm_forums') }}">Arbre des forums</a></li>
<li><a href="{{ url_for('adm_polls') }}">Sondages</a></li>
<li><a href="{{ url_for('adm_attachments') }}">Pièces-jointes</a></li>

View File

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

View File

@ -1,5 +1,7 @@
{% extends "base/base.html" %}
{% set tabtitle = "Administration - Liste des membres" %}
{% block title %}
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Liste des membres</h1>
{% endblock %}

View File

@ -1,8 +1,10 @@
{% extends "base/base.html" %}
{% import "widgets/poll.html" as poll_widget with context %}
{% set tabtitle = "Administration - Gestion des sondages" %}
{% block title %}
<h1>Gestion des sondages</h1>
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Gestion des sondages</h1>
{% endblock %}
{% block content %}

View File

@ -1,80 +0,0 @@
{% extends "base/base.html" %}
{% block title %}
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Titres et trophées</h1>
{% endblock %}
{% block content %}
<section>
<p>Cette page présente une vue d'ensemble des titres et trophées. Les
conditions d'obtention exactes des trophées sont définies dans le code et
non dans la base de données.</p>
<h2>Titres et trophées</h2>
<table style="width:90%; margin: auto;">
<tr><td></td><th>Nom</th><th>Titre</th>
<th>Style</th><th>Modifier</th><th>Supprimer</th></tr>
{% for trophy in trophies %}
<tr><td style="background: white; padding: 0; width: 64px">
<img src="{{ url_for('static', filename='images/trophies/'+slugify(trophy.name))+'.png' }}" alt="{{ trophy.name }}"></td>
<td style="{{ trophy.css }}">{{ trophy.name }}</td>
{% if trophy | is_title %}
<td style="text-align: center; color:green">Oui</td>
{% else %}
<td style="text-align: center; color:red">Non</td>
{% endif %}
<td><code>{{ trophy.css }}</code></td>
<td style="text-align: center"><a href="{{ url_for('adm_edit_trophy', trophy_id=trophy.id) }}">Modifier</a></td>
<td style="text-align: center"><a href="{{ url_for('adm_delete_trophy', trophy_id=trophy.id) }}">Supprimer</a></td>
</tr>
{% endfor %}
</table>
</section>
<section class="form">
<form action="{{ url_for('adm_trophies') }}" method="post" enctype="multipart/form-data">
{{ form.hidden_tag() }}
<h2>Nouveau trophée</h2>
<div>
{{ form.name.label }}
{{ form.name }}
{% for error in form.name.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ form.desc.label }}
{{ form.desc }}
{% for error in form.desc.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ form.hidden.label }}
{{ form.hidden }}
<div class=desc>{{ form.hidden.description }}</div>
{% for error in form.hidden.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ form.title.label }}
{{ form.title }}
<div class=desc>{{ form.title.description }}</div>
{% for error in form.title.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>
{{ form.css.label }}
<div class=desc>{{ form.css.description }}</div>
{{ form.css }}
{% for error in form.css.errors %}
<span class="msgerror">{{ error }}</span>
{% endfor %}
</div>
<div>{{ form.submit(class_="bg-ok") }}</div>
</section>
{% endblock %}

View File

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

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