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)),
})
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():
from .db import db
with db.transaction():
@@ -162,6 +167,7 @@ cache = Cache()
def create_app():
app = Flask(__name__)
app.config['SITE_NAME'] = 'Pyrom'
app.config['SITE_TAGLINE'] = 'anti-social media'
app.config['DISABLE_SIGNUP'] = False
app.config['MODS_CAN_INVITE'] = True
app.config['USERS_CAN_INVITE'] = False
@@ -233,6 +239,7 @@ def create_app():
clear_stale_sessions()
clear_api_limits()
clear_stale_invites()
reparse_babycode()

View File

@@ -43,7 +43,8 @@ MIGRATIONS = [
add_signature_format,
create_default_bookmark_collections,
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():

View File

@@ -511,6 +511,12 @@ class BookmarkCollections(Model):
"""
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):
q = 'SELECT EXISTS(SELECT 1 FROM bookmarked_posts WHERE collection_id = ?) as 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 ..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
bp = Blueprint('hyperapi', __name__, url_prefix='/hyperapi/')
@@ -24,7 +24,15 @@ def get_bookmark_dropdown():
except ValueError:
return 'error', 400
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
note = ''
for collection in collections:
@@ -54,6 +62,9 @@ def bookmark_thread():
bt.delete()
return '', 204
if not Threads.find({'id': thread_id}):
return 'error', 404
target_collection = BookmarkCollections.find({'id': target_collection_id})
note = request.form.get('note', '')
if not target_collection:
@@ -91,6 +102,9 @@ def bookmark_post():
bp.delete()
return '', 204
if not Posts.find({'id': post_id}):
return 'error', 404
target_collection = BookmarkCollections.find({'id': target_collection_id})
note = request.form.get('note', '')
if not target_collection:

View File

@@ -4,7 +4,7 @@ from flask import (
abort, flash, current_app
)
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.color import Color
from wand.exceptions import WandException
@@ -14,9 +14,9 @@ from ..auth import (
login_required, revoke_session, get_active_user,
parse_display_name, revoke_all_sessions, csrf_verified
)
from ..models import Users, Posts, Reactions, Threads, Avatars, PostHistory, Mentions, BookmarkCollections
from ..models import Users, Posts, Reactions, Threads, Avatars, PostHistory, Mentions, BookmarkCollections, InviteKeys
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 ..db import db
import math
@@ -169,15 +169,34 @@ def log_out():
@bp.get('/sign-up/')
@redirect_if_logged_in()
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/')
@redirect_if_logged_in()
def sign_up_post():
generic_error_page = redirect(url_for('.sign_up', error='The username or password you entered is invalid.'))
invalid_username_error_page = redirect(url_for('.sign_up', error='This username cannot be used. Please pick another.'))
passwords_error_page = redirect(url_for('.sign_up', error='The passwords do not match.'))
args_sans_error = dict(request.args)
args_sans_error.pop('error', '')
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='')
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:
return generic_error_page
if request.form.get('password') is None:
@@ -197,12 +216,18 @@ def sign_up_post():
password_hash = digest(request.form.get('password'))
user = Users.create({
user_data = {
'username': username_pair[0],
'password_hash': password_hash,
'permission': PermissionLevel.GUEST.value,
'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)
@@ -217,6 +242,7 @@ def sign_up_post():
if session['remember']:
session.permanent = True
flash(f'Welcome to {current_app.config['SITE_NAME']}!', InfoboxKind.INFO)
return redirect(url_for('topics.all_topics'))
@bp.get('/<username>/')
@@ -286,9 +312,11 @@ def comments(username):
def settings(username):
user = get_active_user()
sort_by = session.get('sort_by', 'activity')
invites = InviteKeys.findall({'created_by': user.id})
return render_template(
'users/settings.html', user=user,
sort_by=sort_by
sort_by=sort_by,
invites=invites,
)
@bp.post('/<username>/settings/set-avatar')
@@ -445,8 +473,72 @@ def inbox(username):
@login_required
@redirect_to_own
def bookmarks(username):
username = username.lower()
return 'stub'
user = get_active_user()
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/')
@login_required
@@ -475,3 +567,40 @@ def delete_confirm_post(username):
user.delete()
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_user ON badges(user_id)',
'CREATE INDEX IF NOT EXISTS idx_invite_key_user ON invite_keys(created_by, key)'
]
def create():

View File

@@ -15,7 +15,6 @@
<body>
<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/bookmark-menu.js"></bitty-8>
{%- include 'common/topnav.html' -%}
{%- with messages = get_flashed_messages(with_categories=true) -%}
{%- 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 emoji&hellip;" class="minimal"><img src="/static/emoji/angry.png" class="emoji"></button>
</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>
<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>
@@ -136,10 +136,16 @@
</div>
{%- 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(
post, render_sig=true, is_latest=false,
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() -%}
{%- set can_delete = post.user_id == get_active_user().id or is_mod() -%}
@@ -169,6 +175,9 @@
<div class="post-content">
<div class="plank even minimal secondary-bg no-shadow post-info">
<span>
{%- if tb_pretext -%}
<span>{{tb_pretext}} &bullet; </span>
{%- endif -%}
<a href="{{get_post_url(post.id, _anchor=true)}}">
{%- if post.edited_at <= post.created_at -%}
<i>Posted on {{timestamp(post.created_at)}}</i>
@@ -181,17 +190,19 @@
{%- endif -%}
</span>
{%- if show_toolbar -%}
<span class="thread-actions">
{%- if owns -%}
<span class="subheader-actions">
{%- if owns and tb_edit -%}
<a class="linkbutton" href="{{url_for('posts.edit', post_id=post.id, _anchor='babycode-content')}}">Edit</a>
{%- 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>
{%- 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>
{%- 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>
{%- endif -%}
</div>
@@ -229,6 +240,18 @@
</div>
{%- 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) -%}
<div class="infobox plank top contain-svg horizontal {{InfoboxHTMLClass[kind]}}">
{%- if kind == InfoboxKind.INFO -%}
@@ -270,7 +293,7 @@
{%- 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 %}>
<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>
<div class="sortable-item-inner">{{ caller() }}</div>
</li>

View File

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

View File

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

View File

@@ -5,7 +5,7 @@
{%- block content -%}
{%- call() subheader("Delete post", "Are you sure you want to delete this post? This action can not be undone.") -%}
<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>
<a href="{{get_post_url(post.id, _anchor=true)}}" class="linkbutton">Cancel</a>
<input type="submit" value="Delete" class="critical">

View File

@@ -6,7 +6,7 @@
<form class="plank primary-bg full-width" method="POST">
<label for="topic">Topic</label>
<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>
{%- endfor -%}
</select>

View File

@@ -1,9 +1,10 @@
{%- from 'common/macros.html' import subheader, timestamp, pager, babycode_editor_component -%}
{%- 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' -%}
{%- block title -%}{{thread.title}}{%- endblock -%}
{%- block content -%}
<bitty-8 data-connect="/static/js/bits/bookmark-menu.js"></bitty-8>
{%- set td -%}
<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>
@@ -18,7 +19,7 @@
</ul>
{%- endset -%}
{%- 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>
{%- if is_logged_in() -%}
{%- 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>
</fieldset>
{%- 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>
{%- if thread.user_id != get_active_user().id -%}
<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">
</form>
</fieldset>
<fieldset class="plank even no-shadow minimal thread-actions">
<fieldset class="plank even no-shadow minimal subheader-actions">
<legend>Page</legend>
{{- pager(page, page_count) -}}
</fieldset>
@@ -69,7 +70,7 @@
{%- endfor -%}
</main>
<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>
{{- pager(page, page_count) -}}
</fieldset>
@@ -82,15 +83,7 @@
<button>Stop updates</button>
</span>
</div>
{%- 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 -%}
{{ bookmark_menu() }}
{%- 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">
<h2 class="info">Reply to "{{thread.title}}"</h2>

View File

@@ -12,7 +12,7 @@
</ul>
{%- endset -%}
{%- 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>
{%- 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>
@@ -27,7 +27,7 @@
</form>
</fieldset>
{%- 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>
<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">
@@ -37,7 +37,7 @@
</fieldset>
{%- endif -%}
{%- 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>
{{- pager(page, page_count, args=request.args) -}}
</fieldset>
@@ -77,7 +77,7 @@
{%- endfor -%}
{%- if threads | length > 0 -%}
<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>
{{- pager(page, page_count, args=request.args) -}}
</fieldset>

View File

@@ -4,7 +4,7 @@
{%- block content -%}
{%- call() subheader('All topics') -%}
{%- 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>
<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>

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

View File

@@ -1,5 +1,5 @@
{%- 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' -%}
{%- block title -%}settings{%- endblock -%}
{%- block content -%}
@@ -15,7 +15,7 @@
<span class="avatar-form-controls">
<label for="avatar" class="linkbutton alt">Upload&hellip;</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="file" style="display: none;" id="avatar" name="avatar" accept="image/*" required>
<input type="submit" value="Save">
<input type="submit" class="warn" value="Clear" formaction="{{url_for('users.clear_avatar', username=user.username)}}">
</span>
@@ -76,6 +76,45 @@
<div>Loading badges&hellip;</div>
<div>If badges fail to load, make sure JS is enabled.</div>
</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 -%}
<fieldset class="plank">
<legend>Disown & Delete account</legend>

View File

@@ -4,13 +4,22 @@
{%- block title -%}sign up{%- endblock -%}
{%- block content -%}
{%- 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 -%}
{{ subheader('Sign up', welcome)}}
{%- if request.args.get('error') -%}
{{infobox(request.args.error, InfoboxKind.ERROR)}}
{%- endif -%}
<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>
<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>
@@ -18,6 +27,6 @@ Please read the rules etc. stub
<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>
<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>
{%- endblock -%}

View File

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

View File

@@ -1,13 +1,13 @@
{%- from 'common/macros.html' import subheader, timestamp, pager, avatar -%}
{%- 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() -%}
{%- block content -%}
{%- call() subheader("%s's profile" % target_user.get_readable_name()) -%}
{%- if is_logged_in() -%}
{%- 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>
<form action="{{url_for('users.log_out')}}" method="POST">
<input type="submit" class="warn" value="Log out">
@@ -16,9 +16,9 @@
{%- endif -%}
{%- 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>
<form class="thread-actions" method="POST">
<form class="subheader-actions" method="POST">
{{csrf_input() | safe}}
{%- 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)}}">
@@ -45,7 +45,7 @@
</div>
</div>
<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>Mention: @{{target_user.username}}</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.
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.
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));
--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);
--bottom-color: hsl(from var(--main-color) h s calc(l * 0.7));
--top-color: hsl(from var(--main-color) h s 95);
--bottom-color: hsl(from var(--main-color) h s calc(l * 0.8));
--top-color: hsl(from var(--main-color) h s 90);
--top-color2: hsl(from var(--main-color) h s calc(l * 1.1));
--inset-color: #fff7;
--current-color: var(--main-color);
/* position: relative; */
/* display: inline-block; */
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);
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); */
/* color: var(--font-color); */
/* HACK: better than contrast-color on critical */
@@ -141,7 +142,8 @@ button, .linkbutton, input[type="submit"], input[type="file"]::file-selector-but
}
&: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']) {
@@ -297,6 +299,25 @@ a.site-title {
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 {
@@ -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 {
display: flex;
gap: var(--big-padding);
@@ -465,7 +467,7 @@ footer {
gap: var(--base-padding);
}
.thread-actions {
.subheader-actions {
display: flex;
flex-wrap: wrap;
gap: var(--base-padding);
@@ -662,12 +664,12 @@ details {
}
}
&:not([open]) summary::before {
&:not([open]) > summary::before {
content: '▶';
padding-inline: var(--base-padding);
}
&[open] summary::before {
&[open] > summary::before {
content: '▼';
padding-inline: var(--base-padding);
}
@@ -677,6 +679,10 @@ details.separated {
margin: 0.5em 0;
}
details.inner {
margin-inline: var(--base-padding);
}
.avatar-form {
display: flex;
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 */
.inline-code {

View File

@@ -7,9 +7,9 @@ async function getHTML(endpoint, options = {}) {
const params = new URLSearchParams(query);
const res = await fetch(`${endpoint}?${params}`, options);
if (!res.ok) {
console.error(res);
}
// if (!res.ok) {
// console.error(res);
// }
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;
if (status !== 204) {
b.trigger('bookmarkMenuShowError');
b.send({ status: status }, 'bookmarkMenuShowError');
return;
}
@@ -99,11 +99,17 @@ export function bookmarkMenuResetSavedButton(_, __, el) {
el.value = 'Save';
}
export function bookmarkMenuShowError(_, __, el) {
export function bookmarkMenuShowError(payload, _, el) {
if (el === undefined) {
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')) {
el.classList.remove('hidden');
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 = {
init: 'enhance',
init: 'enhance enhanceHide',
}
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;
const dragger = node.querySelector('.dragger');
dragger.addEventListener('dragstart', e => { sortableItemDragStart(e, item) });
dragger.addEventListener('dragend', e => { sortableItemDragEnd(e, item) });
dragger.addEventListener('dragstart', e => { sortableItemDragStart(e, node) });
dragger.addEventListener('dragend', e => { sortableItemDragEnd(e, node) });
node.addEventListener('dragover', e => { sortableItemDragOver(e, node) });
listItems.add(node);
listItemsHandled.set(list, listItems);
@@ -95,3 +95,18 @@
});
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();
}
}
})
}
})
}