Compare commits

...

51 Commits

Author SHA1 Message Date
13c5c5cf69 move empty topic plank below motd 2026-06-10 22:32:12 +03:00
bf3028e7d6 re-add rss feeds 2026-06-10 22:30:53 +03:00
50c61da8b6 fix reaction button event 2026-06-10 20:28:07 +03:00
812f322141 add unsubscribe from all button to inbox 2026-06-10 18:16:08 +03:00
6e73186127 add mark as read button(s) to inbox 2026-06-10 17:59:51 +03:00
8a7eb91a34 remove emoji button from babycode editor for now pending implementation 2026-06-10 15:41:14 +03:00
b63b6a1682 bring back reactions 2026-06-07 23:01:58 +03:00
5dfe477607 highlight mentions 2026-06-07 18:31:10 +03:00
b6450a29fd only allow resizing textarea 2026-06-07 13:09:27 +03:00
7b16ac91ed ensure new users get a default collection on signup and any missing users get them too 2026-06-07 13:09:17 +03:00
84dbaa2cd8 babycode editor: change ctrl+enter shortcut to use requestSubmit to fire bitty signals 2026-06-06 02:05:13 +03:00
200bd37a28 add lightbox for post image previews 2 2026-06-05 21:01:29 +03:00
d01bbaca54 replace innerHTML += with proper appendChild 2026-06-05 07:43:43 +03:00
6fab93ebeb bring back the badge editor 2026-06-05 07:19:53 +03:00
c7ba23ad22 update built-in badges to use the plank theme 2026-06-05 07:19:44 +03:00
3c237df93f cleanup 2026-06-03 20:06:04 +03:00
22ca768ad1 finish invites i think 2026-06-03 16:35:59 +03:00
c311fba500 button v5 2026-06-03 11:21:06 +03:00
4083c950c5 start work on invite keys 2026-06-03 11:07:50 +03:00
5853c8b7a8 use config for title and tagline 2026-06-02 18:42:53 +03:00
93ee829405 re-add ctrl+enter to submit babycode 2026-06-02 18:31:07 +03:00
7247ac4cf8 replace old stub 2026-06-02 17:59:28 +03:00
2c8bc6dca8 add bookmarks view 2026-06-02 17:58:06 +03:00
edfa2e232f add bookmark collection editor 2026-06-02 08:12:26 +03:00
5676ced836 make avi upload required 2026-06-02 04:31:12 +03:00
7defd249b5 only include the bookmark menu bit in thread view 2026-06-02 04:30:47 +03:00
74a95075f7 some minor template improvements 2026-05-31 17:22:45 +03:00
c0eb867b2d remove test from mod panel 2026-05-31 17:08:05 +03:00
ae9d33473c slugify to a max of 50 and remove date 2026-05-30 09:18:42 +03:00
d87d9c2977 backend for bookmark menu 2026-05-30 08:52:50 +03:00
8c87489f70 frontend for bookmark menu 2026-05-30 01:56:25 +03:00
07623b294e button v4 2026-05-30 00:10:50 +03:00
4d2f87baf5 remove some unused filters 2026-05-29 05:55:11 +03:00
af5e838232 localize timestamps 2026-05-29 05:54:51 +03:00
81fa054ddf make readme more up to date 2026-05-29 05:24:36 +03:00
818e43dd1b add copy code button 2026-05-29 04:39:44 +03:00
3d633bd529 add saving draft to localStorage 2026-05-29 01:59:46 +03:00
2f78c7459c use new bittyjs url 2026-05-29 01:24:00 +03:00
b0793b8a86 add support for running the server on local network in debug 2026-05-29 01:16:42 +03:00
dc1ff4446e re-parse mentions on display name change 2026-05-29 00:43:32 +03:00
06b417f9a1 add quoting posts 2026-05-29 00:26:23 +03:00
594272d298 add default bookmark collection on new user 2026-05-29 00:25:03 +03:00
cd3fce17ae fix subscriptions 2026-05-28 21:56:07 +03:00
572e6e86c4 add 405 handler 2026-05-28 21:40:46 +03:00
0224e2e390 add char count to babycode editor 2026-05-28 21:40:35 +03:00
27314f34a5 add babycode preview 2026-05-28 10:00:10 +03:00
daf205f200 better plank 2026-05-28 08:37:21 +03:00
687d72e5ab remove erroneous console log in progressive enhancement 2026-05-28 08:21:16 +03:00
ed395a0175 add babycode button controls 2026-05-28 07:50:47 +03:00
160629fca7 reintroduce bitty and add progressive enhancement and tabs 2026-05-28 06:41:59 +03:00
d2ea0bbd51 fix mobile avi in usercard when no badges/status/etc 2026-05-28 04:30:45 +03:00
75 changed files with 2324 additions and 235 deletions

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@ data/static/avatars/*
!data/static/avatars/default.webp
data/static/badges/user
data/_cached
data/static/js/vnd/*.source.*
config/secrets.prod.env
config/pyrom_config.toml

View File

@@ -8,7 +8,7 @@ a live example can be seen in action over at [Porom](https://forum.poto.cafe/).
## stack & structure
on the server side, pyrom is built in Python using the Flask framework. content is rendered mostly server-side with Jinja templates. the database used is SQLite.
on the client side, JS with only one library ([Bitty](https://bitty-js.com)) is used. for CSS, pyrom uses Sass.
on the client side, JS with only one library ([Bitty](https://bittyjs.com)) is used. for CSS, pyrom uses Sass.
below is an explanation of the folder structure:
@@ -32,13 +32,10 @@ below is an explanation of the folder structure:
- `static/` - static files
- `avatars/` - user avatar uploads
- `badges/` - user badge uploads
- `css/` - CSS files generated from Sass sources
- `css/` - stylesheets
- `emoji/` - emoji images used on the forum
- `fonts/`
- `js/`
- `sass/`
- `_default.scss` - the default theme. Sass variables that other themes modify are defined here, along with the default styles. other files define the available themes.
- `build-themes.sh` - script for building Sass files into CSS
- `nginx.conf` - nginx config (production only)
- `uwsgi.ini` - uwsgi config (production only)
@@ -92,10 +89,10 @@ $ pip install -r requirements.txt
4. run dev server:
```bash
$ python -m app.run
$ python -m app.run -d
```
the server will run on localhost:8080. when run for the first time, it will create an admin account and print its credentials to the terminal, so make sure to run this in an interactive session.
the server will run on localhost:8080 (and will be available on the local network as well; if this is not desired, drop the `-d` flag). when run for the first time, it will create an admin account and print its credentials to the terminal, so make sure to run this in an interactive session.
press <kbd>Ctrl</kbd>+<kbd>C</kbd> to stop the server.
@@ -110,4 +107,3 @@ when you want to run the server again, make sure to activate the venv first:
$ source .venv/bin/activate
$ python -m app.run
```

View File

@@ -73,8 +73,8 @@ Repo: https://github.com/emcconville/wand
## Bitty
Affected files: [`data/static/js/vnd/bitty-7.0.0.js`](./data/static/js/vnd/bitty-7.0.0.js)
URL: https://bitty-js.com/
Affected files: [`data/static/js/vnd/bitty-8.0.0.js`](./data/static/js/vnd/bitty-8.0.0.js)
URL: https://bittyjs.com/
License: CC0 1.0
Author: alan w smith https://www.alanwsmith.com/
Repo: https://github.com/alanwsmith/bitty

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():
@@ -149,12 +154,27 @@ def clear_stale_sessions():
for sess in stale_sessions:
sess.delete()
def clear_api_limits():
from .db import db
from .models import APIRateLimits
with db.transaction():
limits = APIRateLimits.select()
for l in limits:
l.delete()
def ensure_default_collection():
from .db import db
from .models import BookmarkCollections
with db.transaction():
for missing_user in BookmarkCollections.get_users_without_default():
BookmarkCollections.create_default(missing_user)
cache = Cache()
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
@@ -203,12 +223,16 @@ def create_app():
from app.routes.guides import bp as guides_bp
from app.routes.mod import bp as mod_bp
from app.routes.posts import bp as posts_bp
from app.routes.api import bp as api_bp
from app.routes.hyperapi import bp as hyperapi_bp
app.register_blueprint(topics_bp)
app.register_blueprint(threads_bp)
app.register_blueprint(users_bp)
app.register_blueprint(guides_bp)
app.register_blueprint(mod_bp)
app.register_blueprint(posts_bp)
app.register_blueprint(api_bp)
app.register_blueprint(hyperapi_bp)
with app.app_context():
from .schema import create as create_tables
@@ -221,9 +245,13 @@ def create_app():
create_deleted_user()
clear_stale_sessions()
clear_api_limits()
clear_stale_invites()
reparse_babycode()
ensure_default_collection()
bind_default_badges(app.config['BADGES_PATH'])
app.config['SESSION_COOKIE_SECURE'] = True
@@ -318,6 +346,15 @@ def create_app():
else:
return render_template('common/404.html'), e.code
@app.errorhandler(405)
def _handle_405(e):
if request.path.startswith('/hyperapi/'):
return '<h1>method not allowed</h1>', e.code
elif request.path.startswith('/api/'):
return {'error': 'method not allowed'}, e.code
else:
return render_template('common/404.html'), e.code
@app.errorhandler(403)
def _handle_403(e):
if request.path.startswith('/hyperapi/'):
@@ -348,13 +385,6 @@ def create_app():
def cachebust(subject):
return f'{subject}?v={str(int(time.time()))}'
@app.template_filter('theme_name')
def get_theme_name(subject: str):
if subject == 'style':
return 'Default'
return f'{subject.removeprefix('theme-').replace('-', ' ').capitalize()} (beta)'
@app.template_filter('fromjson')
def fromjson(subject: str):
return json.loads(subject)

View File

@@ -105,6 +105,14 @@ def login_required(view_func):
return view_func(*args, **kwargs)
return wrapper
def hard_login_required(view_func):
@wraps(view_func)
def wrapper(*args, **kwargs):
if not is_logged_in():
abort(403)
return view_func(*args, **kwargs)
return wrapper
def mod_only(view_func):
@wraps(view_func)
def wrapper(*args, **kwargs):

View File

@@ -49,6 +49,12 @@ class DB:
yield conn
@staticmethod
def binding_list(num: int) -> str:
"""Returns a bindings list string for the given number of bindings."""
return '(%s)' % ','.join('?' * num)
def query(self, sql, *args):
"""Executes a query and returns a list of dictionaries."""
with self.connection() as conn:
@@ -104,8 +110,12 @@ class DB:
conditions = []
params = []
for col, op, val in self._where:
conditions.append(f"{col} {op} ?")
params.append(val)
if isinstance(val, tuple) or isinstance(val, list):
conditions.append(f"{col} {op} {db.binding_list(len(val))}")
params.extend(val)
else:
conditions.append(f"{col} {op} ?")
params.append(val)
return " WHERE " + " AND ".join(conditions), params

View File

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

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

@@ -172,7 +172,7 @@ class Topics(Model):
name = name.strip()
description = description.strip()
now = int(time.time())
slug = f'{slugify(name)}-{now}'
slug = slugify(name, max_length=50)
topic_count = Topics.count()
return Topics.create({
@@ -287,7 +287,7 @@ class Threads(Model):
def new(cls, user_id: int, topic_id: int, title: str, content: str, language: str = 'babycode') -> Threads:
from slugify import slugify
now = int(time.time())
slug = f'{slugify(title)}-{now}'
slug = slugify(title, max_length=50)
thread = Threads.create({
'topic_id': topic_id,
'user_id': user_id,
@@ -506,10 +506,30 @@ class BookmarkCollections(Model):
@classmethod
def create_default(cls, user_id):
q = """INSERT INTO bookmark_collections (user_id, name, is_default, sort_order)
VALUES (?, "Bookmarks", 1, 0) RETURNING id
"""
res = db.fetch_one(q, user_id)
return cls.create({
'user_id': user_id,
'name': 'Bookmarks',
'is_default': True,
'sort_order': 0,
})
@staticmethod
def get_users_without_default():
q = """
SELECT users.id FROM users
WHERE NOT EXISTS (
SELECT 1 FROM bookmark_collections bc
WHERE bc.user_id = users.id
AND bc.is_default = 1
)
"""
return [row['id'] for row in db.query(q)]
@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'
@@ -558,6 +578,21 @@ class BookmarkCollections(Model):
class BookmarkedPosts(Model):
table = 'bookmarked_posts'
@classmethod
def get_for_user(cls, post_id, user_id):
q = """SELECT
bookmarked_posts.id, collection_id, post_id, note
FROM
bookmarked_posts
JOIN
bookmark_collections ON bookmark_collections.id = bookmarked_posts.collection_id
WHERE
post_id = ?
AND
user_id = ?"""
res = db.fetch_one(q, post_id, user_id)
return cls.from_data(res) if res is not None else None
def get_post(self):
return Posts.find({'id': self.post_id})
@@ -565,6 +600,21 @@ class BookmarkedPosts(Model):
class BookmarkedThreads(Model):
table = 'bookmarked_threads'
@classmethod
def get_for_user(cls, thread_id, user_id):
q = """SELECT
bookmarked_threads.id, collection_id, thread_id, note
FROM
bookmarked_threads
JOIN
bookmark_collections ON bookmark_collections.id = bookmarked_threads.collection_id
WHERE
thread_id = ?
AND
user_id = ?"""
res = db.fetch_one(q, thread_id, user_id)
return cls.from_data(res) if res is not None else None
def get_thread(self):
return Threads.find({'id': self.thread_id})
@@ -612,6 +662,12 @@ class BadgeUploads(Model):
class Badges(Model):
table = 'badges'
@classmethod
def get_for_user(cls, user_id):
q = 'SELECT * FROM badges WHERE user_id = ? ORDER BY sort_order ASC'
res = db.query(q, user_id)
return [cls.from_data(row) for row in res]
def get_image_url(self):
bu = BadgeUploads.find({'id': int(self.upload)})
return bu.file_path

89
app/routes/api.py Normal file
View File

@@ -0,0 +1,89 @@
from flask import Blueprint, request
from ..auth import is_logged_in, hard_login_required, get_active_user
from ..lib.babycode import babycode_to_html
from ..models import APIRateLimits, Posts, Threads, Reactions
from ..constants import REACTION_EMOJI
bp = Blueprint('api', __name__, url_prefix='/api/')
@bp.before_request
def ensure_json():
if request.method == 'POST':
if not request.is_json:
return {'error': 'unsupported media type'}, 415
elif not request.content_length:
return {'error': 'body expected'}, 400
elif not isinstance(request.json, dict):
return {'error': 'body must be an object'}, 400
@bp.post('/babycode-preview/')
@hard_login_required
def babycode_preview():
user = get_active_user()
if not APIRateLimits.is_allowed(user.id, 'babycode_preview', 5):
return {'error': 'too many requests'}, 429
markup = str(request.json.get('markup', ''))
if not markup:
return {'error': 'markup field missing or invalid type'}, 400
banned_tags = request.json.get('banned_tags', [])
if not isinstance(banned_tags, list):
return {'error': 'banned_tags field is invalid type'}, 400
rendered = babycode_to_html(markup, banned_tags).result
return {'html': rendered}
@bp.get('/whoami/')
def whoami():
user = get_active_user()
if not user:
return {}
return {
'id': user.id,
'username': user.username,
'display_name': user.display_name,
}
@bp.post('/toggle-reaction/')
@hard_login_required
def toggle_reaction():
user = get_active_user()
emoji = request.json.get('reaction')
if emoji not in REACTION_EMOJI:
return {'error': f'invalid reaction string, given: {emoji}'}, 400
post_id = request.json.get('post', -1)
post = Posts.find({'id': post_id})
if not post:
return {'error': 'post not found'}, 404
thread = Threads.find({'id': post.thread_id})
if not user.can_post_to_thread_or_topic(thread):
return {'error': 'thread is locked'}, 403
reaction_obj = {
'user_id': int(user.id),
'post_id': int(post_id),
'reaction_text': emoji,
}
r = Reactions.find(reaction_obj)
if r:
# remove
r.delete()
return {'status': 'ok', 'added': False}
else:
# add
r = Reactions.create(reaction_obj)
return {'status': 'ok', 'added': True}
@bp.get('/thread-permission/<int:thread_id>')
def thread_permission(thread_id):
user = get_active_user()
if not user:
return {'can_post': False}
thread = Threads.find({'id': thread_id})
if not thread:
return {'can_post': False}
return {'can_post': user.can_post_to_thread_or_topic(thread)}

139
app/routes/hyperapi.py Normal file
View File

@@ -0,0 +1,139 @@
from flask import Blueprint, render_template, request, url_for
from ..auth import get_active_user, is_logged_in, hard_login_required
from ..models import BookmarkCollections, BookmarkedPosts, BookmarkedThreads, Threads, Posts, Badges, BadgeUploads, Reactions
from functools import wraps
bp = Blueprint('hyperapi', __name__, url_prefix='/hyperapi/')
def user_required(view_func):
@wraps(view_func)
def wrapper(*args, **kwargs):
if get_active_user().is_guest():
return '<span>Your account must be approved by a moderator before you may perform this action.</span>', 403
return view_func(*args, **kwargs)
return wrapper
@bp.get('/bookmarks/dropdown/')
@hard_login_required
@user_required
def get_bookmark_dropdown():
user = get_active_user()
concept_kind = request.args.get('concept_kind', 'thread')
try:
concept_id = int(request.args.get('concept_id', 0))
except ValueError:
return 'error', 400
is_thread = concept_kind == 'thread'
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:
callable = collection.has_thread if is_thread else collection.has_post
if callable(concept_id):
in_collection = collection.id
concept = 'thread_id' if is_thread else 'post_id'
note = (BookmarkedThreads if is_thread else BookmarkedPosts).find({'collection_id': in_collection, concept: concept_id}).note
break
submit_url = url_for('.bookmark_thread' if is_thread else '.bookmark_post')
return render_template('hyper/bookmark_dropdown.html', collections=collections, in_collection=in_collection, is_thread=is_thread, concept_id=concept_id, submit_url=submit_url, note=note)
@bp.post('/bookmarks/thread/')
@hard_login_required
@user_required
def bookmark_thread():
user = get_active_user()
try:
thread_id = int(request.form['concept_id'])
target_collection_id = int(request.form['target_collection'])
except ValueError, KeyError:
return 'error', 400
if target_collection_id == -1:
bt = BookmarkedThreads.get_for_user(thread_id, user.id)
if bt:
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:
return 'error', 400
if int(user.id) != int(target_collection.user_id):
return 'error', 400
bt = BookmarkedThreads.get_for_user(thread_id, user.id)
if bt:
bt.update({'collection_id': target_collection_id, 'note': note})
else:
BookmarkedThreads.create({
'collection_id': target_collection_id,
'thread_id': thread_id,
'note': note,
})
return '', 204
@bp.post('/bookmarks/post/')
@hard_login_required
@user_required
def bookmark_post():
user = get_active_user()
try:
post_id = int(request.form['concept_id'])
target_collection_id = int(request.form['target_collection'])
except ValueError, KeyError:
return 'error', 400
if target_collection_id == -1:
bp = BookmarkedPosts.get_for_user(post_id, user.id)
if bp:
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:
return 'error', 400
if int(user.id) != int(target_collection.user_id):
return 'error', 400
bp = BookmarkedPosts.get_for_user(post_id, user.id)
if bp:
bp.update({'collection_id': target_collection_id, 'note': note})
else:
BookmarkedPosts.create({
'collection_id': target_collection_id,
'post_id': post_id,
'note': note,
})
return '', 204
@bp.get('/badges/editor/')
@hard_login_required
@user_required
def badge_editor():
user = get_active_user()
badges = Badges.get_for_user(user.id)
badge_uploads = BadgeUploads.get_for_user(user.id)
return render_template('hyper/badge_editor.html', badges=badges, badge_uploads=badge_uploads)
@bp.get('/reactions/<int:post_id>')
def get_reaction_buttons(post_id):
return render_template('hyper/reaction_buttons.html', Reactions=Reactions, post_id=post_id)

View File

@@ -106,7 +106,7 @@ def edit_topic_post(topic_id):
topic.update({
'name': target_name,
'description': request.form.get('description').strip(),
'slug': slugify(target_name[:50]),
'slug': slugify(target_name, max_length=50),
})
return redirect(url_for('topics.topic_by_id', topic_id=topic.id))

View File

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

View File

@@ -1,7 +1,10 @@
from flask import Blueprint, redirect, url_for, render_template, request, abort
from flask import Blueprint, redirect, url_for, render_template, request, abort, current_app
from functools import wraps
from app import cache
from ..auth import login_required, get_active_user, is_logged_in
from ..db import db
from ..models import Threads, Posts, Topics, Users, Reactions, Subscriptions
from ..lib.render_atom import render_atom_template
from ..util import get_form_checkbox, time_now
import math
@@ -33,7 +36,7 @@ def thread(thread_id, slug):
if not thread:
abort(404)
if thread.slug != slug:
return redirect(url_for('.thread', thread_id=thread_id, slug=thread.slug, **request.kwargs))
return redirect(url_for('.thread', thread_id=thread_id, slug=thread.slug, **request.args))
topic = Topics.find({'id': thread.topic_id})
started_by = Users.find({'id': thread.user_id})
@@ -62,16 +65,30 @@ def thread(thread_id, slug):
user = get_active_user()
if user:
subscription = Subscriptions.find({'user_id': user.id, 'thread_id': thread.id})
if subscription:
if subscription and last_post['created_at'] > int(subscription.last_seen):
subscription.update({'last_seen': last_post['created_at']})
return render_template(
'threads/thread.html', thread=thread,
posts=posts, page=page,
page_count=page_count, topic=topic,
started_by=started_by, topics=Topics.get_list(),
Reactions=Reactions, last_post=last_post
Reactions=Reactions, last_post=last_post,
__feedlink=url_for('.feed', thread_id=thread_id, _external=True),
__feedtitle=f'replies to {thread.title}',
)
@bp.get('/<int:thread_id>/feed.atom/')
@cache.cached(timeout=5 * 60, unless=lambda: current_app.config['DEBUG'])
def feed(thread_id):
thread = Threads.find({'id': thread_id})
if not thread:
abort(404)
topic = Topics.find({'id': thread.topic_id})
posts = thread.get_posts_rss()
return render_atom_template('threads/thread.atom', thread=thread, topic=topic, posts=posts)
@bp.post('/<int:thread_id>/')
@login_required
def reply(thread_id):
@@ -82,13 +99,16 @@ def reply(thread_id):
if not user.can_post_to_thread_or_topic(thread):
return redirect(url_for('.thread_by_id', thread_id=thread_id))
post = Posts.new(user.id, thread.id, request.form.get('babycode_content'))
subscription = Subscriptions.find({'user_id': user.id, 'thread_id': thread.id})
if get_form_checkbox('subscribe'):
if not Subscriptions.find({'user_id': user.id, 'thread_id': thread.id}):
Subscriptions.create({
if not subscription:
subscription = Subscriptions.create({
'user_id': user.id,
'thread_id': thread.id,
'last_seen': time_now(),
})
if subscription and subscription.last_seen < time_now():
subscription.update({'last_seen': time_now()})
return redirect(url_for('.thread_by_id', thread_id=thread_id, after=post.id, _anchor=f'post-{post.id}'))
@bp.get('/<int:thread_id>/edit/')
@@ -112,7 +132,8 @@ def edit_post(thread_id):
abort(400)
if new_title != thread.title:
thread.update({'title': new_title})
from slugify import slugify
thread.update({'title': new_title, 'slug': slugify(new_title, max_length=50)})
return redirect(url_for('.thread_by_id', thread_id=thread_id))
@@ -162,9 +183,33 @@ def unsubscribe(thread_id):
subscription.delete()
return redirect(return_to)
@bp.get('/<int:thread_id>/feed.atom/')
def feed(thread_id):
return 'stub'
@bp.post('/subscriptions/unsubscribe-all/')
@login_required
def unsubscribe_all():
user = get_active_user()
subs = Subscriptions.findall({'user_id': user.id})
if not subs:
return redirect(url_for('users.inbox', username=user.username))
with db.transaction():
for sub in subs:
sub.delete()
return redirect(url_for('users.inbox', username=user.username))
@bp.post('/subscriptions/mark-read/')
@login_required
def mark_read():
# TODO: make a return_to param
user = get_active_user()
sub_ids = request.form.getlist('id[]', type=int)
now = time_now()
for sub_id in sub_ids:
sub = Subscriptions.find({'id': sub_id, 'user_id': user.id})
if not sub:
continue
sub.update({'last_seen': now})
return redirect(url_for('users.inbox', username=user.username))
@bp.get('/new/')
@login_required

View File

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

View File

@@ -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,14 +14,17 @@ 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
from ..models import Users, Posts, Reactions, Threads, Avatars, PostHistory, Mentions, BookmarkCollections, InviteKeys, Badges, BadgeUploads
from ..constants import PermissionLevel, InfoboxKind
from ..util import get_form_checkbox
from ..util import get_form_checkbox, time_now
from ..lib.babycode import babycode_to_html
from ..db import db
import math
import os
import time
AVATAR_MAX_SIZE = 1000 * 1000 # 1MB
BADGE_MAX_SIZE = 1000 * 500 # 500K
bp = Blueprint('users', __name__, url_prefix='/users/')
@@ -58,6 +61,22 @@ def validate_and_create_avatar(input_image, filename):
except WandException:
return False
def validate_and_create_badge(input_image, filename):
try:
with Image(blob=input_image) as img:
if img.width != 88 or img.height != 31:
return False
if hasattr(img, 'sequence') and len(img.sequence) > 1:
img = Image(image=img.sequence[0])
img.strip()
img.format = 'webp'
img.compression_quality = 90
img.save(filename=filename)
return True
except WandException:
return False
def anonymize_user(user_id):
deleted_user = Users.find({'username': 'deleteduser'})
@@ -167,15 +186,37 @@ 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', '')
invite = None
inviter = None
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='')
invite = None
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:
@@ -195,12 +236,21 @@ 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()),
})
'created_at': time_now(),
}
if invite:
user_data['invited_by'] = invite.created_by
user_data['permission'] = PermissionLevel.USER.value
user_data['confirmed_on'] = time_now()
invite.delete()
user = Users.create(user_data)
BookmarkCollections.create_default(user.id)
if username_pair[0] != username_pair[1]:
user.update({
@@ -213,6 +263,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>/')
@@ -221,7 +272,11 @@ def user_page(username):
target_user = Users.find({'username': username})
if not target_user:
abort(404)
return render_template('users/user_page.html', target_user=target_user)
if current_app.config['DISABLE_SIGNUP'] and target_user.invited_by:
invited_by = Users.find({'id': target_user.invited_by})
else:
invited_by = None
return render_template('users/user_page.html', target_user=target_user, invited_by=invited_by)
@bp.get('/<username>/posts/')
def posts(username):
@@ -243,7 +298,8 @@ def posts(username):
return render_template(
'users/posts.html', posts=posts,
page=page, page_count=page_count,
target_user=target_user, Reactions=Reactions
target_user=target_user,
Reactions=Reactions,
)
@bp.get('/<username>/threads/')
@@ -266,7 +322,8 @@ def threads(username):
return render_template(
'users/threads.html', threads=threads,
page=page, page_count=page_count,
target_user=target_user, Reactions=Reactions
target_user=target_user,
Reactions=Reactions,
)
@bp.get('/<username>/comments/')
@@ -282,9 +339,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')
@@ -386,11 +445,27 @@ def set_personalization(username):
session['sort_by'] = request.form.get('sort_by', 'activity')
session['dont_subscribe_by_default'] = not get_form_checkbox('subscribe_by_default')
old_display_name = user.display_name
new_display_name = parse_display_name(request.form.get('display_name', ''))
user.update({
'status': request.form.get('status', '')[:100],
'display_name': parse_display_name(request.form.get('display_name', ''))
'display_name': new_display_name
})
if old_display_name != new_display_name:
# re-parse posts with mentions
q = """SELECT DISTINCT m.revision_id FROM mentions m
JOIN post_history ph ON m.revision_id = ph.id
JOIN posts p ON p.current_revision_id = ph.id
WHERE m.mentioned_user_id = ?"""
mentions = db.query(q, int(user.id))
with db.transaction():
for mention in mentions:
rev = PostHistory.find({'id': int(mention['revision_id'])})
parsed_content = babycode_to_html(rev.original_markup).result
rev.update({'content': parsed_content})
flash('Personalization settings updated.', InfoboxKind.INFO)
return redirect(url_for('.settings', username=username))
@@ -424,8 +499,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
@@ -454,3 +593,143 @@ 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'))
@bp.post('/<username>/settings/badges/')
@login_required
@redirect_to_own
def save_badges(username):
user = get_active_user()
if user.is_guest():
abort(403)
ids = request.form.getlist('id[]', type=int)
badge_choices = request.form.getlist('badge_choice[]')
files = request.files.getlist('badge_file[]')
labels = request.form.getlist('label[]')
links = request.form.getlist('link[]')
existing_badges = {badge.id: badge for badge in Badges.findall({'user_id': user.id})}
if not (len(ids) == len(badge_choices) == len(files) == len(labels) == len(links)):
abort(400)
rejected_badges = []
# print(ids)
# print(db.query(f'SELECT id FROM badges WHERE id NOT IN {db.binding_list(len(ids))}', *ids))
deleted_badges = Badges.findall([
('id', 'NOT IN', ids),
('user_id', '=', user.id),
])
with db.transaction():
for b in deleted_badges:
b.delete()
for i, id in enumerate(ids):
badge_upload_id = badge_choices[i]
label = labels[i]
link = links[i]
pending_badge = {
'label': label,
'link': link,
'sort_order': i,
}
if badge_upload_id == 'custom':
file = files[i]
if not file:
rejected_badges.append(file.filename)
continue
file.seek(0, os.SEEK_END)
file_size = file.tell()
file.seek(0, os.SEEK_SET)
if file_size > BADGE_MAX_SIZE:
rejected_badges.append(file.filename)
continue
file_bytes = file.read()
now = time_now()
filename = f'u{user.id}d{now}s{i}.webp'
output_path = os.path.join(current_app.config['BADGES_UPLOAD_PATH'], filename)
proxied_filename = f'/static/badges/user/{filename}'
res = validate_and_create_badge(file_bytes, output_path)
if not res:
rejected_badges.append(file.filename)
continue
bu = BadgeUploads.create({
'user_id': user.id,
'uploaded_at': now,
'file_path': proxied_filename,
'original_filename': file.filename
})
else:
bu = BadgeUploads.find({'id': badge_upload_id})
if not bu:
continue
pending_badge['upload'] = bu.id
if id == -1:
pending_badge['user_id'] = user.id
badge = Badges.create(pending_badge)
else:
badge = Badges.find({'id': id})
if badge.user_id != user.id:
continue
if not badge:
continue
badge.update(pending_badge)
for stale_upload in BadgeUploads.get_unused_for_user(user.id):
filename = os.path.join(current_app.config['BADGES_UPLOAD_PATH'], os.path.basename(stale_upload.file_path))
os.remove(filename)
stale_upload.delete()
message = 'Badges updated.'
icon = InfoboxKind.INFO
if rejected_badges:
message += f';Some of your badges were incorrect and were not uploaded: {", ".join(rejected_badges)}.'
icon = InfoboxKind.WARN
flash(message, icon)
return redirect(url_for('.settings', username=username))

View File

@@ -1,10 +1,15 @@
from app import create_app
import os
import sys
app = create_app()
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == '-d':
hostname = '0.0.0.0'
else:
hostname = '127.0.0.1'
app.run(
host = "127.0.0.1",
host = hostname,
port = 8080
)

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():

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

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

View File

@@ -11,8 +11,13 @@
{%- else -%}
<title>{{ config.SITE_NAME }}</title>
{%- endif -%}
{%- if __feedlink -%}
<link rel="alternate" type="application/atom+xml" href="{{ __feedlink }}" title="{{ __feedtitle }}">
{%- endif -%}
</head>
<body>
<bitty-8 data-connect="/static/js/bits/progressive-enhancement.js"></bitty-8>
<bitty-8 data-connect="/static/js/bits/ui.js"></bitty-8>
{%- include 'common/topnav.html' -%}
{%- with messages = get_flashed_messages(with_categories=true) -%}
{%- if messages -%}
@@ -23,6 +28,7 @@
{%- endwith -%}
{%- block content -%}{%- endblock -%}
{%- include 'common/footer.html' -%}
<script type="module" src="/static/js/vnd/bitty-8.0.0.js"></script>
<script src="{{'/static/js/ui.js' | cachebust}}"></script>
</body>
</html>

View File

@@ -5,7 +5,7 @@
{%- endmacro %}
{% macro timestamp(unix_ts) -%}
<time datetime="{{ unix_ts | iso8601 }}">{{ unix_ts | ts_datetime('%Y-%m-%d %H:%M')}} <abbr title="Server Time">ST</abbr></time>
<time data-r="localizeTimestamps" datetime="{{ unix_ts | iso8601 }}">{{ unix_ts | ts_datetime('%Y-%m-%d %H:%M')}} <abbr title="Server Time">ST</abbr></time>
{%- endmacro %}
{% macro subheader(title, desc='') -%}
@@ -72,15 +72,15 @@
</span>
{%- endmacro %}
{% macro tabs(prefix='', labels = []) -%}
<div class="tab-container">
{% macro tabs(prefix='', labels=[], signal_ss=[], signal_rs=[]) -%}
<div class="tab-container" data-r="setTab">
<div class="tab-bar" role="tablist">
{%- for tab_label in labels -%}
<button type="button" class="tab-button" role="tab" aria-selected="{{'true' if loop.index0==0 else 'false'}}" id="{{prefix+'-'+(tab_label | lower)+'-tab'}}" aria-controls="{{prefix+'-'+(tab_label | lower)+'-content'}}" disabled>{{tab_label}}</button>
<button type="button" class="tab-button" role="tab" aria-selected="{{'true' if loop.index0==0 else 'false'}}" id="{{prefix+'-'+(tab_label | lower)+'-tab'}}" aria-controls="{{prefix+'-'+(tab_label | lower)+'-content'}}" disabled data-r="enhance" data-s="setTab {{signal_ss[loop.index0] if signal_ss[loop.index0] else ''}}" data-tab-index="{{loop.index0}}">{{tab_label}}</button>
{%- endfor -%}
</div>
{%- for tab_label in labels -%}
<div class="plank secondary-bg even no-shadow tab-content {{'hidden' if loop.index0!=0 else ''}}" role="tabpanel" aria-labelledby="{{prefix+'-'+(tab_label | lower)+'-tab'}}" id="{{prefix+'-'+(tab_label | lower)+'-content'}}">
<div class="plank secondary-bg even no-shadow tab-content {{'hidden' if loop.index0!=0 else ''}}" role="tabpanel" aria-labelledby="{{prefix+'-'+(tab_label | lower)+'-tab'}}" id="{{prefix+'-'+(tab_label | lower)+'-content'}}" data-r="{{signal_rs[loop.index0] if signal_rs[loop.index0] else ''}}">
{{- caller(loop.index0) -}}
</div>
{%- endfor -%}
@@ -94,24 +94,25 @@
id='babycode-content',
banned_tags=[]
) -%}
{%- call(idx) tabs(prefix='babycode', labels=['Write', 'Preview']) -%}
{%- call(idx) tabs(prefix='babycode', labels=['Write', 'Preview'], signal_ss=[none, 'babycodePreviewInit'], signal_rs=[none, 'babycodePreview']) -%}
{%- if idx == 0 -%}
<span class="babycode-editor-controls">
<span class="button-row">
<button type="button" class="minimal" disabled><b>B</b></button>
<button type="button" class="minimal" disabled><i>i</i></button>
<button type="button" class="minimal" disabled><s>S</s></button>
<button type="button" class="minimal" disabled><u>U</u></button>
<button type="button" class="minimal" disabled><code>://</code></button>
<button type="button" class="minimal" disabled><code>&lt;/&gt;</code></button>
<button type="button" class="minimal" disabled>1.</button>
<button type="button" class="minimal" disabled>&bullet;</button>
<button type="button" class="minimal" disabled><img src="/static/emoji/angry.png" class="emoji"></button>
<span class="button-row js-only" data-r="enhance">
<button type="button" title="insert bold" class="minimal" data-babycode-tag="b" data-s="insertBabycode"><b>B</b></button>
<button type="button" title="insert italic" class="minimal" data-babycode-tag="i" data-s="insertBabycode"><i>i</i></button>
<button type="button" title="insert strikethrough" class="minimal" data-babycode-tag="s" data-s="insertBabycode"><s>S</s></button>
<button type="button" title="insert underline" class="minimal" data-babycode-tag="u" data-s="insertBabycode"><u>U</u></button>
<button type="button" title="insert link" class="minimal" data-babycode-tag="url" data-prefill="link label" data-s="insertBabycode"><code>://</code></button>
<button type="button" title="insert code block" class="minimal" data-babycode-tag="code" data-break-line data-s="insertBabycode"><code>&lt;/&gt;</code></button>
<button type="button" title="insert ordered list" class="minimal" data-babycode-tag="ol" data-break-line data-s="insertBabycode">1.</button>
<button type="button" title="insert unordered list" class="minimal" data-babycode-tag="ul" data-break-line data-s="insertBabycode">&bullet;</button>
<button type="button" title="insert 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">{# 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">{{ 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>
{%- if banned_tags -%}
<div>
<span>Forbidden tags:</span>
@@ -123,6 +124,8 @@
</div>
{%- endif -%}
<a href="##">babycode help</a>
{%- else -%}
<div data-r="showBabycodePreview"></div>
{%- endif -%}
{%- endcall -%}
{%- endmacro %}
@@ -133,10 +136,29 @@
</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 reaction_buttons(post_id) -%}
{%- for reaction in Reactions.for_post(post_id) -%}
{% set reactors = Reactions.get_users(post_id, reaction.reaction_text) | map(attribute='username') | list %}
{% set reactors_trimmed = reactors[:10] %}
{% set reactors_str = reactors_trimmed | join (',\n') %}
{% if reactors | count > 10 %}
{% set reactors_str = reactors_str + '\n...and many others' %}
{% endif %}
{% set has_reacted = get_active_user() is not none and get_active_user().username in reactors %}
<button autocomplete="off" type="button" title="{{reactors_str}}" class="minimal {{'alt' if has_reacted else ''}}" data-emoji="{{reaction.reaction_text}}" data-s="toggleReaction" data-r="enableReactionButtons" disabled><img src="/static/emoji/{{reaction.reaction_text}}.png">{{reaction.c}}</button>
{%- endfor -%}
{%- endmacro %}
{% macro full_post(
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() -%}
@@ -152,7 +174,7 @@
<a href="{{url_for('users.user_page', username=post.username)}}" class="usercard-username">{{post.display_name if post.display_name else post.username}}</a>
<abbr title="mention">@{{post.username}}</abbr>
<i>{{post.status}}</i>
{%- set badges=post.badges_json | fromjson -%}
{%- set badges=post.badges_json | fromjson | sort(attribute='sort_order') -%}
<div class="badges-container">
{%- for badge in badges -%}
{%- if badge.link -%}<a href="{{badge.link}}">{%- endif -%}
@@ -163,9 +185,12 @@
</div>
</div>
</div>
<div class="post-content">
<div class="post-content" data-r="collectImages">
<div class="plank even minimal secondary-bg no-shadow post-info">
<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>
@@ -178,17 +203,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 -%}
<button disabled title="This feature requires JavaScript to be enabled.">Quote</button>
{%- 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 disabled title="This feature requires JavaScript to be enabled.">{{icn_bookmark(24)}}Bookmark&hellip;</button>
{%- if tb_bookmark -%}
{{ bookmark_button('post', post.id, bookmark_btn) }}
{%- endif -%}
</span>
{%- endif -%}
</div>
@@ -204,19 +231,10 @@
</div>
<div class="plank even secondary-bg minimal no-shadow">
{%- if show_reactions -%}
<span class="button-row">
{%- for reaction in Reactions.for_post(post.id) -%}
{% set reactors = Reactions.get_users(post.id, reaction.reaction_text) | map(attribute='username') | list %}
{% set reactors_trimmed = reactors[:10] %}
{% set reactors_str = reactors_trimmed | join (',\n') %}
{% if reactors | count > 10 %}
{% set reactors_str = reactors_str + '\n...and many others' %}
{% endif %}
{% set has_reacted = get_active_user() is not none and get_active_user().username in reactors %}
<button type="button" disabled title="{{reactors_str}}" class="minimal {{'alt' if has_reacted else ''}}"><img src="/static/emoji/{{reaction.reaction_text}}.png">{{reaction.c}}</button>
{%- endfor -%}
<span class="button-row" data-r="replaceReactionButtons">
{{- reaction_buttons(post.id) -}}
</span>
{%- if is_logged_in() and allow_reacting -%}<button disabled title="This feature requires JavaScript to be enabled.">Add reaction</button>{%- endif -%}
{%- if is_logged_in() and allow_reacting -%}<button autocomplete='off' data-r="disableReactionMenuButton enableReactionMenuButton" disabled title="This feature requires JavaScript to be enabled." data-s="openReactionMenu">Add reaction</button>{%- endif -%}
{%- elif is_editing -%}
<input type="submit" value="Save">
<a href="{{get_post_url(post.id, _anchor=true)}}" class="linkbutton warn">Cancel</a>
@@ -226,6 +244,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 -%}
@@ -259,16 +289,20 @@
{%- endmacro %}
{% macro sortable_list(attr=none) -%}
<ol class="sortable-list plank even no-shadow minimal tertiary-bg" {% if attr %}{{ dict_to_attr(attr) }}{% endif %}>
<ol class="sortable-list" {% if attr %}{{ dict_to_attr(attr) }}{% endif %}>
{%- if caller -%}
{{ caller() }}
{%- endif -%}
</ol>
{%- endmacro %}
{% macro sortable_list_item(key, immovable=false, attr=none) -%}
<li class="sortable-item{{ ' immovable' if immovable else '' }} plank even no-shadow {{'tertiary-bg' if immovable else ''}}" data-sortable-list-key="{{key}}" {% if attr %}{{ dict_to_attr(attr) }}{% endif %}>
{% macro sortable_list_item(key, immovable=false, attr=none, full=false) -%}
<li class="sortable-item{{ ' immovable' if immovable else '' }} plank even no-shadow {{'secondary-bg' if immovable else ''}}" data-sortable-list-key="{{key}}" {% if attr %}{{ dict_to_attr(attr) }}{% endif %}>
<span class="dragger plank minimal even no-shadow tertiary-bg" draggable="{{ 'true' if not immovable else 'false' }}">{{ icn_dragger() }}</span>
<div class="sortable-item-inner">{{ caller() }}</div>
<div class="sortable-item-inner {{full and 'full' or ''}}">{{ caller() }}</div>
</li>
{%- endmacro %}
{% macro rss_html_content(html) -%}
<content type="html">{{ html }}</content>
{%- endmacro %}

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() -%}
@@ -9,6 +9,9 @@
<li><a class="linkbutton" href="{{url_for('users.settings', username=user.username)}}">Settings</a></li>
<li><a class="linkbutton" href="{{url_for('users.inbox', username=user.username)}}">Inbox{{' (%s)' % uc if uc else ''}}</a></li>
<li><a class="linkbutton" href="{{url_for('users.bookmarks', username=user.username)}}">Bookmarks</a></li>
{%- if user.can_invite() -%}
<a href="{{url_for('users.settings', username=user.username, _anchor='invite')}}" class="linkbutton alt">Invite</a>
{%- endif %}
{% if user.is_mod() -%}
<li><a class="linkbutton" href="{{url_for('mod.index')}}">Moderation</a></li>
{%- endif %}
@@ -21,7 +24,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

@@ -0,0 +1,51 @@
{%- macro badge_input(uploads, label='', link='', selected=none, id=none) -%}
{%- set defaults = uploads | selectattr('user_id', 'none') | list | sort(attribute='file_path') -%}
{%- set user = uploads | selectattr('user_id') | list -%}
{%- if selected is not none -%}
{%- set selected_href = (uploads | selectattr('id', 'equalto', selected) | list)[0].file_path -%}
{%- else -%}
{% set selected_href = defaults[0].file_path %}
{%- endif -%}
<input type="hidden" name="id[]" value="{{id and id or '-1'}}">
<div class="badge-editor-badge-container">
<div class="badge-editor-badge-select">
<select name="badge_choice[]" required data-s="badgeEditorSetPreview badgeEditorToggleFilePicker" data-listen="change">
<optgroup label="Default">
{%- for upload in defaults -%}
<option data-file-path="{{upload.file_path}}" value="{{upload.id}}" {{selected==upload.id and 'selected' or ''}}>{{upload.file_path | basename_noext}}</option>
{%- endfor -%}
</optgroup>
<optgroup label="Your uploads">
{%- for upload in user -%}
<option data-file-path="{{upload.file_path}}" value="{{upload.id}}" {{selected==upload.id and 'selected' or ''}}>{{upload.original_filename | basename_noext}}</option>
{%- endfor -%}
<option value="custom">Upload new&hellip;</option>
</optgroup>
</select>
<img class="badge-button" src="{{selected_href}}" data-r="badgeEditorSetPreview badgeEditorSetPreviewCustom">
</div>
<div class="badge-editor-file-picker hidden" data-r="badgeEditorToggleFilePicker">
<button data-s="badgeEditorShowFilePicker" type="button" class="alt">Upload&hellip;</button>
<input data-s="badgeEditorFileSelected" data-r="badgeEditorShowFilePicker" type="file" accept="image/png, image/jpeg, image/jpg, image/webp" name="badge_file[]">
</div>
<input type="text" required placeholder="Label" value="{{label}}" autocomplete="off" name="label[]">
<input type="url" placeholder="(Optional) Link" value="{{link}}" autocomplete="off" name="link[]" pattern="https://.*">
<button type="button" class="critical" data-s="badgeEditorDelete">Delete</button>
</div>
{%- endmacro -%}
{%- from 'common/macros.html' import sortable_list, sortable_list_item -%}
<button type="button" data-s="badgeEditorAddBadge" data-r="setBadgeCount">Add badge</button>
<input type="submit" value="Save badges">
<span data-r="setBadgeCount">0/10</span>
{%- call() sortable_list(attr={'data-r': 'badgeEditorAddBadge'}) -%}
{%- for badge in badges -%}
{%- call() sortable_list_item('badge', full=true, attr={'data-r': 'badgeEditorDelete badgeEditorAssignImgId'}) -%}
{{badge_input(badge_uploads, badge.label, badge.link, badge.upload, badge.id)}}
{%- endcall -%}
{%- endfor -%}
{%- endcall -%}
<script type="text/html" id="badge-template">
{%- call() sortable_list_item('badge', full=true, attr={'data-r': 'badgeEditorDelete'}) -%}
{{- badge_input(badge_uploads) -}}
{%- endcall -%}
</script>

View File

@@ -0,0 +1,24 @@
<form class="full-width" method="POST" action="{{submit_url}}" data-listen="submit" data-s="bookmarkMenuSubmit" data-r="bookmarkMenuSubmit">
<input type="hidden" name="concept_id" value="{{concept_id}}">
<div class="inline-group bookmark-menu-item">
<input data-s="bookmarkMenuResetSavedButton" autocomplete="off" type="radio" name="target_collection" id="collection-none" {{'checked' if in_collection==none else ''}} value="-1">
<label class="bookmark-menu-label" for="collection-none">
No collection
<small>Choose this option to remove this {{'thread' if is_thread else 'post'}} from your bookmarks.</small>
</label>
</div>
{%- for collection in collections -%}
<div class="inline-group bookmark-menu-item">
<input data-s="bookmarkMenuResetSavedButton" autocomplete="off" type="radio" name="target_collection" id="collection-{{collection.id}}" {{'checked' if in_collection==collection.id else ''}} value="{{collection.id}}">
{%- set tc = collection.get_threads_count() -%}
{%- set pc = collection.get_posts_count() -%}
<label class="bookmark-menu-label" for="collection-{{collection.id}}">
{{collection.name}}
<small>{{tc}} {{'thread' | pluralize(num=tc)}}, {{pc}} {{'post' | pluralize(num=pc)}}</small>
</label>
</div>
{%- endfor -%}
<input type="text" placeholder="Optional memo" maxlength=100 name="note" autocomplete="off" value="{{note}}">
<input type="submit" value="{{'Saved!' if request.args.saved else 'Save'}}" data-r="bookmarkMenuShowSavedButton bookmarkMenuResetSavedButton">
<span class="errors hidden" data-r="bookmarkMenuShowError bookmarkMenuHideError">Something went wrong. Try again later.</span>
</form>

View File

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

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">

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

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

View File

@@ -1,9 +1,11 @@
{%- from 'common/macros.html' import subheader, timestamp, pager, babycode_editor_component -%}
{%- from 'common/macros.html' import subheader, timestamp, pager, babycode_editor_component, bookmark_button -%}
{%- from 'common/icons.html' import icn_bookmark -%}
{%- from 'common/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>
<bitty-8 data-connect="/static/js/bits/thread.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 +20,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 -%}
@@ -29,12 +31,12 @@
<input type="hidden" name="last_post_id" value="{{last_post.id}}">
<input type="submit" value="{{'Subscribe' if not get_active_user().is_subscribed(thread.id) else 'Unsubscribe'}}">
</form>
<button disabled title="This feature requires JavaScript to be enabled.">{{icn_bookmark(24)}}Bookmark&hellip;</button>
{{- bookmark_button('thread', thread.id) -}}
{%- endif -%}
<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 +57,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>
@@ -63,13 +65,13 @@
{%- endcall -%}
<main>
{%- for post in posts -%}
<article id="post-{{post.id}}" class="post plank">
<article id="post-{{post.id}}" class="post plank" data-postid="{{post.id}}">
{{full_post(post)}}
</article>
{%- 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,8 +84,28 @@
<button>Stop updates</button>
</span>
</div>
{{ bookmark_menu() }}
<dialog closedby="any" class="plank thread-lighbox" data-r="showLightbox closeLightbox">
<div class="menu">
<button data-s="closeLightbox">Close</button>
<a href="" target="_blank" rel="noreferrer noopener" class="linkbutton alt">Open original</a>
</div>
<img class="lightbox-image" src="https://placehold.co/900x710">
<div class="menu">
<button data-s="lightboxPrevious">Previous</button>
<span data-r="lightboxSetCounter">0/0</span>
<button data-s="lightboxNext">Next</button>
</div>
</dialog>
{%- if is_logged_in() and get_active_user().can_post_to_thread_or_topic(thread) -%}
<form action="{{url_for('threads.reply', thread_id=thread.id)}}" method="POST" class="plank post-edit-form">
<div class="plank even" id="reaction-popover" popover data-r="openReactionMenu closeReactionMenu">
{%- for emoji in REACTION_EMOJI -%}
<button class="minimal emoji-button" title=":{{emoji}}:" data-emoji="{{emoji}}" data-s="toggleReaction"><img src="/static/emoji/{{emoji}}.png"></button>
{%- endfor -%}
</div>
{%- endif -%}
{%- if is_logged_in() and get_active_user().can_post_to_thread_or_topic(thread) -%}
<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>
{{- babycode_editor_component() -}}
<span>

View File

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

View File

@@ -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>
@@ -20,14 +20,14 @@
<a href="{{url_for('topics.feed', topic_id=topic.id)}}" class="linkbutton rss">Subscribe via RSS</a>
<form method="GET">
<select name="sort_by">
<option value="activity"{% if sort_by == 'activity' %}selected{% endif %}>Sorted by activity</option>
<option value="activity" {% if sort_by == 'activity' %}selected{% endif %}>Sorted by activity</option>
<option value="thread" {% if sort_by == 'thread' %}selected{% endif %}>Sorted by newest</option>
</select>
<input type="submit" value="Sort">
</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,16 +37,16 @@
</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>
{%- endif -%}
{%- endcall -%}
{{ motd(get_motds()) }}
{%- if threads | length == 0 -%}
<div class="plank"><p>There are no threads in this topic yet.{%- if is_logged_in() and get_active_user().can_post_to_thread_or_topic(topic) %} Be the first to start a discussion!{%- endif -%}</p></div>
{%- endif -%}
{{ motd(get_motds()) }}
{%- for thread in threads -%}
<div class="topic-info plank">
<div class="title-container">
@@ -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="center plank even no-shadow minimal secondary-bg"><a href="{{url_for('threads.thread_by_id', thread_id=thread.id)}}">{{thread.title}}</a></td>
<td class="center plank even no-shadow minimal secondary-bg">{{bt.note}}</td>
<td class="center 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

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

View File

@@ -15,7 +15,7 @@ Welcome back! No account yet? <a href="{{url_for('users.sign_up')}}">Sign up</a>
<input type="text" id="username" name="username" autocomplete="username" required>
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="current-password" required>
<span><input type="checkbox" name="remember" id="remember"> <label for="remember">Remember me</label></span>
<div class="inline-group"><input type="checkbox" name="remember" id="remember"> <label for="remember">Remember me</label></div>
<input type="submit" value="Log in">
</form>
{%- 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>
@@ -48,10 +48,10 @@
<input type="text" name="display_name" id="display-name" value="{{user.display_name}}" placeholder="Same as username" pattern="(?:[\w!#$%^*\(\)\-_=+\[\]\{\}\|;:,.?\s]{3,50})?" title="Optional. 3-50 characters, no @, no <>, no &." maxlength="50" autocomplete=off>
<label for="status">Status</label>
<input type="text" name="status" id="status" maxlength="100" value="{{user.status}}" placeholder="Will be shown under your username on posts. Max. 100 characters." autocomplete="off">
<span>
<div class="inline-group">
<input type="checkbox" id="subscribe-by-default" name="subscribe_by_default" {{'' if session['dont_subscribe_by_default'] else 'checked'}} autocomplete="off">
<label for="subscribe-by-default">Automatically subscribe to thread when responding</label>
</span>
</div>
<input type="submit" value="Save">
</form>
</fieldset>
@@ -72,10 +72,52 @@
</form>
</fieldset>#}
<fieldset class="plank">
<bitty-8 data-connect="/static/js/bits/badge-editor.js"></bitty-8>
<legend>Badges</legend>
<div>Loading badges&hellip;</div>
<div>If badges fail to load, make sure JS is enabled.</div>
<form method="POST" action="{{url_for('users.save_badges', username=get_active_user().username)}}" data-listen="submit" data-r="badgeEditorInit" enctype="multipart/form-data">
<p>Loading badges&hellip;</p>
<p>If badges fail to load, make sure JS is enabled.</p>
</form>
</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,16 +45,17 @@
</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>
<span>Rank: {{target_user.permission | permission_string}}</span>
{%- set time = target_user.created_at -%}
{%- if target_user.approved_at -%}
{%- set time = target_user.approved_at -%}
{%- if target_user.confirmed_on -%}
<span>Joined: {{timestamp(target_user.confirmed_on)}}</span>
{%- endif -%}
{%- if invited_by -%}
<span>Invited by: <a href="{{url_for('users.user_page', username=invited_by.username)}}">{{invited_by.get_readable_name()}}</a></span>
{%- endif -%}
<span>Joined: {{timestamp(target_user.created_at)}}</span>
{%- if not target_user.is_guest() -%}
<span>Posts: <a href="{{url_for('users.posts', username=target_user.username)}}">{{stats.post_count}}</a></span>
<span>Threads started: <a href="{{url_for('users.threads', username=target_user.username)}}">{{stats.thread_count}}</a></span>

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1000 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 B

After

Width:  |  Height:  |  Size: 396 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 B

After

Width:  |  Height:  |  Size: 410 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 682 B

After

Width:  |  Height:  |  Size: 666 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 B

After

Width:  |  Height:  |  Size: 458 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 B

After

Width:  |  Height:  |  Size: 386 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 756 B

After

Width:  |  Height:  |  Size: 784 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 478 B

After

Width:  |  Height:  |  Size: 502 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 402 B

After

Width:  |  Height:  |  Size: 384 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 676 B

After

Width:  |  Height:  |  Size: 694 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 772 B

After

Width:  |  Height:  |  Size: 772 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 616 B

After

Width:  |  Height:  |  Size: 638 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 582 B

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 850 B

After

Width:  |  Height:  |  Size: 850 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 690 B

After

Width:  |  Height:  |  Size: 674 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 842 B

After

Width:  |  Height:  |  Size: 848 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 658 B

After

Width:  |  Height:  |  Size: 676 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 620 B

After

Width:  |  Height:  |  Size: 646 B

View File

@@ -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 calc(l * 1.2));
--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) 25%, var(--main-color) 26%, var(--main-color) 50%, 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) 25%, var(--hover-color) 26%, 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']) {
@@ -199,14 +201,13 @@ button, .linkbutton, input[type="submit"], input[type="file"]::file-selector-but
flex-direction: column;
}
input[type="text"], input[type="password"], textarea, select {
input[type="text"], input[type="password"], input[type="url"], textarea, select {
--main-color: hsl(from var(--bg-color-primary) h s calc(l + 10));
--active-color: hsl(from var(--main-color) h s calc(l + 5));
--border-color: hsl(from var(--main-color) h calc(s * 1.3) 25);
background-color: var(--main-color);
border-radius: var(--border-radius);
border: solid var(--border-thickness) var(--border-color);
resize: vertical;
padding: var(--small-padding) var(--medium-padding);
margin: var(--base-padding) 0px;
@@ -217,7 +218,8 @@ input[type="text"], input[type="password"], textarea, select {
}
textarea {
font-family: 'Atkinson Hyperlegible Mono'
font-family: 'Atkinson Hyperlegible Mono';
resize: vertical;
}
h1 {
@@ -263,7 +265,7 @@ a.site-title {
--rotation: 180deg;
padding: var(--medium-padding) var(--huge-padding);
background: linear-gradient(var(--rotation), var(--lighter-color) 0%, var(--main-color) 30%, var(--main-color) 70%, var(--darker-color) 100%);
background: linear-gradient(var(--rotation), var(--lighter-color) 0%, var(--main-color) 40px, var(--main-color) calc(100% - 40px), var(--darker-color) 100%);
background-color: var(--main-color);
border: 2px groove var(--border-color);
@@ -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 {
@@ -314,6 +335,10 @@ form.horizontal {
flex-wrap: wrap;
}
&.inline {
display: inline flex;
}
&> fieldset {
display: flex;
gap: var(--base-padding);
@@ -368,25 +393,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 +471,7 @@ footer {
gap: var(--base-padding);
}
.thread-actions {
.subheader-actions {
display: flex;
flex-wrap: wrap;
gap: var(--base-padding);
@@ -520,6 +526,7 @@ footer {
border-radius: var(--base-padding);
border: var(--base-padding) outset gray;
box-shadow: 0px 0px 12px 2px #0006;
align-self: center;
&::after {
content: '';
position: absolute;
@@ -629,9 +636,20 @@ form.full-width {
display: flex;
flex-direction: column;
align-items: start;
&> textarea, &> select, &> input[type="text"], &> input[type="password"] {
gap: var(--small-padding);
&> textarea, &> select, &> input[type="text"], &> input[type="password"], &> .inline-group {
width: 100%;
}
&> .inline-group {
display: flex;
flex-direction: row;
gap: var(--base-padding);
&> label {
width: 100%;
}
}
}
.context-explain {
@@ -651,12 +669,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);
}
@@ -666,6 +684,10 @@ details.separated {
margin: 0.5em 0;
}
details.inner {
margin-inline: var(--base-padding);
}
.avatar-form {
display: flex;
gap: var(--huge-padding);
@@ -678,6 +700,205 @@ details.separated {
justify-content: center;
}
#reaction-popover {
position: absolute;
margin-block: var(--small-padding);
margin-inline: 0;
width: 300px;
--button-size: calc(var(--huge-padding) * 2);
.emoji-button {
min-width: var(--button-size);
min-height: var(--button-size);
img {
image-rendering: crisp-edges;
width: 30px;
}
}
}
#reaction-popover:popover-open {
--gap: var(--base-padding);
--max-columns: 4;
display: grid;
gap: var(--gap);
justify-items: center;
--grid-item-size: calc((100% - var(--gap) * var(--max-columns)) / var(--max-columns));
grid-template-columns: repeat(
auto-fit,
minmax(max(var(--button-size), var(--grid-item-size)), 1fr)
);
}
#bookmark-popover {
position: absolute;
min-width: 400px;
max-width: 400px;
max-height: 500px;
margin-block: var(--small-padding);
margin-inline: 0;
padding-inline: var(--medium-padding);
overflow: scroll;
.bookmark-menu-header {
display: flex;
justify-content: space-between;
}
.bookmark-menu-inner .errors.hidden {
display: none;
}
}
.bookmark-menu-item {
padding-block: var(--medium-padding);
padding-inline: var(--base-padding);
&:has(.bookmark-menu-label:hover, input:hover) {
background-color: #0001;
}
.bookmark-menu-label {
display: flex;
flex-direction: column;
}
}
table {
border-collapse: collapse;
width: 100%;
th {
width: var(--w, 50%);
}
td.center {
text-align: center;
}
}
ol.sortable-list {
display: flex;
gap: var(--base-padding);
flex-direction: column;
list-style: none;
flex-grow: 1;
margin: 0;
padding-left: 0;
li {
display: flex;
gap: var(--big-padding);
}
li.immovable .dragger {
cursor: not-allowed;
}
}
.plank.dragger {
display: flex;
align-items: center;
/*background-color: var(--bg-color-tertiary);*/
padding: var(--base-padding);
cursor: move;
}
.sortable-item-inner {
display: flex;
gap: var(--base-padding);
flex-grow: 1;
flex-direction: column;
& > * {
flex-grow: 1;
}
&.row {
flex-direction: row;
}
&:not(.row):not(.full) > * {
margin-right: auto;
}
}
.badge-editor-badge-container {
display: flex;
align-items: baseline;
gap: var(--base-padding);
& > input[type=text], & > input[type=url] {
width: 100%;
}
}
.badge-editor-file-picker {
display: flex;
gap: var(--base-padding);
flex-direction: column;
align-items: center;
min-width: 150px;
& > input[type=file] {
width: 100%;
&::file-selector-button {
display: none;
}
}
&.hidden {
display: none;
}
}
.badge-editor-badge-select {
display: flex;
gap: var(--base-padding);
flex-direction: column;
align-items: center;
min-width: 200px;
& > select {
width: 100%;
}
}
.js-only {
display: none;
}
dialog.plank.thread-lighbox {
margin: auto;
min-width: 80vw;
min-height: 70vh;
max-width: 80vw;
max-height: 70vh;
flex-direction: column;
justify-content: space-between;
align-items: center;
& > .menu {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
&:open {
display: flex;
}
}
.lightbox-image {
max-width: 50vw;
max-height: 50vh;
}
/* babycode tags */
.inline-code {
background-color: var(--code-bg-color);
@@ -848,7 +1069,7 @@ a.mention {
padding: var(--base-padding);
background-color: var(--mention-color);
color: black;
border: 1px dashed;
border: 1px solid hsl(from var(--mention-color) h s 55%);
&:hover {
background-color: var(--hover-color);
@@ -859,53 +1080,6 @@ a.mention {
}
}
ol.sortable-list {
display: flex;
gap: var(--base-padding);
flex-direction: column;
list-style: none;
flex-grow: 1;
margin: 0;
padding-left: 0;
li {
display: flex;
gap: var(--big-padding);
}
li.immovable .dragger {
cursor: not-allowed;
}
}
.plank.dragger {
display: flex;
align-items: center;
/*background-color: var(--bg-color-tertiary);*/
padding: var(--base-padding);
cursor: move;
}
.sortable-item-inner {
display: flex;
gap: var(--base-padding);
flex-grow: 1;
flex-direction: column;
& > * {
flex-grow: 1;
}
&.row {
flex-direction: row;
}
&:not(.row) > * {
margin-right: auto;
}
}
@media (max-width: 768px) {
body {
margin-left: 0;
@@ -928,9 +1102,9 @@ ol.sortable-list {
min-height: 140px;
}
.usercard-inner {
flex-direction: row;
justify-content: space-between;
.usercard-inner:has(.usercard-rest) {
display: grid;
grid-template-columns: min-content 1fr;
}
.thread-title-counter {
@@ -971,4 +1145,22 @@ ol.sortable-list {
width: 100%;
text-align: center;
}
.badge-editor-badge-container {
flex-direction: column;
align-items: center;
}
dialog.plank.thread-lighbox {
margin: 0;
min-width: 100vw;
min-height: 100vh;
max-width: unset;
max-height: unset;
}
.lightbox-image {
max-width: 100%;
max-height: 100%;
}
}

View File

@@ -0,0 +1,164 @@
async function getHTML(endpoint, options = {}) {
let query = {};
if (options._query !== undefined) {
query = options._query;
delete options._query;
}
const params = new URLSearchParams(query);
const res = await fetch(`${endpoint}?${params}`, options);
return { body: await res.text(), status: res.status };
}
const validateBase64Img = dataURL => new Promise(resolve => {
const img = new Image();
img.onload = () => {
resolve(img.width === 88 && img.height === 31);
};
img.src = dataURL;
});
export const b = {
init: 'badgeEditorInit',
}
const badgeEditorEndpoint = '/hyperapi/badges/editor/'
const MAX_BADGES = 10;
let badgesCount = 0;
let customImageDatas = {};
export async function badgeEditorInit(_, __, el) {
const res = await getHTML(badgeEditorEndpoint);
if (res.status != 200) {
return;
}
el.innerHTML = res.body;
badgesCount = el.querySelectorAll('.sortable-item').length;
b.trigger('badgeEditorAssignImgId');
b.trigger('setBadgeCount');
}
export function badgeEditorAssignImgId(_, __, el) {
if (el.dataset.imgId) return;
const id = b.uuid();
const filePicker = el.querySelector('input[type=file]');
const img = el.querySelector('img.badge-button');
console.log(img);
el.dataset.imgId = id;
filePicker.dataset.imgId = id;
img.dataset.imgId = id;
}
export function badgeEditorSetPreview(ev, sender, el) {
if (!sender.parentNode.contains(el)) return;
const selectedItem = sender.selectedOptions[0];
if (selectedItem.value !== 'custom') {
el.src = selectedItem.dataset.filePath;
} else if (customImageDatas[el.dataset.imgId]) {
el.src = customImageDatas[el.dataset.imgId];
} else {
el.removeAttribute('src');
}
}
export function badgeEditorSetPreviewCustom(payload, _, el) {
if (!payload.badge.contains(el)) return;
if (!customImageDatas[el.dataset.imgId]) {
el.removeAttribute('src');
} else {
el.src = customImageDatas[el.dataset.imgId];
}
}
export function badgeEditorToggleFilePicker(ev, sender, el) {
if (!sender.parentNode.parentNode.contains(el)) return;
const selectedItem = sender.selectedOptions[0];
const picker = el.querySelector('input[type=file]');
if (selectedItem.value !== 'custom') {
el.classList.add('hidden');
picker.required = false;
picker.setCustomValidity('');
} else {
el.classList.remove('hidden');
picker.required = true;
picker.setCustomValidity(picker.dataset.validity || '');
}
}
export function badgeEditorAddBadge(ev, sender, el) {
// TODO: page templates do not get updated on mutation
const badgeTemplate = document.getElementById('badge-template').innerText;
const parser = new DOMParser();
const e = parser.parseFromString(badgeTemplate, 'text/html').body.firstElementChild;
el.appendChild(e);
b.trigger('badgeEditorAssignImgId');
badgesCount++;
b.trigger('setBadgeCount');
}
export function badgeEditorDelete(ev, sender, el) {
if (!el.contains(sender)) return;
el.remove();
badgesCount--;
b.trigger('setBadgeCount');
}
export function badgeEditorShowFilePicker(ev, sender, el) {
if (sender.nextElementSibling !== el) return;
el.showPicker();
}
export async function badgeEditorFileSelected(ev, sender, el) {
const file = sender.files[0];
const badge = sender.parentNode.parentNode;
if (
!['image/png', 'image/jpeg', 'image/jpg', 'image/webp'].includes(file.type)
) {
sender.dataset.validity = 'The badge file must be an image.';
sender.setCustomValidity(sender.dataset.validity);
sender.reportValidity();
customImageDatas[sender.dataset.imgId] = null;
b.send({ badge: badge }, 'badgeEditorSetPreviewCustom');
return;
}
if (file.size >= 1000 * 500) {
sender.dataset.validity = 'The badge image must be smaller than 500KB.';
sender.setCustomValidity(sender.dataset.validity);
sender.reportValidity();
customImageDatas[sender.dataset.imgId] = null;
b.send({ badge: badge }, 'badgeEditorSetPreviewCustom');
return;
}
const reader = new FileReader();
reader.onload = async e => {
const dimsValid = await validateBase64Img(e.target.result);
if (!dimsValid) {
sender.setCustomValidity('The badge image must be exactly 88x31 pixels.');
sender.reportValidity();
customImageDatas[sender.dataset.imgId] = null;
b.send({ badge: badge }, 'badgeEditorSetPreviewCustom');
return;
}
customImageDatas[sender.dataset.imgId] = e.target.result;
sender.dataset.validity = '';
sender.setCustomValidity('');
b.send({ badge: badge }, 'badgeEditorSetPreviewCustom');
}
reader.readAsDataURL(file);
}
export function setBadgeCount(_, __, el) {
if (el instanceof HTMLButtonElement) {
el.disabled = badgesCount === MAX_BADGES;
} else {
el.innerText = `${badgesCount}/${MAX_BADGES}`;
}
}

View File

@@ -0,0 +1,127 @@
async function getHTML(endpoint, options = {}) {
let query = {};
if (options._query !== undefined) {
query = options._query;
delete options._query;
}
const params = new URLSearchParams(query);
const res = await fetch(`${endpoint}?${params}`, options);
// if (!res.ok) {
// console.error(res);
// }
return { body: await res.text(), status: res.status };
}
export const b = {};
const BOOKMARKS_COLLECTION_ENDPOINT = '/hyperapi/bookmarks/dropdown/';
let bookmarkMenuState = {};
export async function showBookmarkMenu(ev, sender, el) {
if (bookmarkMenuState.state === undefined) {
el.addEventListener('toggle', e => {
if (e.newState === 'closed') {
bookmarkMenuState.state = 'closed';
}
});
}
// dismiss if open and last invoker is the same button that opened it
if (bookmarkMenuState.state === 'open' && bookmarkMenuState.invoker === sender) {
el.hidePopover();
return;
}
bookmarkMenuState.invoker = sender;
bookmarkMenuState.state = 'open';
b.send({ 'plain': 'Loading…' }, 'fillBookmarkMenu');
el.showPopover();
const bRect = sender.getBoundingClientRect();
const menuRect = el.getBoundingClientRect();
const preferredLeft = bRect.right - menuRect.width;
const enoughSpace = preferredLeft >= 0;
const scrollY = window.scrollY;
if (enoughSpace) {
el.style.left = `${preferredLeft}px`;
} else {
el.style.left = `${bRect.left}px`;
}
el.style.top = `${bRect.bottom + scrollY}px`;
bookmarkMenuState.kind = sender.dataset.conceptKind;
bookmarkMenuState.id = sender.dataset.conceptId;
const bookmarkCollections = await getHTML(BOOKMARKS_COLLECTION_ENDPOINT, {
_query: {
concept_kind: bookmarkMenuState.kind,
concept_id: bookmarkMenuState.id,
}
});
b.send({ 'html': bookmarkCollections.body }, 'fillBookmarkMenu');
}
export function fillBookmarkMenu(payload, __, el) {
if (payload.plain) {
el.innerText = payload.plain;
return;
}
el.innerHTML = payload.html;
}
export async function bookmarkMenuSubmit(ev, _, el) {
ev.preventDefault();
const url = el.action;
const body = new URLSearchParams(new FormData(el));
const options = { body: body, method: 'POST' };
const status = (await getHTML(url, options)).status;
if (status !== 204) {
b.send({ status: status }, 'bookmarkMenuShowError');
return;
}
const newCollections = await getHTML(BOOKMARKS_COLLECTION_ENDPOINT, {
_query: {
concept_kind: bookmarkMenuState.kind,
concept_id: bookmarkMenuState.id,
saved: true,
}
});
b.send({ 'html': newCollections.body }, 'fillBookmarkMenu');
}
export function bookmarkMenuResetSavedButton(_, __, el) {
el.value = 'Save';
}
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);
}
}
export function bookmarkMenuHideError(_, __, el) {
if (el === undefined) {
return;
}
if (!el.classList.contains('hidden')) {
el.classList.add('hidden');
}
}

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,20 @@
export const b = {}
export function addCollection(ev, sender, el) {
const parser = new DOMParser();
const e = parser.parseFromString(b.templates.collectionItem, 'text/html').body.firstElementChild;
el.appendChild(e);
}
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

@@ -0,0 +1,28 @@
export const b = {
init: 'enhance enhanceHide',
}
export function enhance(_, __, el) {
if (el === undefined) { // nothing to enhance but init still runs
return;
}
if (el.classList.contains('js-only')) {
el.classList.remove('js-only');
}
if (el.disabled) {
el.disabled = false;
if (el.title.search('JavaScript') !== -1) {
el.removeAttribute('title');
}
}
}
export function enhanceHide(_, __, el) {
if (el === undefined) {
return;
}
el.style.display = 'none';
}

View File

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

227
data/static/js/bits/ui.js Normal file
View File

@@ -0,0 +1,227 @@
export const b = {
init: 'babycodeEditorCharCountInit localizeTimestamps',
}
const BABYCODE_PREVIEW_ENDPOINT = '/api/babycode-preview/';
const getThreadId = () => {
const scheme = window.location.pathname.split("/");
if (scheme[1] !== 'threads' || scheme[2] === 'new') {
return -1;
}
return parseInt(scheme[2]);
}
export function setTab(_, sender, el) {
if (sender.ariaSelected === 'true') {
return;
}
if (!el.contains(sender)) {
return;
}
const tabIndex = parseInt(sender.dataset.tabIndex);
const tabPanels = el.querySelectorAll('.tab-content');
const tabButtons = el.querySelectorAll('.tab-bar button');
for (let i = 0; i < tabPanels.length; i++) {
const tabPanel = tabPanels[i];
const tabButton = tabButtons[i];
if (i === tabIndex) {
tabPanel.classList.remove('hidden');
tabButton.ariaSelected = 'true';
} else if (!tabPanel.classList.contains('hidden')) {
tabPanel.classList.add('hidden');
tabButton.ariaSelected = 'false';
}
}
}
export function insertBabycode(_, sender, el) {
if (!el.parentNode.contains(sender)) {
return;
}
const tagStart = sender.dataset.babycodeTag;
const breakLine = 'breakLine' in sender.dataset;
const prefill = 'prefill' in sender.dataset ? sender.dataset.prefill : '';
const hasAttr = tagStart[tagStart.length - 1] === '=';
let tagEnd = tagStart;
let tagInsertStart = `[${tagStart}]${breakLine ? '\n' : ''}`;
if (hasAttr) {
tagEnd = tagEnd.slice(0, -1);
}
const tagInsertEnd = `${breakLine ? '\n' : ''}[/${tagEnd}]`;
const hasSelection = el.selectionStart !== el.selectionEnd;
const text = el.value;
if (hasSelection) {
const realStart = Math.min(el.selectionStart, el.selectionEnd);
const realEnd = Math.max(el.selectionStart, el.selectionEnd);
const selectionLength = realEnd - realStart;
const strStart = text.slice(0, realStart);
const strEnd = text.substring(realEnd);
const frag = `${tagInsertStart}${text.slice(realStart, realEnd)}${tagInsertEnd}`;
const reconst = `${strStart}${frag}${strEnd}`;
el.value = reconst;
if (!hasAttr) {
el.setSelectionRange(realStart + tagInsertStart.length, realStart + tagInsertEnd.length + selectionLength - 1);
} else {
const attrCursor = realStart + tagInsertEnd.length - (1 + (breakLine ? 1 : 0))
el.setSelectionRange(attrCursor, attrCursor); // cursor on attr
}
} else {
if (hasAttr) {
tagInsertStart += prefill;
}
const cursor = el.selectionStart;
const strStart = text.slice(0, cursor);
const strEnd = text.substring(cursor);
let newCursor = strStart.length + tagInsertStart.length;
if (hasAttr) {
newCursor = cursor + tagInsertStart.length - prefill.length - (1 + (breakLine ? 1 : 0)); //cursor on attr
}
const reconst = `${strStart}${tagInsertStart}${tagInsertEnd}${strEnd}`;
el.value = reconst;
el.setSelectionRange(newCursor, newCursor);
}
el.focus();
b.send({ sender: el }, 'babycodeEditorCharCount');
}
export function babycodeEditorCharCount(evOrPayload, sender, el) {
if (!sender) { // sent from bitty, not input
sender = evOrPayload.sender;
}
if (!sender.parentNode.contains(el)) {
return;
}
const maxLength = sender.maxLength;
const currentLength = sender.value.length;
el.innerText = `${currentLength}/${maxLength}`;
const threadId = getThreadId();
if (threadId !== -1) {
localStorage.setItem(`thread-${threadId}`, sender.value);
}
}
export function clearThreadDraft(_, __, ___) {
const threadId = getThreadId();
if (threadId === -1) return;
localStorage.removeItem(`thread-${threadId}`);
}
export function babycodeEditorCharCountInit(_, __, el) {
if (el === undefined) { // no editors on page
return;
}
const threadId = getThreadId();
if (threadId !== -1) {
el.value = localStorage.getItem(`thread-${threadId}`) || '';
}
b.send({ sender: el }, 'babycodeEditorCharCount');
}
export function babycodePreviewInit(ev, sender, el) {
if (!sender.parentNode.parentNode.contains(el)) { // tab container > tab bar > button
return;
}
b.send({ text: el.value, sender: sender, bannedTags: JSON.parse(el.dataset.bannedTags) }, 'babycodePreview');
}
export async function babycodePreview(payload, _, el) {
if (!payload.sender.parentNode.parentNode.contains(el)) {
return;
}
if (!payload.text.trim()) {
b.send({ plain: 'Type something to get a preview.', sender: el }, 'showBabycodePreview');
return;
}
const options = {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({
markup: payload.text,
banned_tags: payload.bannedTags,
}),
}
const f = await fetch(BABYCODE_PREVIEW_ENDPOINT, options);
try {
if (!f.ok) {
console.error(f);
let msg = '';
switch (f.status) {
case 429:
return;
default:
msg = '(Something went wrong. Try again later.)'
}
b.send({ plain: msg, sender: el }, 'showBabycodePreview');
return;
}
b.send({ ...(await f.json()), sender: el }, 'showBabycodePreview');
} catch (error) {
b.send({ plain: '(Something went wrong. Try again later.)', sender: el }, 'showBabycodePreview');
console.error(error);
return;
}
}
export function showBabycodePreview(payload, _, el) {
if (!payload.sender.parentNode.contains(el)) {
return;
}
if (payload.plain) {
el.innerHTML = `<p>${payload.plain}</p>`;
} else {
el.innerHTML = payload.html;
}
}
export function babycodeEditorQuote(ev, sender, el) {
console.log(sender.dataset.quote);
const newline = el.value.length === 0 ? '' : '\n'
el.value += `${newline}[quote=${sender.dataset.posterName}]\n${sender.dataset.quote}\n[/quote]\n\n`
b.send({ sender: el }, 'babycodeEditorCharCount');
el.focus();
}
export async function copyCode(ev, sender, el) {
if (!el.contains(sender)) {
return;
}
const originalText = sender.textContent;
const doneText = 'Copied!';
const code = el.dataset.code;
try {
await navigator.clipboard.writeText(code);
sender.textContent = doneText;
setTimeout(() => { sender.textContent = originalText }, 2000);
} catch (error) {
console.log(error);
}
}
export function localizeTimestamps(_, __, el) {
if (el === undefined) {
return;
}
const d = new Date(el.dateTime);
el.innerText = d.toLocaleString();
}

View File

@@ -32,6 +32,9 @@
if (!target || target === draggedItem) {
return;
}
if (draggedItem === null) {
return;
}
const inSameList = draggedItem.dataset.sortableListKey === target.dataset.sortableListKey;
if (!inSameList) {
return;
@@ -70,8 +73,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 +98,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.requestSubmit();
}
}
})
}
})
}

File diff suppressed because one or more lines are too long