Compare commits

..

17 Commits

Author SHA1 Message Date
13c5c5cf69 move empty topic plank below motd 2026-06-10 22:32:12 +03:00
bf3028e7d6 re-add rss feeds 2026-06-10 22:30:53 +03:00
50c61da8b6 fix reaction button event 2026-06-10 20:28:07 +03:00
812f322141 add unsubscribe from all button to inbox 2026-06-10 18:16:08 +03:00
6e73186127 add mark as read button(s) to inbox 2026-06-10 17:59:51 +03:00
8a7eb91a34 remove emoji button from babycode editor for now pending implementation 2026-06-10 15:41:14 +03:00
b63b6a1682 bring back reactions 2026-06-07 23:01:58 +03:00
5dfe477607 highlight mentions 2026-06-07 18:31:10 +03:00
b6450a29fd only allow resizing textarea 2026-06-07 13:09:27 +03:00
7b16ac91ed ensure new users get a default collection on signup and any missing users get them too 2026-06-07 13:09:17 +03:00
84dbaa2cd8 babycode editor: change ctrl+enter shortcut to use requestSubmit to fire bitty signals 2026-06-06 02:05:13 +03:00
200bd37a28 add lightbox for post image previews 2 2026-06-05 21:01:29 +03:00
d01bbaca54 replace innerHTML += with proper appendChild 2026-06-05 07:43:43 +03:00
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
53 changed files with 1100 additions and 133 deletions

View File

@@ -162,6 +162,13 @@ def clear_api_limits():
for l in limits: for l in limits:
l.delete() l.delete()
def ensure_default_collection():
from .db import db
from .models import BookmarkCollections
with db.transaction():
for missing_user in BookmarkCollections.get_users_without_default():
BookmarkCollections.create_default(missing_user)
cache = Cache() cache = Cache()
def create_app(): def create_app():
@@ -243,6 +250,8 @@ def create_app():
reparse_babycode() reparse_babycode()
ensure_default_collection()
bind_default_badges(app.config['BADGES_PATH']) bind_default_badges(app.config['BADGES_PATH'])
app.config['SESSION_COOKIE_SECURE'] = True app.config['SESSION_COOKIE_SECURE'] = True

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,6 +110,10 @@ class DB:
conditions = [] conditions = []
params = [] params = []
for col, op, val in self._where: for col, op, val in self._where:
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} ?") conditions.append(f"{col} {op} ?")
params.append(val) params.append(val)

View File

@@ -6,7 +6,7 @@ from pygments.lexers import get_lexer_by_name
from pygments.util import ClassNotFound as PygmentsClassNotFound from pygments.util import ClassNotFound as PygmentsClassNotFound
import re import re
BABYCODE_VERSION = 10 BABYCODE_VERSION = 13
class BabycodeError(Exception): class BabycodeError(Exception):
@@ -183,7 +183,7 @@ class HTMLRenderer(BabycodeRenderer):
if mention_data not in self.mentions: if mention_data not in self.mentions:
self.mentions.append(mention_data) self.mentions.append(mention_data)
return f"<a class='mention{' display' if target_user.has_display_name() else ''}' href='{url_for('users.user_page', username=target_user.username)}' title='@{target_user.username}' data-init='highlightMentions' data-username='{target_user.username}'>{'@' if not target_user.has_display_name() else ''}{target_user.get_readable_name()}</a>" return f"<a class='mention{' display' if target_user.has_display_name() else ''}' href='{url_for('users.user_page', username=target_user.username)}' title='@{target_user.username}' data-r='highlightMentions' data-username='{target_user.username}'>{'@' if not target_user.has_display_name() else ''}{target_user.get_readable_name()}</a>"
def render(self, ast): def render(self, ast):
out = super().render(ast) out = super().render(ast)
@@ -406,6 +406,13 @@ def tag_quote(children, attr):
return f'<fieldset class="plank minimal no-shadow secondary-bg"><legend>{quotee}</legend><blockquote>{children}</blockquote></fieldset>' return f'<fieldset class="plank minimal no-shadow secondary-bg"><legend>{quotee}</legend><blockquote>{children}</blockquote></fieldset>'
def tag_quote_rss(children, attr):
if attr:
quotee = f'Quoting: {attr.strip()}'
return f'<figure><blockquote>{children}</blockquote><figcaption>{quotee}</figcaption></figure>'
else:
return f'<blockquote>{children}</blockquote>'
TAGS = { TAGS = {
"b": lambda children, attr: f"<strong>{children}</strong>", "b": lambda children, attr: f"<strong>{children}</strong>",
"i": lambda children, attr: f"<em>{children}</em>", "i": lambda children, attr: f"<em>{children}</em>",
@@ -462,6 +469,7 @@ RSS_TAGS = {
'url': tag_url_rss, 'url': tag_url_rss,
'spoiler': lambda children, attr: f'<details><summary>{attr or "Spoiler"} (click to reveal)</summary>{children}</details>', 'spoiler': lambda children, attr: f'<details><summary>{attr or "Spoiler"} (click to reveal)</summary>{children}</details>',
'code': tag_code_rss, 'code': tag_code_rss,
'quote': tag_quote_rss,
'big': lambda children, attr: f'<span style="font-size: 1.2em">{children}</span>', 'big': lambda children, attr: f'<span style="font-size: 1.2em">{children}</span>',
'small': lambda children, attr: f'<small>{children}</small>' 'small': lambda children, attr: f'<small>{children}</small>'

View File

@@ -506,10 +506,24 @@ class BookmarkCollections(Model):
@classmethod @classmethod
def create_default(cls, user_id): def create_default(cls, user_id):
q = """INSERT INTO bookmark_collections (user_id, name, is_default, sort_order) return cls.create({
VALUES (?, "Bookmarks", 1, 0) RETURNING id 'user_id': user_id,
'name': 'Bookmarks',
'is_default': True,
'sort_order': 0,
})
@staticmethod
def get_users_without_default():
q = """
SELECT users.id FROM users
WHERE NOT EXISTS (
SELECT 1 FROM bookmark_collections bc
WHERE bc.user_id = users.id
AND bc.is_default = 1
)
""" """
res = db.fetch_one(q, user_id) return [row['id'] for row in db.query(q)]
@classmethod @classmethod
def get_for_user(cls, user_id): def get_for_user(cls, user_id):
@@ -648,6 +662,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,10 +1,21 @@
from flask import Blueprint, request from flask import Blueprint, request
from ..auth import is_logged_in, hard_login_required, get_active_user from ..auth import is_logged_in, hard_login_required, get_active_user
from ..lib.babycode import babycode_to_html from ..lib.babycode import babycode_to_html
from ..models import APIRateLimits from ..models import APIRateLimits, Posts, Threads, Reactions
from ..constants import REACTION_EMOJI
bp = Blueprint('api', __name__, url_prefix='/api/') bp = Blueprint('api', __name__, url_prefix='/api/')
@bp.before_request
def ensure_json():
if request.method == 'POST':
if not request.is_json:
return {'error': 'unsupported media type'}, 415
elif not request.content_length:
return {'error': 'body expected'}, 400
elif not isinstance(request.json, dict):
return {'error': 'body must be an object'}, 400
@bp.post('/babycode-preview/') @bp.post('/babycode-preview/')
@hard_login_required @hard_login_required
def babycode_preview(): def babycode_preview():
@@ -19,3 +30,60 @@ def babycode_preview():
return {'error': 'banned_tags field is invalid type'}, 400 return {'error': 'banned_tags field is invalid type'}, 400
rendered = babycode_to_html(markup, banned_tags).result rendered = babycode_to_html(markup, banned_tags).result
return {'html': rendered} return {'html': rendered}
@bp.get('/whoami/')
def whoami():
user = get_active_user()
if not user:
return {}
return {
'id': user.id,
'username': user.username,
'display_name': user.display_name,
}
@bp.post('/toggle-reaction/')
@hard_login_required
def toggle_reaction():
user = get_active_user()
emoji = request.json.get('reaction')
if emoji not in REACTION_EMOJI:
return {'error': f'invalid reaction string, given: {emoji}'}, 400
post_id = request.json.get('post', -1)
post = Posts.find({'id': post_id})
if not post:
return {'error': 'post not found'}, 404
thread = Threads.find({'id': post.thread_id})
if not user.can_post_to_thread_or_topic(thread):
return {'error': 'thread is locked'}, 403
reaction_obj = {
'user_id': int(user.id),
'post_id': int(post_id),
'reaction_text': emoji,
}
r = Reactions.find(reaction_obj)
if r:
# remove
r.delete()
return {'status': 'ok', 'added': False}
else:
# add
r = Reactions.create(reaction_obj)
return {'status': 'ok', 'added': True}
@bp.get('/thread-permission/<int:thread_id>')
def thread_permission(thread_id):
user = get_active_user()
if not user:
return {'can_post': False}
thread = Threads.find({'id': thread_id})
if not thread:
return {'can_post': False}
return {'can_post': user.can_post_to_thread_or_topic(thread)}

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, Reactions
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,16 @@ 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)
@bp.get('/reactions/<int:post_id>')
def get_reaction_buttons(post_id):
return render_template('hyper/reaction_buttons.html', Reactions=Reactions, post_id=post_id)

View File

@@ -35,6 +35,13 @@ def ownership_or_mod_required(view_func):
return view_func(*args, **kwargs) return view_func(*args, **kwargs)
return wrapper return wrapper
@bp.get('/<int:post_id>/')
def post_by_id(post_id):
post = get_post_url(post_id, _anchor=True)
if not post:
abort(404)
return redirect(post)
@bp.get('/<int:post_id>/edit/') @bp.get('/<int:post_id>/edit/')
@login_required @login_required
@ownership_required @ownership_required

View File

@@ -1,7 +1,10 @@
from flask import Blueprint, redirect, url_for, render_template, request, abort from flask import Blueprint, redirect, url_for, render_template, request, abort, current_app
from functools import wraps from functools import wraps
from app import cache
from ..auth import login_required, get_active_user, is_logged_in from ..auth import login_required, get_active_user, is_logged_in
from ..db import db
from ..models import Threads, Posts, Topics, Users, Reactions, Subscriptions from ..models import Threads, Posts, Topics, Users, Reactions, Subscriptions
from ..lib.render_atom import render_atom_template
from ..util import get_form_checkbox, time_now from ..util import get_form_checkbox, time_now
import math import math
@@ -69,9 +72,23 @@ def thread(thread_id, slug):
posts=posts, page=page, posts=posts, page=page,
page_count=page_count, topic=topic, page_count=page_count, topic=topic,
started_by=started_by, topics=Topics.get_list(), started_by=started_by, topics=Topics.get_list(),
Reactions=Reactions, last_post=last_post Reactions=Reactions, last_post=last_post,
__feedlink=url_for('.feed', thread_id=thread_id, _external=True),
__feedtitle=f'replies to {thread.title}',
) )
@bp.get('/<int:thread_id>/feed.atom/')
@cache.cached(timeout=5 * 60, unless=lambda: current_app.config['DEBUG'])
def feed(thread_id):
thread = Threads.find({'id': thread_id})
if not thread:
abort(404)
topic = Topics.find({'id': thread.topic_id})
posts = thread.get_posts_rss()
return render_atom_template('threads/thread.atom', thread=thread, topic=topic, posts=posts)
@bp.post('/<int:thread_id>/') @bp.post('/<int:thread_id>/')
@login_required @login_required
def reply(thread_id): def reply(thread_id):
@@ -166,9 +183,33 @@ def unsubscribe(thread_id):
subscription.delete() subscription.delete()
return redirect(return_to) return redirect(return_to)
@bp.get('/<int:thread_id>/feed.atom/') @bp.post('/subscriptions/unsubscribe-all/')
def feed(thread_id): @login_required
return 'stub' def unsubscribe_all():
user = get_active_user()
subs = Subscriptions.findall({'user_id': user.id})
if not subs:
return redirect(url_for('users.inbox', username=user.username))
with db.transaction():
for sub in subs:
sub.delete()
return redirect(url_for('users.inbox', username=user.username))
@bp.post('/subscriptions/mark-read/')
@login_required
def mark_read():
# TODO: make a return_to param
user = get_active_user()
sub_ids = request.form.getlist('id[]', type=int)
now = time_now()
for sub_id in sub_ids:
sub = Subscriptions.find({'id': sub_id, 'user_id': user.id})
if not sub:
continue
sub.update({'last_seen': now})
return redirect(url_for('users.inbox', username=user.username))
@bp.get('/new/') @bp.get('/new/')
@login_required @login_required

View File

@@ -1,5 +1,6 @@
from flask import Blueprint, redirect, url_for, render_template, request, session, abort from flask import Blueprint, redirect, url_for, render_template, request, session, abort, current_app
from app import cache
from ..lib.render_atom import render_atom_template
from ..models import Topics, Threads, Subscriptions from ..models import Topics, Threads, Subscriptions
from ..auth import get_active_user from ..auth import get_active_user
import math import math
@@ -42,8 +43,20 @@ def topic(topic_id, slug):
subscription = Subscriptions.find({'user_id': user.id, 'thread_id': thread['id']}) subscription = Subscriptions.find({'user_id': user.id, 'thread_id': thread['id']})
if subscription: if subscription:
subscriptions[thread['id']] = subscription.get_unread_count() subscriptions[thread['id']] = subscription.get_unread_count()
return render_template('topics/topic.html', topic=topic, threads=threads, sort_by=sort_by, page=page, page_count=page_count, subscriptions=subscriptions) return render_template(
'topics/topic.html', topic=topic,
threads=threads, sort_by=sort_by,
page=page, page_count=page_count, subscriptions=subscriptions,
__feedlink=url_for('.feed', topic_id=topic_id, _external=True),
__feedtitle=f'latest threads in {topic.name}',
)
@bp.get('/<int:topic_id>/feed.atom/') @bp.get('/<int:topic_id>/feed.atom/')
@cache.cached(timeout=5 * 60, unless=lambda: current_app.config['DEBUG'])
def feed(topic_id): def feed(topic_id):
return 'stub' topic = Topics.find({'id': topic_id})
if not topic:
abort(404)
threads_list = topic.get_threads_with_op_rss()
return render_atom_template('topics/topic.atom', topic=topic, threads_list=threads_list)

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,106 @@ 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),
])
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))

20
app/templates/base.atom Normal file
View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
{%- if self.title() -%}
<title>{%- block title -%}{%- endblock -%}</title>
{%- else -%}
<title>{{- config.SITE_NAME -}}</title>
{%- endif -%}
{%- if self.feed_updated() -%}
<updated>{%- block feed_updated -%}{%- endblock -%}</updated>
{%- else -%}
<updated>{{- get_time_now() | iso8601 -}}</updated>
{%- endif -%}
<id>{{- __current_page -}}</id>
<link rel="self" href="{{ __current_page }}" />
<link href="{%- block canonical_link -%}{%- endblock -%}" />
{%- if self.feed_author() -%}
<author>{%- block feed_author -%}{%- endblock -%}</author>
{%- endif -%}
{%- block content -%}{%- endblock -%}
</feed>

View File

@@ -11,6 +11,9 @@
{%- else -%} {%- else -%}
<title>{{ config.SITE_NAME }}</title> <title>{{ config.SITE_NAME }}</title>
{%- endif -%} {%- endif -%}
{%- if __feedlink -%}
<link rel="alternate" type="application/atom+xml" href="{{ __feedlink }}" title="{{ __feedtitle }}">
{%- endif -%}
</head> </head>
<body> <body>
<bitty-8 data-connect="/static/js/bits/progressive-enhancement.js"></bitty-8> <bitty-8 data-connect="/static/js/bits/progressive-enhancement.js"></bitty-8>

View File

@@ -107,7 +107,7 @@
<button type="button" title="insert ordered list" class="minimal" data-babycode-tag="ol" data-break-line data-s="insertBabycode">1.</button> <button type="button" title="insert ordered list" class="minimal" data-babycode-tag="ol" data-break-line data-s="insertBabycode">1.</button>
<button type="button" title="insert unordered list" class="minimal" data-babycode-tag="ul" data-break-line data-s="insertBabycode">&bullet;</button> <button type="button" title="insert unordered list" class="minimal" data-babycode-tag="ul" data-break-line data-s="insertBabycode">&bullet;</button>
<button type="button" title="insert spoiler" class="minimal" data-babycode-tag="spoiler=" data-break-line data-prefill="spoiler content" data-s="insertBabycode">s</button> <button type="button" title="insert spoiler" class="minimal" data-babycode-tag="spoiler=" data-break-line data-prefill="spoiler content" data-s="insertBabycode">s</button>
<button type="button" title="insert emoji&hellip;" class="minimal"><img src="/static/emoji/angry.png" class="emoji"></button> {#<button type="button" title="insert emoji&hellip;" class="minimal"><img src="/static/emoji/angry.png" class="emoji"></button>#}
</span> </span>
<span class="flex-last js-only" data-r="enhance babycodeEditorCharCount">0/</span> <span class="flex-last js-only" data-r="enhance babycodeEditorCharCount">0/</span>
</span> </span>
@@ -140,6 +140,19 @@
<button autocomplete='off' data-r="enhance" data-s="showBookmarkMenu" disabled title="This feature requires JavaScript to be enabled." data-concept-kind="{{kind}}" data-concept-id="{{id}}">{{icn_bookmark(24)}}{{text}}&hellip;</button> <button autocomplete='off' data-r="enhance" data-s="showBookmarkMenu" disabled title="This feature requires JavaScript to be enabled." data-concept-kind="{{kind}}" data-concept-id="{{id}}">{{icn_bookmark(24)}}{{text}}&hellip;</button>
{%- endmacro %} {%- endmacro %}
{% macro reaction_buttons(post_id) -%}
{%- for reaction in Reactions.for_post(post_id) -%}
{% set reactors = Reactions.get_users(post_id, reaction.reaction_text) | map(attribute='username') | list %}
{% set reactors_trimmed = reactors[:10] %}
{% set reactors_str = reactors_trimmed | join (',\n') %}
{% if reactors | count > 10 %}
{% set reactors_str = reactors_str + '\n...and many others' %}
{% endif %}
{% set has_reacted = get_active_user() is not none and get_active_user().username in reactors %}
<button autocomplete="off" type="button" title="{{reactors_str}}" class="minimal {{'alt' if has_reacted else ''}}" data-emoji="{{reaction.reaction_text}}" data-s="toggleReaction" data-r="enableReactionButtons" disabled><img src="/static/emoji/{{reaction.reaction_text}}.png">{{reaction.c}}</button>
{%- endfor -%}
{%- endmacro %}
{% macro full_post( {% macro full_post(
post, render_sig=true, is_latest=false, post, render_sig=true, is_latest=false,
show_toolbar=true, is_editing=false, thread=none, show_toolbar=true, is_editing=false, thread=none,
@@ -161,7 +174,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 -%}
@@ -172,7 +185,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="post-content"> <div class="post-content" data-r="collectImages">
<div class="plank even minimal secondary-bg no-shadow post-info"> <div class="plank even minimal secondary-bg no-shadow post-info">
<span> <span>
{%- if tb_pretext -%} {%- if tb_pretext -%}
@@ -218,19 +231,10 @@
</div> </div>
<div class="plank even secondary-bg minimal no-shadow"> <div class="plank even secondary-bg minimal no-shadow">
{%- if show_reactions -%} {%- if show_reactions -%}
<span class="button-row"> <span class="button-row" data-r="replaceReactionButtons">
{%- for reaction in Reactions.for_post(post.id) -%} {{- reaction_buttons(post.id) -}}
{% set reactors = Reactions.get_users(post.id, reaction.reaction_text) | map(attribute='username') | list %}
{% set reactors_trimmed = reactors[:10] %}
{% set reactors_str = reactors_trimmed | join (',\n') %}
{% if reactors | count > 10 %}
{% set reactors_str = reactors_str + '\n...and many others' %}
{% endif %}
{% set has_reacted = get_active_user() is not none and get_active_user().username in reactors %}
<button data-r="enhance" type="button" disabled title="{{reactors_str}}" class="minimal {{'alt' if has_reacted else ''}}"><img src="/static/emoji/{{reaction.reaction_text}}.png">{{reaction.c}}</button>
{%- endfor -%}
</span> </span>
{%- if is_logged_in() and allow_reacting -%}<button autocomplete='off' data-r="enhance" disabled title="This feature requires JavaScript to be enabled.">Add reaction</button>{%- endif -%} {%- if is_logged_in() and allow_reacting -%}<button autocomplete='off' data-r="disableReactionMenuButton enableReactionMenuButton" disabled title="This feature requires JavaScript to be enabled." data-s="openReactionMenu">Add reaction</button>{%- endif -%}
{%- elif is_editing -%} {%- elif is_editing -%}
<input type="submit" value="Save"> <input type="submit" value="Save">
<a href="{{get_post_url(post.id, _anchor=true)}}" class="linkbutton warn">Cancel</a> <a href="{{get_post_url(post.id, _anchor=true)}}" class="linkbutton warn">Cancel</a>
@@ -285,16 +289,20 @@
{%- 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 %}
{% macro rss_html_content(html) -%}
<content type="html">{{ html }}</content>
{%- 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

@@ -0,0 +1,2 @@
{%- from 'common/macros.html' import reaction_buttons with context -%}
{{- reaction_buttons(post_id) -}}

View File

@@ -0,0 +1,20 @@
{% from 'common/macros.html' import rss_html_content %}
{%- extends 'base.atom' -%}
{%- block title -%}replies to {{thread.title}}{%- endblock -%}
{%- block canonical_link -%}{{ url_for('threads.thread_by_id', thread_id=thread.id, _external=true) }}{%- endblock -%}
{%- block content -%}
{%- for post in posts -%}
{%- set post_url = get_post_url(post.id, _anchor=true, external=true) -%}
<entry>
<title>Re: {{ thread.title | escape }}</title>
<link href="{{ post_url }}"/>
<id>{{ post_url }}</id>
<updated>{{ post.edited_at | iso8601 }}</updated>
{{ rss_html_content(post.content_rss) }}
<author>
<name>{{ post.display_name | escape }} @{{ post.username }}</name>
<uri>{{ url_for('users.user_page', username=post.username, _external=true) }}</uri>
</author>
</entry>
{%- endfor -%}
{%- endblock -%}

View File

@@ -1,10 +1,11 @@
{%- from 'common/macros.html' import subheader, timestamp, pager, babycode_editor_component -%} {%- from 'common/macros.html' import subheader, timestamp, pager, babycode_editor_component, bookmark_button -%}
{%- from 'common/icons.html' import icn_bookmark -%} {%- from 'common/icons.html' import icn_bookmark -%}
{%- from 'common/macros.html' import full_post, bookmark_menu with context -%} {%- from 'common/macros.html' import full_post, bookmark_menu with context -%}
{%- extends 'base.html' -%} {%- extends 'base.html' -%}
{%- block title -%}{{thread.title}}{%- endblock -%} {%- block title -%}{{thread.title}}{%- endblock -%}
{%- block content -%} {%- block content -%}
<bitty-8 data-connect="/static/js/bits/bookmark-menu.js"></bitty-8> <bitty-8 data-connect="/static/js/bits/bookmark-menu.js"></bitty-8>
<bitty-8 data-connect="/static/js/bits/thread.js"></bitty-8>
{%- set td -%} {%- set td -%}
<ul class="horizontal"> <ul class="horizontal">
<li>Started by <a href="{{url_for('users.user_page', username=started_by.username)}}">{{started_by.get_readable_name()}}</a> in topic <a href="{{url_for('topics.topic_by_id', topic_id=topic.id)}}">{{topic.name}}</a></li> <li>Started by <a href="{{url_for('users.user_page', username=started_by.username)}}">{{started_by.get_readable_name()}}</a> in topic <a href="{{url_for('topics.topic_by_id', topic_id=topic.id)}}">{{topic.name}}</a></li>
@@ -30,7 +31,7 @@
<input type="hidden" name="last_post_id" value="{{last_post.id}}"> <input type="hidden" name="last_post_id" value="{{last_post.id}}">
<input type="submit" value="{{'Subscribe' if not get_active_user().is_subscribed(thread.id) else 'Unsubscribe'}}"> <input type="submit" value="{{'Subscribe' if not get_active_user().is_subscribed(thread.id) else 'Unsubscribe'}}">
</form> </form>
<button disabled autocomplete='off' data-r="enhance" data-s="showBookmarkMenu" title="This feature requires JavaScript to be enabled." data-concept-kind="thread" data-concept-id="{{thread.id}}">{{icn_bookmark(24)}}Bookmark&hellip;</button> {{- bookmark_button('thread', thread.id) -}}
{%- endif -%} {%- endif -%}
<a href="{{url_for('threads.feed', thread_id=thread.id)}}" class="linkbutton rss">Subscribe via RSS</a> <a href="{{url_for('threads.feed', thread_id=thread.id)}}" class="linkbutton rss">Subscribe via RSS</a>
</fieldset> </fieldset>
@@ -64,7 +65,7 @@
{%- endcall -%} {%- endcall -%}
<main> <main>
{%- for post in posts -%} {%- for post in posts -%}
<article id="post-{{post.id}}" class="post plank"> <article id="post-{{post.id}}" class="post plank" data-postid="{{post.id}}">
{{full_post(post)}} {{full_post(post)}}
</article> </article>
{%- endfor -%} {%- endfor -%}
@@ -84,6 +85,25 @@
</span> </span>
</div> </div>
{{ bookmark_menu() }} {{ bookmark_menu() }}
<dialog closedby="any" class="plank thread-lighbox" data-r="showLightbox closeLightbox">
<div class="menu">
<button data-s="closeLightbox">Close</button>
<a href="" target="_blank" rel="noreferrer noopener" class="linkbutton alt">Open original</a>
</div>
<img class="lightbox-image" src="https://placehold.co/900x710">
<div class="menu">
<button data-s="lightboxPrevious">Previous</button>
<span data-r="lightboxSetCounter">0/0</span>
<button data-s="lightboxNext">Next</button>
</div>
</dialog>
{%- if is_logged_in() and get_active_user().can_post_to_thread_or_topic(thread) -%}
<div class="plank even" id="reaction-popover" popover data-r="openReactionMenu closeReactionMenu">
{%- for emoji in REACTION_EMOJI -%}
<button class="minimal emoji-button" title=":{{emoji}}:" data-emoji="{{emoji}}" data-s="toggleReaction"><img src="/static/emoji/{{emoji}}.png"></button>
{%- endfor -%}
</div>
{%- endif -%}
{%- if is_logged_in() and get_active_user().can_post_to_thread_or_topic(thread) -%} {%- if is_logged_in() and get_active_user().can_post_to_thread_or_topic(thread) -%}
<form action="{{url_for('threads.reply', thread_id=thread.id)}}" method="POST" class="plank post-edit-form" data-listen="submit" data-r="clearThreadDraft" data-s="clearThreadDraft"> <form action="{{url_for('threads.reply', thread_id=thread.id)}}" method="POST" class="plank post-edit-form" data-listen="submit" data-r="clearThreadDraft" data-s="clearThreadDraft">
<h2 class="info">Reply to "{{thread.title}}"</h2> <h2 class="info">Reply to "{{thread.title}}"</h2>

View File

@@ -0,0 +1,21 @@
{% from 'common/macros.html' import rss_html_content %}
{%- extends 'base.atom' -%}
{%- block title -%}latest threads in {{topic.name | escape}}{%- endblock -%}
{%- block canonical_link -%}{{ url_for('topics.topic_by_id', topic_id=topic.id, _external=true) }}{%- endblock -%}
{%- block content -%}
<subtitle>{{ topic.description | escape }}</subtitle>
{%- for thread in threads_list -%}
<entry>
<title>{{ thread.title | escape }}</title>
<link href="{{ url_for('threads.thread_by_id', thread_id=thread.id, _external=true) }}"/>
<link rel="replies" type="application/atom+xml" href="{{ url_for('threads.feed', thread_id=thread.id, _external=true) }}"/>
<id>{{ url_for('threads.thread_by_id', thread_id=thread.id, _external=true) }}</id>
{{ rss_html_content(thread.original_post_content) }}
<updated>{{ thread.created_at | iso8601 }}</updated>
<author>
<name>{{ thread.started_by_display_name | escape }} @{{ thread.started_by }}</name>
<uri>{{ url_for('users.user_page', username=thread.started_by, _external=true) }}</uri>
</author>
</entry>
{%- endfor -%}
{%- endblock -%}

View File

@@ -43,10 +43,10 @@
</fieldset> </fieldset>
{%- endif -%} {%- endif -%}
{%- endcall -%} {%- endcall -%}
{{ motd(get_motds()) }}
{%- if threads | length == 0 -%} {%- if threads | length == 0 -%}
<div class="plank"><p>There are no threads in this topic yet.{%- if is_logged_in() and get_active_user().can_post_to_thread_or_topic(topic) %} Be the first to start a discussion!{%- endif -%}</p></div> <div class="plank"><p>There are no threads in this topic yet.{%- if is_logged_in() and get_active_user().can_post_to_thread_or_topic(topic) %} Be the first to start a discussion!{%- endif -%}</p></div>
{%- endif -%} {%- endif -%}
{{ motd(get_motds()) }}
{%- for thread in threads -%} {%- for thread in threads -%}
<div class="topic-info plank"> <div class="topic-info plank">
<div class="title-container"> <div class="title-container">

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

@@ -12,7 +12,22 @@
You do not have any subscriptions. You do not have any subscriptions.
{%- endif -%} {%- endif -%}
{%- endset -%} {%- endset -%}
{{ subheader('Your inbox', topline) }} {%- call() subheader('Your inbox', topline) -%}
{%- if subscriptions -%}
<fieldset class="plank even no-shadow minimal subheader-actions">
<legend>Actions</legend>
<form method="POST" action="{{url_for('threads.mark_read')}}">
{%- for sub in subscriptions -%}
<input type="hidden" name="id[]" value="{{sub.id}}">
{%- endfor -%}
<button>Mark all as read</button>
</form>
<form method="POST" action="{{url_for('threads.unsubscribe_all')}}">
<button class="warn">Unsubscribe from all</button>
</form>
</fieldset>
{%- endif -%}
{%- endcall -%}
{%- if subscriptions | length > 0 -%} {%- if subscriptions | length > 0 -%}
<div class="plank"> <div class="plank">
{%- for sub in subscriptions -%} {%- for sub in subscriptions -%}
@@ -20,11 +35,17 @@
{%- set thread = sub.get_thread() -%} {%- set thread = sub.get_thread() -%}
<summary class="plank secondary-bg no-shadow even"> <summary class="plank secondary-bg no-shadow even">
{{thread.title}} ({{sub.get_unread_count()}} unread) {{thread.title}} ({{sub.get_unread_count()}} unread)
<form method="POST" action="{{url_for('threads.unsubscribe', thread_id=thread.id)}}"> <div>
<form class="inline horizontal" method="POST" action="{{url_for('threads.unsubscribe', thread_id=thread.id)}}">
<input type="hidden" name="return_to" value="{{url_for('users.inbox', username=get_active_user().username)}}"> <input type="hidden" name="return_to" value="{{url_for('users.inbox', username=get_active_user().username)}}">
<a href="{{url_for('threads.thread_by_id', thread_id=thread.id)}}" class="linkbutton">Go to thread</a> <a href="{{url_for('threads.thread_by_id', thread_id=thread.id)}}" class="linkbutton">Go to thread</a>
<input type="submit" value="Unsubscribe" class="warn"> <input type="submit" value="Unsubscribe" class="warn">
</form> </form>
<form class="inline horizontal" method="POST" action="{{url_for('threads.mark_read')}}">
<input type="hidden" name="id[]" value="{{sub.id}}">
<button>Mark as read</button>
</form>
</div>
</summary> </summary>
{%- set posts = sub.get_full_posts_view() -%} {%- set posts = sub.get_full_posts_view() -%}

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,14 +201,13 @@ 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);
background-color: var(--main-color); background-color: var(--main-color);
border-radius: var(--border-radius); border-radius: var(--border-radius);
border: solid var(--border-thickness) var(--border-color); border: solid var(--border-thickness) var(--border-color);
resize: vertical;
padding: var(--small-padding) var(--medium-padding); padding: var(--small-padding) var(--medium-padding);
margin: var(--base-padding) 0px; margin: var(--base-padding) 0px;
@@ -219,7 +218,8 @@ input[type="text"], input[type="password"], textarea, select {
} }
textarea { textarea {
font-family: 'Atkinson Hyperlegible Mono' font-family: 'Atkinson Hyperlegible Mono';
resize: vertical;
} }
h1 { h1 {
@@ -335,6 +335,10 @@ form.horizontal {
flex-wrap: wrap; flex-wrap: wrap;
} }
&.inline {
display: inline flex;
}
&> fieldset { &> fieldset {
display: flex; display: flex;
gap: var(--base-padding); gap: var(--base-padding);
@@ -522,6 +526,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;
@@ -695,6 +700,39 @@ details.inner {
justify-content: center; justify-content: center;
} }
#reaction-popover {
position: absolute;
margin-block: var(--small-padding);
margin-inline: 0;
width: 300px;
--button-size: calc(var(--huge-padding) * 2);
.emoji-button {
min-width: var(--button-size);
min-height: var(--button-size);
img {
image-rendering: crisp-edges;
width: 30px;
}
}
}
#reaction-popover:popover-open {
--gap: var(--base-padding);
--max-columns: 4;
display: grid;
gap: var(--gap);
justify-items: center;
--grid-item-size: calc((100% - var(--gap) * var(--max-columns)) / var(--max-columns));
grid-template-columns: repeat(
auto-fit,
minmax(max(var(--button-size), var(--grid-item-size)), 1fr)
);
}
#bookmark-popover { #bookmark-popover {
position: absolute; position: absolute;
min-width: 400px; min-width: 400px;
@@ -742,6 +780,125 @@ table {
} }
} }
ol.sortable-list {
display: flex;
gap: var(--base-padding);
flex-direction: column;
list-style: none;
flex-grow: 1;
margin: 0;
padding-left: 0;
li {
display: flex;
gap: var(--big-padding);
}
li.immovable .dragger {
cursor: not-allowed;
}
}
.plank.dragger {
display: flex;
align-items: center;
/*background-color: var(--bg-color-tertiary);*/
padding: var(--base-padding);
cursor: move;
}
.sortable-item-inner {
display: flex;
gap: var(--base-padding);
flex-grow: 1;
flex-direction: column;
& > * {
flex-grow: 1;
}
&.row {
flex-direction: 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;
}
dialog.plank.thread-lighbox {
margin: auto;
min-width: 80vw;
min-height: 70vh;
max-width: 80vw;
max-height: 70vh;
flex-direction: column;
justify-content: space-between;
align-items: center;
& > .menu {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
&:open {
display: flex;
}
}
.lightbox-image {
max-width: 50vw;
max-height: 50vh;
}
/* babycode tags */ /* babycode tags */
.inline-code { .inline-code {
background-color: var(--code-bg-color); background-color: var(--code-bg-color);
@@ -923,56 +1080,6 @@ a.mention {
} }
} }
ol.sortable-list {
display: flex;
gap: var(--base-padding);
flex-direction: column;
list-style: none;
flex-grow: 1;
margin: 0;
padding-left: 0;
li {
display: flex;
gap: var(--big-padding);
}
li.immovable .dragger {
cursor: not-allowed;
}
}
.plank.dragger {
display: flex;
align-items: center;
/*background-color: var(--bg-color-tertiary);*/
padding: var(--base-padding);
cursor: move;
}
.sortable-item-inner {
display: flex;
gap: var(--base-padding);
flex-grow: 1;
flex-direction: column;
& > * {
flex-grow: 1;
}
&.row {
flex-direction: row;
}
&:not(.row) > * {
margin-right: auto;
}
}
.js-only {
display: none;
}
@media (max-width: 768px) { @media (max-width: 768px) {
body { body {
margin-left: 0; margin-left: 0;
@@ -1038,4 +1145,22 @@ ol.sortable-list {
width: 100%; width: 100%;
text-align: center; text-align: center;
} }
.badge-editor-badge-container {
flex-direction: column;
align-items: center;
}
dialog.plank.thread-lighbox {
margin: 0;
min-width: 100vw;
min-height: 100vh;
max-width: unset;
max-height: unset;
}
.lightbox-image {
max-width: 100%;
max-height: 100%;
}
} }

View File

@@ -0,0 +1,164 @@
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 badgeTemplate = document.getElementById('badge-template').innerText;
const parser = new DOMParser();
const e = parser.parseFromString(badgeTemplate, 'text/html').body.firstElementChild;
el.appendChild(e);
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

@@ -14,28 +14,28 @@ async function getHTML(endpoint, options = {}) {
return { body: await res.text(), status: res.status }; return { body: await res.text(), status: res.status };
} }
export const b = { export const b = {};
bookmarksCollectionEndpoint: '/hyperapi/bookmarks/dropdown/',
bookmarkMenuState: {}, const BOOKMARKS_COLLECTION_ENDPOINT = '/hyperapi/bookmarks/dropdown/';
} let bookmarkMenuState = {};
export async function showBookmarkMenu(ev, sender, el) { export async function showBookmarkMenu(ev, sender, el) {
if (b.bookmarkMenuState.state === undefined) { if (bookmarkMenuState.state === undefined) {
el.addEventListener('toggle', e => { el.addEventListener('toggle', e => {
if (e.newState === 'closed') { if (e.newState === 'closed') {
b.bookmarkMenuState.state = 'closed'; bookmarkMenuState.state = 'closed';
} }
}); });
} }
// dismiss if open and last invoker is the same button that opened it // dismiss if open and last invoker is the same button that opened it
if (b.bookmarkMenuState.state === 'open' && b.bookmarkMenuState.invoker === sender) { if (bookmarkMenuState.state === 'open' && bookmarkMenuState.invoker === sender) {
el.hidePopover(); el.hidePopover();
return; return;
} }
b.bookmarkMenuState.invoker = sender; bookmarkMenuState.invoker = sender;
b.bookmarkMenuState.state = 'open'; bookmarkMenuState.state = 'open';
b.send({ 'plain': 'Loading…' }, 'fillBookmarkMenu'); b.send({ 'plain': 'Loading…' }, 'fillBookmarkMenu');
el.showPopover(); el.showPopover();
const bRect = sender.getBoundingClientRect(); const bRect = sender.getBoundingClientRect();
@@ -52,13 +52,13 @@ export async function showBookmarkMenu(ev, sender, el) {
} }
el.style.top = `${bRect.bottom + scrollY}px`; el.style.top = `${bRect.bottom + scrollY}px`;
b.bookmarkMenuState.kind = sender.dataset.conceptKind; bookmarkMenuState.kind = sender.dataset.conceptKind;
b.bookmarkMenuState.id = sender.dataset.conceptId; bookmarkMenuState.id = sender.dataset.conceptId;
const bookmarkCollections = await getHTML(b.bookmarksCollectionEndpoint, { const bookmarkCollections = await getHTML(BOOKMARKS_COLLECTION_ENDPOINT, {
_query: { _query: {
concept_kind: b.bookmarkMenuState.kind, concept_kind: bookmarkMenuState.kind,
concept_id: b.bookmarkMenuState.id, concept_id: bookmarkMenuState.id,
} }
}); });
b.send({ 'html': bookmarkCollections.body }, 'fillBookmarkMenu'); b.send({ 'html': bookmarkCollections.body }, 'fillBookmarkMenu');
@@ -85,10 +85,10 @@ export async function bookmarkMenuSubmit(ev, _, el) {
return; return;
} }
const newCollections = await getHTML(b.bookmarksCollectionEndpoint, { const newCollections = await getHTML(BOOKMARKS_COLLECTION_ENDPOINT, {
_query: { _query: {
concept_kind: b.bookmarkMenuState.kind, concept_kind: bookmarkMenuState.kind,
concept_id: b.bookmarkMenuState.id, concept_id: bookmarkMenuState.id,
saved: true, saved: true,
} }
}); });

View File

@@ -1,7 +1,9 @@
export const b = {} export const b = {}
export function addCollection(ev, sender, el) { export function addCollection(ev, sender, el) {
el.innerHTML += b.templates.collectionItem; const parser = new DOMParser();
const e = parser.parseFromString(b.templates.collectionItem, 'text/html').body.firstElementChild;
el.appendChild(e);
} }
export function deleteCollection(ev, sender, el) { export function deleteCollection(ev, sender, el) {

View File

@@ -0,0 +1,180 @@
export const b = {
init: 'activatePostImages getUserData',
}
const POST_IMAGES_SELECTOR = 'img.post-image:not(aside img.post-image)'
const WHOAMI_ENDPOINT = '/api/whoami/'
const THREAD_PERM_ENDPOINT = '/api/thread-permission/'
const TOGGLE_REACTION_ENDPOINT = '/api/toggle-reaction/'
const REPLACE_REACTIONS_ENDPOINT = '/hyperapi/reactions/'
const getThreadId = () => {
const scheme = window.location.pathname.split("/");
if (scheme[1] !== 'threads' || scheme[2] === 'new') {
return -1;
}
return parseInt(scheme[2]);
}
let images = [];
let currentIndex = 0;
let currentUser = null;
let reactionMenuState = {};
export function activatePostImages(_, __, ___) {
const images = document.querySelectorAll(POST_IMAGES_SELECTOR);
images.forEach(image => {
image.style.cursor = 'pointer';
image.dataset.s = 'collectImages';
});
}
export function collectImages(_, sender, el) {
if (!el.contains(sender)) return;
images = Array.from(el.querySelectorAll(POST_IMAGES_SELECTOR));
currentIndex = images.indexOf(sender);
b.trigger('showLightbox');
}
export function showLightbox(_, __, el) {
const originalImg = images[currentIndex];
const lightboxImg = el.querySelector('img');
const anchor = el.querySelector('a');
anchor.href = originalImg.src;
lightboxImg.src = originalImg.src;
lightboxImg.alt = originalImg.alt;
if (!el.open) {
el.showModal();
}
b.trigger('lightboxSetCounter');
}
export function closeLightbox(_, __, el) {
el.close();
}
export function lightboxSetCounter(_, __, el) {
el.innerText = `${currentIndex + 1}/${images.length}`;
}
export function lightboxNext(_, __, ___) {
if (images.length == 1) return;
currentIndex++;
if (currentIndex >= images.length) {
currentIndex = 0;
}
b.trigger('showLightbox');
}
export function lightboxPrevious(_, __, ___) {
if (images.length == 1) return;
currentIndex--;
if (currentIndex < 0) {
currentIndex = images.length - 1;
}
b.trigger('showLightbox');
}
export async function getUserData(_, __, ___) {
currentUser = await b.getData(WHOAMI_ENDPOINT);
b.trigger('highlightMentions');
const d = (await b.getData(`${THREAD_PERM_ENDPOINT}${getThreadId()}`)).can_post;
if (d) {
b.trigger('enableReactionMenuButton');
b.trigger('enableReactionButtons');
} else {
b.trigger('disableReactionMenuButton');
}
}
export function highlightMentions(_, __, el) {
if (!el) return;
if (el.dataset.username === currentUser.username) {
el.classList.add('me');
}
}
export function openReactionMenu(ev, sender, el) {
if (!el) return;
if (reactionMenuState.state === undefined) {
el.addEventListener('toggle', e => {
if (e.newState === 'closed') {
reactionMenuState.state = 'closed';
}
});
}
if (reactionMenuState.state === 'open' && reactionMenuState.invoker === sender) {
el.hidePopover();
return;
}
// TODO: [el, sender].prop(key) searches for ancestors with attr [data-${key}] if current element does not have `dataset[key]` but dataset transforms key names whereas css does not
reactionMenuState.post = sender.prop('postid');
reactionMenuState.invoker = sender;
reactionMenuState.state = 'open';
el.showPopover();
const bRect = sender.getBoundingClientRect();
const scrollY = window.scrollY;
el.style.left = `${bRect.left}px`;
el.style.top = `${bRect.bottom + scrollY}px`;
}
export function closeReactionMenu(_, __, el) {
el.hidePopover();
}
export async function toggleReaction(_, sender, __) {
const emoji = sender.dataset.emoji;
const post = sender.prop('postid') ? sender.prop('postid') : reactionMenuState.post;
const res = await fetch(TOGGLE_REACTION_ENDPOINT, {
method: 'POST',
body: JSON.stringify({ reaction: emoji, post: post }),
headers: {
'content-type': 'application/json',
},
});
if (res.status !== 200) {
return;
}
b.send({ postId: post }, 'replaceReactionButtons');
}
export async function replaceReactionButtons(payload, __, el) {
if (payload.postId !== el.prop('postid')) return;
const res = await fetch(`${REPLACE_REACTIONS_ENDPOINT}${payload.postId}`);
if (res.status !== 200) {
return;
}
const body = await res.text();
const p = new DOMParser();
const e = p.parseFromString(body, 'text/html').body;
el.replaceChildren(...e.children);
el.childNodes.forEach(b => {
if (!b instanceof HTMLButtonElement) return;
b.disabled = false;
})
}
export function disableReactionMenuButton(_, __, el) {
el.title = 'You do not have permission to add reactions to this post.';
}
export function enableReactionMenuButton(_, __, el) {
el.disabled = false;
el.title = '';
}
export function enableReactionButtons(_, __, el) {
if (!el) return;
el.disabled = false;
}

View File

@@ -1,8 +1,9 @@
export const b = { export const b = {
babycodePreviewEndpoint: '/api/babycode-preview/',
init: 'babycodeEditorCharCountInit localizeTimestamps', init: 'babycodeEditorCharCountInit localizeTimestamps',
} }
const BABYCODE_PREVIEW_ENDPOINT = '/api/babycode-preview/';
const getThreadId = () => { const getThreadId = () => {
const scheme = window.location.pathname.split("/"); const scheme = window.location.pathname.split("/");
if (scheme[1] !== 'threads' || scheme[2] === 'new') { if (scheme[1] !== 'threads' || scheme[2] === 'new') {
@@ -114,6 +115,7 @@ export function babycodeEditorCharCount(evOrPayload, sender, el) {
export function clearThreadDraft(_, __, ___) { export function clearThreadDraft(_, __, ___) {
const threadId = getThreadId(); const threadId = getThreadId();
if (threadId === -1) return;
localStorage.removeItem(`thread-${threadId}`); localStorage.removeItem(`thread-${threadId}`);
} }
@@ -157,7 +159,7 @@ export async function babycodePreview(payload, _, el) {
}), }),
} }
const f = await fetch(b.babycodePreviewEndpoint, options); const f = await fetch(BABYCODE_PREVIEW_ENDPOINT, options);
try { try {
if (!f.ok) { if (!f.ok) {
console.error(f); console.error(f);

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;
@@ -103,7 +106,7 @@
ta.addEventListener('keydown', e => { ta.addEventListener('keydown', e => {
if (e.ctrlKey && e.key === 'Enter') { if (e.ctrlKey && e.key === 'Enter') {
if (ta.form.reportValidity()) { if (ta.form.reportValidity()) {
ta.form.submit(); ta.form.requestSubmit();
} }
} }
}) })