Compare commits
10 Commits
a5a3565496
...
6b7a0e7a17
| Author | SHA1 | Date | |
|---|---|---|---|
|
6b7a0e7a17
|
|||
|
303750a281
|
|||
|
e74b9403e9
|
|||
|
e729b43a28
|
|||
|
9ca40e1814
|
|||
|
84ee969e7a
|
|||
|
13667d5f6c
|
|||
|
72172dbb1c
|
|||
|
5db63d6907
|
|||
|
daed16f099
|
17
app/auth.py
17
app/auth.py
@@ -66,6 +66,13 @@ def revoke_session(user_id):
|
||||
sess.delete()
|
||||
session.clear()
|
||||
|
||||
def revoke_all_sessions(user_id):
|
||||
if not is_logged_in():
|
||||
return
|
||||
|
||||
Sessions.revoke_all(user_id)
|
||||
session.clear()
|
||||
|
||||
def parse_username(username: str) -> Tuple[str, str]:
|
||||
"""first is the unmodified name/display name, second is username"""
|
||||
if len(username) < 3:
|
||||
@@ -77,6 +84,15 @@ def parse_username(username: str) -> Tuple[str, str]:
|
||||
invalid_regex = r'[^a-zA-Z0-9_-]'
|
||||
return re.sub(invalid_regex, '_', username.lower())[:24], username
|
||||
|
||||
def parse_display_name(display_name: str) -> str:
|
||||
if len(display_name) == 0:
|
||||
return display_name
|
||||
invalid_regex = r'[@<>&]'
|
||||
res = re.sub(invalid_regex, '_', display_name)[:50]
|
||||
while len(res) < 3:
|
||||
res += '_'
|
||||
return res
|
||||
|
||||
def is_password_valid(password: str) -> bool:
|
||||
return re.match(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,255}$', password) is not None
|
||||
|
||||
@@ -130,4 +146,3 @@ def csrf_verified(view_func):
|
||||
|
||||
return view_func(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
@@ -63,15 +63,7 @@ class Users(Model):
|
||||
return db.query(q, self.id, per_page, (page - 1) * per_page)
|
||||
|
||||
def get_all_subscriptions(self):
|
||||
q = """
|
||||
SELECT threads.title AS thread_title, threads.slug AS thread_slug
|
||||
FROM
|
||||
threads
|
||||
JOIN
|
||||
subscriptions ON subscriptions.thread_id = threads.id
|
||||
WHERE
|
||||
subscriptions.user_id = ?"""
|
||||
return db.query(q, self.id)
|
||||
return Subscriptions.findall({'user_id': self.id})
|
||||
|
||||
def can_post_to_thread_or_topic(self, thread_or_topic):
|
||||
if self.is_guest():
|
||||
@@ -117,6 +109,42 @@ class Users(Model):
|
||||
def is_subscribed(self, thread_id):
|
||||
return Subscriptions.count({'user_id': self.id, 'thread_id': thread_id}) > 0
|
||||
|
||||
def get_unread_count(self):
|
||||
q = """
|
||||
SELECT
|
||||
COUNT(posts.id) as c
|
||||
FROM
|
||||
subscriptions
|
||||
JOIN
|
||||
threads ON subscriptions.thread_id = threads.id
|
||||
JOIN
|
||||
posts ON threads.id = posts.thread_id
|
||||
WHERE
|
||||
subscriptions.user_id = ?
|
||||
AND
|
||||
posts.created_at > subscriptions.last_seen
|
||||
"""
|
||||
res = db.fetch_one(q, self.id)
|
||||
return res["c"] or 0
|
||||
|
||||
def set_signature(self, content:str, language: str = 'babycode'):
|
||||
if not content:
|
||||
self.update({
|
||||
'signature_original_markup': '',
|
||||
'signature_rendered': '',
|
||||
'signature_format_version': None,
|
||||
})
|
||||
return
|
||||
|
||||
from .lib.babycode import babycode_to_html, BABYCODE_VERSION
|
||||
from .constants import SIG_BANNED_TAGS
|
||||
signature_rendered = babycode_to_html(content, SIG_BANNED_TAGS).result
|
||||
self.update({
|
||||
'signature_original_markup': content,
|
||||
'signature_rendered': signature_rendered,
|
||||
'signature_format_version': BABYCODE_VERSION,
|
||||
})
|
||||
|
||||
|
||||
class Topics(Model):
|
||||
table = 'topics'
|
||||
@@ -386,6 +414,12 @@ class PostHistory(Model):
|
||||
class Sessions(Model):
|
||||
table = 'sessions'
|
||||
|
||||
@classmethod
|
||||
def revoke_all(cls, user_id: int):
|
||||
qb = db.QueryBuilder(cls.table).where({'user_id': user_id})
|
||||
sql, params = qb.build_delete()
|
||||
db.execute(sql, *params)
|
||||
|
||||
class Avatars(Model):
|
||||
table = 'avatars'
|
||||
|
||||
@@ -402,6 +436,13 @@ class Subscriptions(Model):
|
||||
return res['unread_count']
|
||||
return None
|
||||
|
||||
def get_thread(self):
|
||||
return Threads.find({'id': self.thread_id})
|
||||
|
||||
def get_full_posts_view(self):
|
||||
q = f'{Posts.FULL_POSTS_QUERY} WHERE posts.thread_id = ? AND posts.created_at > ? ORDER BY posts.created_at ASC'
|
||||
return db.query(q, self.thread_id, self.last_seen)
|
||||
|
||||
class APIRateLimits(Model):
|
||||
table = 'api_rate_limits'
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from flask import Blueprint, abort, redirect, url_for, request, render_template, flash
|
||||
from ..constants import InfoboxKind, PermissionLevel
|
||||
from ..constants import InfoboxKind, PermissionLevel, MOTD_BANNED_TAGS
|
||||
from ..auth import is_logged_in, get_active_user, csrf_verified
|
||||
from ..models import Topics, Threads, Users
|
||||
from ..models import Topics, Threads, Users, MOTD
|
||||
from ..db import db
|
||||
from ..lib.babycode import babycode_to_html, BABYCODE_VERSION
|
||||
from slugify import slugify
|
||||
from functools import wraps
|
||||
import time
|
||||
@@ -24,7 +26,50 @@ def admin_only(view_func):
|
||||
|
||||
@bp.get('/')
|
||||
def index():
|
||||
return 'stub'
|
||||
motd = MOTD.get_all()[0] if MOTD.has_motd() else None
|
||||
topics = Topics.get_list()
|
||||
return render_template('mod/panel.html', MOTD_BANNED_TAGS=MOTD_BANNED_TAGS, motd=motd, topics=topics)
|
||||
|
||||
@bp.post('/motd/set/')
|
||||
def set_motd():
|
||||
title = request.form.get('motd_title', '')
|
||||
if not title:
|
||||
flash('MOTD must have a title.', InfoboxKind.ERROR)
|
||||
return redirect(url_for('.index'))
|
||||
orig_body = request.form.get('babycode_content', '')
|
||||
if not orig_body:
|
||||
flash('MOTD must have a body.', InfoboxKind.ERROR)
|
||||
return redirect(url_for('.index'))
|
||||
user = get_active_user()
|
||||
data = {
|
||||
'title': title.strip(),
|
||||
'body_original_markup': orig_body,
|
||||
'body_rendered': babycode_to_html(orig_body, banned_tags=MOTD_BANNED_TAGS).result,
|
||||
'format_version': BABYCODE_VERSION,
|
||||
'edited_at': int(time.time()),
|
||||
'user_id': user.id,
|
||||
}
|
||||
if MOTD.has_motd():
|
||||
motd = MOTD.get_all()[0]
|
||||
motd.update(data)
|
||||
message = 'MOTD updated.'
|
||||
else:
|
||||
data['created_at'] = int(time.time())
|
||||
motd = MOTD.create(data)
|
||||
message = 'MOTD created.'
|
||||
|
||||
flash(message, InfoboxKind.INFO)
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
@bp.post('/motd/clear/')
|
||||
def clear_motd():
|
||||
if not MOTD.has_motd():
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
current = MOTD.get_all()[0]
|
||||
current.delete()
|
||||
flash('MOTD cleared.', InfoboxKind.INFO)
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
@bp.get('/topics/new/')
|
||||
def new_topic():
|
||||
@@ -35,10 +80,6 @@ def new_topic_post():
|
||||
topic = Topics.new(request.form.get('name'), request.form.get('description'))
|
||||
return redirect(url_for('topics.topic_by_id', topic_id=topic.id))
|
||||
|
||||
@bp.get('/topics/sort/')
|
||||
def sort_topics():
|
||||
return 'stub'
|
||||
|
||||
@bp.get('/topics/<int:topic_id>/edit/')
|
||||
def edit_topic(topic_id):
|
||||
topic = Topics.find({'id': topic_id})
|
||||
@@ -104,6 +145,15 @@ def sticky_thread(thread_id):
|
||||
thread.update({'is_stickied': request.form.get('sticky')})
|
||||
return redirect(url_for('threads.thread_by_id', thread_id=thread.id))
|
||||
|
||||
@bp.post('/threads/sort/')
|
||||
def sort_topics():
|
||||
topics_list = request.form.getlist('topics[]')
|
||||
with db.transaction():
|
||||
for new_order, topic_id in enumerate(topics_list):
|
||||
db.execute('UPDATE topics SET sort_order = ? WHERE id = ?', new_order, topic_id)
|
||||
|
||||
return redirect(url_for('.index', _anchor='sort-topics'))
|
||||
|
||||
@bp.post('/users/<int:user_id>/make-guest/')
|
||||
@csrf_verified
|
||||
def make_user_guest(user_id):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from flask import Blueprint, redirect, url_for, render_template, request, abort
|
||||
from functools import wraps
|
||||
from ..auth import login_required, get_active_user
|
||||
from ..auth import login_required, get_active_user, is_logged_in
|
||||
from ..models import Threads, Posts, Topics, Users, Reactions, Subscriptions
|
||||
from ..util import get_form_checkbox, time_now
|
||||
import math
|
||||
@@ -59,6 +59,11 @@ def thread(thread_id, slug):
|
||||
abort(404)
|
||||
posts = thread.get_posts(PER_PAGE, page)
|
||||
last_post = posts[-1]
|
||||
user = get_active_user()
|
||||
if user:
|
||||
subscription = Subscriptions.find({'user_id': user.id, 'thread_id': thread.id})
|
||||
if subscription:
|
||||
subscription.update({'last_seen': last_post['created_at']})
|
||||
return render_template(
|
||||
'threads/thread.html', thread=thread,
|
||||
posts=posts, page=page,
|
||||
@@ -143,15 +148,19 @@ def unsubscribe(thread_id):
|
||||
|
||||
user = get_active_user()
|
||||
last_post_id = request.form.get('last_post_id', None)
|
||||
if last_post_id is None:
|
||||
|
||||
return_to = request.form.get('return_to', None)
|
||||
if return_to is None and last_post_id is None:
|
||||
abort(400)
|
||||
elif return_to is None and last_post_id is not None:
|
||||
return_to = url_for('.thread_by_id', thread_id=thread_id, after=last_post_id)
|
||||
|
||||
subscription = Subscriptions.find({'user_id': user.id, 'thread_id': thread_id})
|
||||
if not subscription:
|
||||
return redirect(url_for('.thread_by_id', thread_id=thread_id, after=last_post_id))
|
||||
return redirect(return_to)
|
||||
|
||||
subscription.delete()
|
||||
return redirect(url_for('.thread_by_id', thread_id=thread_id, after=last_post_id))
|
||||
return redirect(return_to)
|
||||
|
||||
@bp.get('/<int:thread_id>/feed.atom/')
|
||||
def feed(thread_id):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from flask import Blueprint, redirect, url_for, render_template, request, session, abort
|
||||
|
||||
from ..models import Topics, Threads
|
||||
from ..models import Topics, Threads, Subscriptions
|
||||
from ..auth import get_active_user
|
||||
import math
|
||||
|
||||
bp = Blueprint('topics', __name__, url_prefix = '/topics/')
|
||||
@@ -33,7 +34,15 @@ def topic(topic_id, slug):
|
||||
page = max(1, min(int(request.args.get('page', default=1)), page_count))
|
||||
except ValueError:
|
||||
abort(404)
|
||||
return render_template('topics/topic.html', topic=topic, threads=topic.get_threads(PER_PAGE, page, sort_by), sort_by=sort_by, page=page, page_count=page_count)
|
||||
threads = topic.get_threads(PER_PAGE, page, sort_by)
|
||||
subscriptions = {}
|
||||
user = get_active_user()
|
||||
if user:
|
||||
for thread in threads:
|
||||
subscription = Subscriptions.find({'user_id': user.id, 'thread_id': thread['id']})
|
||||
if subscription:
|
||||
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)
|
||||
|
||||
@bp.get('/<int:topic_id>/feed.atom/')
|
||||
def feed(topic_id):
|
||||
|
||||
@@ -1,19 +1,108 @@
|
||||
from flask import Blueprint, redirect, url_for, render_template, request, session, abort
|
||||
from flask import (
|
||||
Blueprint, redirect, url_for,
|
||||
render_template, request, session,
|
||||
abort, flash, current_app
|
||||
)
|
||||
from functools import wraps
|
||||
import time
|
||||
|
||||
from secrets import compare_digest as compare_timesafe
|
||||
from wand.image import Image
|
||||
from wand.color import Color
|
||||
from wand.exceptions import WandException
|
||||
from ..auth import (
|
||||
digest, verify, create_session,
|
||||
is_logged_in, parse_username, is_password_valid,
|
||||
login_required, revoke_session, get_active_user
|
||||
login_required, revoke_session, get_active_user,
|
||||
parse_display_name, revoke_all_sessions, csrf_verified
|
||||
)
|
||||
from ..models import Users, Posts, Reactions, Threads
|
||||
from ..constants import PermissionLevel
|
||||
from secrets import compare_digest as compare_timesafe
|
||||
from ..models import Users, Posts, Reactions, Threads, Avatars, PostHistory, Mentions
|
||||
from ..constants import PermissionLevel, InfoboxKind
|
||||
from ..util import get_form_checkbox
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
|
||||
AVATAR_MAX_SIZE = 1000 * 1000 # 1MB
|
||||
|
||||
bp = Blueprint('users', __name__, url_prefix='/users/')
|
||||
|
||||
def validate_and_create_avatar(input_image, filename):
|
||||
try:
|
||||
with Image(blob=input_image) as img:
|
||||
if hasattr(img, 'sequence') and len(img.sequence) > 1:
|
||||
img = Image(image=img.sequence[0])
|
||||
img.strip()
|
||||
img.gravity = 'center'
|
||||
|
||||
width, height = img.width, img.height
|
||||
min_dim = min(width, height)
|
||||
if min_dim > 256:
|
||||
ratio = 256.0 / min_dim
|
||||
new_width = int(width * ratio)
|
||||
new_height = int(height * ratio)
|
||||
img.resize(new_width, new_height)
|
||||
|
||||
width, height = img.width, img.height
|
||||
crop_size = min(width, height)
|
||||
x_offset = (width - crop_size) // 2
|
||||
y_offset = (height - crop_size) // 2
|
||||
img.crop(left=x_offset, top=y_offset,
|
||||
width=crop_size, height=crop_size)
|
||||
|
||||
img.resize(256, 256)
|
||||
img.background_color = Color('white')
|
||||
img.alpha_channel = 'remove'
|
||||
img.format = 'webp'
|
||||
img.compression_quality = 85
|
||||
img.save(filename=filename)
|
||||
return True
|
||||
except WandException:
|
||||
return False
|
||||
|
||||
def anonymize_user(user_id):
|
||||
deleted_user = Users.find({'username': 'deleteduser'})
|
||||
|
||||
from ..lib.babycode import sanitize, babycode_to_html
|
||||
from ..db import db
|
||||
threads = Threads.findall({'user_id': user_id})
|
||||
posts = Posts.findall({'user_id': user_id})
|
||||
|
||||
revs_q = """SELECT DISTINCT m.revision_id FROM mentions m
|
||||
WHERE m.mentioned_user_id = ?"""
|
||||
|
||||
mentioned_revs = db.query(revs_q, int(user_id))
|
||||
with db.transaction():
|
||||
for thread in threads:
|
||||
thread.update({'user_id': int(deleted_user.id)})
|
||||
for post in posts:
|
||||
post.update({'user_id': int(deleted_user.id)})
|
||||
|
||||
revs = {}
|
||||
for rev in mentioned_revs:
|
||||
ph = PostHistory.find({'id': int(rev['revision_id'])})
|
||||
ms = Mentions.findall({
|
||||
'mentioned_user_id': int(user_id),
|
||||
'revision_id': int(rev['revision_id']),
|
||||
})
|
||||
data = {
|
||||
'text': sanitize(ph.original_markup),
|
||||
'mentions': ms,
|
||||
}
|
||||
data['mentions'] = sorted(data['mentions'], key=lambda x: int(x.end_index), reverse=True)
|
||||
revs[rev['revision_id']] = data
|
||||
|
||||
for rev_id, data in revs.items():
|
||||
text = data['text']
|
||||
for mention in data['mentions']:
|
||||
text = text[:mention.start_index] + '@deleteduser' + text[mention.end_index:]
|
||||
mention.delete()
|
||||
|
||||
res = babycode_to_html(text)
|
||||
ph = PostHistory.find({'id': int(rev_id)})
|
||||
ph.update({
|
||||
'original_markup': text.unescape(),
|
||||
'content': res.result,
|
||||
})
|
||||
|
||||
def redirect_if_logged_in(destination='topics.all_topics'):
|
||||
def decorator(view_func):
|
||||
@wraps(view_func)
|
||||
@@ -36,6 +125,15 @@ def redirect_to_own(view_func):
|
||||
return view_func(username, *args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
def user_required(view_func):
|
||||
@wraps(view_func)
|
||||
def wrapper(username, *args, **kwargs):
|
||||
user = get_active_user()
|
||||
if user.is_guest():
|
||||
abort(403)
|
||||
|
||||
return view_func(username, *args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
@bp.get('/log-in/')
|
||||
@redirect_if_logged_in()
|
||||
@@ -106,7 +204,7 @@ def sign_up_post():
|
||||
|
||||
if username_pair[0] != username_pair[1]:
|
||||
user.update({
|
||||
'display_name': username_pair[1]
|
||||
'display_name': parse_display_name(username_pair[1])
|
||||
})
|
||||
|
||||
session['remember'] = request.form.get('remember') == 'on'
|
||||
@@ -182,15 +280,145 @@ def comments(username):
|
||||
@login_required
|
||||
@redirect_to_own
|
||||
def settings(username):
|
||||
username = username.lower()
|
||||
user = get_active_user()
|
||||
sort_by = session.get('sort_by', 'activity')
|
||||
return render_template(
|
||||
'users/settings.html', user=user,
|
||||
sort_by=sort_by
|
||||
)
|
||||
|
||||
@bp.post('/<username>/settings/set-avatar')
|
||||
@login_required
|
||||
@user_required
|
||||
@redirect_to_own
|
||||
def set_avatar(username):
|
||||
user = get_active_user()
|
||||
return_to = redirect(url_for('.settings', username=user.username))
|
||||
if 'avatar' not in request.files:
|
||||
abort(400)
|
||||
|
||||
avi_file = request.files['avatar']
|
||||
|
||||
if avi_file.filename == '':
|
||||
abort(400)
|
||||
|
||||
avi_file.seek(0, os.SEEK_END)
|
||||
file_size = avi_file.tell()
|
||||
avi_file.seek(0, os.SEEK_SET)
|
||||
|
||||
if file_size > AVATAR_MAX_SIZE:
|
||||
flash('Your avatar must be 1MB or less.', InfoboxKind.ERROR)
|
||||
return return_to
|
||||
|
||||
avi_bytes = avi_file.read()
|
||||
now = int(time.time())
|
||||
filename = f'u{user.id}d{now}.webp'
|
||||
output_path = os.path.join(current_app.config['AVATAR_UPLOAD_PATH'], filename)
|
||||
proxied_filename = f'/static/avatars/{filename}'
|
||||
|
||||
res = validate_and_create_avatar(avi_bytes, output_path)
|
||||
if res:
|
||||
flash('Avatar updated.', InfoboxKind.INFO)
|
||||
avatar = Avatars.create({
|
||||
'file_path': proxied_filename,
|
||||
'uploaded_at': now,
|
||||
})
|
||||
old_avatar = Avatars.find({'id': user.avatar_id})
|
||||
user.update({'avatar_id': avatar.id})
|
||||
if int(old_avatar.id) != 1:
|
||||
filename = os.path.join(current_app.config['AVATAR_UPLOAD_PATH'], os.path.basename(old_avatar.file_path))
|
||||
os.remove(filename)
|
||||
old_avatar.delete()
|
||||
return return_to
|
||||
else:
|
||||
flash('Something went wrong.;Please try again.', InfoboxKind.ERROR)
|
||||
|
||||
return return_to
|
||||
|
||||
@bp.post('/<username>/settings/clear-avatar')
|
||||
@login_required
|
||||
@user_required
|
||||
@redirect_to_own
|
||||
def clear_avatar(username):
|
||||
user = get_active_user()
|
||||
if user.is_default_avatar():
|
||||
return redirect(url_for('.settings', username=username))
|
||||
|
||||
old_avatar = Avatars.find({'id': user.avatar_id})
|
||||
user.update({'avatar_id': 1})
|
||||
filename = os.path.join(current_app.config['AVATAR_UPLOAD_PATH'], os.path.basename(old_avatar.file_path))
|
||||
os.remove(filename)
|
||||
old_avatar.delete()
|
||||
return redirect(url_for('.settings', username=username))
|
||||
|
||||
@bp.post('/<username>/settings/change-password')
|
||||
@login_required
|
||||
@redirect_to_own
|
||||
def change_password(username):
|
||||
user = get_active_user()
|
||||
current_password = request.form.get('current_password', '')
|
||||
new_password = request.form.get('new_password', '')
|
||||
new_password2 = request.form.get('new_password2', '')
|
||||
|
||||
new_hash = digest(new_password)
|
||||
|
||||
old_correct = verify(user.password_hash, current_password)
|
||||
new_match = compare_timesafe(new_password, new_password2)
|
||||
|
||||
if (old_correct and new_match) == False:
|
||||
flash('The current password is incorrect or the new passwords do not match.;Please try again.', InfoboxKind.ERROR)
|
||||
return redirect(url_for('.settings', username=username))
|
||||
|
||||
user.update({
|
||||
'password_hash': new_hash
|
||||
})
|
||||
|
||||
revoke_all_sessions(user.id)
|
||||
|
||||
return redirect(url_for('.log_in'))
|
||||
|
||||
@bp.post('/<username>/settings/set-personalization')
|
||||
@login_required
|
||||
@user_required
|
||||
@redirect_to_own
|
||||
def set_personalization(username):
|
||||
user = get_active_user()
|
||||
session['sort_by'] = request.form.get('sort_by', 'activity')
|
||||
session['dont_subscribe_by_default'] = not get_form_checkbox('subscribe_by_default')
|
||||
|
||||
user.update({
|
||||
'status': request.form.get('status', '')[:100],
|
||||
'display_name': parse_display_name(request.form.get('display_name', ''))
|
||||
})
|
||||
|
||||
flash('Personalization settings updated.', InfoboxKind.INFO)
|
||||
return redirect(url_for('.settings', username=username))
|
||||
|
||||
@bp.post('/<username>/settings/set-sig')
|
||||
@login_required
|
||||
@user_required
|
||||
@redirect_to_own
|
||||
def set_sig(username):
|
||||
user = get_active_user()
|
||||
user.set_signature(request.form.get('babycode_content', ''))
|
||||
flash('Signature updated.', InfoboxKind.INFO)
|
||||
return redirect(url_for('.settings', username=username))
|
||||
|
||||
@bp.post('/<username>/settings/set-bio')
|
||||
@login_required
|
||||
@user_required
|
||||
@redirect_to_own
|
||||
def set_bio(username):
|
||||
return 'stub'
|
||||
|
||||
@bp.get('/<username>/inbox/')
|
||||
@login_required
|
||||
@redirect_to_own
|
||||
def inbox(username):
|
||||
username = username.lower()
|
||||
return 'stub'
|
||||
user = get_active_user()
|
||||
unread_count = user.get_unread_count()
|
||||
subscriptions = user.get_all_subscriptions()
|
||||
return render_template('users/inbox.html', unread_count=unread_count, subscriptions=subscriptions)
|
||||
|
||||
@bp.get('/<username>/bookmarks/')
|
||||
@login_required
|
||||
@@ -199,3 +427,30 @@ def bookmarks(username):
|
||||
username = username.lower()
|
||||
return 'stub'
|
||||
|
||||
@bp.get('/<username>/delete-confirm/')
|
||||
@login_required
|
||||
@redirect_to_own
|
||||
def delete_confirm(username):
|
||||
return render_template('users/delete_confirm.html')
|
||||
|
||||
@bp.post('/<username>/delete-confirm/')
|
||||
@login_required
|
||||
@redirect_to_own
|
||||
@csrf_verified
|
||||
def delete_confirm_post(username):
|
||||
user = get_active_user()
|
||||
|
||||
password = request.form.get('password', '')
|
||||
if not verify(user.password_hash, password):
|
||||
flash('Incorrect password.', InfoboxKind.ERROR)
|
||||
return redirect(url_for('.delete_confirm', username=username))
|
||||
|
||||
if user.is_admin():
|
||||
flash('You can not delete the admin account.', InfoboxKind.ERROR)
|
||||
return redirect(url_for('.delete_confirm', username=username))
|
||||
|
||||
anonymize_user(user.id)
|
||||
revoke_all_sessions(user.id)
|
||||
user.delete()
|
||||
|
||||
return redirect(url_for('topics.all_topics'))
|
||||
|
||||
@@ -23,5 +23,6 @@
|
||||
{%- endwith -%}
|
||||
{%- block content -%}{%- endblock -%}
|
||||
{%- include 'common/footer.html' -%}
|
||||
<script src="{{'/static/js/ui.js' | cachebust}}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
<img src="/static/icons/error.svg" alt="error" style="width: {{width}}px;">
|
||||
{%- endmacro -%}
|
||||
|
||||
{%- macro icn_megaphone(width=48) -%}
|
||||
<img src="/static/icons/megaphone.svg" alt="megaphone" style="width: {{width}}px;">
|
||||
{%- endmacro -%}
|
||||
|
||||
{%- macro icn_bookmark(width=16) -%}
|
||||
<img src="/static/icons/bookmark.svg" alt="bookmark" style="width: {{width}}px;">
|
||||
{%- endmacro -%}
|
||||
@@ -21,3 +25,7 @@
|
||||
{%- macro icn_stickied(width=16) -%}
|
||||
<img src="/static/icons/sticky.svg" title="Stickied" alt="paper held by pushpin" style="width: {{width}}px;">
|
||||
{%- endmacro -%}
|
||||
|
||||
{%- macro icn_dragger(width=24) -%}
|
||||
<img src="/static/icons/dragger.svg" style="width: {{width}}px;">
|
||||
{%- endmacro -%}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{%- from 'common/icons.html' import icn_info, icn_warn, icn_error, icn_bookmark -%}
|
||||
{%- from 'common/icons.html' import icn_info, icn_warn, icn_error, icn_bookmark, icn_megaphone, icn_dragger -%}
|
||||
|
||||
{% macro dict_to_attr(attrs) -%}
|
||||
{%- for key, value in attrs.items() if value is not none -%}{{' '}}{{key}}="{{value}}"{%- endfor -%}
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro timestamp(unix_ts) -%}
|
||||
<time datetime="{{ unix_ts | iso8601 }}">{{ unix_ts | ts_datetime('%Y-%m-%d %H:%M')}} <abbr title="Server Time">ST</abbr></time>
|
||||
@@ -72,7 +76,7 @@
|
||||
<div class="tab-container">
|
||||
<div class="tab-bar" role="tablist">
|
||||
{%- for tab_label in labels -%}
|
||||
<button type="button" class="tab-button" role="tab" aria-selected="{{'true' if loop.index0==0 else 'false'}}" id="{{prefix+'-'+(tab_label | lower)+'-tab'}}" aria-controls="{{prefix+'-'+(tab_label | lower)+'-content'}}">{{tab_label}}</button>
|
||||
<button type="button" class="tab-button" role="tab" aria-selected="{{'true' if loop.index0==0 else 'false'}}" id="{{prefix+'-'+(tab_label | lower)+'-tab'}}" aria-controls="{{prefix+'-'+(tab_label | lower)+'-content'}}" disabled>{{tab_label}}</button>
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
{%- for tab_label in labels -%}
|
||||
@@ -87,29 +91,48 @@
|
||||
placeholder='Post content',
|
||||
prefill='',
|
||||
required=true,
|
||||
id='babycode-content'
|
||||
id='babycode-content',
|
||||
banned_tags=[]
|
||||
) -%}
|
||||
{%- call(idx) tabs(prefix='babycode', labels=['Write', 'Preview']) -%}
|
||||
{%- if idx == 0 -%}
|
||||
<span class="babycode-editor-controls">
|
||||
<span class="button-row">
|
||||
<button type="button" class="minimal"><b>B</b></button>
|
||||
<button type="button" class="minimal"><i>i</i></button>
|
||||
<button type="button" class="minimal"><s>S</s></button>
|
||||
<button type="button" class="minimal"><u>U</u></button>
|
||||
<button type="button" class="minimal"><code>://</code></button>
|
||||
<button type="button" class="minimal"><code></></code></button>
|
||||
<button type="button" class="minimal">1.</button>
|
||||
<button type="button" class="minimal">•</button>
|
||||
<button type="button" class="minimal"><img src="/static/emoji/angry.png" class="emoji"></button>
|
||||
<button type="button" class="minimal" disabled><b>B</b></button>
|
||||
<button type="button" class="minimal" disabled><i>i</i></button>
|
||||
<button type="button" class="minimal" disabled><s>S</s></button>
|
||||
<button type="button" class="minimal" disabled><u>U</u></button>
|
||||
<button type="button" class="minimal" disabled><code>://</code></button>
|
||||
<button type="button" class="minimal" disabled><code></></code></button>
|
||||
<button type="button" class="minimal" disabled>1.</button>
|
||||
<button type="button" class="minimal" disabled>•</button>
|
||||
<button type="button" class="minimal" disabled><img src="/static/emoji/angry.png" class="emoji"></button>
|
||||
</span>
|
||||
<span class="flex-last">{# stub: char count #}</span>
|
||||
</span>
|
||||
<textarea name="babycode_content" id="{{id}}" class="babycode-editor" placeholder="{{placeholder}}" {{'required' if required else ''}}>{{ prefill }}</textarea>
|
||||
<input type="hidden" name="babycode_banned_tags" id="{{id}}-banned-tags" value="{{banned_tags | unique | list | tojson | forceescape}}">
|
||||
<textarea name="babycode_content" id="{{id}}" class="babycode-editor" placeholder="{{placeholder}}" {{'required' if required else ''}} autocomplete="off" maxlength="5000">{{ prefill }}</textarea>
|
||||
{%- if banned_tags -%}
|
||||
<div>
|
||||
<span>Forbidden tags:</span>
|
||||
<ul class="horizontal">
|
||||
{%- for tag in banned_tags -%}
|
||||
<li><code class="inline-code">{{tag}}</code></li>
|
||||
{%- endfor -%}
|
||||
</ul>
|
||||
</div>
|
||||
{%- endif -%}
|
||||
<a href="##">babycode help</a>
|
||||
{%- endif -%}
|
||||
{%- endcall -%}
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro avatar(url) -%}
|
||||
<div class="avatar-container">
|
||||
<img src="{{url}}" class="avatar">
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro full_post(
|
||||
post, render_sig=true, is_latest=false,
|
||||
show_toolbar=true, is_editing=false, thread=none,
|
||||
@@ -124,9 +147,9 @@
|
||||
{%- set can_reply = (is_logged_in()) and (not thread.locked or is_mod()) -%}
|
||||
<div class="usercard plank even contrast-bg minimal no-shadow">
|
||||
<div class="usercard-inner">
|
||||
<img src="{{post.avatar_path}}" class="avatar">
|
||||
{{avatar(post.avatar_path)}}
|
||||
<div class="usercard-rest">
|
||||
<a href="{{url_for('users.user_page', username=post.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>
|
||||
<i>{{post.status}}</i>
|
||||
{%- set badges=post.badges_json | fromjson -%}
|
||||
@@ -219,3 +242,33 @@
|
||||
{%- endif -%}
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro motd(motd_objs) -%}
|
||||
{%- if motd_objs -%}
|
||||
<div class="motd plank contrast-bg">
|
||||
<div class="contain-svg">
|
||||
{{ icn_megaphone(64) }}
|
||||
<i><abbr title="Message of the Day">MOTD</abbr></i>
|
||||
</div>
|
||||
<div class="motd-content mobile-fill-flex">
|
||||
<h2 class="info">{{ motd_objs[0].title }}</h2>
|
||||
<div class="motd-body">{{ motd_objs[0].body_rendered | safe }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{%- endif -%}
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro sortable_list(attr=none) -%}
|
||||
<ol class="sortable-list plank even no-shadow minimal tertiary-bg" {% if attr %}{{ dict_to_attr(attr) }}{% endif %}>
|
||||
{%- if caller -%}
|
||||
{{ caller() }}
|
||||
{%- endif -%}
|
||||
</ol>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro sortable_list_item(key, immovable=false, attr=none) -%}
|
||||
<li class="sortable-item{{ ' immovable' if immovable else '' }} plank even no-shadow {{'tertiary-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>
|
||||
<div class="sortable-item-inner">{{ caller() }}</div>
|
||||
</li>
|
||||
{%- endmacro %}
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
<span>anti-social media</span>
|
||||
{%- if is_logged_in() -%}
|
||||
{%- with user = get_active_user() -%}
|
||||
{%- set uc = user.get_unread_count() -%}
|
||||
<ul class="horizontal wrap">
|
||||
<li class="mobile-fill-flex">Welcome, <a href="{{url_for('users.user_page', username=user.username)}}">{{ user.get_readable_name() }}</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</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>
|
||||
{% if user.is_mod() -%}
|
||||
<li><a class="linkbutton" href="{{url_for('mod.index')}}">Moderation</a></li>
|
||||
|
||||
36
app/templates/mod/panel.html
Normal file
36
app/templates/mod/panel.html
Normal file
@@ -0,0 +1,36 @@
|
||||
{%- from 'common/macros.html' import subheader, babycode_editor_component, sortable_list, sortable_list_item -%}
|
||||
{%- extends 'base.html' -%}
|
||||
{%- block title -%}settings{%- endblock -%}
|
||||
{%- block content -%}
|
||||
{{- subheader('Moderation panel') -}}
|
||||
<fieldset class="plank">
|
||||
<legend>Message of the Day</legend>
|
||||
<p>The Message of the Day is shown on the front page and on each topic's thread list.</p>
|
||||
<form class="full-width" method="POST" action="{{url_for('mod.set_motd')}}" id="motd-form">
|
||||
<label for="motd-title">Title</label>
|
||||
<input type="text" id="motd-title" name="motd_title" autocomplete="off" value="{{motd.title}}" required>
|
||||
<label for="motd-content">Body</label>
|
||||
{{ babycode_editor_component(placeholder='MOTD content', prefill=motd.body_original_markup, id='motd-content', banned_tags=MOTD_BANNED_TAGS) }}
|
||||
</form>
|
||||
<input type="submit" value="Save MOTD" form="motd-form">
|
||||
<form method="POST" action="{{url_for('mod.clear_motd')}}">
|
||||
<input type="submit" value="Clear MOTD" class="warn">
|
||||
</form>
|
||||
</fieldset>
|
||||
<fieldset class="plank" id="sort-topics">
|
||||
<legend>Sort topics</legend>
|
||||
<p>Drag topics around to reorder them. Press "Save order" when done.</p>
|
||||
<form method="POST" action="{{url_for('mod.sort_topics')}}">
|
||||
<input type="submit" value="Save order">
|
||||
{%- call() sortable_list() -%}
|
||||
{%- for topic in topics -%}
|
||||
{%- call() sortable_list_item(key="topics") -%}
|
||||
<h2 class="info">{{ topic.name }}</h2>
|
||||
<div>{{topic.description}}</div>
|
||||
<input type="hidden" name="topics[]" value="{{ topic.id }}">
|
||||
{%- endcall -%}
|
||||
{%- endfor -%}
|
||||
{%- endcall -%}
|
||||
</form>
|
||||
</fieldset>
|
||||
{%- endblock -%}
|
||||
@@ -87,7 +87,7 @@
|
||||
<h2 class="info">Reply to "{{thread.title}}"</h2>
|
||||
{{- babycode_editor_component() -}}
|
||||
<span>
|
||||
<input type="checkbox" checked name="subscribe" id="subscribe">
|
||||
<input type="checkbox" {{'' if session['dont_subscribe_by_default'] else 'checked'}} name="subscribe" id="subscribe" autocomplete=off>
|
||||
<label for="subscribe">Subscribe to thread</label>
|
||||
</span>
|
||||
<span><input type="submit" value="Post reply"></span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{% from 'common/macros.html' import timestamp, subheader, pager %}
|
||||
{% from 'common/macros.html' import timestamp, subheader, pager, motd %}
|
||||
{% from 'common/icons.html' import icn_locked, icn_stickied %}
|
||||
{%- extends 'base.html' -%}
|
||||
{%- block title -%}browsing topic {{topic.name}}{%- endblock -%}
|
||||
@@ -46,10 +46,17 @@
|
||||
{%- 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>
|
||||
{%- endif -%}
|
||||
{{ motd(get_motds()) }}
|
||||
{%- for thread in threads -%}
|
||||
<div class="topic-info plank">
|
||||
<div class="title-container">
|
||||
<span class="info thread-title-counter"><a href="{{url_for('threads.thread_by_id', thread_id=thread.id)}}">{{thread.title}}</a>
|
||||
<span class="info thread-title-counter">
|
||||
<ul class="horizontal">
|
||||
<li><a href="{{url_for('threads.thread_by_id', thread_id=thread.id)}}">{{thread.title}}</a></li>
|
||||
{%- if subscriptions[thread.id] -%}
|
||||
<li>({{subscriptions[thread.id]}} unread)</li>
|
||||
{%- endif -%}
|
||||
</ul>
|
||||
</span>
|
||||
<ul class="horizontal">
|
||||
{%- if thread.is_locked -%}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{% from 'common/macros.html' import timestamp, subheader %}
|
||||
{% from 'common/macros.html' import timestamp, subheader, motd %}
|
||||
{% from 'common/icons.html' import icn_locked %}
|
||||
{%- extends 'base.html' -%}
|
||||
{%- block content -%}
|
||||
@@ -7,10 +7,11 @@
|
||||
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||
<legend>Moderation actions</legend>
|
||||
<a href="{{url_for('mod.new_topic')}}" class="linkbutton">New topic</a>
|
||||
<a href="{{url_for('mod.sort_topics')}}" class="linkbutton">Sort topics</a>
|
||||
<a href="{{url_for('mod.index', _anchor='sort-topics')}}" class="linkbutton">Sort topics</a>
|
||||
</fieldset>
|
||||
{%- endif -%}
|
||||
{%- endcall -%}
|
||||
{{ motd(get_motds()) }}
|
||||
{%- for topic in topics -%}
|
||||
<div class="topic-info plank">
|
||||
<div class="title-container">
|
||||
|
||||
20
app/templates/users/delete_confirm.html
Normal file
20
app/templates/users/delete_confirm.html
Normal file
@@ -0,0 +1,20 @@
|
||||
{%- from 'common/macros.html' import subheader -%}
|
||||
{%- extends 'base.html' -%}
|
||||
{%- block title -%}account deletion confirmation{%- endblock -%}
|
||||
{%- block content -%}
|
||||
{%- set sub -%}
|
||||
<a href="{{url_for('users.settings', username=get_active_user().username)}}">← Back to settings</a>
|
||||
{%- endset -%}
|
||||
{{- subheader('Confirm account deletion', sub) -}}
|
||||
<div class="plank">
|
||||
<form class="full-width" method="POST">
|
||||
<p>Are you sure you want to delete your account on {{ config.SITE_NAME }}? <strong>This action is irreversible.</strong> Your posts and threads will remain accessible to preserve history but will be de-personalized, showing up as authored by a system user. Posts that @mention you will also mention the system user instead.</p>
|
||||
<p>If you wish for any and all content relating to you to be removed, you will have to <a href="{{url_for('guides.contact')}}" target="_blank">contact {{ config.SITE_NAME }}'s administrators separately.</a></p>
|
||||
<p>If you are sure, please confirm your current password below.</p>
|
||||
<label for="password">Confirm password</label>
|
||||
{{csrf_input() | safe}}
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password">
|
||||
<input type="submit" class="critical" value="Permanently delete account">
|
||||
</form>
|
||||
</div>
|
||||
{%- endblock -%}
|
||||
38
app/templates/users/inbox.html
Normal file
38
app/templates/users/inbox.html
Normal file
@@ -0,0 +1,38 @@
|
||||
{% from 'common/macros.html' import subheader %}
|
||||
{% from 'common/macros.html' import full_post with context %}
|
||||
{%- extends 'base.html' -%}
|
||||
{%- block title -%}inbox{%- endblock -%}
|
||||
{%- block content -%}
|
||||
{%- set topline -%}
|
||||
{%- if unread_count -%}
|
||||
You have {{unread_count}} unread posts across {{subscriptions | length}} threads.
|
||||
{%- elif subscriptions | length > 0 -%}
|
||||
You have no unread posts.
|
||||
{%- else -%}
|
||||
You do not have any subscriptions.
|
||||
{%- endif -%}
|
||||
{%- endset -%}
|
||||
{{ subheader('Your inbox', topline) }}
|
||||
{%- if subscriptions | length > 0 -%}
|
||||
<div class="plank">
|
||||
{%- for sub in subscriptions -%}
|
||||
<details class="separated">
|
||||
{%- set thread = sub.get_thread() -%}
|
||||
<summary class="plank secondary-bg no-shadow even">
|
||||
{{thread.title}} ({{sub.get_unread_count()}} unread)
|
||||
<form 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)}}">
|
||||
<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">
|
||||
</form>
|
||||
</summary>
|
||||
{%- set posts = sub.get_full_posts_view() -%}
|
||||
|
||||
{%- for post in posts -%}
|
||||
<div class="plank post no-shadow even">{{full_post(post, show_toolbar=false, show_reactions=false)}}</div>
|
||||
{%- endfor -%}
|
||||
</details>
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
{%- endif -%}
|
||||
{%- endblock -%}
|
||||
84
app/templates/users/settings.html
Normal file
84
app/templates/users/settings.html
Normal file
@@ -0,0 +1,84 @@
|
||||
{%- from 'common/macros.html' import babycode_editor_component -%}
|
||||
{%- from 'common/macros.html' import subheader, avatar -%}
|
||||
{%- extends 'base.html' -%}
|
||||
{%- block title -%}settings{%- endblock -%}
|
||||
{%- block content -%}
|
||||
{%- set sub -%}
|
||||
{%- if user.is_guest() -%}You are a guest. Your customization options are limited until a moderator confirms your account.{%- endif -%}
|
||||
{%- endset -%}
|
||||
{{- subheader('User settings', sub) -}}
|
||||
{%- if not user.is_guest() -%}
|
||||
<fieldset class="plank">
|
||||
<legend>Avatar</legend>
|
||||
<form method="POST" class="avatar-form" action="{{url_for('users.set_avatar', username=user.username)}}" enctype="multipart/form-data">
|
||||
{{- avatar(user.get_avatar_url()) -}}
|
||||
<span class="avatar-form-controls">
|
||||
<label for="avatar" class="linkbutton alt">Upload…</label>
|
||||
<span class="avatar-form-size-label">1MB max. Will be cropped to square.</span>
|
||||
<input type="file" style="display: none;" id="avatar" name="avatar" accept="image/*">
|
||||
<input type="submit" value="Save">
|
||||
<input type="submit" class="warn" value="Clear" formaction="{{url_for('users.clear_avatar', username=user.username)}}">
|
||||
</span>
|
||||
</form>
|
||||
</fieldset>
|
||||
{%- endif -%}
|
||||
<fieldset class="plank">
|
||||
<legend>Change password</legend>
|
||||
<p>After you change your password, you will be logged out of all sessions and will need to log in again.</p>
|
||||
<form class="full-width" method="POST" action="{{ url_for('users.change_password', username=user.username) }}">
|
||||
<label for="current-password">Current password</label>
|
||||
<input type="password" name="current_password" id="current-password" autocomplete="current-password" required>
|
||||
<label for="new-password">New password</label>
|
||||
<input type="password" name="new_password" id="new-password" autocomplete="new-password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,255}" title="10+ chars with at least: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required>
|
||||
<label for="new-password2">Confirm new password</label>
|
||||
<input type="password" name="new_password2" id="new-password2" autocomplete="new-password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,255}" title="10+ chars with at least: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required>
|
||||
<input type="submit" value="Change password" class="warn">
|
||||
</form>
|
||||
</fieldset>
|
||||
{%- if not user.is_guest() -%}
|
||||
<fieldset class="plank">
|
||||
<legend>Personalization</legend>
|
||||
<form class="full-width" method="POST" action="{{ url_for('users.set_personalization', username=user.username)}}">
|
||||
<label for="sort-by">Sort threads by:</label>
|
||||
<select name="sort_by" id="sort-by" autocomplete=off>
|
||||
<option value="activity" {{ 'selected' if sort_by == 'activity' else '' }}>Activity</option>
|
||||
<option value="thread" {{ 'selected' if sort_by == 'thread' else '' }}>Newest</option>
|
||||
</select>
|
||||
<label for="display-name">Display name</label>
|
||||
<input type="text" name="display_name" id="display-name" value="{{user.display_name}}" placeholder="Same as username" pattern="(?:[\w!#$%^*\(\)\-_=+\[\]\{\}\|;:,.?\s]{3,50})?" title="Optional. 3-50 characters, no @, no <>, no &." maxlength="50" autocomplete=off>
|
||||
<label for="status">Status</label>
|
||||
<input type="text" name="status" id="status" maxlength="100" value="{{user.status}}" placeholder="Will be shown under your username on posts. Max. 100 characters." autocomplete="off">
|
||||
<span>
|
||||
<input type="checkbox" id="subscribe-by-default" name="subscribe_by_default" {{'' if session['dont_subscribe_by_default'] else 'checked'}} autocomplete="off">
|
||||
<label for="subscribe-by-default">Automatically subscribe to thread when responding</label>
|
||||
</span>
|
||||
<input type="submit" value="Save">
|
||||
</form>
|
||||
</fieldset>
|
||||
<fieldset class="plank">
|
||||
<legend>Signature</legend>
|
||||
<form method="POST" class="full-width" action="{{url_for('users.set_sig', username=user.username)}}">
|
||||
<p>The signature will appear under each of your posts.</p>
|
||||
{{babycode_editor_component(id='signature-content', placeholder='Signature content', prefill=user.signature_original_markup, required=false, banned_tags=['@mention'])}}
|
||||
<input type="submit" value="Save signature">
|
||||
</form>
|
||||
</fieldset>
|
||||
{#<fieldset class="plank">
|
||||
<legend>About me/Bio</legend>
|
||||
<form method="POST" class="full-width">
|
||||
<span>Your bio will appear on your profile.</span>
|
||||
{{babycode_editor_component(id='bio-content', placeholder='Bio content', prefill=user.signature_original_markup, required=false, banned_tags=['@mention'])}}
|
||||
<input type="submit" value="Save bio">
|
||||
</form>
|
||||
</fieldset>#}
|
||||
<fieldset class="plank">
|
||||
<legend>Badges</legend>
|
||||
<div>Loading badges…</div>
|
||||
<div>If badges fail to load, make sure JS is enabled.</div>
|
||||
</fieldset>
|
||||
{%- endif -%}
|
||||
<fieldset class="plank">
|
||||
<legend>Disown & Delete account</legend>
|
||||
<a class="linkbutton critical" href="{{url_for('users.delete_confirm', username=user.username)}}">Delete account</a>
|
||||
</fieldset>
|
||||
{%- endblock -%}
|
||||
@@ -14,9 +14,9 @@ Please read the rules etc. stub
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" pattern="[a-zA-Z0-9_\-]{3,24}" title="3-24 characters. Only upper and lowercase letters, digits, hyphens, and underscores" autocomplete="username" required>
|
||||
<label for="password">Create password</label>
|
||||
<input type="password" id="password" name="password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,255}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" autocomplete="new-password" required>
|
||||
<input type="password" id="password" name="password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,255}" title="10+ chars with at least: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" autocomplete="new-password" required>
|
||||
<label for="password2">Confirm password</label>
|
||||
<input type="password" id="password2" name="password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,255}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" autocomplete="new-password" required>
|
||||
<input type="password" id="password2" name="password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,255}" title="10+ chars with at least: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" autocomplete="new-password" required>
|
||||
<span><input type="checkbox" name="remember" id="remember"> <label for="remember">Remember me</label></span>
|
||||
<input type="submit" value="Sign up">
|
||||
</form>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{%- from 'common/macros.html' import subheader, timestamp, pager -%}
|
||||
{%- from 'common/macros.html' import subheader, timestamp, pager, avatar -%}
|
||||
{%- extends 'base.html' -%}
|
||||
{%- block title -%}{{ target_user.get_readable_name() }}'s profile{%- endblock -%}
|
||||
{%- set stats = target_user.get_post_stats() -%}
|
||||
@@ -41,7 +41,7 @@
|
||||
<div class="userpage-usercard">
|
||||
<div class="usercard plank even contrast-bg minimal no-shadow">
|
||||
<div class="usercard-inner">
|
||||
<img src="{{target_user.get_avatar_url()}}" class="avatar">
|
||||
{{- avatar(target_user.get_avatar_url()) -}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="plank even minimal no-shadow user-stats">
|
||||
@@ -70,10 +70,12 @@
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
{%- endif -%}
|
||||
{#
|
||||
<fieldset class="plank secondary-bg minimal even no-shadow">
|
||||
<legend>About me</legend>
|
||||
<p>stub</p>
|
||||
</fieldset>
|
||||
#}
|
||||
{%- if target_user.signature_rendered -%}
|
||||
<fieldset class="plank secondary-bg minimal even no-shadow">
|
||||
<legend>Signature</legend>
|
||||
|
||||
@@ -85,7 +85,7 @@ body {
|
||||
margin: var(--big-padding) var(--wrapper-side-margin);
|
||||
}
|
||||
|
||||
button, .linkbutton, input[type="submit"] {
|
||||
button, .linkbutton, input[type="submit"], input[type="file"]::file-selector-button {
|
||||
--main-color: var(--button-color-primary);
|
||||
--font-color: var(--font-color-main);
|
||||
--border-color: hsl(from var(--main-color) h calc(s * 1.3) 25);
|
||||
@@ -98,7 +98,7 @@ button, .linkbutton, input[type="submit"] {
|
||||
--inset-color: #fff7;
|
||||
/* position: relative; */
|
||||
/* display: inline-block; */
|
||||
padding: var(--small-padding) var(--big-padding);
|
||||
padding: var(--small-padding) var(--medium-padding);
|
||||
margin: var(--base-padding) 0px;
|
||||
border-radius: var(--border-radius);
|
||||
border: solid var(--border-thickness) var(--border-color);
|
||||
@@ -189,7 +189,6 @@ button, .linkbutton, input[type="submit"] {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.babycode-editor {
|
||||
width: 100%;
|
||||
min-height: 150px;
|
||||
@@ -390,7 +389,7 @@ ul.horizontal, ol.horizontal {
|
||||
|
||||
.motd {
|
||||
display: flex;
|
||||
gap: var(--base-padding);
|
||||
gap: var(--big-padding);
|
||||
}
|
||||
|
||||
.contain-svg {
|
||||
@@ -466,19 +465,6 @@ footer {
|
||||
gap: var(--base-padding);
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
gap: var(--base-padding);
|
||||
--grid-item-base-width: 600px;
|
||||
--grid-item-max-width: calc((100% - var(--grid-item-base-width)) / 2);
|
||||
grid-template-columns: repeat(auto-fill, minmax(max(var(--grid-item-base-width), var(--grid-item-max-width)), 1fr));
|
||||
|
||||
&> * {
|
||||
height: fit-content;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.thread-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -520,10 +506,38 @@ footer {
|
||||
gap: var(--base-padding);
|
||||
}
|
||||
|
||||
.usercard.plank {
|
||||
padding: var(--base-padding);
|
||||
}
|
||||
|
||||
.usercard-username {
|
||||
word-wrap: anywhere;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
border-radius: var(--base-padding);
|
||||
border: var(--base-padding) outset gray;
|
||||
box-shadow: 0px 0px 12px 2px #0006;
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-shadow: inset 0px 0px var(--big-padding) 2px #d4ddc9;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border: 2px solid #545454;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
max-width: min(100%, 256px);
|
||||
max-height: min(100%, 256px);
|
||||
object-fit: contain;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.post-content {
|
||||
@@ -625,6 +639,45 @@ form.full-width {
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
details {
|
||||
summary {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
& :last-child {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&:not([open]) summary::before {
|
||||
content: '▶';
|
||||
padding-inline: var(--base-padding);
|
||||
}
|
||||
|
||||
&[open] summary::before {
|
||||
content: '▼';
|
||||
padding-inline: var(--base-padding);
|
||||
}
|
||||
}
|
||||
|
||||
details.separated {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.avatar-form {
|
||||
display: flex;
|
||||
gap: var(--huge-padding);
|
||||
}
|
||||
|
||||
.avatar-form-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* babycode tags */
|
||||
.inline-code {
|
||||
background-color: var(--code-bg-color);
|
||||
@@ -768,10 +821,6 @@ pre code {
|
||||
.il { color: #A5D6FF } /* Literal.Number.Integer.Long */
|
||||
}
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
@@ -810,16 +859,59 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
--grid-item-base-width: 400px;
|
||||
}
|
||||
|
||||
.mobile-fill-flex {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -861,4 +953,22 @@ a.mention {
|
||||
max-width: min(75vw, 400px);
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
.avatar-form {
|
||||
flex-direction: column;
|
||||
.avatar {
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
.avatar-form-controls {
|
||||
flex-direction: row;
|
||||
gap: var(--base-padding);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.avatar-form-size-label {
|
||||
order: 999;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
46
data/static/icons/dragger.svg
Normal file
46
data/static/icons/dragger.svg
Normal file
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1" />
|
||||
<g
|
||||
id="layer1">
|
||||
<rect
|
||||
style="fill:#000000;stroke:none;stroke-width:0.999996;stroke-linecap:round;stroke-linejoin:round"
|
||||
id="rect1"
|
||||
width="16"
|
||||
height="2"
|
||||
x="4"
|
||||
y="8" />
|
||||
<rect
|
||||
style="fill:#000000;stroke:none;stroke-width:0.999996;stroke-linecap:round;stroke-linejoin:round"
|
||||
id="rect2"
|
||||
width="16"
|
||||
height="2"
|
||||
x="4"
|
||||
y="11" />
|
||||
<rect
|
||||
style="fill:#000000;stroke:none;stroke-width:0.999996;stroke-linecap:round;stroke-linejoin:round"
|
||||
id="rect3"
|
||||
width="16"
|
||||
height="2"
|
||||
x="4"
|
||||
y="14" />
|
||||
<path
|
||||
style="fill:#000000;stroke:none;stroke-linecap:round;stroke-linejoin:round"
|
||||
d="m 6,6 6,-6 6,6 H 16 L 12,2 8,6 Z"
|
||||
id="path3" />
|
||||
<path
|
||||
style="fill:#000000;stroke:none;stroke-linecap:round;stroke-linejoin:round"
|
||||
d="m 6,18 6,6 6,-6 h -2 l -4,4 -4,-4 z"
|
||||
id="path4" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
1
data/static/icons/megaphone.svg
Normal file
1
data/static/icons/megaphone.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="1.25" x2="28" y1="16" y2="16"><stop offset="0" stop-color="#fcf1d0"/><stop offset=".40270719" stop-color="#fcf1d0"/><stop offset=".40270719" stop-color="#f53232"/><stop offset="1" stop-color="#af2f2f"/></linearGradient><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="19" x2="19.734835" y1="8" y2="10"><stop offset="0" stop-color="#fff" stop-opacity=".320554"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><linearGradient id="c" gradientUnits="userSpaceOnUse" x1=".75" x2="31.5" y1="16.5" y2="16.5"><stop offset="0" stop-color="#c8c6b8"/><stop offset=".36670572" stop-color="#c8c6b8"/><stop offset=".36670572" stop-color="#fec8c8"/><stop offset=".8211382" stop-color="#fec8c8"/><stop offset=".82119328" stop-color="#c8c6b8"/><stop offset=".88617885" stop-color="#c8c6b8"/><stop offset=".88617885" stop-color="#fec8c8"/></linearGradient><g stroke-linecap="round" stroke-linejoin="round"><path d="m12 9.9999997c4 0 16-5.9999997 16-5.9999997 4 0 4 24 0 24 0 0-12-6-16-6l-2-.209922v7.209922h-3v-7.5l-5-.5c-1 0-1-10 0-10z" fill="none" stroke="#000" stroke-opacity=".501633" stroke-width="1.5"/><path d="m12 9.9999997c4 0 16-5.9999997 16-5.9999997 4 0 4 24 0 24 0 0-12-6-16-6l-2-.209922v7.209922h-3v-7.5l-5-.5c-1 0-1-10 0-10z" fill="none" stroke="url(#c)"/><g stroke-width=".25"><path d="m7 21h3v8h-3z" fill="#9e9b90"/><path d="m28 4c4 0 4 24 0 24 0 0-2-2-2-12s2-12 2-12z" fill="#5e1d1d"/><path d="m26 12v8c3 0 3-8 0-8z" fill="#f53232"/><path d="m12 10c4 0 16-6 16-6-2 8-2 16 0 24 0 0-12-6-16-6l-10-1c-1 0-1-10 0-10z" fill="url(#a)"/><path d="m26.005569 4.9644521c-2.005569 3.0355479-2.005569 19.0355479.020489 22.0807919l1.973942.954756c-2-8-2-16 0-24z" fill="#fcf1d0"/><path d="m12 13v-3c4 0 16-6 16-6-1 3.4038059-1.374501 9-1.374501 9z" fill="url(#b)"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
97
data/static/js/ui.js
Normal file
97
data/static/js/ui.js
Normal file
@@ -0,0 +1,97 @@
|
||||
'use strict';
|
||||
|
||||
{
|
||||
function isBefore(el1, el2) {
|
||||
if (el2.parentNode === el1.parentNode) {
|
||||
for (let cur = el1.previousSibling; cur; cur = cur.previousSibling) {
|
||||
if (cur === el2) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
let draggedItem = null;
|
||||
|
||||
function sortableItemDragStart(e, item) {
|
||||
const box = item.getBoundingClientRect();
|
||||
const oX = e.clientX - box.left;
|
||||
const oY = e.clientY - box.top;
|
||||
draggedItem = item;
|
||||
item.classList.add('contrast-bg');
|
||||
e.dataTransfer.setDragImage(item, oX, oY);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
|
||||
function sortableItemDragEnd(e, item) {
|
||||
draggedItem = null;
|
||||
item.classList.remove('contrast-bg');
|
||||
}
|
||||
|
||||
function sortableItemDragOver(e, item) {
|
||||
const target = e.target.closest('.sortable-item');
|
||||
if (!target || target === draggedItem) {
|
||||
return;
|
||||
}
|
||||
const inSameList = draggedItem.dataset.sortableListKey === target.dataset.sortableListKey;
|
||||
if (!inSameList) {
|
||||
return;
|
||||
}
|
||||
const targetList = draggedItem.closest('.sortable-list');
|
||||
if (isBefore(draggedItem, target)) {
|
||||
targetList.insertBefore(draggedItem, target);
|
||||
} else {
|
||||
targetList.insertBefore(draggedItem, target.nextSibling);
|
||||
}
|
||||
}
|
||||
|
||||
const listItemsHandled = new Map();
|
||||
|
||||
const getListItemsHandled = (list) => {
|
||||
return listItemsHandled.get(list) || new Set();
|
||||
}
|
||||
|
||||
function registerSortableList(list) {
|
||||
list.querySelectorAll('li:not(.immovable)').forEach(item => {
|
||||
const listItems = getListItemsHandled(list);
|
||||
listItems.add(item);
|
||||
listItemsHandled.set(list, listItems);
|
||||
const dragger = item.querySelector('.dragger');
|
||||
dragger.addEventListener('dragstart', e => { sortableItemDragStart(e, item) });
|
||||
dragger.addEventListener('dragend', e => { sortableItemDragEnd(e, item) });
|
||||
item.addEventListener('dragover', e => { sortableItemDragOver(e, item) });
|
||||
});
|
||||
|
||||
const obs = new MutationObserver(records => {
|
||||
for (const mutation of records) {
|
||||
mutation.addedNodes.forEach(node => {
|
||||
if (!(node instanceof HTMLElement)) return;
|
||||
if (!node.classList.contains('sortable-item')) return;
|
||||
const listItems = getListItemsHandled(list);
|
||||
if (listItems.has(node)) return;
|
||||
|
||||
const dragger = node.querySelector('.dragger');
|
||||
dragger.addEventListener('dragstart', e => { sortableItemDragStart(e, item) });
|
||||
dragger.addEventListener('dragend', e => { sortableItemDragEnd(e, item) });
|
||||
node.addEventListener('dragover', e => { sortableItemDragOver(e, node) });
|
||||
listItems.add(node);
|
||||
listItemsHandled.set(list, listItems);
|
||||
});
|
||||
}
|
||||
});
|
||||
obs.observe(list, { childList: true });
|
||||
}
|
||||
|
||||
document.querySelectorAll('.sortable-list').forEach(registerSortableList);
|
||||
|
||||
const listsObs = new MutationObserver(records => {
|
||||
for (const mutation of records) {
|
||||
mutation.addedNodes.forEach(node => {
|
||||
if (!(node instanceof HTMLElement)) return;
|
||||
if (!node.classList.contains('sortable-list')) return;
|
||||
|
||||
registerSortableList(node);
|
||||
});
|
||||
}
|
||||
});
|
||||
listsObs.observe(document.body, { childList: true, subtree: true })
|
||||
}
|
||||
Reference in New Issue
Block a user