Compare commits

...

11 Commits

29 changed files with 548 additions and 96 deletions

View File

@@ -139,6 +139,11 @@ def bind_default_badges(path):
'uploaded_at': int(os.path.getmtime(real_path)), 'uploaded_at': int(os.path.getmtime(real_path)),
}) })
def clear_stale_invites():
from .db import db
from .util import time_now
db.execute('DELETE FROM "invite_keys" WHERE expires_at < ?', time_now())
def clear_stale_sessions(): def clear_stale_sessions():
from .db import db from .db import db
with db.transaction(): with db.transaction():
@@ -162,6 +167,7 @@ cache = Cache()
def create_app(): def create_app():
app = Flask(__name__) app = Flask(__name__)
app.config['SITE_NAME'] = 'Pyrom' app.config['SITE_NAME'] = 'Pyrom'
app.config['SITE_TAGLINE'] = 'anti-social media'
app.config['DISABLE_SIGNUP'] = False app.config['DISABLE_SIGNUP'] = False
app.config['MODS_CAN_INVITE'] = True app.config['MODS_CAN_INVITE'] = True
app.config['USERS_CAN_INVITE'] = False app.config['USERS_CAN_INVITE'] = False
@@ -233,6 +239,7 @@ def create_app():
clear_stale_sessions() clear_stale_sessions()
clear_api_limits() clear_api_limits()
clear_stale_invites()
reparse_babycode() reparse_babycode()

View File

@@ -43,7 +43,8 @@ MIGRATIONS = [
add_signature_format, add_signature_format,
create_default_bookmark_collections, create_default_bookmark_collections,
add_display_name, add_display_name,
'ALTER TABLE "post_history" ADD COLUMN "content_rss" STRING DEFAULT NULL' 'ALTER TABLE "post_history" ADD COLUMN "content_rss" STRING DEFAULT NULL',
'ALTER TABLE "invite_keys" ADD COLUMN "expires_at" INTEGER NOT NULL DEFAULT 0',
] ]
def run_migrations(): def run_migrations():

View File

@@ -511,6 +511,12 @@ class BookmarkCollections(Model):
""" """
res = db.fetch_one(q, user_id) res = db.fetch_one(q, user_id)
@classmethod
def get_for_user(cls, user_id):
q = """SELECT * FROM bookmark_collections WHERE user_id = ? ORDER BY sort_order ASC"""
res = db.query(q, user_id)
return [cls.from_data(row) for row in res]
def has_posts(self): def has_posts(self):
q = 'SELECT EXISTS(SELECT 1 FROM bookmarked_posts WHERE collection_id = ?) as e' q = 'SELECT EXISTS(SELECT 1 FROM bookmarked_posts WHERE collection_id = ?) as e'
res = db.fetch_one(q, self.id)['e'] res = db.fetch_one(q, self.id)['e']

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 from ..models import BookmarkCollections, BookmarkedPosts, BookmarkedThreads, Threads, Posts
from functools import wraps from functools import wraps
bp = Blueprint('hyperapi', __name__, url_prefix='/hyperapi/') bp = Blueprint('hyperapi', __name__, url_prefix='/hyperapi/')
@@ -24,7 +24,15 @@ def get_bookmark_dropdown():
except ValueError: except ValueError:
return 'error', 400 return 'error', 400
is_thread = concept_kind == 'thread' is_thread = concept_kind == 'thread'
collections = BookmarkCollections.findall({'user_id': user.id}) if is_thread:
target_thread = Threads.find({'id': concept_id})
if not target_thread:
return 'This thread no longer exists. Please refresh the page.', 404
else:
target_post = Posts.find({'id': concept_id})
if not target_post:
return 'This post no longer exists. Please refresh the page.', 404
collections = BookmarkCollections.get_for_user(user.id)
in_collection = None in_collection = None
note = '' note = ''
for collection in collections: for collection in collections:
@@ -54,6 +62,9 @@ def bookmark_thread():
bt.delete() bt.delete()
return '', 204 return '', 204
if not Threads.find({'id': thread_id}):
return 'error', 404
target_collection = BookmarkCollections.find({'id': target_collection_id}) target_collection = BookmarkCollections.find({'id': target_collection_id})
note = request.form.get('note', '') note = request.form.get('note', '')
if not target_collection: if not target_collection:
@@ -91,6 +102,9 @@ def bookmark_post():
bp.delete() bp.delete()
return '', 204 return '', 204
if not Posts.find({'id': post_id}):
return 'error', 404
target_collection = BookmarkCollections.find({'id': target_collection_id}) target_collection = BookmarkCollections.find({'id': target_collection_id})
note = request.form.get('note', '') note = request.form.get('note', '')
if not target_collection: if not target_collection:

View File

@@ -4,7 +4,7 @@ from flask import (
abort, flash, current_app abort, flash, current_app
) )
from functools import wraps from functools import wraps
from secrets import compare_digest as compare_timesafe from secrets import compare_digest as compare_timesafe, token_urlsafe
from wand.image import Image from wand.image import Image
from wand.color import Color from wand.color import Color
from wand.exceptions import WandException from wand.exceptions import WandException
@@ -14,9 +14,9 @@ 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 from ..models import Users, Posts, Reactions, Threads, Avatars, PostHistory, Mentions, BookmarkCollections, InviteKeys
from ..constants import PermissionLevel, InfoboxKind from ..constants import PermissionLevel, InfoboxKind
from ..util import get_form_checkbox from ..util import get_form_checkbox, time_now
from ..lib.babycode import babycode_to_html from ..lib.babycode import babycode_to_html
from ..db import db from ..db import db
import math import math
@@ -169,15 +169,34 @@ def log_out():
@bp.get('/sign-up/') @bp.get('/sign-up/')
@redirect_if_logged_in() @redirect_if_logged_in()
def sign_up(): def sign_up():
return render_template('users/sign_up.html') key = request.args.get('key', '')
if not key and current_app.config['DISABLE_SIGNUP']:
return redirect(url_for('topics.all_topics'))
elif key and current_app.config['DISABLE_SIGNUP']:
invite = InviteKeys.find({'key': key})
if not invite:
return redirect(url_for('topics.all_topics'))
inviter = Users.find({'id': invite.created_by})
return render_template('users/sign_up.html', invite=invite, inviter=inviter)
@bp.post('/sign-up/') @bp.post('/sign-up/')
@redirect_if_logged_in() @redirect_if_logged_in()
def sign_up_post(): def sign_up_post():
generic_error_page = redirect(url_for('.sign_up', error='The username or password you entered is invalid.')) args_sans_error = dict(request.args)
invalid_username_error_page = redirect(url_for('.sign_up', error='This username cannot be used. Please pick another.')) args_sans_error.pop('error', '')
passwords_error_page = redirect(url_for('.sign_up', error='The passwords do not match.')) generic_error_page = redirect(url_for('.sign_up', error='The username or password you entered is invalid.', **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))
username = request.form.get('username', default='') username = request.form.get('username', default='')
if current_app.config['DISABLE_SIGNUP']:
key = request.form.get('key', '')
if not key:
return generic_error_page
invite = InviteKeys.find({'key': key})
if not invite:
return generic_error_page
if invite.expires_at < time_now():
return generic_error_page
if not username: if not username:
return generic_error_page return generic_error_page
if request.form.get('password') is None: if request.form.get('password') is None:
@@ -197,12 +216,18 @@ def sign_up_post():
password_hash = digest(request.form.get('password')) password_hash = digest(request.form.get('password'))
user = Users.create({ user_data = {
'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': int(time.time()),
}) }
if invite:
user_data['invited_by'] = invite.created_by
user_data['permission'] = PermissionLevel.USER.value
invite.delete()
user = Users.create(user_data)
BookmarkCollections.create_default(user.id) BookmarkCollections.create_default(user.id)
@@ -217,6 +242,7 @@ def sign_up_post():
if session['remember']: if session['remember']:
session.permanent = True session.permanent = True
flash(f'Welcome to {current_app.config['SITE_NAME']}!', InfoboxKind.INFO)
return redirect(url_for('topics.all_topics')) return redirect(url_for('topics.all_topics'))
@bp.get('/<username>/') @bp.get('/<username>/')
@@ -286,9 +312,11 @@ def comments(username):
def settings(username): def settings(username):
user = get_active_user() user = get_active_user()
sort_by = session.get('sort_by', 'activity') sort_by = session.get('sort_by', 'activity')
invites = InviteKeys.findall({'created_by': user.id})
return render_template( return render_template(
'users/settings.html', user=user, 'users/settings.html', user=user,
sort_by=sort_by sort_by=sort_by,
invites=invites,
) )
@bp.post('/<username>/settings/set-avatar') @bp.post('/<username>/settings/set-avatar')
@@ -445,8 +473,72 @@ def inbox(username):
@login_required @login_required
@redirect_to_own @redirect_to_own
def bookmarks(username): def bookmarks(username):
username = username.lower() user = get_active_user()
return 'stub' collections = BookmarkCollections.get_for_user(user.id)
return render_template('users/bookmarks.html', collections=collections)
@bp.get('/<username>/bookmarks/collections/')
@login_required
@user_required
@redirect_to_own
def bookmark_collections(username):
user = get_active_user()
collections = BookmarkCollections.get_for_user(user.id)
return render_template('users/manage_collections.html', collections=collections)
@bp.post('/<username>/bookmarks/collections/')
@login_required
@user_required
@redirect_to_own
def edit_bookmark_collections(username):
user = get_active_user()
ids = request.form.getlist('id[]')
names = request.form.getlist('name[]')
if len(ids) == 0 or len(ids) != len(names):
abort(400)
deleted_ids = filter(lambda x: x.strip(), request.form.get('deleted_ids', '').split(';'))
try:
deleted_ids = map(lambda x: int(x), deleted_ids)
except ValueError:
abort(400)
with db.transaction():
for new_order, id in enumerate(ids):
new_name = names[new_order]
if id == 'new':
bc = BookmarkCollections.create({
'user_id': user.id,
'is_default': False,
'name': new_name,
'sort_order': new_order,
})
continue
id = int(id)
bc = BookmarkCollections.find({'id': id})
if not bc:
continue
if bc.user_id != user.id:
continue
if bc.is_default:
new_order = 0
elif new_order == 0:
new_order = 1
bc.update({
'name': new_name,
'sort_order': new_order,
})
for deleted_id in deleted_ids:
bc = BookmarkCollections.find({'id': deleted_id})
if not bc:
continue
if bc.user_id != user.id:
continue
if bc.is_default:
continue
bc.delete()
return redirect(url_for('.bookmark_collections', username=username))
@bp.get('/<username>/delete-confirm/') @bp.get('/<username>/delete-confirm/')
@login_required @login_required
@@ -475,3 +567,40 @@ def delete_confirm_post(username):
user.delete() user.delete()
return redirect(url_for('topics.all_topics')) return redirect(url_for('topics.all_topics'))
@bp.post('/<username>/invite-keys/create/')
@login_required
@redirect_to_own
@csrf_verified
def create_invite_key(username):
user = get_active_user()
if not user.can_invite():
abort(404)
key = token_urlsafe(16)
expires_at = time_now() + 48 * 60 * 60
invite = InviteKeys.create({
'created_by': user.id,
'expires_at': expires_at,
'key': key,
})
return redirect(url_for('.settings', username=username, _anchor='invite'))
@bp.post('/<username>/invite-keys/revoke/')
@login_required
@redirect_to_own
@csrf_verified
def revoke_invite_key(username):
user = get_active_user()
if not user.can_invite():
abort(404)
key = request.form.get('key', '')
invite = InviteKeys.find({'created_by': user.id, 'key': key})
if not invite:
abort(404)
invite.delete()
return redirect(url_for('.settings', username=username, _anchor='invite'))

View File

@@ -187,6 +187,8 @@ SCHEMA = [
'CREATE INDEX IF NOT EXISTS idx_badge_upload_user ON badge_uploads(user_id)', 'CREATE INDEX IF NOT EXISTS idx_badge_upload_user ON badge_uploads(user_id)',
'CREATE INDEX IF NOT EXISTS idx_badge_user ON badges(user_id)', 'CREATE INDEX IF NOT EXISTS idx_badge_user ON badges(user_id)',
'CREATE INDEX IF NOT EXISTS idx_invite_key_user ON invite_keys(created_by, key)'
] ]
def create(): def create():

View File

@@ -15,7 +15,6 @@
<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>
<bitty-8 data-connect="/static/js/bits/ui.js"></bitty-8> <bitty-8 data-connect="/static/js/bits/ui.js"></bitty-8>
<bitty-8 data-connect="/static/js/bits/bookmark-menu.js"></bitty-8>
{%- include 'common/topnav.html' -%} {%- include 'common/topnav.html' -%}
{%- with messages = get_flashed_messages(with_categories=true) -%} {%- with messages = get_flashed_messages(with_categories=true) -%}
{%- if messages -%} {%- if messages -%}

View File

@@ -109,7 +109,7 @@
<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">stub: char count</span> <span class="flex-last js-only" data-r="enhance babycodeEditorCharCount">0/</span>
</span> </span>
<input type="hidden" name="babycode_banned_tags" id="{{id}}-banned-tags" value="{{banned_tags | unique | list | tojson | forceescape}}"> <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" data-r="insertBabycode babycodePreviewInit babycodeEditorCharCountInit babycodeEditorQuote" data-listen="input" data-s="babycodeEditorCharCount" data-banned-tags="{{banned_tags | unique | list | tojson | forceescape}}">{{ prefill }}</textarea> <textarea name="babycode_content" id="{{id}}" class="babycode-editor" placeholder="{{placeholder}}" {{'required' if required else ''}} autocomplete="off" maxlength="5000" data-r="insertBabycode babycodePreviewInit babycodeEditorCharCountInit babycodeEditorQuote" data-listen="input" data-s="babycodeEditorCharCount" data-banned-tags="{{banned_tags | unique | list | tojson | forceescape}}">{{ prefill }}</textarea>
@@ -136,10 +136,16 @@
</div> </div>
{%- endmacro %} {%- endmacro %}
{% macro bookmark_button(kind, id, text='Bookmark') -%}
<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 %}
{% 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,
show_reactions=true, show_thread=false, allow_reacting=true show_reactions=true, show_thread=false, allow_reacting=true,
tb_edit=true, tb_quote=true, tb_delete=true, tb_bookmark=true,
bookmark_btn='Bookmark', tb_pretext=''
) -%} ) -%}
{%- if is_logged_in() -%} {%- if is_logged_in() -%}
{%- set can_delete = post.user_id == get_active_user().id or is_mod() -%} {%- set can_delete = post.user_id == get_active_user().id or is_mod() -%}
@@ -169,6 +175,9 @@
<div class="post-content"> <div class="post-content">
<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 -%}
<span>{{tb_pretext}} &bullet; </span>
{%- endif -%}
<a href="{{get_post_url(post.id, _anchor=true)}}"> <a href="{{get_post_url(post.id, _anchor=true)}}">
{%- if post.edited_at <= post.created_at -%} {%- if post.edited_at <= post.created_at -%}
<i>Posted on {{timestamp(post.created_at)}}</i> <i>Posted on {{timestamp(post.created_at)}}</i>
@@ -181,17 +190,19 @@
{%- endif -%} {%- endif -%}
</span> </span>
{%- if show_toolbar -%} {%- if show_toolbar -%}
<span class="thread-actions"> <span class="subheader-actions">
{%- if owns -%} {%- if owns and tb_edit -%}
<a class="linkbutton" href="{{url_for('posts.edit', post_id=post.id, _anchor='babycode-content')}}">Edit</a> <a class="linkbutton" href="{{url_for('posts.edit', post_id=post.id, _anchor='babycode-content')}}">Edit</a>
{%- endif -%} {%- endif -%}
{%- if can_reply -%} {%- if can_reply and tb_quote -%}
<button autocomplete='off' data-r="enhance" data-s="babycodeEditorQuote" disabled title="This feature requires JavaScript to be enabled." data-quote="{{post.original_markup}}" data-poster-name="{{ post.display_name if post.display_name else post.username }}">Quote</button> <button autocomplete='off' data-r="enhance" data-s="babycodeEditorQuote" disabled title="This feature requires JavaScript to be enabled." data-quote="{{post.original_markup}}" data-poster-name="{{ post.display_name if post.display_name else post.username }}">Quote</button>
{%- endif -%} {%- endif -%}
{%- if can_delete -%} {%- if can_delete and tb_delete -%}
<a class="linkbutton critical" href="{{url_for('posts.delete', post_id=post.id)}}">Delete</a> <a class="linkbutton critical" href="{{url_for('posts.delete', post_id=post.id)}}">Delete</a>
{%- endif -%} {%- endif -%}
<button autocomplete='off' data-r="enhance" data-s="showBookmarkMenu" disabled title="This feature requires JavaScript to be enabled." data-concept-kind="post" data-concept-id="{{post.id}}">{{icn_bookmark(24)}}Bookmark&hellip;</button> {%- if tb_bookmark -%}
{{ bookmark_button('post', post.id, bookmark_btn) }}
{%- endif -%}
</span> </span>
{%- endif -%} {%- endif -%}
</div> </div>
@@ -229,6 +240,18 @@
</div> </div>
{%- endmacro %} {%- endmacro %}
{% macro bookmark_menu() -%}
{%- if is_logged_in() -%}
<div id="bookmark-popover" data-r="showBookmarkMenu" class="plank even" popover>
<div class="bookmark-menu-header">
<span>Bookmark collections</span>
<a href="{{url_for('users.bookmarks', username=get_active_user().username)}}">View bookmarks</a>
</div>
<div class="bookmark-menu-inner" data-r="fillBookmarkMenu">Loading&hellip;</div>
</div>
{%- endif -%}
{%- endmacro %}
{% macro infobox(message, kind=InfoboxKind.INFO) -%} {% macro infobox(message, kind=InfoboxKind.INFO) -%}
<div class="infobox plank top contain-svg horizontal {{InfoboxHTMLClass[kind]}}"> <div class="infobox plank top contain-svg horizontal {{InfoboxHTMLClass[kind]}}">
{%- if kind == InfoboxKind.INFO -%} {%- if kind == InfoboxKind.INFO -%}
@@ -270,7 +293,7 @@
{%- endmacro %} {%- endmacro %}
{% macro sortable_list_item(key, immovable=false, attr=none) -%} {% 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 %}> <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">{{ caller() }}</div>
</li> </li>

View File

@@ -1,6 +1,6 @@
<nav id="header" class="plank top"> <nav id="header" class="plank top">
<a class="site-title" href="/">Porom</a> <a class="site-title" href="/">{{config.SITE_NAME}}</a>
<span>anti-social media</span> <span>{{config.SITE_TAGLINE or '&nbsp;' | safe}}</span>
{%- if is_logged_in() -%} {%- if is_logged_in() -%}
{%- with user = get_active_user() -%} {%- with user = get_active_user() -%}
{%- set uc = user.get_unread_count() -%} {%- set uc = user.get_unread_count() -%}
@@ -21,7 +21,9 @@
<input type="password" placeholder="Password" name="password" autocomplete="current-password" required> <input type="password" placeholder="Password" name="password" autocomplete="current-password" required>
<span><input type="checkbox" name="remember" id="remember"> <label for="remember">Remember me</label></span> <span><input type="checkbox" name="remember" id="remember"> <label for="remember">Remember me</label></span>
<input type="submit" value="Log in"> <input type="submit" value="Log in">
{%- if not config.DISABLE_SIGNUP -%}
<a href="{{url_for('users.sign_up')}}" class="linkbutton alt">Sign up</a> <a href="{{url_for('users.sign_up')}}" class="linkbutton alt">Sign up</a>
{%- endif -%}
</form> </form>
{%- endif -%} {%- endif -%}
</nav> </nav>

View File

@@ -1,6 +1,6 @@
{%- from 'common/macros.html' import subheader, babycode_editor_component, sortable_list, sortable_list_item -%} {%- from 'common/macros.html' import subheader, babycode_editor_component, sortable_list, sortable_list_item -%}
{%- extends 'base.html' -%} {%- extends 'base.html' -%}
{%- block title -%}settings{%- endblock -%} {%- block title -%}moderation panel{%- endblock -%}
{%- block content -%} {%- block content -%}
{{- subheader('Moderation panel') -}} {{- subheader('Moderation panel') -}}
<fieldset class="plank"> <fieldset class="plank">
@@ -17,7 +17,6 @@
<input type="submit" value="Clear MOTD" class="warn"> <input type="submit" value="Clear MOTD" class="warn">
</form> </form>
</fieldset> </fieldset>
{{babycode_editor_component(placeholder='test', id='test-content')}}
<fieldset class="plank" id="sort-topics"> <fieldset class="plank" id="sort-topics">
<legend>Sort topics</legend> <legend>Sort topics</legend>
<p>Drag topics around to reorder them. Press "Save order" when done.</p> <p>Drag topics around to reorder them. Press "Save order" when done.</p>

View File

@@ -5,7 +5,7 @@
{%- block content -%} {%- block content -%}
{%- call() subheader("Delete post", "Are you sure you want to delete this post? This action can not be undone.") -%} {%- call() subheader("Delete post", "Are you sure you want to delete this post? This action can not be undone.") -%}
<form method="POST"> <form method="POST">
<fieldset class="plank minimal even no-shadow thread-actions"> <fieldset class="plank minimal even no-shadow subheader-actions">
<legend>Please confirm</legend> <legend>Please confirm</legend>
<a href="{{get_post_url(post.id, _anchor=true)}}" class="linkbutton">Cancel</a> <a href="{{get_post_url(post.id, _anchor=true)}}" class="linkbutton">Cancel</a>
<input type="submit" value="Delete" class="critical"> <input type="submit" value="Delete" class="critical">

View File

@@ -6,7 +6,7 @@
<form class="plank primary-bg full-width" method="POST"> <form class="plank primary-bg full-width" method="POST">
<label for="topic">Topic</label> <label for="topic">Topic</label>
<select name="topic_id" id="topic" autocomplete="off"> <select name="topic_id" id="topic" autocomplete="off">
{%- for topic in topics -%} {%- for topic in topics | sort(attribute='sort_order') -%}
<option value="{{topic.id}}" {{'selected' if selected_topic == topic.id else ''}} {{'disabled' if not get_active_user().can_post_to_thread_or_topic(topic) else ''}}>{{topic.name}}{{ ' (locked)' if topic.locked() else ''}}</option> <option value="{{topic.id}}" {{'selected' if selected_topic == topic.id else ''}} {{'disabled' if not get_active_user().can_post_to_thread_or_topic(topic) else ''}}>{{topic.name}}{{ ' (locked)' if topic.locked() else ''}}</option>
{%- endfor -%} {%- endfor -%}
</select> </select>

View File

@@ -1,9 +1,10 @@
{%- from 'common/macros.html' import subheader, timestamp, pager, babycode_editor_component -%} {%- from 'common/macros.html' import subheader, timestamp, pager, babycode_editor_component -%}
{%- from 'common/icons.html' import icn_bookmark -%} {%- from 'common/icons.html' import icn_bookmark -%}
{%- from 'common/macros.html' import full_post 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>
{%- 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>
@@ -18,7 +19,7 @@
</ul> </ul>
{%- endset -%} {%- endset -%}
{%- call() subheader(thread.title, td) -%} {%- call() subheader(thread.title, td) -%}
<fieldset class="plank even no-shadow minimal thread-actions"> <fieldset class="plank even no-shadow minimal subheader-actions">
<legend>Actions</legend> <legend>Actions</legend>
{%- if is_logged_in() -%} {%- if is_logged_in() -%}
{%- if thread.user_id == get_active_user().id -%} {%- if thread.user_id == get_active_user().id -%}
@@ -34,7 +35,7 @@
<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>
{%- if is_mod() -%} {%- if is_mod() -%}
<fieldset class="plank even no-shadow minimal thread-actions"> <fieldset class="plank even no-shadow minimal subheader-actions">
<legend>Moderation actions</legend> <legend>Moderation actions</legend>
{%- if thread.user_id != get_active_user().id -%} {%- if thread.user_id != get_active_user().id -%}
<a class="linkbutton warn" href="{{url_for('threads.edit', thread_id=thread.id)}}">Rename</a> <a class="linkbutton warn" href="{{url_for('threads.edit', thread_id=thread.id)}}">Rename</a>
@@ -55,7 +56,7 @@
<input type="submit" value="Move" class="warn"> <input type="submit" value="Move" class="warn">
</form> </form>
</fieldset> </fieldset>
<fieldset class="plank even no-shadow minimal thread-actions"> <fieldset class="plank even no-shadow minimal subheader-actions">
<legend>Page</legend> <legend>Page</legend>
{{- pager(page, page_count) -}} {{- pager(page, page_count) -}}
</fieldset> </fieldset>
@@ -69,7 +70,7 @@
{%- endfor -%} {%- endfor -%}
</main> </main>
<div class="plank secondary-bg"> <div class="plank secondary-bg">
<fieldset class="plank even no-shadow minimal thread-actions"> <fieldset class="plank even no-shadow minimal subheader-actions">
<legend>Page</legend> <legend>Page</legend>
{{- pager(page, page_count) -}} {{- pager(page, page_count) -}}
</fieldset> </fieldset>
@@ -82,15 +83,7 @@
<button>Stop updates</button> <button>Stop updates</button>
</span> </span>
</div> </div>
{%- if is_logged_in() -%} {{ bookmark_menu() }}
<div id="bookmark-popover" data-r="showBookmarkMenu" class="plank even" popover>
<div class="bookmark-menu-header">
<span>Bookmark collections</span>
<a href="{{url_for('users.bookmarks', username=get_active_user().username)}}">View bookmarks</a>
</div>
<div class="bookmark-menu-inner" data-r="fillBookmarkMenu">Loading&hellip;</div>
</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

@@ -12,7 +12,7 @@
</ul> </ul>
{%- endset -%} {%- endset -%}
{%- call() subheader(('Threads in "%s"' % topic.name), td) -%} {%- call() subheader(('Threads in "%s"' % topic.name), td) -%}
<fieldset class="plank even no-shadow minimal thread-actions"> <fieldset class="plank even no-shadow minimal subheader-actions">
<legend>Actions</legend> <legend>Actions</legend>
{%- if is_logged_in() and get_active_user().can_post_to_thread_or_topic(topic) -%} {%- if is_logged_in() and get_active_user().can_post_to_thread_or_topic(topic) -%}
<a href="{{url_for('threads.new', topic_id=topic.id)}}" class="linkbutton">New thread</a> <a href="{{url_for('threads.new', topic_id=topic.id)}}" class="linkbutton">New thread</a>
@@ -27,7 +27,7 @@
</form> </form>
</fieldset> </fieldset>
{%- if is_mod() -%} {%- if is_mod() -%}
<fieldset class="plank even no-shadow minimal thread-actions"> <fieldset class="plank even no-shadow minimal subheader-actions">
<legend>Moderation actions</legend> <legend>Moderation actions</legend>
<a href="{{url_for('mod.edit_topic', topic_id=topic.id)}}" class="linkbutton">Edit</a> <a href="{{url_for('mod.edit_topic', topic_id=topic.id)}}" class="linkbutton">Edit</a>
<form action="{{url_for('mod.lock_topic', topic_id=topic.id)}}" method="POST"> <form action="{{url_for('mod.lock_topic', topic_id=topic.id)}}" method="POST">
@@ -37,7 +37,7 @@
</fieldset> </fieldset>
{%- endif -%} {%- endif -%}
{%- if threads | length > 0 -%} {%- if threads | length > 0 -%}
<fieldset class="plank even no-shadow minimal thread-actions"> <fieldset class="plank even no-shadow minimal subheader-actions">
<legend>Page</legend> <legend>Page</legend>
{{- pager(page, page_count, args=request.args) -}} {{- pager(page, page_count, args=request.args) -}}
</fieldset> </fieldset>
@@ -77,7 +77,7 @@
{%- endfor -%} {%- endfor -%}
{%- if threads | length > 0 -%} {%- if threads | length > 0 -%}
<div class="plank secondary-bg"> <div class="plank secondary-bg">
<fieldset class="plank even no-shadow minimal thread-actions"> <fieldset class="plank even no-shadow minimal subheader-actions">
<legend>Page</legend> <legend>Page</legend>
{{- pager(page, page_count, args=request.args) -}} {{- pager(page, page_count, args=request.args) -}}
</fieldset> </fieldset>

View File

@@ -4,7 +4,7 @@
{%- block content -%} {%- block content -%}
{%- call() subheader('All topics') -%} {%- call() subheader('All topics') -%}
{%- if is_mod() -%} {%- if is_mod() -%}
<fieldset class="plank even no-shadow minimal thread-actions"> <fieldset class="plank even no-shadow minimal subheader-actions">
<legend>Moderation actions</legend> <legend>Moderation actions</legend>
<a href="{{url_for('mod.new_topic')}}" class="linkbutton">New topic</a> <a href="{{url_for('mod.new_topic')}}" class="linkbutton">New topic</a>
<a href="{{url_for('mod.index', _anchor='sort-topics')}}" class="linkbutton">Sort topics</a> <a href="{{url_for('mod.index', _anchor='sort-topics')}}" class="linkbutton">Sort topics</a>

View File

@@ -0,0 +1,56 @@
{%- from 'common/macros.html' import full_post, bookmark_menu with context -%}
{%- from 'common/macros.html' import subheader, bookmark_button -%}
{%- extends 'base.html' -%}
{%- block title -%}bookmarks"{%- endblock -%}
{%- block content -%}
<bitty-8 data-connect="/static/js/bits/bookmark-menu.js"></bitty-8>
<bitty-8 data-connect="/static/js/bits/bookmarks.js"></bitty-8>
{%- call() subheader('Your bookmarks') -%}
<fieldset class="plank even no-shadow minimal subheader-actions js-only" data-r="enhance">
<legend>Actions</legend>
<a href="{{url_for('users.bookmark_collections', username=get_active_user().username)}}" class="linkbutton">Manage collections</a>
</fieldset>
{%- endcall -%}
<div class="plank">
{%- for collection in collections -%}
{%- set thread_count = collection.get_threads_count() -%}
{%- set post_count = collection.get_posts_count() -%}
<details class="separated" data-id="{{collection.id}}" data-r="restoreCollectionDetails setCollectionDetails" data-s="setCollectionDetails">
<summary class="plank secondary-bg no-shadow even">{{collection.name}} ({{thread_count}} {{'thread' | pluralize(num=thread_count)}}, {{post_count}} {{'post' | pluralize(num=post_count)}})</summary>
{%- if thread_count > 0 -%}
<details class="inner" data-id="{{collection.id}}" data-r="restoreThreadDetails setThreadDetails" data-s="setThreadDetails">
<summary class="plank no-shadow even">Threads</summary>
<table>
<thead>
<tr>
<th class="plank even no-shadow contrast-bg" style="--w:65%">Title</th>
<th class="plank even no-shadow contrast-bg" style="--w:25%">Memo</th>
<th class="plank even no-shadow contrast-bg" style="--w:10%">Manage</th>
</tr>
</thead>
<tbody>
{%- for bt in collection.get_threads() -%}
{%- set thread = bt.get_thread() -%}
<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="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>
</tr>
{%- endfor -%}
</tbody>
</table>
</details>
{%- endif -%}
{%- if post_count > 0 -%}
<details class="inner" data-id="{{collection.id}}" data-r="restorePostDetails setPostDetails" data-s="setPostDetails">
<summary class="plank no-shadow even">Posts</summary>
{%- for bp in collection.get_posts() -%}
<div class="post plank no-shadow even">{{ full_post(bp.get_post().get_full_post_view(), render_sig=false, show_thread=true, show_reactions=false, tb_edit=false, tb_quote=false, tb_delete=false, bookmark_btn='Manage', tb_pretext=('memo: ' + bp.note) if bp.note else '') }}</div>
{%- endfor -%}
</details>
{%- endif -%}
</details>
{%- endfor -%}
</div>
{{ bookmark_menu() }}
{%- endblock -%}

View File

@@ -0,0 +1,44 @@
{%- from 'common/macros.html' import subheader, sortable_list, sortable_list_item -%}
{%- macro collection_item(name='', can_delete=true, id=-1, thread_count=0, post_count=0) -%}
<input name="name[]" type="text" autocomplete="off" value="{{name}}" required maxlength=60 placeholder="Collection name">
<input type="hidden" name="id[]" value="{{ 'new' if id == -1 else id}}" autocomplete="off">
<span>{{thread_count}} {{'thread' | pluralize(num=thread_count)}}, {{post_count}} {{'post' | pluralize(num=post_count)}}</span>
{%- if not can_delete -%}
<i>Default collection</i>
{%- else -%}
<button type="button" class="critical" data-s="deleteCollection">Delete</button>
{%- endif -%}
{%- endmacro -%}
{%- extends 'base.html' -%}
{%- block title -%}managing bookmark collections{%- endblock -%}
{%- block content -%}
<bitty-8 data-connect="/static/js/bits/collections-editor.js"></bitty-8>
{%- set sh -%}
<span class="js-only" data-r="enhance">
Drag collections to reoder them. You cannot move or remove the default collection, but you can rename it.
</span>
<div data-r="enhanceHide">This page requires JS enabled to work correctly.</div>
{%- endset -%}
{%- call() subheader('Manage bookmark collections', sh) -%}
<fieldset class="plank even no-shadow minimal subheader-actions js-only" data-r="enhance">
<legend>Actions</legend>
<button data-s="addCollection">Add new collection</button>
<input type="submit" class="alt" value="Save collections" form="collections-form">
</fieldset>
{%- endcall -%}
<form class="plank" method="POST" id="collections-form">
<input type="hidden" autocomplete="off" name="deleted_ids" value="" data-r="countDeletedCollection">
{%- call() sortable_list(attr={'data-r': 'addCollection'}) -%}
{%- for collection in collections -%}
{%- call() sortable_list_item(key='bc', immovable=collection.is_default == 1, attr={'data-r': 'deleteCollection', 'data-id': collection.id}) -%}
{{ collection_item(name=collection.name, can_delete=collection.is_default != 1, thread_count=collection.get_threads_count(), post_count=collection.get_posts_count(), id=collection.id) }}
{%- endcall -%}
{%- endfor -%}
{%- endcall -%}
</form>
<script type="text/html" data-template="collectionItem">
{%- call() sortable_list_item(key='bc', attr={'data-r': 'deleteCollection', 'data-id': 'new'}) -%}
{{- collection_item() -}}
{%- endcall -%}
</script>
{%- endblock -%}

View File

@@ -8,7 +8,7 @@
{%- endset -%} {%- endset -%}
{%- call() subheader("%s's posts" % target_user.get_readable_name(), td) -%} {%- call() subheader("%s's posts" % target_user.get_readable_name(), td) -%}
{%- if posts -%} {%- if posts -%}
<fieldset class="plank even no-shadow minimal thread-actions"> <fieldset class="plank even no-shadow minimal subheader-actions">
<legend>Page</legend> <legend>Page</legend>
{{- pager(page, page_count) -}} {{- pager(page, page_count) -}}
</fieldset> </fieldset>
@@ -19,7 +19,7 @@
<div class="post plank">{{full_post(post, show_toolbar=false, show_thread=true, allow_reacting=false)}}</div> <div class="post plank">{{full_post(post, show_toolbar=false, show_thread=true, allow_reacting=false)}}</div>
{%- endfor -%} {%- endfor -%}
<div class="plank"> <div class="plank">
<fieldset class="plank even no-shadow minimal thread-actions"> <fieldset class="plank even no-shadow minimal subheader-actions">
<legend>Page</legend> <legend>Page</legend>
{{- pager(page, page_count) -}} {{- pager(page, page_count) -}}
</fieldset> </fieldset>

View File

@@ -1,5 +1,5 @@
{%- from 'common/macros.html' import babycode_editor_component -%} {%- from 'common/macros.html' import babycode_editor_component -%}
{%- from 'common/macros.html' import subheader, avatar -%} {%- from 'common/macros.html' import subheader, avatar, timestamp -%}
{%- extends 'base.html' -%} {%- extends 'base.html' -%}
{%- block title -%}settings{%- endblock -%} {%- block title -%}settings{%- endblock -%}
{%- block content -%} {%- block content -%}
@@ -15,7 +15,7 @@
<span class="avatar-form-controls"> <span class="avatar-form-controls">
<label for="avatar" class="linkbutton alt">Upload&hellip;</label> <label for="avatar" class="linkbutton alt">Upload&hellip;</label>
<span class="avatar-form-size-label">1MB max. Will be cropped to square.</span> <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="file" style="display: none;" id="avatar" name="avatar" accept="image/*" required>
<input type="submit" value="Save"> <input type="submit" value="Save">
<input type="submit" class="warn" value="Clear" formaction="{{url_for('users.clear_avatar', username=user.username)}}"> <input type="submit" class="warn" value="Clear" formaction="{{url_for('users.clear_avatar', username=user.username)}}">
</span> </span>
@@ -76,6 +76,45 @@
<div>Loading badges&hellip;</div> <div>Loading badges&hellip;</div>
<div>If badges fail to load, make sure JS is enabled.</div> <div>If badges fail to load, make sure JS is enabled.</div>
</fieldset> </fieldset>
{%- if user.can_invite() -%}
<fieldset class="plank" id="invite">
<legend>Invite keys</legend>
<p>To manage growth, {{ config.SITE_NAME }} disallows direct sign ups. Instead, users already with an account may invite people they know. You can create invite links here.</p>
<p>Invite links are valid for 48 hours. Once an invite link is used to sign up, it can no longer be used.</p>
<form method="POST" action="{{url_for('users.create_invite_key', username=user.username)}}">
{{ csrf_input() | safe }}
<input type="submit" value="Create new invite">
</form>
{%- if invites -%}
<table>
<thead>
<tr>
<th class="plank even no-shadow contrast-bg" style="--w: 50%;">Link</th>
<th class="plank even no-shadow contrast-bg" style="--w: 30%;">Expires</th>
<th class="plank even no-shadow contrast-bg" style="--w: 20%;">Revoke</th>
</tr>
</thead>
<tbody>
{%- for invite in invites -%}
<tr>
<td class="plank even no-shadow minimal"><a href="{{url_for('users.sign_up', key=invite.key)}}">Copy this</a></td>
<td class="plank even no-shadow minimal">{{timestamp(invite.expires_at)}}</td>
<td class="plank even no-shadow minimal center">
<form method="POST" action="{{url_for('users.revoke_invite_key', username=user.username)}}">
{{ csrf_input() | safe }}
<input type="hidden" name="key" value="{{invite.key}}">
<input type="submit" class="warn" value="Revoke">
</form>
</td>
</tr>
{%- endfor -%}
</tbody>
</table>
{%- else -%}
<p>You do not have any invites pending activation.</p>
{%- endif -%}
</fieldset>
{%- endif -%}
{%- endif -%} {%- endif -%}
<fieldset class="plank"> <fieldset class="plank">
<legend>Disown & Delete account</legend> <legend>Disown & Delete account</legend>

View File

@@ -4,13 +4,22 @@
{%- block title -%}sign up{%- endblock -%} {%- block title -%}sign up{%- endblock -%}
{%- block content -%} {%- block content -%}
{%- set welcome -%} {%- set welcome -%}
Please read the rules etc. stub <p>Please read the rules etc. stub</p>
{%- if not inviter -%}
<p>After you sign up, a moderator will need to confirm your account before you will be allowed to post.
{%- else -%}
You have been invited by <a href="{{url_for('users.user_page', username=inviter.username)}}">{{inviter.get_readable_name()}}</a> to join {{config.SITE_NAME}}. Create an identity below.
{%- endif -%}
</p>
{%- endset -%} {%- endset -%}
{{ subheader('Sign up', welcome)}} {{ subheader('Sign up', welcome)}}
{%- if request.args.get('error') -%} {%- if request.args.get('error') -%}
{{infobox(request.args.error, InfoboxKind.ERROR)}} {{infobox(request.args.error, InfoboxKind.ERROR)}}
{%- endif -%} {%- endif -%}
<form class="plank primary-bg full-width" method="POST"> <form class="plank primary-bg full-width" method="POST">
{%- if invite -%}
<input type="hidden" name="key" value="{{invite.key}}">
{%- endif -%}
<label for="username">Username</label> <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> <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> <label for="password">Create password</label>
@@ -18,6 +27,6 @@ Please read the rules etc. stub
<label for="password2">Confirm password</label> <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 at least: 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> <span><input type="checkbox" name="remember" id="remember"> <label for="remember">Remember me</label></span>
<input type="submit" value="Sign up"> <input type="submit" value="Sign up" class="alt">
</form> </form>
{%- endblock -%} {%- endblock -%}

View File

@@ -8,7 +8,7 @@
{%- endset -%} {%- endset -%}
{%- call() subheader("%s's started threads" % target_user.get_readable_name(), td) -%} {%- call() subheader("%s's started threads" % target_user.get_readable_name(), td) -%}
{%- if threads -%} {%- if threads -%}
<fieldset class="plank even no-shadow minimal thread-actions"> <fieldset class="plank even no-shadow minimal subheader-actions">
<legend>Page</legend> <legend>Page</legend>
{{- pager(page, page_count) -}} {{- pager(page, page_count) -}}
</fieldset> </fieldset>
@@ -22,7 +22,7 @@
</div> </div>
{%- endfor -%} {%- endfor -%}
<div class="plank"> <div class="plank">
<fieldset class="plank even no-shadow minimal thread-actions"> <fieldset class="plank even no-shadow minimal subheader-actions">
<legend>Page</legend> <legend>Page</legend>
{{- pager(page, page_count) -}} {{- pager(page, page_count) -}}
</fieldset> </fieldset>

View File

@@ -1,13 +1,13 @@
{%- from 'common/macros.html' import subheader, timestamp, pager, avatar -%} {%- from 'common/macros.html' import subheader, timestamp, pager, avatar -%}
{%- extends 'base.html' -%} {%- extends 'base.html' -%}
{%- block title -%}{{ target_user.get_readable_name() }}'s profile{%- endblock -%} {%- block title -%}@{{ target_user.username }}{%- endblock -%}
{%- set stats = target_user.get_post_stats() -%} {%- set stats = target_user.get_post_stats() -%}
{%- block content -%} {%- block content -%}
{%- call() subheader("%s's profile" % target_user.get_readable_name()) -%} {%- call() subheader("%s's profile" % target_user.get_readable_name()) -%}
{%- if is_logged_in() -%} {%- if is_logged_in() -%}
{%- if target_user.id == get_active_user().id -%} {%- if target_user.id == get_active_user().id -%}
<fieldset class="plank even no-shadow minimal thread-actions"> <fieldset class="plank even no-shadow minimal subheader-actions">
<legend>Actions</legend> <legend>Actions</legend>
<form action="{{url_for('users.log_out')}}" method="POST"> <form action="{{url_for('users.log_out')}}" method="POST">
<input type="submit" class="warn" value="Log out"> <input type="submit" class="warn" value="Log out">
@@ -16,9 +16,9 @@
{%- endif -%} {%- endif -%}
{%- if get_active_user().is_mod() and target_user.id != get_active_user().id and target_user.permission < get_active_user().permission -%} {%- if get_active_user().is_mod() and target_user.id != get_active_user().id and target_user.permission < get_active_user().permission -%}
<fieldset class="plank even no-shadow minimal thread-actions"> <fieldset class="plank even no-shadow minimal subheader-actions">
<legend>Moderation actions</legend> <legend>Moderation actions</legend>
<form class="thread-actions" method="POST"> <form class="subheader-actions" method="POST">
{{csrf_input() | safe}} {{csrf_input() | safe}}
{%- if target_user.is_guest() -%} {%- if target_user.is_guest() -%}
<input class="warn" type="submit" value="Approve user" formaction="{{url_for('mod.make_user_regular', user_id=target_user.id)}}"> <input class="warn" type="submit" value="Approve user" formaction="{{url_for('mod.make_user_regular', user_id=target_user.id)}}">
@@ -45,7 +45,7 @@
</div> </div>
</div> </div>
<div class="plank even minimal no-shadow user-stats"> <div class="plank even minimal no-shadow user-stats">
<h3 class="info">{{target_user.get_readable_name()}}</h3> <h3 class="info">{{target_user.get_readable_name()}} (@{{target_user.username}})</h3>
<span>Display name: {{target_user.get_readable_name()}}</span> <span>Display name: {{target_user.get_readable_name()}}</span>
<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>

View File

@@ -15,6 +15,9 @@ SERVER_NAME = "forum.your.domain"
# your forum's name, shown on the header. # your forum's name, shown on the header.
SITE_NAME = "Pyrom" SITE_NAME = "Pyrom"
# the forum's tagline, shown below the title.
SITE_TAGLINE = "anti-social media"
# if true, users can not sign up manually. see the following two settings. # if true, users can not sign up manually. see the following two settings.
DISABLE_SIGNUP = false DISABLE_SIGNUP = false

View File

@@ -92,17 +92,18 @@ button, .linkbutton, input[type="submit"], input[type="file"]::file-selector-but
--hover-color: hsl(from var(--main-color) h s calc(l * 1.05)); --hover-color: hsl(from var(--main-color) h s calc(l * 1.05));
--active-color: hsl(from var(--main-color) h s calc(l * 0.8)); --active-color: hsl(from var(--main-color) h s calc(l * 0.8));
--disabled-color: hsl(from var(--main-color) h calc(s * 0.5) l); --disabled-color: hsl(from var(--main-color) h calc(s * 0.5) l);
--bottom-color: hsl(from var(--main-color) h s calc(l * 0.7)); --bottom-color: hsl(from var(--main-color) h s calc(l * 0.8));
--top-color: hsl(from var(--main-color) h s 95); --top-color: hsl(from var(--main-color) h s 90);
--top-color2: hsl(from var(--main-color) h s calc(l * 1.1)); --top-color2: hsl(from var(--main-color) h s calc(l * 1.1));
--inset-color: #fff7; --inset-color: #fff7;
--current-color: var(--main-color);
/* position: relative; */ /* position: relative; */
/* display: inline-block; */ /* display: inline-block; */
padding: var(--small-padding) var(--medium-padding); padding: var(--small-padding) var(--medium-padding);
margin: var(--base-padding) 0px; margin: var(--base-padding) 0px;
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);
background: linear-gradient(var(--top-color) 0%, var(--top-color2) 10%, var(--main-color) 12%, var(--main-color) 66%, var(--bottom-color) 100%); background: linear-gradient(to bottom, var(--top-color), var(--main-color) 50%, var(--bottom-color) 75%);
/* box-shadow: inset 0px 2px 5px 3px var(--inset-color); */ /* box-shadow: inset 0px 2px 5px 3px var(--inset-color); */
/* color: var(--font-color); */ /* color: var(--font-color); */
/* HACK: better than contrast-color on critical */ /* HACK: better than contrast-color on critical */
@@ -141,7 +142,8 @@ button, .linkbutton, input[type="submit"], input[type="file"]::file-selector-but
} }
&:hover { &:hover {
background: linear-gradient(var(--top-color) 0%, var(--top-color2) 10%, var(--hover-color) 12%, var(--hover-color) 80%, var(--bottom-color) 100%); /*background: linear-gradient(var(--top-color) 0%, var(--top-color2) 10%, var(--hover-color) 12%, var(--hover-color) 80%, var(--bottom-color) 100%);*/
background: linear-gradient(to bottom, var(--top-color), var(--hover-color) 50%, var(--hover-color) 80%, var(--bottom-color) 100%);
} }
&:is(:active, .active, [aria-selected='true']) { &:is(:active, .active, [aria-selected='true']) {
@@ -297,6 +299,25 @@ a.site-title {
margin-top: var(--medium-padding); margin-top: var(--medium-padding);
} }
} }
&.primary-bg {
--main-color: var(--bg-color-primary);
background-color: var(--bg-color-primary);
}
&.secondary-bg {
--main-color: var(--bg-color-secondary);
--rotation: 0deg;
}
&.tertiary-bg {
--main-color: var(--bg-color-tertiary);
--rotation: 0deg;
}
&.contrast-bg {
--main-color: var(--bg-color-contrast);
}
} }
.info { .info {
@@ -368,25 +389,6 @@ ul.horizontal, ol.horizontal {
} }
} }
.primary-bg {
--main-color: var(--bg-color-primary);
background-color: var(--bg-color-primary);
}
.secondary-bg {
--main-color: var(--bg-color-secondary);
--rotation: 0deg;
}
.tertiary-bg {
--main-color: var(--bg-color-tertiary);
--rotation: 0deg;
}
.contrast-bg {
--main-color: var(--bg-color-contrast);
}
.motd { .motd {
display: flex; display: flex;
gap: var(--big-padding); gap: var(--big-padding);
@@ -465,7 +467,7 @@ footer {
gap: var(--base-padding); gap: var(--base-padding);
} }
.thread-actions { .subheader-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--base-padding); gap: var(--base-padding);
@@ -662,12 +664,12 @@ details {
} }
} }
&:not([open]) summary::before { &:not([open]) > summary::before {
content: '▶'; content: '▶';
padding-inline: var(--base-padding); padding-inline: var(--base-padding);
} }
&[open] summary::before { &[open] > summary::before {
content: '▼'; content: '▼';
padding-inline: var(--base-padding); padding-inline: var(--base-padding);
} }
@@ -677,6 +679,10 @@ details.separated {
margin: 0.5em 0; margin: 0.5em 0;
} }
details.inner {
margin-inline: var(--base-padding);
}
.avatar-form { .avatar-form {
display: flex; display: flex;
gap: var(--huge-padding); gap: var(--huge-padding);
@@ -724,6 +730,17 @@ details.separated {
} }
} }
table {
border-collapse: collapse;
width: 100%;
th {
width: var(--w, 50%);
}
td.center {
text-align: center;
}
}
/* babycode tags */ /* babycode tags */
.inline-code { .inline-code {

View File

@@ -7,9 +7,9 @@ async function getHTML(endpoint, options = {}) {
const params = new URLSearchParams(query); const params = new URLSearchParams(query);
const res = await fetch(`${endpoint}?${params}`, options); const res = await fetch(`${endpoint}?${params}`, options);
if (!res.ok) { // if (!res.ok) {
console.error(res); // console.error(res);
} // }
return { body: await res.text(), status: res.status }; return { body: await res.text(), status: res.status };
} }
@@ -81,7 +81,7 @@ export async function bookmarkMenuSubmit(ev, _, el) {
const status = (await getHTML(url, options)).status; const status = (await getHTML(url, options)).status;
if (status !== 204) { if (status !== 204) {
b.trigger('bookmarkMenuShowError'); b.send({ status: status }, 'bookmarkMenuShowError');
return; return;
} }
@@ -99,11 +99,17 @@ export function bookmarkMenuResetSavedButton(_, __, el) {
el.value = 'Save'; el.value = 'Save';
} }
export function bookmarkMenuShowError(_, __, el) { export function bookmarkMenuShowError(payload, _, el) {
if (el === undefined) { if (el === undefined) {
return; return;
} }
if (payload.status === 404) {
el.innerText = 'This thread or post no longer exists. Please refresh the page.';
} else {
el.innerText = 'Something went wrong. Try again later.';
}
if (el.classList.contains('hidden')) { if (el.classList.contains('hidden')) {
el.classList.remove('hidden'); el.classList.remove('hidden');
setTimeout(() => { b.trigger('bookmarkMenuHideError') }, 4000); setTimeout(() => { b.trigger('bookmarkMenuHideError') }, 4000);

View File

@@ -0,0 +1,62 @@
export const b = {
init: 'restoreCollectionDetails restoreThreadDetails restorePostDetails',
}
const COLLECTION_DETAILS_KEY = 'collectionsOpen';
const THREAD_DETAILS_KEY = 'threadsOpen';
const POST_DETAILS_KEY = 'postsOpen';
let collectionDetailsData = {};
let collectionThreadDetailsData = {};
let collectionPostDetailsData = {};
async function setDetailsData(obj, key, id, isOpen) {
obj[id] = isOpen;
await b.savePageData(obj, key);
}
export async function restoreCollectionDetails(_, __, el) {
collectionDetailsData = await b.loadPageData(COLLECTION_DETAILS_KEY, {});
el.open = collectionDetailsData[el.dataset.id] === true;
}
export async function setCollectionDetails(ev, sender, el) {
if (el !== sender) {
return;
}
if (ev.target !== el.querySelector('summary')) {
return;
}
console.log(!el.open);
await setDetailsData(collectionDetailsData, COLLECTION_DETAILS_KEY, el.dataset.id, !el.open);
}
export async function restoreThreadDetails(_, __, el) {
collectionThreadDetailsData = await b.loadPageData(THREAD_DETAILS_KEY, {});
el.open = collectionThreadDetailsData[el.dataset.id] === true;
}
export async function setThreadDetails(ev, sender, el) {
if (el !== sender) {
return;
}
if (ev.target !== el.querySelector('summary')) {
return;
}
await setDetailsData(collectionThreadDetailsData, THREAD_DETAILS_KEY, el.dataset.id, !el.open);
}
export async function restorePostDetails(_, __, el) {
collectionPostDetailsData = await b.loadPageData(POST_DETAILS_KEY, {});
el.open = collectionPostDetailsData[el.dataset.id] === true;
}
export async function setPostDetails(ev, sender, el) {
if (el !== sender) {
return;
}
if (ev.target !== el.querySelector('summary')) {
return;
}
await setDetailsData(collectionPostDetailsData, POST_DETAILS_KEY, el.dataset.id, !el.open);
}

View File

@@ -0,0 +1,18 @@
export const b = {}
export function addCollection(ev, sender, el) {
el.innerHTML += b.templates.collectionItem;
}
export function deleteCollection(ev, sender, el) {
if (!el.contains(sender)) return;
b.send({ 'id': el.prop('id') }, 'countDeletedCollection');
el.remove();
}
export function countDeletedCollection(payload, _, el) {
if (payload.id === 'new') {
return;
}
el.value += `${payload.id};`
}

View File

@@ -1,5 +1,5 @@
export const b = { export const b = {
init: 'enhance', init: 'enhance enhanceHide',
} }
export function enhance(_, __, el) { export function enhance(_, __, el) {
@@ -18,3 +18,11 @@ export function enhance(_, __, el) {
} }
} }
} }
export function enhanceHide(_, __, el) {
if (el === undefined) {
return;
}
el.style.display = 'none';
}

View File

@@ -70,8 +70,8 @@
if (listItems.has(node)) return; if (listItems.has(node)) return;
const dragger = node.querySelector('.dragger'); const dragger = node.querySelector('.dragger');
dragger.addEventListener('dragstart', e => { sortableItemDragStart(e, item) }); dragger.addEventListener('dragstart', e => { sortableItemDragStart(e, node) });
dragger.addEventListener('dragend', e => { sortableItemDragEnd(e, item) }); dragger.addEventListener('dragend', e => { sortableItemDragEnd(e, node) });
node.addEventListener('dragover', e => { sortableItemDragOver(e, node) }); node.addEventListener('dragover', e => { sortableItemDragOver(e, node) });
listItems.add(node); listItems.add(node);
listItemsHandled.set(list, listItems); listItemsHandled.set(list, listItems);
@@ -95,3 +95,18 @@
}); });
listsObs.observe(document.body, { childList: true, subtree: true }) listsObs.observe(document.body, { childList: true, subtree: true })
} }
{
// babycode editor: press ctrl+enter to submit
document.querySelectorAll('.babycode-editor').forEach(ta => {
if (ta.form instanceof HTMLFormElement) {
ta.addEventListener('keydown', e => {
if (e.ctrlKey && e.key === 'Enter') {
if (ta.form.reportValidity()) {
ta.form.submit();
}
}
})
}
})
}