Compare commits

...

4 Commits

Author SHA1 Message Date
6fab93ebeb bring back the badge editor 2026-06-05 07:19:53 +03:00
c7ba23ad22 update built-in badges to use the plank theme 2026-06-05 07:19:44 +03:00
3c237df93f cleanup 2026-06-03 20:06:04 +03:00
22ca768ad1 finish invites i think 2026-06-03 16:35:59 +03:00
35 changed files with 449 additions and 24 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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'})
@@ -170,6 +187,8 @@ def log_out():
@redirect_if_logged_in() @redirect_if_logged_in()
def sign_up(): def sign_up():
key = request.args.get('key', '') key = request.args.get('key', '')
invite = None
inviter = None
if not key and current_app.config['DISABLE_SIGNUP']: if not key and current_app.config['DISABLE_SIGNUP']:
return redirect(url_for('topics.all_topics')) return redirect(url_for('topics.all_topics'))
elif key and current_app.config['DISABLE_SIGNUP']: elif key and current_app.config['DISABLE_SIGNUP']:
@@ -188,6 +207,7 @@ def sign_up_post():
invalid_username_error_page = redirect(url_for('.sign_up', error='This username cannot be used. Please pick another.', **args_sans_error)) invalid_username_error_page = redirect(url_for('.sign_up', error='This username cannot be used. Please pick another.', **args_sans_error))
passwords_error_page = redirect(url_for('.sign_up', error='The passwords do not match.', **args_sans_error)) passwords_error_page = redirect(url_for('.sign_up', error='The passwords do not match.', **args_sans_error))
username = request.form.get('username', default='') username = request.form.get('username', default='')
invite = None
if current_app.config['DISABLE_SIGNUP']: if current_app.config['DISABLE_SIGNUP']:
key = request.form.get('key', '') key = request.form.get('key', '')
if not key: if not key:
@@ -220,11 +240,12 @@ def sign_up_post():
'username': username_pair[0], 'username': username_pair[0],
'password_hash': password_hash, 'password_hash': password_hash,
'permission': PermissionLevel.GUEST.value, 'permission': PermissionLevel.GUEST.value,
'created_at': int(time.time()), 'created_at': time_now(),
} }
if invite: if invite:
user_data['invited_by'] = invite.created_by user_data['invited_by'] = invite.created_by
user_data['permission'] = PermissionLevel.USER.value user_data['permission'] = PermissionLevel.USER.value
user_data['confirmed_on'] = time_now()
invite.delete() invite.delete()
user = Users.create(user_data) user = Users.create(user_data)
@@ -251,7 +272,11 @@ def user_page(username):
target_user = Users.find({'username': username}) target_user = Users.find({'username': username})
if not target_user: if not target_user:
abort(404) abort(404)
return render_template('users/user_page.html', target_user=target_user) if current_app.config['DISABLE_SIGNUP'] and target_user.invited_by:
invited_by = Users.find({'id': target_user.invited_by})
else:
invited_by = None
return render_template('users/user_page.html', target_user=target_user, invited_by=invited_by)
@bp.get('/<username>/posts/') @bp.get('/<username>/posts/')
def posts(username): def posts(username):
@@ -273,7 +298,8 @@ def posts(username):
return render_template( return render_template(
'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, Reactions=Reactions target_user=target_user,
Reactions=Reactions,
) )
@bp.get('/<username>/threads/') @bp.get('/<username>/threads/')
@@ -296,7 +322,8 @@ def threads(username):
return render_template( return render_template(
'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, Reactions=Reactions target_user=target_user,
Reactions=Reactions,
) )
@bp.get('/<username>/comments/') @bp.get('/<username>/comments/')
@@ -439,7 +466,6 @@ def set_personalization(username):
parsed_content = babycode_to_html(rev.original_markup).result parsed_content = babycode_to_html(rev.original_markup).result
rev.update({'content': parsed_content}) rev.update({'content': parsed_content})
flash('Personalization settings updated.', InfoboxKind.INFO) flash('Personalization settings updated.', InfoboxKind.INFO)
return redirect(url_for('.settings', username=username)) return redirect(url_for('.settings', username=username))
@@ -604,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))

View File

@@ -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 %}

View File

@@ -9,6 +9,9 @@
<li><a class="linkbutton" href="{{url_for('users.settings', username=user.username)}}">Settings</a></li> <li><a class="linkbutton" href="{{url_for('users.settings', username=user.username)}}">Settings</a></li>
<li><a class="linkbutton" href="{{url_for('users.inbox', username=user.username)}}">Inbox{{' (%s)' % uc if uc else ''}}</a></li> <li><a class="linkbutton" href="{{url_for('users.inbox', username=user.username)}}">Inbox{{' (%s)' % uc if uc else ''}}</a></li>
<li><a class="linkbutton" href="{{url_for('users.bookmarks', username=user.username)}}">Bookmarks</a></li> <li><a class="linkbutton" href="{{url_for('users.bookmarks', username=user.username)}}">Bookmarks</a></li>
{%- if user.can_invite() -%}
<a href="{{url_for('users.settings', username=user.username, _anchor='invite')}}" class="linkbutton alt">Invite</a>
{%- endif %}
{% if user.is_mod() -%} {% if user.is_mod() -%}
<li><a class="linkbutton" href="{{url_for('mod.index')}}">Moderation</a></li> <li><a class="linkbutton" href="{{url_for('mod.index')}}">Moderation</a></li>
{%- endif %} {%- endif %}

View 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&hellip;</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&hellip;</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>

View File

@@ -32,9 +32,9 @@
{%- for bt in collection.get_threads() -%} {%- for bt in collection.get_threads() -%}
{%- set thread = bt.get_thread() -%} {%- set thread = bt.get_thread() -%}
<tr> <tr>
<td class="plank even no-shadow minimal secondary-bg"><a href="{{url_for('threads.thread_by_id', thread_id=thread.id)}}">{{thread.title}}</a></td> <td class="center plank even no-shadow minimal secondary-bg"><a href="{{url_for('threads.thread_by_id', thread_id=thread.id)}}">{{thread.title}}</a></td>
<td class="plank even no-shadow minimal secondary-bg">{{bt.note}}</td> <td class="center plank even no-shadow minimal secondary-bg">{{bt.note}}</td>
<td class="plank even no-shadow minimal secondary-bg">{{bookmark_button('thread', id=thread.id, text='Manage')}}</td> <td class="center plank even no-shadow minimal secondary-bg">{{bookmark_button('thread', id=thread.id, text='Manage')}}</td>
</tr> </tr>
{%- endfor -%} {%- endfor -%}
</tbody> </tbody>

View File

@@ -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&hellip;</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&hellip;</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">

View File

@@ -50,11 +50,12 @@
<span>Mention: @{{target_user.username}}</span> <span>Mention: @{{target_user.username}}</span>
<span>Status: <em>{{target_user.status}}</em></span> <span>Status: <em>{{target_user.status}}</em></span>
<span>Rank: {{target_user.permission | permission_string}}</span> <span>Rank: {{target_user.permission | permission_string}}</span>
{%- set time = target_user.created_at -%} {%- if target_user.confirmed_on -%}
{%- if target_user.approved_at -%} <span>Joined: {{timestamp(target_user.confirmed_on)}}</span>
{%- set time = target_user.approved_at -%} {%- endif -%}
{%- if invited_by -%}
<span>Invited by: <a href="{{url_for('users.user_page', username=invited_by.username)}}">{{invited_by.get_readable_name()}}</a></span>
{%- endif -%} {%- endif -%}
<span>Joined: {{timestamp(target_user.created_at)}}</span>
{%- if not target_user.is_guest() -%} {%- if not target_user.is_guest() -%}
<span>Posts: <a href="{{url_for('users.posts', username=target_user.username)}}">{{stats.post_count}}</a></span> <span>Posts: <a href="{{url_for('users.posts', username=target_user.username)}}">{{stats.post_count}}</a></span>
<span>Threads started: <a href="{{url_for('users.threads', username=target_user.username)}}">{{stats.thread_count}}</a></span> <span>Threads started: <a href="{{url_for('users.threads', username=target_user.username)}}">{{stats.thread_count}}</a></span>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1000 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 B

After

Width:  |  Height:  |  Size: 396 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 B

After

Width:  |  Height:  |  Size: 410 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 682 B

After

Width:  |  Height:  |  Size: 666 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 B

After

Width:  |  Height:  |  Size: 458 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 B

After

Width:  |  Height:  |  Size: 386 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 756 B

After

Width:  |  Height:  |  Size: 784 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 478 B

After

Width:  |  Height:  |  Size: 502 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 402 B

After

Width:  |  Height:  |  Size: 384 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 676 B

After

Width:  |  Height:  |  Size: 694 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 772 B

After

Width:  |  Height:  |  Size: 772 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 616 B

After

Width:  |  Height:  |  Size: 638 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 582 B

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 850 B

After

Width:  |  Height:  |  Size: 850 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 690 B

After

Width:  |  Height:  |  Size: 674 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 842 B

After

Width:  |  Height:  |  Size: 848 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 658 B

After

Width:  |  Height:  |  Size: 676 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 620 B

After

Width:  |  Height:  |  Size: 646 B

View File

@@ -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;
}
} }

View 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}`;
}
}

View File

@@ -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;