bring back the badge editor
This commit is contained in:
14
app/db.py
14
app/db.py
@@ -49,6 +49,12 @@ class DB:
|
|||||||
yield conn
|
yield conn
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def binding_list(num: int) -> str:
|
||||||
|
"""Returns a bindings list string for the given number of bindings."""
|
||||||
|
return '(%s)' % ','.join('?' * num)
|
||||||
|
|
||||||
|
|
||||||
def query(self, sql, *args):
|
def query(self, sql, *args):
|
||||||
"""Executes a query and returns a list of dictionaries."""
|
"""Executes a query and returns a list of dictionaries."""
|
||||||
with self.connection() as conn:
|
with self.connection() as conn:
|
||||||
@@ -104,8 +110,12 @@ class DB:
|
|||||||
conditions = []
|
conditions = []
|
||||||
params = []
|
params = []
|
||||||
for col, op, val in self._where:
|
for col, op, val in self._where:
|
||||||
conditions.append(f"{col} {op} ?")
|
if isinstance(val, tuple) or isinstance(val, list):
|
||||||
params.append(val)
|
conditions.append(f"{col} {op} {db.binding_list(len(val))}")
|
||||||
|
params.extend(val)
|
||||||
|
else:
|
||||||
|
conditions.append(f"{col} {op} ?")
|
||||||
|
params.append(val)
|
||||||
|
|
||||||
return " WHERE " + " AND ".join(conditions), params
|
return " WHERE " + " AND ".join(conditions), params
|
||||||
|
|
||||||
|
|||||||
@@ -648,6 +648,12 @@ class BadgeUploads(Model):
|
|||||||
class Badges(Model):
|
class Badges(Model):
|
||||||
table = 'badges'
|
table = 'badges'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_for_user(cls, user_id):
|
||||||
|
q = 'SELECT * FROM badges WHERE user_id = ? ORDER BY sort_order ASC'
|
||||||
|
res = db.query(q, user_id)
|
||||||
|
return [cls.from_data(row) for row in res]
|
||||||
|
|
||||||
def get_image_url(self):
|
def get_image_url(self):
|
||||||
bu = BadgeUploads.find({'id': int(self.upload)})
|
bu = BadgeUploads.find({'id': int(self.upload)})
|
||||||
return bu.file_path
|
return bu.file_path
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from flask import Blueprint, render_template, request, url_for
|
from flask import Blueprint, render_template, request, url_for
|
||||||
from ..auth import get_active_user, is_logged_in, hard_login_required
|
from ..auth import get_active_user, is_logged_in, hard_login_required
|
||||||
from ..models import BookmarkCollections, BookmarkedPosts, BookmarkedThreads, Threads, Posts
|
from ..models import BookmarkCollections, BookmarkedPosts, BookmarkedThreads, Threads, Posts, Badges, BadgeUploads
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
bp = Blueprint('hyperapi', __name__, url_prefix='/hyperapi/')
|
bp = Blueprint('hyperapi', __name__, url_prefix='/hyperapi/')
|
||||||
@@ -124,3 +124,12 @@ def bookmark_post():
|
|||||||
})
|
})
|
||||||
|
|
||||||
return '', 204
|
return '', 204
|
||||||
|
|
||||||
|
@bp.get('/badges/editor/')
|
||||||
|
@hard_login_required
|
||||||
|
@user_required
|
||||||
|
def badge_editor():
|
||||||
|
user = get_active_user()
|
||||||
|
badges = Badges.get_for_user(user.id)
|
||||||
|
badge_uploads = BadgeUploads.get_for_user(user.id)
|
||||||
|
return render_template('hyper/badge_editor.html', badges=badges, badge_uploads=badge_uploads)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from ..auth import (
|
|||||||
login_required, revoke_session, get_active_user,
|
login_required, revoke_session, get_active_user,
|
||||||
parse_display_name, revoke_all_sessions, csrf_verified
|
parse_display_name, revoke_all_sessions, csrf_verified
|
||||||
)
|
)
|
||||||
from ..models import Users, Posts, Reactions, Threads, Avatars, PostHistory, Mentions, BookmarkCollections, InviteKeys
|
from ..models import Users, Posts, Reactions, Threads, Avatars, PostHistory, Mentions, BookmarkCollections, InviteKeys, Badges, BadgeUploads
|
||||||
from ..constants import PermissionLevel, InfoboxKind
|
from ..constants import PermissionLevel, InfoboxKind
|
||||||
from ..util import get_form_checkbox, time_now
|
from ..util import get_form_checkbox, time_now
|
||||||
from ..lib.babycode import babycode_to_html
|
from ..lib.babycode import babycode_to_html
|
||||||
@@ -24,6 +24,7 @@ import os
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
AVATAR_MAX_SIZE = 1000 * 1000 # 1MB
|
AVATAR_MAX_SIZE = 1000 * 1000 # 1MB
|
||||||
|
BADGE_MAX_SIZE = 1000 * 500 # 500K
|
||||||
|
|
||||||
bp = Blueprint('users', __name__, url_prefix='/users/')
|
bp = Blueprint('users', __name__, url_prefix='/users/')
|
||||||
|
|
||||||
@@ -60,6 +61,22 @@ def validate_and_create_avatar(input_image, filename):
|
|||||||
except WandException:
|
except WandException:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def validate_and_create_badge(input_image, filename):
|
||||||
|
try:
|
||||||
|
with Image(blob=input_image) as img:
|
||||||
|
if img.width != 88 or img.height != 31:
|
||||||
|
return False
|
||||||
|
if hasattr(img, 'sequence') and len(img.sequence) > 1:
|
||||||
|
img = Image(image=img.sequence[0])
|
||||||
|
img.strip()
|
||||||
|
|
||||||
|
img.format = 'webp'
|
||||||
|
img.compression_quality = 90
|
||||||
|
img.save(filename=filename)
|
||||||
|
return True
|
||||||
|
except WandException:
|
||||||
|
return False
|
||||||
|
|
||||||
def anonymize_user(user_id):
|
def anonymize_user(user_id):
|
||||||
deleted_user = Users.find({'username': 'deleteduser'})
|
deleted_user = Users.find({'username': 'deleteduser'})
|
||||||
|
|
||||||
@@ -282,6 +299,7 @@ def posts(username):
|
|||||||
'users/posts.html', posts=posts,
|
'users/posts.html', posts=posts,
|
||||||
page=page, page_count=page_count,
|
page=page, page_count=page_count,
|
||||||
target_user=target_user,
|
target_user=target_user,
|
||||||
|
Reactions=Reactions,
|
||||||
)
|
)
|
||||||
|
|
||||||
@bp.get('/<username>/threads/')
|
@bp.get('/<username>/threads/')
|
||||||
@@ -305,6 +323,7 @@ def threads(username):
|
|||||||
'users/threads.html', threads=threads,
|
'users/threads.html', threads=threads,
|
||||||
page=page, page_count=page_count,
|
page=page, page_count=page_count,
|
||||||
target_user=target_user,
|
target_user=target_user,
|
||||||
|
Reactions=Reactions,
|
||||||
)
|
)
|
||||||
|
|
||||||
@bp.get('/<username>/comments/')
|
@bp.get('/<username>/comments/')
|
||||||
@@ -611,3 +630,107 @@ def revoke_invite_key(username):
|
|||||||
|
|
||||||
invite.delete()
|
invite.delete()
|
||||||
return redirect(url_for('.settings', username=username, _anchor='invite'))
|
return redirect(url_for('.settings', username=username, _anchor='invite'))
|
||||||
|
|
||||||
|
@bp.post('/<username>/settings/badges/')
|
||||||
|
@login_required
|
||||||
|
@redirect_to_own
|
||||||
|
def save_badges(username):
|
||||||
|
user = get_active_user()
|
||||||
|
if user.is_guest():
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
ids = request.form.getlist('id[]', type=int)
|
||||||
|
badge_choices = request.form.getlist('badge_choice[]')
|
||||||
|
files = request.files.getlist('badge_file[]')
|
||||||
|
labels = request.form.getlist('label[]')
|
||||||
|
links = request.form.getlist('link[]')
|
||||||
|
|
||||||
|
existing_badges = {badge.id: badge for badge in Badges.findall({'user_id': user.id})}
|
||||||
|
|
||||||
|
if not (len(ids) == len(badge_choices) == len(files) == len(labels) == len(links)):
|
||||||
|
abort(400)
|
||||||
|
|
||||||
|
rejected_badges = []
|
||||||
|
# print(ids)
|
||||||
|
|
||||||
|
# print(db.query(f'SELECT id FROM badges WHERE id NOT IN {db.binding_list(len(ids))}', *ids))
|
||||||
|
deleted_badges = Badges.findall([
|
||||||
|
('id', 'NOT IN', ids),
|
||||||
|
('user_id', '=', user.id),
|
||||||
|
])
|
||||||
|
print(list(map(lambda x: x.id, deleted_badges)))
|
||||||
|
|
||||||
|
with db.transaction():
|
||||||
|
for b in deleted_badges:
|
||||||
|
b.delete()
|
||||||
|
|
||||||
|
for i, id in enumerate(ids):
|
||||||
|
badge_upload_id = badge_choices[i]
|
||||||
|
label = labels[i]
|
||||||
|
link = links[i]
|
||||||
|
pending_badge = {
|
||||||
|
'label': label,
|
||||||
|
'link': link,
|
||||||
|
'sort_order': i,
|
||||||
|
}
|
||||||
|
if badge_upload_id == 'custom':
|
||||||
|
file = files[i]
|
||||||
|
if not file:
|
||||||
|
rejected_badges.append(file.filename)
|
||||||
|
continue
|
||||||
|
file.seek(0, os.SEEK_END)
|
||||||
|
file_size = file.tell()
|
||||||
|
file.seek(0, os.SEEK_SET)
|
||||||
|
|
||||||
|
if file_size > BADGE_MAX_SIZE:
|
||||||
|
rejected_badges.append(file.filename)
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_bytes = file.read()
|
||||||
|
now = time_now()
|
||||||
|
filename = f'u{user.id}d{now}s{i}.webp'
|
||||||
|
output_path = os.path.join(current_app.config['BADGES_UPLOAD_PATH'], filename)
|
||||||
|
proxied_filename = f'/static/badges/user/{filename}'
|
||||||
|
res = validate_and_create_badge(file_bytes, output_path)
|
||||||
|
if not res:
|
||||||
|
rejected_badges.append(file.filename)
|
||||||
|
continue
|
||||||
|
|
||||||
|
bu = BadgeUploads.create({
|
||||||
|
'user_id': user.id,
|
||||||
|
'uploaded_at': now,
|
||||||
|
'file_path': proxied_filename,
|
||||||
|
'original_filename': file.filename
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
bu = BadgeUploads.find({'id': badge_upload_id})
|
||||||
|
if not bu:
|
||||||
|
continue
|
||||||
|
|
||||||
|
pending_badge['upload'] = bu.id
|
||||||
|
|
||||||
|
if id == -1:
|
||||||
|
pending_badge['user_id'] = user.id
|
||||||
|
badge = Badges.create(pending_badge)
|
||||||
|
else:
|
||||||
|
badge = Badges.find({'id': id})
|
||||||
|
if badge.user_id != user.id:
|
||||||
|
continue
|
||||||
|
if not badge:
|
||||||
|
continue
|
||||||
|
badge.update(pending_badge)
|
||||||
|
|
||||||
|
for stale_upload in BadgeUploads.get_unused_for_user(user.id):
|
||||||
|
filename = os.path.join(current_app.config['BADGES_UPLOAD_PATH'], os.path.basename(stale_upload.file_path))
|
||||||
|
os.remove(filename)
|
||||||
|
stale_upload.delete()
|
||||||
|
|
||||||
|
message = 'Badges updated.'
|
||||||
|
icon = InfoboxKind.INFO
|
||||||
|
if rejected_badges:
|
||||||
|
message += f';Some of your badges were incorrect and were not uploaded: {", ".join(rejected_badges)}.'
|
||||||
|
icon = InfoboxKind.WARN
|
||||||
|
|
||||||
|
flash(message, icon)
|
||||||
|
|
||||||
|
return redirect(url_for('.settings', username=username))
|
||||||
|
|||||||
@@ -161,7 +161,7 @@
|
|||||||
<a href="{{url_for('users.user_page', username=post.username)}}" class="usercard-username">{{post.display_name if post.display_name else post.username}}</a>
|
<a href="{{url_for('users.user_page', username=post.username)}}" class="usercard-username">{{post.display_name if post.display_name else post.username}}</a>
|
||||||
<abbr title="mention">@{{post.username}}</abbr>
|
<abbr title="mention">@{{post.username}}</abbr>
|
||||||
<i>{{post.status}}</i>
|
<i>{{post.status}}</i>
|
||||||
{%- set badges=post.badges_json | fromjson -%}
|
{%- set badges=post.badges_json | fromjson | sort(attribute='sort_order') -%}
|
||||||
<div class="badges-container">
|
<div class="badges-container">
|
||||||
{%- for badge in badges -%}
|
{%- for badge in badges -%}
|
||||||
{%- if badge.link -%}<a href="{{badge.link}}">{%- endif -%}
|
{%- if badge.link -%}<a href="{{badge.link}}">{%- endif -%}
|
||||||
@@ -285,16 +285,16 @@
|
|||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
{% macro sortable_list(attr=none) -%}
|
{% macro sortable_list(attr=none) -%}
|
||||||
<ol class="sortable-list plank even no-shadow minimal tertiary-bg" {% if attr %}{{ dict_to_attr(attr) }}{% endif %}>
|
<ol class="sortable-list" {% if attr %}{{ dict_to_attr(attr) }}{% endif %}>
|
||||||
{%- if caller -%}
|
{%- if caller -%}
|
||||||
{{ caller() }}
|
{{ caller() }}
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
</ol>
|
</ol>
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
{% macro sortable_list_item(key, immovable=false, attr=none) -%}
|
{% macro sortable_list_item(key, immovable=false, attr=none, full=false) -%}
|
||||||
<li class="sortable-item{{ ' immovable' if immovable else '' }} plank even no-shadow {{'secondary-bg' if immovable else ''}}" data-sortable-list-key="{{key}}" {% if attr %}{{ dict_to_attr(attr) }}{% endif %}>
|
<li class="sortable-item{{ ' immovable' if immovable else '' }} plank even no-shadow {{'secondary-bg' if immovable else ''}}" data-sortable-list-key="{{key}}" {% if attr %}{{ dict_to_attr(attr) }}{% endif %}>
|
||||||
<span class="dragger plank minimal even no-shadow tertiary-bg" draggable="{{ 'true' if not immovable else 'false' }}">{{ icn_dragger() }}</span>
|
<span class="dragger plank minimal even no-shadow tertiary-bg" draggable="{{ 'true' if not immovable else 'false' }}">{{ icn_dragger() }}</span>
|
||||||
<div class="sortable-item-inner">{{ caller() }}</div>
|
<div class="sortable-item-inner {{full and 'full' or ''}}">{{ caller() }}</div>
|
||||||
</li>
|
</li>
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|||||||
51
app/templates/hyper/badge_editor.html
Normal file
51
app/templates/hyper/badge_editor.html
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{%- macro badge_input(uploads, label='', link='', selected=none, id=none) -%}
|
||||||
|
{%- set defaults = uploads | selectattr('user_id', 'none') | list | sort(attribute='file_path') -%}
|
||||||
|
{%- set user = uploads | selectattr('user_id') | list -%}
|
||||||
|
{%- if selected is not none -%}
|
||||||
|
{%- set selected_href = (uploads | selectattr('id', 'equalto', selected) | list)[0].file_path -%}
|
||||||
|
{%- else -%}
|
||||||
|
{% set selected_href = defaults[0].file_path %}
|
||||||
|
{%- endif -%}
|
||||||
|
<input type="hidden" name="id[]" value="{{id and id or '-1'}}">
|
||||||
|
<div class="badge-editor-badge-container">
|
||||||
|
<div class="badge-editor-badge-select">
|
||||||
|
<select name="badge_choice[]" required data-s="badgeEditorSetPreview badgeEditorToggleFilePicker" data-listen="change">
|
||||||
|
<optgroup label="Default">
|
||||||
|
{%- for upload in defaults -%}
|
||||||
|
<option data-file-path="{{upload.file_path}}" value="{{upload.id}}" {{selected==upload.id and 'selected' or ''}}>{{upload.file_path | basename_noext}}</option>
|
||||||
|
{%- endfor -%}
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Your uploads">
|
||||||
|
{%- for upload in user -%}
|
||||||
|
<option data-file-path="{{upload.file_path}}" value="{{upload.id}}" {{selected==upload.id and 'selected' or ''}}>{{upload.original_filename | basename_noext}}</option>
|
||||||
|
{%- endfor -%}
|
||||||
|
<option value="custom">Upload new…</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
<img class="badge-button" src="{{selected_href}}" data-r="badgeEditorSetPreview badgeEditorSetPreviewCustom">
|
||||||
|
</div>
|
||||||
|
<div class="badge-editor-file-picker hidden" data-r="badgeEditorToggleFilePicker">
|
||||||
|
<button data-s="badgeEditorShowFilePicker" type="button" class="alt">Upload…</button>
|
||||||
|
<input data-s="badgeEditorFileSelected" data-r="badgeEditorShowFilePicker" type="file" accept="image/png, image/jpeg, image/jpg, image/webp" name="badge_file[]">
|
||||||
|
</div>
|
||||||
|
<input type="text" required placeholder="Label" value="{{label}}" autocomplete="off" name="label[]">
|
||||||
|
<input type="url" placeholder="(Optional) Link" value="{{link}}" autocomplete="off" name="link[]" pattern="https://.*">
|
||||||
|
<button type="button" class="critical" data-s="badgeEditorDelete">Delete</button>
|
||||||
|
</div>
|
||||||
|
{%- endmacro -%}
|
||||||
|
{%- from 'common/macros.html' import sortable_list, sortable_list_item -%}
|
||||||
|
<button type="button" data-s="badgeEditorAddBadge" data-r="setBadgeCount">Add badge</button>
|
||||||
|
<input type="submit" value="Save badges">
|
||||||
|
<span data-r="setBadgeCount">0/10</span>
|
||||||
|
{%- call() sortable_list(attr={'data-r': 'badgeEditorAddBadge'}) -%}
|
||||||
|
{%- for badge in badges -%}
|
||||||
|
{%- call() sortable_list_item('badge', full=true, attr={'data-r': 'badgeEditorDelete badgeEditorAssignImgId'}) -%}
|
||||||
|
{{badge_input(badge_uploads, badge.label, badge.link, badge.upload, badge.id)}}
|
||||||
|
{%- endcall -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
{%- endcall -%}
|
||||||
|
<script type="text/html" id="badge-template">
|
||||||
|
{%- call() sortable_list_item('badge', full=true, attr={'data-r': 'badgeEditorDelete'}) -%}
|
||||||
|
{{- badge_input(badge_uploads) -}}
|
||||||
|
{%- endcall -%}
|
||||||
|
</script>
|
||||||
@@ -72,9 +72,12 @@
|
|||||||
</form>
|
</form>
|
||||||
</fieldset>#}
|
</fieldset>#}
|
||||||
<fieldset class="plank">
|
<fieldset class="plank">
|
||||||
|
<bitty-8 data-connect="/static/js/bits/badge-editor.js"></bitty-8>
|
||||||
<legend>Badges</legend>
|
<legend>Badges</legend>
|
||||||
<div>Loading badges…</div>
|
<form method="POST" action="{{url_for('users.save_badges', username=get_active_user().username)}}" data-listen="submit" data-r="badgeEditorInit" enctype="multipart/form-data">
|
||||||
<div>If badges fail to load, make sure JS is enabled.</div>
|
<p>Loading badges…</p>
|
||||||
|
<p>If badges fail to load, make sure JS is enabled.</p>
|
||||||
|
</form>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
{%- if user.can_invite() -%}
|
{%- if user.can_invite() -%}
|
||||||
<fieldset class="plank" id="invite">
|
<fieldset class="plank" id="invite">
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ button, .linkbutton, input[type="submit"], input[type="file"]::file-selector-but
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"], input[type="password"], textarea, select {
|
input[type="text"], input[type="password"], input[type="url"], textarea, select {
|
||||||
--main-color: hsl(from var(--bg-color-primary) h s calc(l + 10));
|
--main-color: hsl(from var(--bg-color-primary) h s calc(l + 10));
|
||||||
--active-color: hsl(from var(--main-color) h s calc(l + 5));
|
--active-color: hsl(from var(--main-color) h s calc(l + 5));
|
||||||
--border-color: hsl(from var(--main-color) h calc(s * 1.3) 25);
|
--border-color: hsl(from var(--main-color) h calc(s * 1.3) 25);
|
||||||
@@ -522,6 +522,7 @@ footer {
|
|||||||
border-radius: var(--base-padding);
|
border-radius: var(--base-padding);
|
||||||
border: var(--base-padding) outset gray;
|
border: var(--base-padding) outset gray;
|
||||||
box-shadow: 0px 0px 12px 2px #0006;
|
box-shadow: 0px 0px 12px 2px #0006;
|
||||||
|
align-self: center;
|
||||||
&::after {
|
&::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -964,11 +965,52 @@ ol.sortable-list {
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:not(.row) > * {
|
&:not(.row):not(.full) > * {
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge-editor-badge-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
|
||||||
|
& > input[type=text], & > input[type=url] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-editor-file-picker {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 150px;
|
||||||
|
|
||||||
|
& > input[type=file] {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&::file-selector-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-editor-badge-select {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 200px;
|
||||||
|
& > select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.js-only {
|
.js-only {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -1038,4 +1080,9 @@ ol.sortable-list {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge-editor-badge-container {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
162
data/static/js/bits/badge-editor.js
Normal file
162
data/static/js/bits/badge-editor.js
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
async function getHTML(endpoint, options = {}) {
|
||||||
|
let query = {};
|
||||||
|
if (options._query !== undefined) {
|
||||||
|
query = options._query;
|
||||||
|
delete options._query;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(query);
|
||||||
|
const res = await fetch(`${endpoint}?${params}`, options);
|
||||||
|
|
||||||
|
return { body: await res.text(), status: res.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateBase64Img = dataURL => new Promise(resolve => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
resolve(img.width === 88 && img.height === 31);
|
||||||
|
};
|
||||||
|
img.src = dataURL;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const b = {
|
||||||
|
init: 'badgeEditorInit',
|
||||||
|
}
|
||||||
|
|
||||||
|
const badgeEditorEndpoint = '/hyperapi/badges/editor/'
|
||||||
|
const MAX_BADGES = 10;
|
||||||
|
let badgesCount = 0;
|
||||||
|
let customImageDatas = {};
|
||||||
|
|
||||||
|
export async function badgeEditorInit(_, __, el) {
|
||||||
|
const res = await getHTML(badgeEditorEndpoint);
|
||||||
|
if (res.status != 200) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = res.body;
|
||||||
|
badgesCount = el.querySelectorAll('.sortable-item').length;
|
||||||
|
b.trigger('badgeEditorAssignImgId');
|
||||||
|
b.trigger('setBadgeCount');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function badgeEditorAssignImgId(_, __, el) {
|
||||||
|
if (el.dataset.imgId) return;
|
||||||
|
|
||||||
|
const id = b.uuid();
|
||||||
|
const filePicker = el.querySelector('input[type=file]');
|
||||||
|
const img = el.querySelector('img.badge-button');
|
||||||
|
console.log(img);
|
||||||
|
el.dataset.imgId = id;
|
||||||
|
filePicker.dataset.imgId = id;
|
||||||
|
img.dataset.imgId = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function badgeEditorSetPreview(ev, sender, el) {
|
||||||
|
if (!sender.parentNode.contains(el)) return;
|
||||||
|
|
||||||
|
const selectedItem = sender.selectedOptions[0];
|
||||||
|
if (selectedItem.value !== 'custom') {
|
||||||
|
el.src = selectedItem.dataset.filePath;
|
||||||
|
} else if (customImageDatas[el.dataset.imgId]) {
|
||||||
|
el.src = customImageDatas[el.dataset.imgId];
|
||||||
|
} else {
|
||||||
|
el.removeAttribute('src');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function badgeEditorSetPreviewCustom(payload, _, el) {
|
||||||
|
if (!payload.badge.contains(el)) return;
|
||||||
|
if (!customImageDatas[el.dataset.imgId]) {
|
||||||
|
el.removeAttribute('src');
|
||||||
|
} else {
|
||||||
|
el.src = customImageDatas[el.dataset.imgId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function badgeEditorToggleFilePicker(ev, sender, el) {
|
||||||
|
if (!sender.parentNode.parentNode.contains(el)) return;
|
||||||
|
|
||||||
|
const selectedItem = sender.selectedOptions[0];
|
||||||
|
const picker = el.querySelector('input[type=file]');
|
||||||
|
if (selectedItem.value !== 'custom') {
|
||||||
|
el.classList.add('hidden');
|
||||||
|
picker.required = false;
|
||||||
|
picker.setCustomValidity('');
|
||||||
|
} else {
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
picker.required = true;
|
||||||
|
picker.setCustomValidity(picker.dataset.validity || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function badgeEditorAddBadge(ev, sender, el) {
|
||||||
|
// TODO: page templates do not get updated on mutation
|
||||||
|
const badge = document.getElementById('badge-template').innerText;
|
||||||
|
el.innerHTML += badge;
|
||||||
|
b.trigger('badgeEditorAssignImgId');
|
||||||
|
badgesCount++;
|
||||||
|
b.trigger('setBadgeCount');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function badgeEditorDelete(ev, sender, el) {
|
||||||
|
if (!el.contains(sender)) return;
|
||||||
|
el.remove();
|
||||||
|
badgesCount--;
|
||||||
|
b.trigger('setBadgeCount');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function badgeEditorShowFilePicker(ev, sender, el) {
|
||||||
|
if (sender.nextElementSibling !== el) return;
|
||||||
|
el.showPicker();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function badgeEditorFileSelected(ev, sender, el) {
|
||||||
|
const file = sender.files[0];
|
||||||
|
const badge = sender.parentNode.parentNode;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!['image/png', 'image/jpeg', 'image/jpg', 'image/webp'].includes(file.type)
|
||||||
|
) {
|
||||||
|
sender.dataset.validity = 'The badge file must be an image.';
|
||||||
|
sender.setCustomValidity(sender.dataset.validity);
|
||||||
|
sender.reportValidity();
|
||||||
|
customImageDatas[sender.dataset.imgId] = null;
|
||||||
|
b.send({ badge: badge }, 'badgeEditorSetPreviewCustom');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size >= 1000 * 500) {
|
||||||
|
sender.dataset.validity = 'The badge image must be smaller than 500KB.';
|
||||||
|
sender.setCustomValidity(sender.dataset.validity);
|
||||||
|
sender.reportValidity();
|
||||||
|
customImageDatas[sender.dataset.imgId] = null;
|
||||||
|
b.send({ badge: badge }, 'badgeEditorSetPreviewCustom');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async e => {
|
||||||
|
const dimsValid = await validateBase64Img(e.target.result);
|
||||||
|
if (!dimsValid) {
|
||||||
|
sender.setCustomValidity('The badge image must be exactly 88x31 pixels.');
|
||||||
|
sender.reportValidity();
|
||||||
|
customImageDatas[sender.dataset.imgId] = null;
|
||||||
|
b.send({ badge: badge }, 'badgeEditorSetPreviewCustom');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
customImageDatas[sender.dataset.imgId] = e.target.result;
|
||||||
|
|
||||||
|
sender.dataset.validity = '';
|
||||||
|
sender.setCustomValidity('');
|
||||||
|
b.send({ badge: badge }, 'badgeEditorSetPreviewCustom');
|
||||||
|
}
|
||||||
|
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setBadgeCount(_, __, el) {
|
||||||
|
if (el instanceof HTMLButtonElement) {
|
||||||
|
el.disabled = badgesCount === MAX_BADGES;
|
||||||
|
} else {
|
||||||
|
el.innerText = `${badgesCount}/${MAX_BADGES}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,9 @@
|
|||||||
if (!target || target === draggedItem) {
|
if (!target || target === draggedItem) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (draggedItem === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const inSameList = draggedItem.dataset.sortableListKey === target.dataset.sortableListKey;
|
const inSameList = draggedItem.dataset.sortableListKey === target.dataset.sortableListKey;
|
||||||
if (!inSameList) {
|
if (!inSameList) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user