From 6fab93ebeb0447ceed3107ac1d4805a724e29892 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lera=20Elvo=C3=A9?= Date: Fri, 5 Jun 2026 07:19:53 +0300 Subject: [PATCH] bring back the badge editor --- app/db.py | 14 ++- app/models.py | 6 + app/routes/hyperapi.py | 11 +- app/routes/users.py | 125 +++++++++++++++++++- app/templates/common/macros.html | 8 +- app/templates/hyper/badge_editor.html | 51 ++++++++ app/templates/users/settings.html | 7 +- data/static/css/style.css | 51 +++++++- data/static/js/bits/badge-editor.js | 162 ++++++++++++++++++++++++++ data/static/js/ui.js | 3 + 10 files changed, 426 insertions(+), 12 deletions(-) create mode 100644 app/templates/hyper/badge_editor.html create mode 100644 data/static/js/bits/badge-editor.js diff --git a/app/db.py b/app/db.py index afd660d..9e0a7b8 100644 --- a/app/db.py +++ b/app/db.py @@ -49,6 +49,12 @@ class DB: 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): """Executes a query and returns a list of dictionaries.""" with self.connection() as conn: @@ -104,8 +110,12 @@ class DB: conditions = [] params = [] for col, op, val in self._where: - conditions.append(f"{col} {op} ?") - params.append(val) + if isinstance(val, tuple) or isinstance(val, list): + 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 diff --git a/app/models.py b/app/models.py index 391a1f3..f476d51 100644 --- a/app/models.py +++ b/app/models.py @@ -648,6 +648,12 @@ class BadgeUploads(Model): class Badges(Model): 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): bu = BadgeUploads.find({'id': int(self.upload)}) return bu.file_path diff --git a/app/routes/hyperapi.py b/app/routes/hyperapi.py index 837b66e..a4dfc4a 100644 --- a/app/routes/hyperapi.py +++ b/app/routes/hyperapi.py @@ -1,6 +1,6 @@ from flask import Blueprint, render_template, request, url_for 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 bp = Blueprint('hyperapi', __name__, url_prefix='/hyperapi/') @@ -124,3 +124,12 @@ def bookmark_post(): }) 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) diff --git a/app/routes/users.py b/app/routes/users.py index 84930f5..c885bcf 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -14,7 +14,7 @@ from ..auth import ( login_required, revoke_session, get_active_user, 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 ..util import get_form_checkbox, time_now from ..lib.babycode import babycode_to_html @@ -24,6 +24,7 @@ import os import time AVATAR_MAX_SIZE = 1000 * 1000 # 1MB +BADGE_MAX_SIZE = 1000 * 500 # 500K bp = Blueprint('users', __name__, url_prefix='/users/') @@ -60,6 +61,22 @@ def validate_and_create_avatar(input_image, filename): except WandException: 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): deleted_user = Users.find({'username': 'deleteduser'}) @@ -282,6 +299,7 @@ def posts(username): 'users/posts.html', posts=posts, page=page, page_count=page_count, target_user=target_user, + Reactions=Reactions, ) @bp.get('//threads/') @@ -305,6 +323,7 @@ def threads(username): 'users/threads.html', threads=threads, page=page, page_count=page_count, target_user=target_user, + Reactions=Reactions, ) @bp.get('//comments/') @@ -611,3 +630,107 @@ def revoke_invite_key(username): invite.delete() return redirect(url_for('.settings', username=username, _anchor='invite')) + +@bp.post('//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)) diff --git a/app/templates/common/macros.html b/app/templates/common/macros.html index e4f787d..8393f47 100644 --- a/app/templates/common/macros.html +++ b/app/templates/common/macros.html @@ -161,7 +161,7 @@ {{post.display_name if post.display_name else post.username}} @{{post.username}} {{post.status}} - {%- set badges=post.badges_json | fromjson -%} + {%- set badges=post.badges_json | fromjson | sort(attribute='sort_order') -%}
{%- for badge in badges -%} {%- if badge.link -%}{%- endif -%} @@ -285,16 +285,16 @@ {%- endmacro %} {% macro sortable_list(attr=none) -%} -
    +
      {%- if caller -%} {{ caller() }} {%- endif -%}
    {%- endmacro %} -{% macro sortable_list_item(key, immovable=false, attr=none) -%} +{% macro sortable_list_item(key, immovable=false, attr=none, full=false) -%}
  1. {{ icn_dragger() }} -
    {{ caller() }}
    +
    {{ caller() }}
  2. {%- endmacro %} diff --git a/app/templates/hyper/badge_editor.html b/app/templates/hyper/badge_editor.html new file mode 100644 index 0000000..65318df --- /dev/null +++ b/app/templates/hyper/badge_editor.html @@ -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 -%} + +
    +
    + + +
    + + + + +
    +{%- endmacro -%} +{%- from 'common/macros.html' import sortable_list, sortable_list_item -%} + + +0/10 +{%- 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 -%} + diff --git a/app/templates/users/settings.html b/app/templates/users/settings.html index b8833f7..0d519a9 100644 --- a/app/templates/users/settings.html +++ b/app/templates/users/settings.html @@ -72,9 +72,12 @@ #}
    + Badges -
    Loading badges…
    -
    If badges fail to load, make sure JS is enabled.
    +
    +

    Loading badges…

    +

    If badges fail to load, make sure JS is enabled.

    +
    {%- if user.can_invite() -%}
    diff --git a/data/static/css/style.css b/data/static/css/style.css index d451628..d12f555 100644 --- a/data/static/css/style.css +++ b/data/static/css/style.css @@ -201,7 +201,7 @@ button, .linkbutton, input[type="submit"], input[type="file"]::file-selector-but 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)); --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); @@ -522,6 +522,7 @@ footer { border-radius: var(--base-padding); border: var(--base-padding) outset gray; box-shadow: 0px 0px 12px 2px #0006; + align-self: center; &::after { content: ''; position: absolute; @@ -964,11 +965,52 @@ ol.sortable-list { flex-direction: row; } - &:not(.row) > * { + &:not(.row):not(.full) > * { 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 { display: none; } @@ -1038,4 +1080,9 @@ ol.sortable-list { width: 100%; text-align: center; } + + .badge-editor-badge-container { + flex-direction: column; + align-items: center; + } } diff --git a/data/static/js/bits/badge-editor.js b/data/static/js/bits/badge-editor.js new file mode 100644 index 0000000..407a31a --- /dev/null +++ b/data/static/js/bits/badge-editor.js @@ -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}`; + } +} diff --git a/data/static/js/ui.js b/data/static/js/ui.js index 0454e15..a95e192 100644 --- a/data/static/js/ui.js +++ b/data/static/js/ui.js @@ -32,6 +32,9 @@ if (!target || target === draggedItem) { return; } + if (draggedItem === null) { + return; + } const inSameList = draggedItem.dataset.sortableListKey === target.dataset.sortableListKey; if (!inSameList) { return;