bring back the badge editor

This commit is contained in:
2026-06-05 07:19:53 +03:00
parent c7ba23ad22
commit 6fab93ebeb
10 changed files with 426 additions and 12 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'})
@@ -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))

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

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

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

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