Compare commits
22 Commits
af57e2f10c
...
0c2e920206
| Author | SHA1 | Date | |
|---|---|---|---|
|
0c2e920206
|
|||
|
9682295dae
|
|||
|
f798bb5d7d
|
|||
|
68958e304b
|
|||
|
d2cdeaed1d
|
|||
|
9d8404b774
|
|||
|
84e69187ff
|
|||
|
0e71f597c9
|
|||
|
76d600f01d
|
|||
|
54ed6fef3a
|
|||
|
7c0cb623e3
|
|||
|
9c4f271259
|
|||
|
d6b44da6c2
|
|||
|
d0daaf4494
|
|||
|
7db111d18b
|
|||
|
dd54f5fe33
|
|||
|
4aa4e58c58
|
|||
|
ce9bca0a75
|
|||
|
099b5c135e
|
|||
|
5d53a0d179
|
|||
|
f31752797e
|
|||
|
0b845b75c4
|
@@ -30,13 +30,6 @@ Copyright: Copyright 2020-2024 The Atkinson Hyperlegible Mono Project Authors (h
|
|||||||
License: SIL Open Font License 1.1
|
License: SIL Open Font License 1.1
|
||||||
Designers: Elliott Scott, Megan Eiswerth, Braille Institute, Applied Design Works, Letters From Sweden
|
Designers: Elliott Scott, Megan Eiswerth, Braille Institute, Applied Design Works, Letters From Sweden
|
||||||
|
|
||||||
## ICONCINO
|
|
||||||
|
|
||||||
Affected files: [`app/templates/common/icons.html`](./app/templates/common/icons.html)
|
|
||||||
URL: https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license
|
|
||||||
Designers: Gabriele Malaspina
|
|
||||||
License: CC0 1.0
|
|
||||||
|
|
||||||
## Forumoji
|
## Forumoji
|
||||||
|
|
||||||
Affected files: everything in [`data/static/emoji`](./data/static/emoji) except [`data/static/emoji/scissors.png`](data/static/emoji/scissors.png)
|
Affected files: everything in [`data/static/emoji`](./data/static/emoji) except [`data/static/emoji/scissors.png`](data/static/emoji/scissors.png)
|
||||||
@@ -100,3 +93,13 @@ Some rights reserved.
|
|||||||
|
|
||||||
License: BSD-3-Clause ([see more](https://github.com/pallets-eco/flask-caching/blob/e59bc040cd47cd2b43e501d636d43d442c50b3ff/LICENSE))
|
License: BSD-3-Clause ([see more](https://github.com/pallets-eco/flask-caching/blob/e59bc040cd47cd2b43e501d636d43d442c50b3ff/LICENSE))
|
||||||
Repo: https://github.com/pallets-eco/flask-caching
|
Repo: https://github.com/pallets-eco/flask-caching
|
||||||
|
|
||||||
|
# Legacy
|
||||||
|
|
||||||
|
this section lists credits for files/libraries that are no longer used by the project.
|
||||||
|
|
||||||
|
## ICONCINO
|
||||||
|
|
||||||
|
URL: https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license
|
||||||
|
Designers: Gabriele Malaspina
|
||||||
|
License: CC0 1.0
|
||||||
|
|||||||
147
app/__init__.py
147
app/__init__.py
@@ -1,7 +1,7 @@
|
|||||||
from flask import Flask, session, request, render_template
|
from flask import Flask, session, request, render_template, redirect, url_for
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from .models import Avatars, Users, PostHistory, Posts, MOTD, BadgeUploads, Sessions
|
from .models import Avatars, Users, PostHistory, Posts, MOTD, BadgeUploads, Sessions
|
||||||
from .auth import digest
|
from .auth import digest, is_logged_in, get_active_user
|
||||||
from .constants import (
|
from .constants import (
|
||||||
PermissionLevel, permission_level_string,
|
PermissionLevel, permission_level_string,
|
||||||
InfoboxKind, InfoboxHTMLClass,
|
InfoboxKind, InfoboxHTMLClass,
|
||||||
@@ -10,45 +10,47 @@ from .constants import (
|
|||||||
)
|
)
|
||||||
from .lib.babycode import babycode_to_html, babycode_to_rssxml, EMOJI, BABYCODE_VERSION
|
from .lib.babycode import babycode_to_html, babycode_to_rssxml, EMOJI, BABYCODE_VERSION
|
||||||
from .lib.exceptions import SiteNameMissingException
|
from .lib.exceptions import SiteNameMissingException
|
||||||
|
from .util import get_post_url, dict_to_query_string, csrf_input, get_csrf_token
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from flask_caching import Cache
|
from flask_caching import Cache
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import secrets
|
import secrets
|
||||||
|
import hmac
|
||||||
import tomllib
|
import tomllib
|
||||||
import json
|
import json
|
||||||
|
|
||||||
def create_default_avatar():
|
def create_default_avatar():
|
||||||
if Avatars.count() == 0:
|
if Avatars.count() == 0:
|
||||||
print("Creating default avatar reference")
|
print('Creating default avatar reference')
|
||||||
Avatars.create({
|
Avatars.create({
|
||||||
"file_path": "/static/avatars/default.webp",
|
'file_path': '/static/avatars/default.webp',
|
||||||
"uploaded_at": int(time.time())
|
'uploaded_at': int(time.time())
|
||||||
})
|
})
|
||||||
|
|
||||||
def create_admin():
|
def create_admin():
|
||||||
username = "admin"
|
username = 'admin'
|
||||||
if Users.count({"username": username}) == 0:
|
if Users.count({'username': username}) == 0:
|
||||||
print("!!!!!Creating admin account!!!!!")
|
print('!!!!!Creating admin account!!!!!')
|
||||||
password_length = 16
|
password_length = 16
|
||||||
password = secrets.token_urlsafe(password_length)
|
password = secrets.token_urlsafe(password_length)
|
||||||
hashed = digest(password)
|
hashed = digest(password)
|
||||||
Users.create({
|
Users.create({
|
||||||
"username": username,
|
'username': username,
|
||||||
"password_hash": hashed,
|
'password_hash': hashed,
|
||||||
"permission": PermissionLevel.ADMIN.value,
|
'permission': PermissionLevel.ADMIN.value,
|
||||||
})
|
})
|
||||||
print(f"!!!!!Administrator account created, use '{username}' as the login and '{password}' as the password. This will only be shown once!!!!!")
|
print(f"!!!!!Administrator account created, use '{username}' as the login and '{password}' as the password. This will only be shown once!!!!!")
|
||||||
|
|
||||||
def create_deleted_user():
|
def create_deleted_user():
|
||||||
username = "DeletedUser"
|
username = 'DeletedUser'
|
||||||
if Users.count({"username": username.lower()}) == 0:
|
if Users.count({'username': username.lower()}) == 0:
|
||||||
print("Creating DeletedUser")
|
print('Creating DeletedUser')
|
||||||
Users.create({
|
Users.create({
|
||||||
"username": username.lower(),
|
'username': username.lower(),
|
||||||
"display_name": username,
|
'display_name': username,
|
||||||
"password_hash": "",
|
'password_hash': '',
|
||||||
"permission": PermissionLevel.SYSTEM.value,
|
'permission': PermissionLevel.SYSTEM.value,
|
||||||
})
|
})
|
||||||
|
|
||||||
def reparse_babycode():
|
def reparse_babycode():
|
||||||
@@ -167,26 +169,26 @@ def create_app():
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
print('No configuration file found, leaving defaults.')
|
print('No configuration file found, leaving defaults.')
|
||||||
|
|
||||||
if os.getenv("PYROM_PROD") is None:
|
if os.getenv('PYROM_PROD') is None:
|
||||||
app.static_folder = os.path.join(os.path.dirname(__file__), "../data/static")
|
app.static_folder = os.path.join(os.path.dirname(__file__), '../data/static')
|
||||||
app.debug = True
|
app.debug = True
|
||||||
app.config["DB_PATH"] = "data/db/db.dev.sqlite"
|
app.config['DB_PATH'] = 'data/db/db.dev.sqlite'
|
||||||
app.config["SERVER_NAME"] = "localhost:8080"
|
app.config['SERVER_NAME'] = 'localhost:8080'
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
else:
|
else:
|
||||||
app.config["DB_PATH"] = "data/db/db.prod.sqlite"
|
app.config['DB_PATH'] = 'data/db/db.prod.sqlite'
|
||||||
if not app.config["SERVER_NAME"]:
|
if not app.config['SERVER_NAME']:
|
||||||
raise SiteNameMissingException()
|
raise SiteNameMissingException()
|
||||||
|
|
||||||
app.config["SECRET_KEY"] = os.getenv("FLASK_SECRET_KEY")
|
app.config['SECRET_KEY'] = os.getenv('FLASK_SECRET_KEY')
|
||||||
|
|
||||||
app.config['AVATAR_UPLOAD_PATH'] = 'data/static/avatars/'
|
app.config['AVATAR_UPLOAD_PATH'] = 'data/static/avatars/'
|
||||||
app.config['BADGES_PATH'] = 'data/static/badges/'
|
app.config['BADGES_PATH'] = 'data/static/badges/'
|
||||||
app.config['BADGES_UPLOAD_PATH'] = 'data/static/badges/user/'
|
app.config['BADGES_UPLOAD_PATH'] = 'data/static/badges/user/'
|
||||||
app.config['MAX_CONTENT_LENGTH'] = 3 * 1000 * 1000 # 3M total, subject to further limits per route
|
app.config['MAX_CONTENT_LENGTH'] = 3 * 1000 * 1000 # 3M total, subject to further limits per route
|
||||||
|
|
||||||
os.makedirs(os.path.dirname(app.config["DB_PATH"]), exist_ok = True)
|
os.makedirs(os.path.dirname(app.config['DB_PATH']), exist_ok = True)
|
||||||
os.makedirs(os.path.dirname(app.config["BADGES_UPLOAD_PATH"]), exist_ok = True)
|
os.makedirs(os.path.dirname(app.config['BADGES_UPLOAD_PATH']), exist_ok = True)
|
||||||
|
|
||||||
if app.config['CACHE_TYPE'] == 'FileSystemCache':
|
if app.config['CACHE_TYPE'] == 'FileSystemCache':
|
||||||
cache_dir = app.config.get('CACHE_DIR', 'data/_cached')
|
cache_dir = app.config.get('CACHE_DIR', 'data/_cached')
|
||||||
@@ -195,6 +197,21 @@ def create_app():
|
|||||||
|
|
||||||
cache.init_app(app)
|
cache.init_app(app)
|
||||||
|
|
||||||
|
from app.routes.app import bp as app_bp
|
||||||
|
from app.routes.topics import bp as topics_bp
|
||||||
|
from app.routes.threads import bp as threads_bp
|
||||||
|
from app.routes.users import bp as users_bp
|
||||||
|
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
|
||||||
|
app.register_blueprint(app_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)
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
from .schema import create as create_tables
|
from .schema import create as create_tables
|
||||||
from .migrations import run_migrations
|
from .migrations import run_migrations
|
||||||
@@ -214,24 +231,40 @@ def create_app():
|
|||||||
app.config['SESSION_COOKIE_SECURE'] = True
|
app.config['SESSION_COOKIE_SECURE'] = True
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def make_session_permanent():
|
def revoke_session():
|
||||||
session.permanent = True
|
if is_logged_in():
|
||||||
|
sess = Sessions.find({'key': session['pyrom_session_key']})
|
||||||
|
if int(time.time()) > int(sess.expires_at):
|
||||||
|
sess.delete()
|
||||||
|
session.clear()
|
||||||
|
return redirect(url_for('topics.all_topics'))
|
||||||
|
|
||||||
commit = ""
|
@app.before_request
|
||||||
|
def generate_csrf_token():
|
||||||
|
if is_logged_in() and not session.get('csrf'):
|
||||||
|
rng = secrets.token_bytes(32)
|
||||||
|
session_key = session['pyrom_session_key']
|
||||||
|
message = f'd${len(session_key)}${session_key}@{len(rng)}@{rng.hex()}'
|
||||||
|
hashed = hmac.digest(app.config['SECRET_KEY'].encode('utf-8'), message.encode('utf-8'), 'SHA256')
|
||||||
|
csrf_token = f'{hashed.hex()}.{rng.hex()}'
|
||||||
|
|
||||||
|
session['csrf'] = csrf_token
|
||||||
|
|
||||||
|
commit = ''
|
||||||
with open('.git/refs/heads/main') as f:
|
with open('.git/refs/heads/main') as f:
|
||||||
commit = f.read().strip()
|
commit = f.read().strip()
|
||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
def inject_constants():
|
def inject_constants():
|
||||||
return {
|
return {
|
||||||
"InfoboxHTMLClass": InfoboxHTMLClass,
|
'InfoboxHTMLClass': InfoboxHTMLClass,
|
||||||
"InfoboxKind": InfoboxKind,
|
'InfoboxKind': InfoboxKind,
|
||||||
"PermissionLevel": PermissionLevel,
|
'PermissionLevel': PermissionLevel,
|
||||||
"__commit": commit,
|
'__commit': commit,
|
||||||
"__emoji": EMOJI,
|
'__emoji': EMOJI,
|
||||||
"REACTION_EMOJI": REACTION_EMOJI,
|
'REACTION_EMOJI': REACTION_EMOJI,
|
||||||
"MOTD_BANNED_TAGS": MOTD_BANNED_TAGS,
|
'MOTD_BANNED_TAGS': MOTD_BANNED_TAGS,
|
||||||
"SIG_BANNED_TAGS": SIG_BANNED_TAGS,
|
'SIG_BANNED_TAGS': SIG_BANNED_TAGS,
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
@@ -239,20 +272,30 @@ def create_app():
|
|||||||
return {
|
return {
|
||||||
'get_motds': MOTD.get_all,
|
'get_motds': MOTD.get_all,
|
||||||
'get_time_now': lambda: int(time.time()),
|
'get_time_now': lambda: int(time.time()),
|
||||||
|
'is_logged_in': is_logged_in,
|
||||||
|
'is_mod': lambda: is_logged_in() and get_active_user().is_mod(),
|
||||||
|
'get_active_user': get_active_user,
|
||||||
|
'get_post_url': get_post_url,
|
||||||
|
'csrf_input': csrf_input,
|
||||||
|
'get_csrf_token': get_csrf_token,
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.template_filter("ts_datetime")
|
@app.template_filter('ts_datetime')
|
||||||
def ts_datetime(ts, format):
|
def ts_datetime(ts, format):
|
||||||
return datetime.utcfromtimestamp(ts or int(time.time())).strftime(format)
|
return datetime.utcfromtimestamp(ts or int(time.time())).strftime(format)
|
||||||
|
|
||||||
@app.template_filter("pluralize")
|
@app.template_filter('dict_to_query_string')
|
||||||
def pluralize(subject, num=1, singular = "", plural = "s"):
|
def d2q(d):
|
||||||
|
return dict_to_query_string(d)
|
||||||
|
|
||||||
|
@app.template_filter('pluralize')
|
||||||
|
def pluralize(subject, num=1, singular = '', plural = 's'):
|
||||||
if int(num) == 1:
|
if int(num) == 1:
|
||||||
return subject + singular
|
return subject + singular
|
||||||
|
|
||||||
return subject + plural
|
return subject + plural
|
||||||
|
|
||||||
@app.template_filter("permission_string")
|
@app.template_filter('permission_string')
|
||||||
def permission_string(term):
|
def permission_string(term):
|
||||||
return permission_level_string(term)
|
return permission_level_string(term)
|
||||||
|
|
||||||
@@ -276,22 +319,22 @@ def create_app():
|
|||||||
return {'error': 'not found'}, e.code
|
return {'error': 'not found'}, e.code
|
||||||
else:
|
else:
|
||||||
return render_template('common/404.html'), e.code
|
return render_template('common/404.html'), e.code
|
||||||
|
#
|
||||||
@app.errorhandler(413)
|
# @app.errorhandler(413)
|
||||||
def _handle_413(e):
|
# def _handle_413(e):
|
||||||
if request.path.startswith('/hyperapi/'):
|
# if request.path.startswith('/hyperapi/'):
|
||||||
return '<h1>request body too large</h1>', e.code
|
# return '<h1>request body too large</h1>', e.code
|
||||||
elif request.path.startswith('/api/'):
|
# elif request.path.startswith('/api/'):
|
||||||
return {'error': 'body too large'}, e.code
|
# return {'error': 'body too large'}, e.code
|
||||||
else:
|
# else:
|
||||||
return render_template('common/413.html'), e.code
|
# return render_template('common/413.html'), e.code
|
||||||
|
|
||||||
# this only happens at build time but
|
# this only happens at build time but
|
||||||
# build time is when updates are done anyway
|
# build time is when updates are done anyway
|
||||||
# sooo... /shrug
|
# sooo... /shrug
|
||||||
@app.template_filter('cachebust')
|
@app.template_filter('cachebust')
|
||||||
def cachebust(subject):
|
def cachebust(subject):
|
||||||
return f"{subject}?v={str(int(time.time()))}"
|
return f'{subject}?v={str(int(time.time()))}'
|
||||||
|
|
||||||
@app.template_filter('theme_name')
|
@app.template_filter('theme_name')
|
||||||
def get_theme_name(subject: str):
|
def get_theme_name(subject: str):
|
||||||
|
|||||||
112
app/auth.py
112
app/auth.py
@@ -1,7 +1,25 @@
|
|||||||
|
from flask import session, flash, redirect, url_for, abort, request, current_app
|
||||||
|
from .models import Sessions, Users
|
||||||
from argon2 import PasswordHasher
|
from argon2 import PasswordHasher
|
||||||
|
from functools import wraps
|
||||||
|
import secrets
|
||||||
|
import hmac
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
|
||||||
ph = PasswordHasher()
|
ph = PasswordHasher()
|
||||||
|
|
||||||
|
FORBIDDEN_USERNAMES = (
|
||||||
|
'administrator', 'administration', 'administrators',
|
||||||
|
'system',
|
||||||
|
'mod', 'moderator', 'moderators', 'moderation',
|
||||||
|
'deleted-user', 'deleted_user',
|
||||||
|
'support',
|
||||||
|
#routes
|
||||||
|
'log-in', 'log_in', 'login',
|
||||||
|
'sign-up', 'sign_up', 'signup',
|
||||||
|
)
|
||||||
|
|
||||||
def digest(password):
|
def digest(password):
|
||||||
return ph.hash(password)
|
return ph.hash(password)
|
||||||
|
|
||||||
@@ -10,3 +28,97 @@ def verify(expected, given):
|
|||||||
return ph.verify(expected, given)
|
return ph.verify(expected, given)
|
||||||
except:
|
except:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def is_logged_in() -> bool:
|
||||||
|
if 'pyrom_session_key' not in session:
|
||||||
|
return False
|
||||||
|
sess = Sessions.find({'key': session['pyrom_session_key']})
|
||||||
|
if not sess:
|
||||||
|
return False
|
||||||
|
if sess.expires_at < int(time.time()):
|
||||||
|
session.clear()
|
||||||
|
sess.delete()
|
||||||
|
# flash('Your session expired.;Please log in again.', InfoboxKind.INFO)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_active_user() -> Users | None:
|
||||||
|
if not is_logged_in():
|
||||||
|
return None
|
||||||
|
|
||||||
|
sess = Sessions.find({'key': session['pyrom_session_key']})
|
||||||
|
return Users.find({'id': sess.user_id})
|
||||||
|
|
||||||
|
def create_session(user_id, temporary=False):
|
||||||
|
expires_days = 2 if temporary else 31
|
||||||
|
return Sessions.create({
|
||||||
|
'key': secrets.token_hex(16),
|
||||||
|
'user_id': user_id,
|
||||||
|
'expires_at': int(time.time()) + (expires_days * 24 * 60 * 60),
|
||||||
|
})
|
||||||
|
|
||||||
|
def parse_username(username: str) -> Tuple[str, str]:
|
||||||
|
"""first is the unmodified name/display name, second is username"""
|
||||||
|
if len(username) < 3:
|
||||||
|
raise ValueError
|
||||||
|
|
||||||
|
if username.lower() in FORBIDDEN_USERNAMES:
|
||||||
|
raise ValueError
|
||||||
|
|
||||||
|
invalid_regex = r'[^a-zA-Z0-9_-]'
|
||||||
|
return re.sub(invalid_regex, '_', username.lower())[:24], username
|
||||||
|
|
||||||
|
def is_password_valid(password: str) -> bool:
|
||||||
|
return re.match(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,255}$', password) is not None
|
||||||
|
|
||||||
|
# annotations
|
||||||
|
def login_required(view_func):
|
||||||
|
@wraps(view_func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
if not is_logged_in():
|
||||||
|
return redirect(url_for('users.log_in'))
|
||||||
|
return view_func(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
def mod_only(view_func):
|
||||||
|
@wraps(view_func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
if not is_logged_in():
|
||||||
|
abort(403)
|
||||||
|
if not get_active_user().is_mod():
|
||||||
|
abort(403)
|
||||||
|
return view_func(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
def csrf_verified(view_func):
|
||||||
|
"""
|
||||||
|
protects a request with a form against csrf and invalidates the csrf token stored in the session.
|
||||||
|
|
||||||
|
requires @login_requred.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@wraps(view_func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
if not session.get('csrf'):
|
||||||
|
abort(403)
|
||||||
|
if not request.form.get('csrf'):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
parts = request.form['csrf'].split('.')
|
||||||
|
if len(parts) != 2:
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
given_message = parts[0]
|
||||||
|
rng = bytes.fromhex(parts[1])
|
||||||
|
session_key = session['pyrom_session_key']
|
||||||
|
message = f'd${len(session_key)}${session_key}@{len(rng)}@{rng.hex()}'
|
||||||
|
expected = hmac.digest(current_app.config['SECRET_KEY'].encode('utf-8'), message.encode('utf-8'), 'SHA256').hex()
|
||||||
|
|
||||||
|
if not hmac.compare_digest(given_message, expected):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
session.pop('csrf')
|
||||||
|
|
||||||
|
return view_func(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|||||||
@@ -70,8 +70,8 @@ class InfoboxKind(IntEnum):
|
|||||||
ERROR = 3
|
ERROR = 3
|
||||||
|
|
||||||
InfoboxHTMLClass = {
|
InfoboxHTMLClass = {
|
||||||
InfoboxKind.INFO: "",
|
InfoboxKind.INFO: '',
|
||||||
InfoboxKind.LOCK: "warn",
|
InfoboxKind.LOCK: 'warn',
|
||||||
InfoboxKind.WARN: "warn",
|
InfoboxKind.WARN: 'warn',
|
||||||
InfoboxKind.ERROR: "critical",
|
InfoboxKind.ERROR: 'critical',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from pygments.lexers import get_lexer_by_name
|
|||||||
from pygments.util import ClassNotFound as PygmentsClassNotFound
|
from pygments.util import ClassNotFound as PygmentsClassNotFound
|
||||||
import re
|
import re
|
||||||
|
|
||||||
BABYCODE_VERSION = 8
|
BABYCODE_VERSION = 10
|
||||||
|
|
||||||
|
|
||||||
class BabycodeError(Exception):
|
class BabycodeError(Exception):
|
||||||
@@ -183,7 +183,7 @@ class HTMLRenderer(BabycodeRenderer):
|
|||||||
if mention_data not in self.mentions:
|
if mention_data not in self.mentions:
|
||||||
self.mentions.append(mention_data)
|
self.mentions.append(mention_data)
|
||||||
|
|
||||||
return f"<a class='mention{' display' if target_user.has_display_name() else ''}' href='{url_for('users.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-init='highlightMentions' data-username='{target_user.username}'>{'@' if not target_user.has_display_name() else ''}{target_user.get_readable_name()}</a>"
|
||||||
|
|
||||||
def render(self, ast):
|
def render(self, ast):
|
||||||
out = super().render(ast)
|
out = super().render(ast)
|
||||||
@@ -201,7 +201,7 @@ class RSSXMLRenderer(BabycodeRenderer):
|
|||||||
if not target_user:
|
if not target_user:
|
||||||
return f"@{e['name']}"
|
return f"@{e['name']}"
|
||||||
|
|
||||||
return f'<a href="{url_for('users.page', username=target_user.username, _external=True)}" title="@{target_user.username}">{'@' if not target_user.has_display_name() else ''}{target_user.get_readable_name()}</a>'
|
return f'<a href="{url_for('users.user_page', username=target_user.username, _external=True)}" title="@{target_user.username}">{'@' if not target_user.has_display_name() else ''}{target_user.get_readable_name()}</a>'
|
||||||
|
|
||||||
|
|
||||||
NAMED_COLORS = [
|
NAMED_COLORS = [
|
||||||
@@ -345,16 +345,21 @@ def tag_code(children, attr):
|
|||||||
return f"<code class=\"inline-code\">{children}</code>"
|
return f"<code class=\"inline-code\">{children}</code>"
|
||||||
else:
|
else:
|
||||||
input_code = children.strip()
|
input_code = children.strip()
|
||||||
button = f"<button type=button class=\"copy-code\" value=\"{input_code}\" data-send=\"copyCode\" data-receive=\"copyCode\">Copy</button>"
|
language = 'code block'
|
||||||
unhighlighted = f"<pre><span class=\"copy-code-container\"><span class=\"code-language-identifier\">code block</span>{button}</span><code>{input_code}</code></pre>"
|
if attr:
|
||||||
if not attr:
|
|
||||||
return unhighlighted
|
|
||||||
try:
|
try:
|
||||||
lexer = get_lexer_by_name(attr.strip())
|
lexer = get_lexer_by_name(attr.strip())
|
||||||
formatter = HtmlFormatter(nowrap=True)
|
formatter = HtmlFormatter(nowrap=True)
|
||||||
return f"<pre><span class=\"copy-code-container\"><span class=\"code-language-identifier\">{lexer.name}</span>{button}</span><code>{highlight(Markup(input_code).unescape(), lexer, formatter)}</code></pre>"
|
language = lexer.name
|
||||||
|
code = highlight(Markup(input_code).unescape(), lexer, formatter)
|
||||||
except PygmentsClassNotFound:
|
except PygmentsClassNotFound:
|
||||||
return unhighlighted
|
code = input_code
|
||||||
|
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>'
|
||||||
|
return block
|
||||||
|
|
||||||
|
|
||||||
def tag_list(children):
|
def tag_list(children):
|
||||||
@@ -383,16 +388,24 @@ def tag_color(children, attr):
|
|||||||
|
|
||||||
def tag_spoiler(children, attr):
|
def tag_spoiler(children, attr):
|
||||||
spoiler_name = attr if attr else "Spoiler"
|
spoiler_name = attr if attr else "Spoiler"
|
||||||
content = f"<div class='accordion-content post-accordion-content hidden'>{children}</div>"
|
content = f"<div class='plank minimal even no-shadow hidden'>{children}</div>"
|
||||||
container = f"""<div class='accordion hidden' data-receive='toggleAccordion'><div class='accordion-header'><button type='button' class='accordion-toggle' data-send='toggleAccordion'>+</button><span>{spoiler_name}</span></div>{content}</div>"""
|
container = f"""<details><summary class='plank secondary-bg no-shadow even'>{spoiler_name}</summary>{content}</details>"""
|
||||||
return container
|
return container
|
||||||
|
|
||||||
|
|
||||||
def tag_image(children, attr):
|
def tag_image(children, attr):
|
||||||
img = f"<img class=\"post-image\" src=\"{attr}\" alt=\"{children}\">"
|
img = f"<img class=\"post-image\" src=\"{attr}\" alt=\"{children}\">"
|
||||||
return f"<div class=post-img-container>{img}</div>"
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def tag_quote(children, attr):
|
||||||
|
if attr:
|
||||||
|
quotee = f'Quoting: {attr.strip()}'
|
||||||
|
else:
|
||||||
|
quotee = 'Quote'
|
||||||
|
|
||||||
|
return f'<fieldset class="plank minimal no-shadow secondary-bg"><legend>{quotee}</legend><blockquote>{children}</blockquote></fieldset>'
|
||||||
|
|
||||||
TAGS = {
|
TAGS = {
|
||||||
"b": lambda children, attr: f"<strong>{children}</strong>",
|
"b": lambda children, attr: f"<strong>{children}</strong>",
|
||||||
"i": lambda children, attr: f"<em>{children}</em>",
|
"i": lambda children, attr: f"<em>{children}</em>",
|
||||||
@@ -401,7 +414,7 @@ TAGS = {
|
|||||||
|
|
||||||
"img": tag_image,
|
"img": tag_image,
|
||||||
"url": lambda children, attr: f"<a href={attr}>{children}</a>",
|
"url": lambda children, attr: f"<a href={attr}>{children}</a>",
|
||||||
"quote": lambda children, attr: f"<blockquote>{children}</blockquote>",
|
"quote": tag_quote,
|
||||||
"code": tag_code,
|
"code": tag_code,
|
||||||
"ul": lambda children, attr: f"<ul>{tag_list(children)}</ul>",
|
"ul": lambda children, attr: f"<ul>{tag_list(children)}</ul>",
|
||||||
"ol": lambda children, attr: f"<ol>{tag_list(children)}</ol>",
|
"ol": lambda children, attr: f"<ol>{tag_list(children)}</ol>",
|
||||||
@@ -463,11 +476,8 @@ VOID_TAGS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# [img] is considered block for the purposes of collapsing whitespace,
|
|
||||||
# despite being potentially inline (since the resulting <img> tag is inline, but creates a block container around itself and sibling images).
|
|
||||||
# [code] has a special case in is_inline().
|
|
||||||
INLINE_TAGS = {
|
INLINE_TAGS = {
|
||||||
'b', 'i', 's', 'u', 'color', 'big', 'small', 'url', 'lb', 'rb', 'at', 'd'
|
'b', 'i', 's', 'u', 'color', 'big', 'small', 'url', 'lb', 'rb', 'at', 'd', 'img'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -53,11 +53,11 @@ def run_migrations():
|
|||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
if len(MIGRATIONS) == 0:
|
if len(MIGRATIONS) == 0:
|
||||||
print("No migrations defined.")
|
print('No migrations defined.')
|
||||||
return
|
return
|
||||||
print("Running migrations...")
|
print('Running migrations...')
|
||||||
ran = 0
|
ran = 0
|
||||||
completed = {int(row["id"]) for row in db.query("SELECT id FROM _migrations")}
|
completed = {int(row['id']) for row in db.query('SELECT id FROM _migrations')}
|
||||||
to_run = {idx: migration_obj for idx, migration_obj in enumerate(MIGRATIONS) if idx not in completed}
|
to_run = {idx: migration_obj for idx, migration_obj in enumerate(MIGRATIONS) if idx not in completed}
|
||||||
if not to_run:
|
if not to_run:
|
||||||
print('No migrations need to run.')
|
print('No migrations need to run.')
|
||||||
@@ -74,4 +74,4 @@ def run_migrations():
|
|||||||
|
|
||||||
db.execute('INSERT INTO _migrations (id) VALUES (?)', migration_id)
|
db.execute('INSERT INTO _migrations (id) VALUES (?)', migration_id)
|
||||||
ran += 1
|
ran += 1
|
||||||
print(f"Ran {ran} migrations.")
|
print(f'Ran {ran} migrations.')
|
||||||
|
|||||||
199
app/models.py
199
app/models.py
@@ -4,10 +4,10 @@ from flask import current_app
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
class Users(Model):
|
class Users(Model):
|
||||||
table = "users"
|
table = 'users'
|
||||||
|
|
||||||
def get_avatar_url(self):
|
def get_avatar_url(self):
|
||||||
return Avatars.find({"id": self.avatar_id}).file_path
|
return Avatars.find({'id': self.avatar_id}).file_path
|
||||||
|
|
||||||
def is_default_avatar(self):
|
def is_default_avatar(self):
|
||||||
return int(Avatars.find({'id': self.avatar_id}).id) == 1
|
return int(Avatars.find({'id': self.avatar_id}).id) == 1
|
||||||
@@ -104,71 +104,85 @@ class Users(Model):
|
|||||||
|
|
||||||
|
|
||||||
class Topics(Model):
|
class Topics(Model):
|
||||||
table = "topics"
|
table = 'topics'
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_list(_cls):
|
def get_list(_cls):
|
||||||
q = """
|
q = """
|
||||||
SELECT
|
SELECT
|
||||||
topics.id, topics.name, topics.slug, topics.description, topics.is_locked,
|
topics.id, topics.name, topics.slug, topics.description, topics.is_locked,
|
||||||
users.username AS latest_thread_username,
|
COUNT(DISTINCT threads.id) as threads_count,
|
||||||
users.display_name AS latest_thread_display_name,
|
COUNT(posts.id) AS posts_count,
|
||||||
threads.title AS latest_thread_title,
|
MAX(posts.created_at) as latest_post_timestamp
|
||||||
threads.slug AS latest_thread_slug,
|
|
||||||
threads.created_at AS latest_thread_created_at
|
|
||||||
FROM
|
FROM
|
||||||
topics
|
topics
|
||||||
LEFT JOIN (
|
|
||||||
SELECT
|
|
||||||
*,
|
|
||||||
row_number() OVER (PARTITION BY threads.topic_id ORDER BY threads.created_at DESC) as rn
|
|
||||||
FROM
|
|
||||||
threads
|
|
||||||
) threads ON threads.topic_id = topics.id AND threads.rn = 1
|
|
||||||
LEFT JOIN
|
LEFT JOIN
|
||||||
users on users.id = threads.user_id
|
threads ON threads.topic_id = topics.id
|
||||||
ORDER BY
|
LEFT JOIN
|
||||||
topics.sort_order ASC"""
|
posts ON posts.thread_id = threads.id
|
||||||
|
GROUP BY topics.id ORDER BY topics.sort_order ASC"""
|
||||||
return db.query(q)
|
return db.query(q)
|
||||||
|
|
||||||
def get_threads(self, per_page, page, sort_by = "activity"):
|
@classmethod
|
||||||
order_clause = ""
|
def new(_cls, name: str, description: str) -> Topics:
|
||||||
if sort_by == "thread":
|
from slugify import slugify
|
||||||
order_clause = "ORDER BY threads.is_stickied DESC, threads.created_at DESC"
|
name = name.strip()
|
||||||
|
description = description.strip()
|
||||||
|
now = int(time.time())
|
||||||
|
slug = f'{slugify(name)}-{now}'
|
||||||
|
|
||||||
|
topic_count = Topics.count()
|
||||||
|
return Topics.create({
|
||||||
|
'name': name,
|
||||||
|
'description': description,
|
||||||
|
'slug': slug,
|
||||||
|
'sort_order': topic_count + 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
def get_threads(self, per_page, page, sort_by = 'activity'):
|
||||||
|
order_clause = ''
|
||||||
|
if sort_by == 'thread':
|
||||||
|
order_clause = 'ORDER BY threads.is_stickied DESC, threads.created_at DESC'
|
||||||
else:
|
else:
|
||||||
order_clause = "ORDER BY threads.is_stickied DESC, latest_post_created_at DESC"
|
order_clause = 'ORDER BY threads.is_stickied DESC, latest_post_created_at DESC'
|
||||||
|
|
||||||
q = """
|
q = """
|
||||||
|
WITH latest_posts AS (
|
||||||
SELECT
|
SELECT
|
||||||
threads.id, threads.title, threads.slug, threads.created_at, threads.is_locked, threads.is_stickied,
|
thread_id,
|
||||||
users.username AS started_by,
|
id AS latest_post_id,
|
||||||
users.display_name AS started_by_display_name,
|
user_id AS latest_post_user_id,
|
||||||
u.username AS latest_post_username,
|
created_at AS latest_post_created_at,
|
||||||
u.display_name AS latest_post_display_name,
|
ROW_NUMBER() OVER (PARTITION BY thread_id ORDER BY created_at DESC) AS rn
|
||||||
ph.content AS latest_post_content,
|
FROM posts
|
||||||
posts.created_at AS latest_post_created_at,
|
),
|
||||||
posts.id AS latest_post_id
|
post_counts AS (
|
||||||
FROM
|
|
||||||
threads
|
|
||||||
JOIN users ON users.id = threads.user_id
|
|
||||||
JOIN (
|
|
||||||
SELECT
|
SELECT
|
||||||
posts.thread_id,
|
thread_id,
|
||||||
posts.id,
|
COUNT(*) AS posts_count
|
||||||
posts.user_id,
|
FROM posts
|
||||||
posts.created_at,
|
GROUP BY thread_id
|
||||||
posts.current_revision_id,
|
)
|
||||||
ROW_NUMBER() OVER (PARTITION BY posts.thread_id ORDER BY posts.created_at DESC) AS rn
|
SELECT
|
||||||
FROM
|
threads.title,
|
||||||
posts
|
threads.slug,
|
||||||
) posts ON posts.thread_id = threads.id AND posts.rn = 1
|
threads.created_at,
|
||||||
JOIN
|
threads.is_locked,
|
||||||
post_history ph ON ph.id = posts.current_revision_id
|
threads.is_stickied,
|
||||||
JOIN
|
starter.username AS started_by,
|
||||||
users u ON u.id = posts.user_id
|
starter.display_name AS started_by_display_name,
|
||||||
WHERE
|
latest_poster.username AS latest_post_username,
|
||||||
threads.topic_id = ?
|
latest_poster.display_name AS latest_post_display_name,
|
||||||
""" + order_clause + " LIMIT ? OFFSET ?"
|
latest_posts.latest_post_created_at,
|
||||||
|
latest_posts.latest_post_id,
|
||||||
|
COALESCE(post_counts.posts_count, 0) AS posts_count
|
||||||
|
FROM threads
|
||||||
|
JOIN users AS starter ON starter.id = threads.user_id
|
||||||
|
LEFT JOIN latest_posts ON latest_posts.thread_id = threads.id AND latest_posts.rn = 1
|
||||||
|
LEFT JOIN users AS latest_poster ON latest_poster.id = latest_posts.latest_post_user_id
|
||||||
|
LEFT JOIN post_counts ON post_counts.thread_id = threads.id
|
||||||
|
WHERE threads.topic_id = ?
|
||||||
|
""" + order_clause + ' LIMIT ? OFFSET ?'
|
||||||
|
|
||||||
return db.query(q, self.id, per_page, (page - 1) * per_page)
|
return db.query(q, self.id, per_page, (page - 1) * per_page)
|
||||||
|
|
||||||
@@ -204,13 +218,16 @@ class Topics(Model):
|
|||||||
|
|
||||||
return db.query(q, self.id)
|
return db.query(q, self.id)
|
||||||
|
|
||||||
|
def locked(self):
|
||||||
|
return bool(self.is_locked)
|
||||||
|
|
||||||
|
|
||||||
class Threads(Model):
|
class Threads(Model):
|
||||||
table = "threads"
|
table = 'threads'
|
||||||
|
|
||||||
def get_posts(self, limit, offset):
|
def get_posts(self, per_page, page):
|
||||||
q = Posts.FULL_POSTS_QUERY + " WHERE posts.thread_id = ? ORDER BY posts.created_at ASC LIMIT ? OFFSET ?"
|
q = Posts.FULL_POSTS_QUERY + ' WHERE posts.thread_id = ? ORDER BY posts.created_at ASC LIMIT ? OFFSET ?'
|
||||||
return db.query(q, self.id, limit, offset)
|
return db.query(q, self.id, per_page, (page - 1) * per_page)
|
||||||
|
|
||||||
def get_posts_rss(self):
|
def get_posts_rss(self):
|
||||||
q = Posts.FULL_POSTS_QUERY + ' WHERE posts.thread_id = ?'
|
q = Posts.FULL_POSTS_QUERY + ' WHERE posts.thread_id = ?'
|
||||||
@@ -222,6 +239,21 @@ class Threads(Model):
|
|||||||
def stickied(self):
|
def stickied(self):
|
||||||
return bool(self.is_stickied)
|
return bool(self.is_stickied)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
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}'
|
||||||
|
thread = Threads.create({
|
||||||
|
'topic_id': topic_id,
|
||||||
|
'user_id': user_id,
|
||||||
|
'title': title.strip(),
|
||||||
|
'slug': slug,
|
||||||
|
'created_at': int(time.time()),
|
||||||
|
})
|
||||||
|
post = Posts.new(user_id, thread.id, content, language)
|
||||||
|
return thread
|
||||||
|
|
||||||
class Posts(Model):
|
class Posts(Model):
|
||||||
FULL_POSTS_QUERY = """
|
FULL_POSTS_QUERY = """
|
||||||
WITH user_badges AS (
|
WITH user_badges AS (
|
||||||
@@ -263,23 +295,56 @@ class Posts(Model):
|
|||||||
LEFT JOIN
|
LEFT JOIN
|
||||||
user_badges ON users.id = user_badges.user_id"""
|
user_badges ON users.id = user_badges.user_id"""
|
||||||
|
|
||||||
table = "posts"
|
table = 'posts'
|
||||||
|
|
||||||
def get_full_post_view(self):
|
def get_full_post_view(self):
|
||||||
q = f'{self.FULL_POSTS_QUERY} WHERE posts.id = ?'
|
q = f'{self.FULL_POSTS_QUERY} WHERE posts.id = ?'
|
||||||
return db.fetch_one(q, self.id)
|
return db.fetch_one(q, self.id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def new(cls, user_id: int, thread_id: int, content: str, language: str = 'babycode') -> Posts:
|
||||||
|
from .lib.babycode import babycode_to_html, babycode_to_rssxml, BABYCODE_VERSION
|
||||||
|
html_content = babycode_to_html(content)
|
||||||
|
rssxml_content = babycode_to_rssxml(content)
|
||||||
|
with db.transaction():
|
||||||
|
post = Posts.create({
|
||||||
|
'thread_id': thread_id,
|
||||||
|
'user_id': user_id,
|
||||||
|
'current_revision_id': None,
|
||||||
|
})
|
||||||
|
|
||||||
|
revision = PostHistory.create({
|
||||||
|
'post_id': post.id,
|
||||||
|
'content': html_content.result,
|
||||||
|
'content_rss': rssxml_content,
|
||||||
|
'is_initial_revision': True,
|
||||||
|
'original_markup': content,
|
||||||
|
'markup_language': language,
|
||||||
|
'format_version': BABYCODE_VERSION,
|
||||||
|
})
|
||||||
|
|
||||||
|
for mention in html_content.mentions:
|
||||||
|
Mentions.create({
|
||||||
|
'revision_id': revision.id,
|
||||||
|
'mentioned_iser_id': mention['mentioned_iser_id'],
|
||||||
|
'start_index': mention['start'],
|
||||||
|
'end_index': mention['end'],
|
||||||
|
})
|
||||||
|
|
||||||
|
post.update({'current_revision_id': revision.id})
|
||||||
|
return post
|
||||||
|
|
||||||
class PostHistory(Model):
|
class PostHistory(Model):
|
||||||
table = "post_history"
|
table = 'post_history'
|
||||||
|
|
||||||
class Sessions(Model):
|
class Sessions(Model):
|
||||||
table = "sessions"
|
table = 'sessions'
|
||||||
|
|
||||||
class Avatars(Model):
|
class Avatars(Model):
|
||||||
table = "avatars"
|
table = 'avatars'
|
||||||
|
|
||||||
class Subscriptions(Model):
|
class Subscriptions(Model):
|
||||||
table = "subscriptions"
|
table = 'subscriptions'
|
||||||
|
|
||||||
def get_unread_count(self):
|
def get_unread_count(self):
|
||||||
q = """SELECT COUNT(*) AS unread_count
|
q = """SELECT COUNT(*) AS unread_count
|
||||||
@@ -316,15 +381,15 @@ class APIRateLimits(Model):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
class Reactions(Model):
|
class Reactions(Model):
|
||||||
table = "reactions"
|
table = 'reactions'
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def for_post(cls, post_id):
|
def for_post(cls, post_id):
|
||||||
qb = db.QueryBuilder(cls.table)\
|
qb = db.QueryBuilder(cls.table)\
|
||||||
.select("reaction_text, COUNT(*) as c")\
|
.select('reaction_text, COUNT(*) as c')\
|
||||||
.where({"post_id": post_id})\
|
.where({'post_id': post_id})\
|
||||||
.group_by("reaction_text")\
|
.group_by('reaction_text')\
|
||||||
.order_by("c", False)
|
.order_by('c', False)
|
||||||
result = qb.all()
|
result = qb.all()
|
||||||
return result if result else []
|
return result if result else []
|
||||||
|
|
||||||
@@ -342,7 +407,7 @@ class Reactions(Model):
|
|||||||
|
|
||||||
|
|
||||||
class PasswordResetLinks(Model):
|
class PasswordResetLinks(Model):
|
||||||
table = "password_reset_links"
|
table = 'password_reset_links'
|
||||||
|
|
||||||
|
|
||||||
class InviteKeys(Model):
|
class InviteKeys(Model):
|
||||||
@@ -446,7 +511,7 @@ class BadgeUploads(Model):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_for_user(cls, user_id):
|
def get_for_user(cls, user_id):
|
||||||
q = "SELECT * FROM badge_uploads WHERE user_id = ? OR user_id IS NULL ORDER BY uploaded_at"
|
q = 'SELECT * FROM badge_uploads WHERE user_id = ? OR user_id IS NULL ORDER BY uploaded_at'
|
||||||
res = db.query(q, int(user_id))
|
res = db.query(q, int(user_id))
|
||||||
return [cls.from_data(row) for row in res]
|
return [cls.from_data(row) for row in res]
|
||||||
|
|
||||||
|
|||||||
6
app/routes/app.py
Normal file
6
app/routes/app.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from flask import Blueprint, redirect, url_for, render_template
|
||||||
|
bp = Blueprint('app', __name__, url_prefix = '/')
|
||||||
|
|
||||||
|
@bp.get('/')
|
||||||
|
def index():
|
||||||
|
return redirect(url_for('topics.all_topics'))
|
||||||
11
app/routes/guides.py
Normal file
11
app/routes/guides.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
bp = Blueprint('guides', __name__, url_prefix = '/guides/')
|
||||||
|
|
||||||
|
@bp.get('/')
|
||||||
|
def index():
|
||||||
|
return 'stub'
|
||||||
|
|
||||||
|
@bp.get('/contact')
|
||||||
|
def contact():
|
||||||
|
return 'stub'
|
||||||
96
app/routes/mod.py
Normal file
96
app/routes/mod.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
from flask import Blueprint, abort, redirect, url_for, request, render_template
|
||||||
|
from ..auth import is_logged_in, get_active_user, csrf_verified
|
||||||
|
from ..models import Topics, Threads
|
||||||
|
bp = Blueprint('mod', __name__, url_prefix='/mod/')
|
||||||
|
|
||||||
|
@bp.before_request
|
||||||
|
def mod_only():
|
||||||
|
if not is_logged_in():
|
||||||
|
abort(403)
|
||||||
|
if not get_active_user().is_mod():
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
@bp.get('/')
|
||||||
|
def index():
|
||||||
|
return 'stub'
|
||||||
|
|
||||||
|
@bp.get('/topics/new/')
|
||||||
|
def new_topic():
|
||||||
|
return render_template('mod/new_topic.html')
|
||||||
|
|
||||||
|
@bp.post('/topics/new/')
|
||||||
|
def new_topic_post():
|
||||||
|
topic = Topics.new(request.form.get('name'), request.form.get('description'))
|
||||||
|
return redirect(url_for('topics.topic', slug=topic.slug))
|
||||||
|
|
||||||
|
@bp.get('/topics/sort/')
|
||||||
|
def sort_topics():
|
||||||
|
return 'stub'
|
||||||
|
|
||||||
|
@bp.get('/topics/<int:topic_id>/edit/')
|
||||||
|
def edit_topic(topic_id):
|
||||||
|
topic = Topics.find({'id': topic_id})
|
||||||
|
if not topic:
|
||||||
|
abort(404)
|
||||||
|
return render_template('mod/edit_topic.html', topic=topic)
|
||||||
|
|
||||||
|
@bp.post('/topics/<int:topic_id>/edit/')
|
||||||
|
def edit_topic_post(topic_id):
|
||||||
|
topic = Topics.find({'id': topic_id})
|
||||||
|
if not topic:
|
||||||
|
abort(404)
|
||||||
|
topic.update({
|
||||||
|
'name': request.form.get('name').strip(),
|
||||||
|
'description': request.form.get('description').strip(),
|
||||||
|
})
|
||||||
|
return redirect(url_for('topics.topic', slug=topic.slug))
|
||||||
|
|
||||||
|
@bp.post('/topics/<int:topic_id>/lock/')
|
||||||
|
def lock_topic(topic_id):
|
||||||
|
topic = Topics.find({'id': topic_id})
|
||||||
|
if not topic:
|
||||||
|
abort(404)
|
||||||
|
topic.update({'is_locked': request.form.get('lock', default=0)})
|
||||||
|
return redirect(url_for('topics.topic', slug=topic.slug))
|
||||||
|
|
||||||
|
@bp.post('/threads/<int:thread_id>/move/')
|
||||||
|
def move_thread(thread_id):
|
||||||
|
thread = Threads.find({'id': thread_id})
|
||||||
|
if not thread:
|
||||||
|
abort(404)
|
||||||
|
target_topic = Topics.find({'id': request.form.get('new_topic_id', default=None)})
|
||||||
|
if not target_topic:
|
||||||
|
abort(404)
|
||||||
|
thread.update({'topic_id': target_topic.id})
|
||||||
|
return redirect(url_for('threads.thread', slug=thread.slug))
|
||||||
|
|
||||||
|
@bp.post('/threads/<int:thread_id>/lock/')
|
||||||
|
def lock_thread(thread_id):
|
||||||
|
thread = Threads.find({'id': thread_id})
|
||||||
|
if not thread:
|
||||||
|
abort(404)
|
||||||
|
thread.update({'is_locked': request.form.get('lock')})
|
||||||
|
return redirect(url_for('threads.thread', slug=thread.slug))
|
||||||
|
|
||||||
|
@bp.post('/threads/<int:thread_id>/sticky/')
|
||||||
|
def sticky_thread(thread_id):
|
||||||
|
thread = Threads.find({'id': thread_id})
|
||||||
|
if not thread:
|
||||||
|
abort(404)
|
||||||
|
thread.update({'is_stickied': request.form.get('sticky')})
|
||||||
|
return redirect(url_for('threads.thread', slug=thread.slug))
|
||||||
|
|
||||||
|
@bp.post('/users/<int:user_id>/make-guest/')
|
||||||
|
@csrf_verified
|
||||||
|
def make_user_guest(user_id):
|
||||||
|
return 'stub'
|
||||||
|
|
||||||
|
@bp.post('/users/<int:user_id>/make-user/')
|
||||||
|
@csrf_verified
|
||||||
|
def make_user_regular(user_id):
|
||||||
|
return 'stub'
|
||||||
|
|
||||||
|
@bp.post('/users/<int:user_id>/make-mod/')
|
||||||
|
@csrf_verified
|
||||||
|
def make_user_mod(user_id):
|
||||||
|
return 'stub'
|
||||||
7
app/routes/posts.py
Normal file
7
app/routes/posts.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
bp = Blueprint('posts', __name__, url_prefix='/posts/')
|
||||||
|
|
||||||
|
@bp.get('/<int:post_id>/edit/')
|
||||||
|
def edit(post_id):
|
||||||
|
return 'stub'
|
||||||
92
app/routes/threads.py
Normal file
92
app/routes/threads.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
from flask import Blueprint, redirect, url_for, render_template, request, abort
|
||||||
|
from ..auth import login_required, get_active_user
|
||||||
|
from ..models import Threads, Posts, Topics, Users, Reactions
|
||||||
|
import math
|
||||||
|
|
||||||
|
bp = Blueprint('threads', __name__, url_prefix='/threads/')
|
||||||
|
|
||||||
|
@bp.get('/<slug>/')
|
||||||
|
def thread(slug):
|
||||||
|
thread = Threads.find({'slug': slug})
|
||||||
|
if not thread:
|
||||||
|
abort(404)
|
||||||
|
topic = Topics.find({'id': thread.topic_id})
|
||||||
|
started_by = Users.find({'id': thread.user_id})
|
||||||
|
PER_PAGE = 10
|
||||||
|
posts_count = Posts.count({'thread_id': thread.id})
|
||||||
|
page_count = max(1, math.ceil(posts_count / PER_PAGE))
|
||||||
|
page = 1
|
||||||
|
after = request.args.get('after')
|
||||||
|
if after is not None:
|
||||||
|
try:
|
||||||
|
after_id = int(after)
|
||||||
|
post_position = Posts.count([
|
||||||
|
('thread_id', '=', thread.id),
|
||||||
|
('id', '<=', after_id),
|
||||||
|
])
|
||||||
|
page = math.ceil((post_position) / PER_PAGE)
|
||||||
|
except ValueError:
|
||||||
|
abort(404)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
page = max(1, min(int(request.args.get('page', default=1)), page_count))
|
||||||
|
except ValueError:
|
||||||
|
abort(404)
|
||||||
|
return render_template('threads/thread.html', thread=thread, posts=thread.get_posts(PER_PAGE, page), page=page, page_count=page_count, topic=topic, started_by=started_by, topics=Topics.get_list(), Reactions=Reactions)
|
||||||
|
|
||||||
|
@bp.post('/<slug>/reply/')
|
||||||
|
@login_required
|
||||||
|
def reply(slug):
|
||||||
|
user = get_active_user()
|
||||||
|
thread = Threads.find({'slug': slug})
|
||||||
|
if not thread:
|
||||||
|
abort(404)
|
||||||
|
if thread.locked() and not user.is_mod():
|
||||||
|
# TODO: flash
|
||||||
|
return redirect(url_for('.thread', slug=slug))
|
||||||
|
post = Posts.new(user.id, thread.id, request.form.get('babycode_content'))
|
||||||
|
return redirect(url_for('.thread', slug=slug, after=post.id, _anchor=f'post-{post.id}'))
|
||||||
|
|
||||||
|
@bp.get('/<slug>/feed.atom/')
|
||||||
|
def feed(slug):
|
||||||
|
return 'stub'
|
||||||
|
|
||||||
|
@bp.get('/new/')
|
||||||
|
@login_required
|
||||||
|
def new():
|
||||||
|
topics = Topics.select()
|
||||||
|
try:
|
||||||
|
selected_topic = int(request.args.get('topic_id'))
|
||||||
|
except ValueError, TypeError:
|
||||||
|
selected_topic = None
|
||||||
|
return render_template('threads/new_thread.html', topics=topics, selected_topic=selected_topic)
|
||||||
|
|
||||||
|
@bp.post('/new/')
|
||||||
|
@login_required
|
||||||
|
def new_post():
|
||||||
|
try:
|
||||||
|
topic_id = int(request.form.get('topic_id'))
|
||||||
|
except ValueError, TypeError:
|
||||||
|
abort(404)
|
||||||
|
topic_id = int(topic_id)
|
||||||
|
topic = Topics.find({'id': topic_id})
|
||||||
|
if not topic:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
user = get_active_user()
|
||||||
|
if not user.can_post_to_topic(topic):
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
title = request.form.get('title')
|
||||||
|
if not title:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
if not title.strip():
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
title = title.strip()
|
||||||
|
|
||||||
|
content = request.form.get('babycode_content')
|
||||||
|
|
||||||
|
thread = Threads.new(user.id, topic.id, title, content)
|
||||||
|
return redirect(url_for('.thread', slug=thread.slug))
|
||||||
30
app/routes/topics.py
Normal file
30
app/routes/topics.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from flask import Blueprint, redirect, url_for, render_template, request, session, abort
|
||||||
|
|
||||||
|
from ..models import Topics, Threads
|
||||||
|
import math
|
||||||
|
|
||||||
|
bp = Blueprint('topics', __name__, url_prefix = '/topics/')
|
||||||
|
|
||||||
|
@bp.get('/')
|
||||||
|
def all_topics():
|
||||||
|
topic_list = Topics.get_list()
|
||||||
|
return render_template('topics/topics.html', topics=topic_list)
|
||||||
|
|
||||||
|
@bp.get('/<slug>/')
|
||||||
|
def topic(slug):
|
||||||
|
topic = Topics.find({'slug': slug})
|
||||||
|
if not topic:
|
||||||
|
abort(404)
|
||||||
|
sort_by = request.args.get('sort_by', default=session.get('sort_by', default='activity'))
|
||||||
|
PER_PAGE = 3
|
||||||
|
threads_count = Threads.count({'topic_id': topic.id})
|
||||||
|
page_count = max(1, math.ceil(threads_count / PER_PAGE))
|
||||||
|
try:
|
||||||
|
page = max(1, min(int(request.args.get('page', default=1)), page_count))
|
||||||
|
except ValueError:
|
||||||
|
abort(404)
|
||||||
|
return render_template('topics/topic.html', topic=topic, threads=topic.get_threads(PER_PAGE, page, sort_by), sort_by=sort_by, page=page, page_count=page_count)
|
||||||
|
|
||||||
|
@bp.get('/<slug>/feed.atom')
|
||||||
|
def feed(slug):
|
||||||
|
return 'stub'
|
||||||
135
app/routes/users.py
Normal file
135
app/routes/users.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
from flask import Blueprint, redirect, url_for, render_template, request, session
|
||||||
|
from functools import wraps
|
||||||
|
import time
|
||||||
|
|
||||||
|
from ..auth import (
|
||||||
|
digest, verify, create_session,
|
||||||
|
is_logged_in, parse_username, is_password_valid,
|
||||||
|
login_required
|
||||||
|
)
|
||||||
|
from ..models import Users
|
||||||
|
from ..constants import PermissionLevel
|
||||||
|
from secrets import compare_digest as compare_timesafe
|
||||||
|
|
||||||
|
bp = Blueprint('users', __name__, url_prefix='/users/')
|
||||||
|
|
||||||
|
def redirect_if_logged_in(destination='topics.all_topics'):
|
||||||
|
def decorator(view_func):
|
||||||
|
@wraps(view_func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
if is_logged_in():
|
||||||
|
return redirect(url_for(destination))
|
||||||
|
return view_func(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
@bp.get('/log-in/')
|
||||||
|
@redirect_if_logged_in()
|
||||||
|
def log_in():
|
||||||
|
return render_template('users/log_in.html')
|
||||||
|
|
||||||
|
@bp.post('/log-out/')
|
||||||
|
@login_required
|
||||||
|
def log_out():
|
||||||
|
return 'stub'
|
||||||
|
|
||||||
|
@bp.post('/log-in/')
|
||||||
|
@redirect_if_logged_in()
|
||||||
|
def log_in_post():
|
||||||
|
username = request.form.get('username', default='').lower()
|
||||||
|
user = Users.find({'username': username})
|
||||||
|
if not user:
|
||||||
|
return redirect(url_for('.log_in', error='The username or password you entered is incorrect.'))
|
||||||
|
password = request.form.get('password', default='')
|
||||||
|
if not verify(user.password_hash, password):
|
||||||
|
return redirect(url_for('.log_in', error='The username or password you entered is incorrect.'))
|
||||||
|
|
||||||
|
session['remember'] = request.form.get('remember') == 'on'
|
||||||
|
sess = create_session(user.id, not session['remember'])
|
||||||
|
session['pyrom_session_key'] = sess.key
|
||||||
|
if session['remember']:
|
||||||
|
session.permanent = True
|
||||||
|
return redirect(request.form.get('return_to', default=url_for('topics.all_topics')))
|
||||||
|
|
||||||
|
@bp.get('/sign-up/')
|
||||||
|
@redirect_if_logged_in()
|
||||||
|
def sign_up():
|
||||||
|
return render_template('users/sign_up.html')
|
||||||
|
|
||||||
|
@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.'))
|
||||||
|
username = request.form.get('username', default='')
|
||||||
|
if not username:
|
||||||
|
return generic_error_page
|
||||||
|
if request.form.get('password') is None:
|
||||||
|
return generic_error_page
|
||||||
|
if len(request.form.getlist('password')) != 2:
|
||||||
|
return passwords_error_page
|
||||||
|
try:
|
||||||
|
username_pair = parse_username(username)
|
||||||
|
except ValueError:
|
||||||
|
return invalid_username_error_page
|
||||||
|
potential_user = Users.find({'username': username})
|
||||||
|
if potential_user:
|
||||||
|
return invalid_username_error_page
|
||||||
|
|
||||||
|
if not compare_timesafe(request.form.getlist('password')[0], request.form.getlist('password')[1]):
|
||||||
|
return passwords_error_page
|
||||||
|
|
||||||
|
password_hash = digest(request.form.get('password'))
|
||||||
|
|
||||||
|
user = Users.create({
|
||||||
|
'username': username_pair[0],
|
||||||
|
'password_hash': password_hash,
|
||||||
|
'permission': PermissionLevel.GUEST.value,
|
||||||
|
'created_at': int(time.time()),
|
||||||
|
})
|
||||||
|
|
||||||
|
if username_pair[0] != username_pair[1]:
|
||||||
|
user.update({
|
||||||
|
'display_name': username_pair[1]
|
||||||
|
})
|
||||||
|
|
||||||
|
session['remember'] = request.form.get('remember') == 'on'
|
||||||
|
sess = create_session(user.id, not session['remember'])
|
||||||
|
session['pyrom_session_key'] = sess.key
|
||||||
|
if session['remember']:
|
||||||
|
session.permanent = True
|
||||||
|
|
||||||
|
return redirect(url_for('topics.all_topics'))
|
||||||
|
|
||||||
|
@bp.get('/<username>/')
|
||||||
|
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)
|
||||||
|
|
||||||
|
@bp.get('/<username>/posts/')
|
||||||
|
def posts(username):
|
||||||
|
return 'stub'
|
||||||
|
|
||||||
|
@bp.get('/<username>/threads/')
|
||||||
|
def threads(username):
|
||||||
|
return 'stub'
|
||||||
|
|
||||||
|
@bp.get('/<username>/comments/')
|
||||||
|
def comments(username):
|
||||||
|
return 'stub'
|
||||||
|
|
||||||
|
@bp.get('/<username>/settings/')
|
||||||
|
def settings(username):
|
||||||
|
return 'stub'
|
||||||
|
|
||||||
|
@bp.get('/<username>/inbox/')
|
||||||
|
def inbox(username):
|
||||||
|
return 'stub'
|
||||||
|
|
||||||
|
@bp.get('/<username>/bookmarks/')
|
||||||
|
def bookmarks(username):
|
||||||
|
return 'stub'
|
||||||
|
|
||||||
@@ -159,39 +159,39 @@ SCHEMA = [
|
|||||||
)""",
|
)""",
|
||||||
|
|
||||||
# INDEXES
|
# INDEXES
|
||||||
"CREATE INDEX IF NOT EXISTS idx_post_history_post_id ON post_history(post_id)",
|
'CREATE INDEX IF NOT EXISTS idx_post_history_post_id ON post_history(post_id)',
|
||||||
"CREATE INDEX IF NOT EXISTS idx_posts_thread ON posts(thread_id, created_at, id)",
|
'CREATE INDEX IF NOT EXISTS idx_posts_thread ON posts(thread_id, created_at, id)',
|
||||||
"CREATE INDEX IF NOT EXISTS idx_posts_thread_id ON posts(thread_id)",
|
'CREATE INDEX IF NOT EXISTS idx_posts_thread_id ON posts(thread_id)',
|
||||||
"CREATE INDEX IF NOT EXISTS idx_rate_limit_user_method ON api_rate_limits (user_id, method)",
|
'CREATE INDEX IF NOT EXISTS idx_rate_limit_user_method ON api_rate_limits (user_id, method)',
|
||||||
"CREATE INDEX IF NOT EXISTS idx_subscription_user_thread ON subscriptions (user_id, thread_id)",
|
'CREATE INDEX IF NOT EXISTS idx_subscription_user_thread ON subscriptions (user_id, thread_id)',
|
||||||
"CREATE INDEX IF NOT EXISTS idx_threads_slug ON threads(slug)",
|
'CREATE INDEX IF NOT EXISTS idx_threads_slug ON threads(slug)',
|
||||||
"CREATE INDEX IF NOT EXISTS idx_threads_topic_id ON threads(topic_id)",
|
'CREATE INDEX IF NOT EXISTS idx_threads_topic_id ON threads(topic_id)',
|
||||||
"CREATE INDEX IF NOT EXISTS idx_topics_slug ON topics(slug)",
|
'CREATE INDEX IF NOT EXISTS idx_topics_slug ON topics(slug)',
|
||||||
"CREATE INDEX IF NOT EXISTS session_keys ON sessions(key)",
|
'CREATE INDEX IF NOT EXISTS session_keys ON sessions(key)',
|
||||||
"CREATE INDEX IF NOT EXISTS sessions_user_id ON sessions(user_id)",
|
'CREATE INDEX IF NOT EXISTS sessions_user_id ON sessions(user_id)',
|
||||||
|
|
||||||
"CREATE INDEX IF NOT EXISTS reaction_post_text ON reactions(post_id, reaction_text)",
|
'CREATE INDEX IF NOT EXISTS reaction_post_text ON reactions(post_id, reaction_text)',
|
||||||
"CREATE INDEX IF NOT EXISTS reaction_user_post_text ON reactions(user_id, post_id, reaction_text)",
|
'CREATE INDEX IF NOT EXISTS reaction_user_post_text ON reactions(user_id, post_id, reaction_text)',
|
||||||
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_bookmark_collections_user_id ON bookmark_collections(user_id)",
|
'CREATE INDEX IF NOT EXISTS idx_bookmark_collections_user_id ON bookmark_collections(user_id)',
|
||||||
"CREATE INDEX IF NOT EXISTS idx_bookmark_collections_user_default ON bookmark_collections(user_id, is_default) WHERE is_default = 1",
|
'CREATE INDEX IF NOT EXISTS idx_bookmark_collections_user_default ON bookmark_collections(user_id, is_default) WHERE is_default = 1',
|
||||||
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_bookmarked_posts_collection ON bookmarked_posts(collection_id)",
|
'CREATE INDEX IF NOT EXISTS idx_bookmarked_posts_collection ON bookmarked_posts(collection_id)',
|
||||||
"CREATE INDEX IF NOT EXISTS idx_bookmarked_posts_post ON bookmarked_posts(post_id)",
|
'CREATE INDEX IF NOT EXISTS idx_bookmarked_posts_post ON bookmarked_posts(post_id)',
|
||||||
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_bookmarked_threads_collection ON bookmarked_threads(collection_id)",
|
'CREATE INDEX IF NOT EXISTS idx_bookmarked_threads_collection ON bookmarked_threads(collection_id)',
|
||||||
"CREATE INDEX IF NOT EXISTS idx_bookmarked_threads_thread ON bookmarked_threads(thread_id)",
|
'CREATE INDEX IF NOT EXISTS idx_bookmarked_threads_thread ON bookmarked_threads(thread_id)',
|
||||||
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_mentioned_user ON mentions(mentioned_user_id)",
|
'CREATE INDEX IF NOT EXISTS idx_mentioned_user ON mentions(mentioned_user_id)',
|
||||||
"CREATE INDEX IF NOT EXISTS idx_mention_revision_id ON mentions(revision_id)",
|
'CREATE INDEX IF NOT EXISTS idx_mention_revision_id ON mentions(revision_id)',
|
||||||
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_badge_upload_user ON badge_uploads(user_id)",
|
'CREATE INDEX IF NOT EXISTS idx_badge_upload_user ON badge_uploads(user_id)',
|
||||||
"CREATE INDEX IF NOT EXISTS idx_badge_user ON badges(user_id)",
|
'CREATE INDEX IF NOT EXISTS idx_badge_user ON badges(user_id)',
|
||||||
]
|
]
|
||||||
|
|
||||||
def create():
|
def create():
|
||||||
print("Creating schema...")
|
print('Creating schema...')
|
||||||
with db.transaction():
|
with db.transaction():
|
||||||
for stmt in SCHEMA:
|
for stmt in SCHEMA:
|
||||||
db.execute(stmt)
|
db.execute(stmt)
|
||||||
print("Schema completed.")
|
print('Schema completed.')
|
||||||
|
|||||||
19
app/templates/base.html
Normal file
19
app/templates/base.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png">
|
||||||
|
<link rel="stylesheet" href="{{ "/static/css/style.css" | cachebust }}">
|
||||||
|
{% if self.title() -%}
|
||||||
|
<title>{{ config.SITE_NAME }} - {% block title -%}{%- endblock -%}</title>
|
||||||
|
{%- else -%}
|
||||||
|
<title>{{ config.SITE_NAME }}</title>
|
||||||
|
{%- endif -%}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{%- include 'common/topnav.html' -%}
|
||||||
|
{%- block content -%}{%- endblock -%}
|
||||||
|
{%- include 'common/footer.html' -%}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
8
app/templates/common/404.html
Normal file
8
app/templates/common/404.html
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{%- from 'common/macros.html' import subheader -%}
|
||||||
|
{%- extends 'base.html' -%}
|
||||||
|
{%- block title -%}Not found{%- endblock -%}
|
||||||
|
{%- block content -%}
|
||||||
|
{%- call() subheader('404 Not Found') -%}
|
||||||
|
<span>The requested URL was not found.</span>
|
||||||
|
{%- endcall -%}
|
||||||
|
{%- endblock -%}
|
||||||
7
app/templates/common/footer.html
Normal file
7
app/templates/common/footer.html
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<footer class="plank secondary-bg bottom">
|
||||||
|
<span>Pyrom commit <a href="{{ "https://git.poto.cafe/yagich/pyrom/commit/" + __commit }}">{{ __commit[:8] }}</a></span>
|
||||||
|
<ul class="horizontal">
|
||||||
|
<li><a href="{{url_for('guides.contact')}}">Contact</a></li>
|
||||||
|
<li><a href="{{url_for('guides.index')}}">Guides</a></li>
|
||||||
|
</ul>
|
||||||
|
</footer>
|
||||||
182
app/templates/common/macros.html
Normal file
182
app/templates/common/macros.html
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
{% macro timestamp(unix_ts) -%}
|
||||||
|
<span class="timestamp" data-utc="{{ unix_ts }}">{{ unix_ts | ts_datetime('%Y-%m-%d %H:%M')}} <abbr title="Server Time">ST</abbr></span>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro subheader(title, desc='') -%}
|
||||||
|
<div id="subheader" class="plank secondary-bg">
|
||||||
|
<h1 class="info">{{title}}</h1>
|
||||||
|
{%- if desc -%}<span>{{desc}}</span>{%- endif -%}
|
||||||
|
<div class="actions-group">{% if caller %}{{- caller() -}}{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro pager(current_page, page_count, classes='', url='', args={}) -%}
|
||||||
|
{%- set args = dict(args.items() | rejectattr(0, 'equalto', 'page')) -%}
|
||||||
|
{%- if args -%}
|
||||||
|
{#- remove the page query argument -#}
|
||||||
|
{%- set url = url + (args | dict_to_query_string) + '&page=' -%}
|
||||||
|
{%- else -%}
|
||||||
|
{%- set url = url + '?page=' -%}
|
||||||
|
{%- endif -%}
|
||||||
|
<span class="button-row {{classes}}">
|
||||||
|
{%- if current_page == 0 -%}
|
||||||
|
{%- if page_count <= 3 -%}
|
||||||
|
{%- for i in range(page_count) -%}
|
||||||
|
<a href="{{url}}{{i+1}}" class="linkbutton minimal">{{i+1}}</a>
|
||||||
|
{%- endfor -%}
|
||||||
|
{%- else -%}
|
||||||
|
<a href="{{url}}1" class="linkbutton minimal">1</a>
|
||||||
|
<a href="{{url}}2" class="linkbutton minimal">2</a>
|
||||||
|
<button class="minimal" disabled>…</button>
|
||||||
|
<a href="{{url}}{{page_count - 1}}" class="linkbutton minimal">{{page_count - 1}}</a>
|
||||||
|
<a href="{{url}}{{page_count}}" class="linkbutton minimal">{{page_count}}</a>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- else -%}
|
||||||
|
{%- set left_start = [2, current_page - 1] | max -%}
|
||||||
|
{%- set right_end = [page_count - 1, current_page + 1] | min -%}
|
||||||
|
|
||||||
|
{%- if current_page != 1 -%}
|
||||||
|
<a href="{{url}}1" class="linkbutton minimal">1</a>
|
||||||
|
{%- endif -%}
|
||||||
|
|
||||||
|
{%- if left_start > 2 -%}
|
||||||
|
<button class="minimal" disabled>…</button>
|
||||||
|
{%- endif -%}
|
||||||
|
|
||||||
|
{%- for i in range(left_start, current_page) -%}
|
||||||
|
<a href="{{url}}{{i}}" class="linkbutton minimal">{{i}}</a>
|
||||||
|
{%- endfor -%}
|
||||||
|
|
||||||
|
{%- if page_count > 0 -%}
|
||||||
|
<button class="minimal" disabled>{{current_page}}</button>
|
||||||
|
{%- endif -%}
|
||||||
|
|
||||||
|
{%- for i in range(current_page + 1, right_end + 1) -%}
|
||||||
|
<a href="{{url}}{{i}}" class="linkbutton minimal">{{i}}</a>
|
||||||
|
{%- endfor -%}
|
||||||
|
|
||||||
|
{%- if right_end < page_count - 1 -%}
|
||||||
|
<button class="minimal" disabled>…</button>
|
||||||
|
{%- endif -%}
|
||||||
|
|
||||||
|
{%- if page_count > 1 and current_page != page_count -%}
|
||||||
|
<a href="{{url}}{{page_count}}" class="linkbutton minimal">{{page_count}}</a>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endif -%}
|
||||||
|
</span>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro tabs(prefix='', labels = []) -%}
|
||||||
|
<div class="tab-container">
|
||||||
|
<div class="tab-bar" role="tablist">
|
||||||
|
{%- for tab_label in labels -%}
|
||||||
|
<button type="button" class="tab-button" role="tab" aria-selected="{{'true' if loop.index0==0 else 'false'}}" id="{{prefix+'-'+(tab_label | lower)+'-tab'}}" aria-controls="{{prefix+'-'+(tab_label | lower)+'-content'}}">{{tab_label}}</button>
|
||||||
|
{%- 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'}}">
|
||||||
|
{{- caller(loop.index0) -}}
|
||||||
|
</div>
|
||||||
|
{%- endfor -%}
|
||||||
|
</div>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro babycode_editor_component(
|
||||||
|
placeholder='Post content',
|
||||||
|
prefill='',
|
||||||
|
required=true,
|
||||||
|
id='babycode-content'
|
||||||
|
) -%}
|
||||||
|
{%- call(idx) tabs(prefix='babycode', labels=['Write', 'Preview']) -%}
|
||||||
|
{%- if idx == 0 -%}
|
||||||
|
<span class="babycode-editor-controls">
|
||||||
|
<span class="button-row">
|
||||||
|
<button type="button" class="minimal"><b>B</b></button>
|
||||||
|
<button type="button" class="minimal"><i>i</i></button>
|
||||||
|
<button type="button" class="minimal"><s>S</s></button>
|
||||||
|
<button type="button" class="minimal"><u>U</u></button>
|
||||||
|
<button type="button" class="minimal"><code>://</code></button>
|
||||||
|
<button type="button" class="minimal"><code></></code></button>
|
||||||
|
<button type="button" class="minimal">1.</button>
|
||||||
|
<button type="button" class="minimal">•</button>
|
||||||
|
<button type="button" class="minimal"><img src="/static/emoji/angry.png" class="emoji"></button>
|
||||||
|
</span>
|
||||||
|
<a href="##">babycode help</a>
|
||||||
|
</span>
|
||||||
|
<textarea name="babycode_content" id="{{id}}" class="babycode-editor" placeholder="{{placeholder}}" {{'required' if required else ''}}>{{ prefill }}</textarea>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endcall -%}
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro full_post(
|
||||||
|
post, render_sig=true, is_latest=false,
|
||||||
|
show_toolbar=true, is_editing=false, thread=none,
|
||||||
|
show_reactions=true
|
||||||
|
) -%}
|
||||||
|
{%- if is_logged_in() -%}
|
||||||
|
{%- set can_delete = post.user_id == get_active_user().id or is_mod() -%}
|
||||||
|
{%- else -%}
|
||||||
|
{%- set show_toolbar = false -%}
|
||||||
|
{%- endif -%}
|
||||||
|
{%- set owns = is_logged_in() and post.user_id == get_active_user().id -%}
|
||||||
|
{%- set can_reply = (is_logged_in()) and (not thread.locked or is_mod()) -%}
|
||||||
|
<div class="usercard plank even contrast-bg minimal no-shadow">
|
||||||
|
<div class="usercard-inner">
|
||||||
|
<img src="{{post.avatar_path}}" class="avatar">
|
||||||
|
<div class="usercard-rest">
|
||||||
|
<a href="{{url_for('users.user_page', username=post.username)}}">{{post.display_name if post.display_name else post.username}}</a>
|
||||||
|
<abbr title="mention">@{{post.username}}</abbr>
|
||||||
|
<i>{{post.status}}</i>
|
||||||
|
{%- set badges=post.badges_json | fromjson -%}
|
||||||
|
<div class="badges-container">
|
||||||
|
{%- for badge in badges -%}
|
||||||
|
{%- if badge.link -%}<a href="{{badge.link}}">{%- endif -%}
|
||||||
|
<img src="{{badge.file_path}}" alt="{{badge.label}}" title="{{badge.label}}" class="badge-button">
|
||||||
|
{%- if badge.link -%}</a>{%- endif -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="post-content">
|
||||||
|
<div class="plank even minimal secondary-bg no-shadow post-info">
|
||||||
|
<a href="{{get_post_url(post.id, _anchor=true)}}"><i>Posted on {{timestamp(post.created_at)}}</i></a>
|
||||||
|
{%- if show_toolbar -%}
|
||||||
|
<span class="thread-actions">
|
||||||
|
{%- if owns -%}
|
||||||
|
<a class="linkbutton" href="{{url_for('posts.edit', post_id=post.id)}}">Edit</a>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- if can_reply -%}
|
||||||
|
<button>Quote</button>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- if can_delete -%}
|
||||||
|
<button class="critical">Delete</button>
|
||||||
|
{%- endif -%}
|
||||||
|
<button>Bookmark…</button>
|
||||||
|
</span>
|
||||||
|
{%- endif -%}
|
||||||
|
</div>
|
||||||
|
<div class="plank even no-shadow post-content-inner minimal">{{post.content | safe}}
|
||||||
|
{%- if render_sig and post.signature_rendered -%}
|
||||||
|
<div class="post-signature">{{post.signature_rendered | safe}}</div>
|
||||||
|
{%- endif -%}
|
||||||
|
</div>
|
||||||
|
{%- if show_reactions -%}
|
||||||
|
<div class="plank even secondary-bg minimal no-shadow">
|
||||||
|
<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 {{'disabled' if not is_logged_in() else ''}} title="{{reactors_str}}" class="minimal {{'alt' if has_reacted else ''}}"><img src="/static/emoji/{{reaction.reaction_text}}.png">{{reaction.c}}</button>
|
||||||
|
{%- endfor -%}
|
||||||
|
</span>
|
||||||
|
{%- if is_logged_in() -%}<button>Add reaction</button>{%- endif -%}
|
||||||
|
</div>
|
||||||
|
{%- endif -%}
|
||||||
|
</div>
|
||||||
|
{%- endmacro %}
|
||||||
26
app/templates/common/topnav.html
Normal file
26
app/templates/common/topnav.html
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<nav id="header" class="plank top">
|
||||||
|
<a class="site-title" href="/">Porom</a>
|
||||||
|
<span>anti-social media</span>
|
||||||
|
{%- if is_logged_in() -%}
|
||||||
|
{%- with user = get_active_user() -%}
|
||||||
|
<ul class="horizontal wrap">
|
||||||
|
<li class="mobile-fill-flex">Welcome, <a href="{{url_for('users.user_page', username=user.username)}}">{{ user.get_readable_name() }}</a></li>
|
||||||
|
<li><a class="linkbutton" href="{{url_for('users.settings', username=user.username)}}">Settings</a></li>
|
||||||
|
<li><a class="linkbutton" href="{{url_for('users.inbox', username=user.username)}}">Inbox</a></li>
|
||||||
|
<li><a class="linkbutton" href="{{url_for('users.bookmarks', username=user.username)}}">Bookmarks</a></li>
|
||||||
|
{% if user.is_mod() -%}
|
||||||
|
<li><a class="linkbutton" href="{{url_for('mod.index')}}">Moderation</a></li>
|
||||||
|
{%- endif %}
|
||||||
|
</ul>
|
||||||
|
{%- endwith -%}
|
||||||
|
{%- elif request.path != url_for('users.sign_up') and request.path != url_for('users.log_in') -%}
|
||||||
|
<form class="horizontal wrap" method="POST" action="{{url_for('users.log_in_post')}}">
|
||||||
|
<input type="hidden" name="return_to" value="{{request.path}}">
|
||||||
|
<input type="text" placeholder="Username" name="username" autocomplete="username" required>
|
||||||
|
<input type="password" placeholder="Password" name="password" autocomplete="current-password" required>
|
||||||
|
<span><input type="checkbox" name="remember" id="remember"> <label for="remember">Remember me</label></span>
|
||||||
|
<input type="submit" value="Log in">
|
||||||
|
<a href="{{url_for('users.sign_up')}}" class="linkbutton alt">Sign up</a>
|
||||||
|
</form>
|
||||||
|
{%- endif -%}
|
||||||
|
</nav>
|
||||||
13
app/templates/mod/edit_topic.html
Normal file
13
app/templates/mod/edit_topic.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{%- from 'common/macros.html' import subheader -%}
|
||||||
|
{%- extends 'base.html' -%}
|
||||||
|
{%- block title -%}editing topic {{topic.name}}{%- endblock -%}
|
||||||
|
{%- block content -%}
|
||||||
|
{{subheader('Editing topic %s' % topic.name, 'To preserve history, the URL of the topic can not be changed.')}}
|
||||||
|
<form class="plank primary-bg full-width" method="POST">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input type="text" id="name" name="name" required value="{{topic.name}}">
|
||||||
|
<label for="description">Description</label>
|
||||||
|
<textarea name="description" id="description" rows="5" required>{{topic.description}}</textarea>
|
||||||
|
<input type="submit" value="Save">
|
||||||
|
</form>
|
||||||
|
{%- endblock -%}
|
||||||
13
app/templates/mod/new_topic.html
Normal file
13
app/templates/mod/new_topic.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{%- from 'common/macros.html' import subheader -%}
|
||||||
|
{%- extends 'base.html' -%}
|
||||||
|
{%- block title -%}creating a topic{%- endblock -%}
|
||||||
|
{%- block content -%}
|
||||||
|
{{subheader('Create topic', 'The new topic will appear at the bottom of the current topic list. You can sort it later.')}}
|
||||||
|
<form class="plank primary-bg full-width" method="POST">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input type="text" id="name" name="name" required>
|
||||||
|
<label for="description">Description</label>
|
||||||
|
<textarea name="description" id="description" rows="5" required></textarea>
|
||||||
|
<input type="submit" value="Create">
|
||||||
|
</form>
|
||||||
|
{%- endblock -%}
|
||||||
19
app/templates/threads/new_thread.html
Normal file
19
app/templates/threads/new_thread.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{%- from 'common/macros.html' import subheader, babycode_editor_component -%}
|
||||||
|
{%- extends 'base.html' -%}
|
||||||
|
{%- block title -%}drafting a thread{%- endblock -%}
|
||||||
|
{%- block content -%}
|
||||||
|
{{subheader('New thread')}}
|
||||||
|
<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 -%}
|
||||||
|
<option value="{{topic.id}}" {{'selected' if selected_topic == topic.id else ''}} {{'disabled' if not get_active_user().can_post_to_topic(topic) else ''}}>{{topic.name}}{{ ' (locked)' if topic.locked() else ''}}</option>
|
||||||
|
{%- endfor -%}
|
||||||
|
</select>
|
||||||
|
<label for="title">Title</label>
|
||||||
|
<input type="text" id="title" name="title" required>
|
||||||
|
<label for="babycode-content">Starting post</label>
|
||||||
|
{{ babycode_editor_component() }}
|
||||||
|
<input type="submit" value="Create">
|
||||||
|
</form>
|
||||||
|
{%- endblock -%}
|
||||||
76
app/templates/threads/thread.html
Normal file
76
app/templates/threads/thread.html
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
{%- from 'common/macros.html' import subheader, timestamp, pager, babycode_editor_component -%}
|
||||||
|
{%- from 'common/macros.html' import full_post with context -%}
|
||||||
|
{%- extends 'base.html' -%}
|
||||||
|
{%- block title -%}{{thread.title}}{%- endblock -%}
|
||||||
|
{%- block content -%}
|
||||||
|
{%- 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', slug=topic.slug)}}">{{topic.name}}</a></li>
|
||||||
|
{%- if thread.locked() or thread.stickied() -%}
|
||||||
|
{%- if thread.locked() -%}
|
||||||
|
<li class="visible">Locked</li>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- if thread.stickied() -%}
|
||||||
|
<li class="visible">Stickied</li>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endif -%}
|
||||||
|
</ul>
|
||||||
|
{%- endset -%}
|
||||||
|
{%- call() subheader(thread.title, td) -%}
|
||||||
|
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||||
|
<legend>Actions</legend>
|
||||||
|
{%- if is_logged_in() -%}
|
||||||
|
<button>Subscribe</button>
|
||||||
|
<button>Bookmark…</button>
|
||||||
|
{%- endif -%}
|
||||||
|
<a href="{{url_for('threads.feed', slug=thread.slug)}}" class="linkbutton rss">Subscribe via RSS</a>
|
||||||
|
</fieldset>
|
||||||
|
{%- if is_mod() -%}
|
||||||
|
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||||
|
<legend>Moderation actions</legend>
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="lock" value="{{(not thread.locked()) | int}}">
|
||||||
|
<input type="hidden" name="sticky" value="{{(not thread.stickied()) | int}}">
|
||||||
|
<input type="submit" class="warn" value="{{'Unlock' if thread.locked() else 'Lock'}}" formaction="{{url_for('mod.lock_thread', thread_id=thread.id)}}">
|
||||||
|
<input type="submit" class="warn" value="{{'Unsticky' if thread.stickied() else 'Sticky'}}" formaction="{{url_for('mod.sticky_thread', thread_id=thread.id)}}">
|
||||||
|
</form>
|
||||||
|
<form class="horizontal wrap" method="POST" action="{{url_for('mod.move_thread', thread_id=thread.id)}}">
|
||||||
|
<select name="new_topic_id" id="new-topic-id">
|
||||||
|
{%- for t in topics -%}
|
||||||
|
<option value="{{t.id}}" {{'selected disabled' if t.id == topic.id else ''}} autocomplete="off">{{t.name}}</option>
|
||||||
|
{%- endfor -%}
|
||||||
|
</select>
|
||||||
|
<input type="submit" value="Move" class="warn">
|
||||||
|
</form>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||||
|
<legend>Page</legend>
|
||||||
|
{{- pager(page, page_count) -}}
|
||||||
|
</fieldset>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endcall -%}
|
||||||
|
<main>
|
||||||
|
{%- for post in posts -%}
|
||||||
|
<article id="post-{{post.id}}" class="post plank">
|
||||||
|
{{full_post(post)}}
|
||||||
|
</article>
|
||||||
|
{%- endfor -%}
|
||||||
|
</main>
|
||||||
|
<div class="plank secondary-bg">
|
||||||
|
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||||
|
<legend>Page</legend>
|
||||||
|
{{- pager(page, page_count) -}}
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
{%- if is_logged_in() -%}
|
||||||
|
<form action="{{url_for('threads.reply', slug=thread.slug)}}" method="POST" class="plank post-edit-form">
|
||||||
|
<h2 class="info">Reply to "{{thread.title}}"</h2>
|
||||||
|
{{- babycode_editor_component() -}}
|
||||||
|
<span>
|
||||||
|
<input type="checkbox" checked name="subscribe" id="subscribe">
|
||||||
|
<label for="subscribe">Subscribe to thread</label>
|
||||||
|
</span>
|
||||||
|
<span><input type="submit" value="Post reply"></span>
|
||||||
|
</form>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endblock -%}
|
||||||
70
app/templates/topics/topic.html
Normal file
70
app/templates/topics/topic.html
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
{% from 'common/macros.html' import timestamp, subheader, pager %}
|
||||||
|
{%- extends 'base.html' -%}
|
||||||
|
{%- block title -%}browsing topic {{topic.name}}{%- endblock -%}
|
||||||
|
{%- block content -%}
|
||||||
|
{%- set td -%}
|
||||||
|
<ul class="horizontal">
|
||||||
|
<li>{{topic.description}}</li>
|
||||||
|
{%- if topic.locked() -%}
|
||||||
|
<li class="visible">Locked</li>
|
||||||
|
{%- endif -%}
|
||||||
|
</ul>
|
||||||
|
{%- endset -%}
|
||||||
|
{%- call() subheader(('Threads in "%s"' % topic.name), td) -%}
|
||||||
|
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||||
|
<legend>Actions</legend>
|
||||||
|
{%- if is_logged_in() and get_active_user().can_post_to_topic(topic) -%}
|
||||||
|
<a href="{{url_for('threads.new', topic_id=topic.id)}}" class="linkbutton">New thread</a>
|
||||||
|
{%- endif -%}
|
||||||
|
<a href="{{url_for('topics.feed', slug=topic.slug)}}" 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="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">
|
||||||
|
<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">
|
||||||
|
<input type="hidden" value="{{(not topic.locked()) | int}}" name="lock">
|
||||||
|
<input type="submit" class="warn" value="{{'Unlock' if topic.locked() else 'Lock'}}">
|
||||||
|
</form>
|
||||||
|
</fieldset>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- if threads | length > 0 -%}
|
||||||
|
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||||
|
<legend>Page</legend>
|
||||||
|
{{- pager(page, page_count, args=request.args) -}}
|
||||||
|
</fieldset>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endcall -%}
|
||||||
|
{%- 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_topic(topic) %} Be the first to start a discussion!{%- endif -%}</p></div>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- for thread in threads -%}
|
||||||
|
<div class="topic-info plank">
|
||||||
|
<div class="title-container">
|
||||||
|
<span class="info thread-title-counter"><a href="{{url_for('threads.thread', slug=thread.slug)}}">{{thread.title}}</a></span>
|
||||||
|
<ul class="horizontal"></ul>
|
||||||
|
{%- if thread.posts_count / 10 > 1 -%}
|
||||||
|
{{pager(0, (((thread.posts_count / 10) | round(0, 'ceil') )| int), 'flex-last', url=url_for('threads.thread', slug=thread.slug))}}
|
||||||
|
{%- endif -%}
|
||||||
|
</div>
|
||||||
|
<span>Started by <a href="{{url_for('users.user_page', username=thread.started_by)}}">{{thread.started_by_display_name if thread.started_by_display_name else thread.started_by}}</a> on {{timestamp(thread.created_at)}}</span>
|
||||||
|
<span>{{thread.posts_count}} {{'repl' | pluralize(thread.posts_count, 'y', 'ies')}}</span>
|
||||||
|
<span>Latest post by <a href="{{get_post_url(thread.latest_post_id, _anchor=true)}}">{{thread.latest_post_display_name if thread.latest_post_display_name else thread.latest_post_username}} on {{timestamp(thread.latest_post_created_at)}}</a>{{' (OP)' if thread.posts_count == 1 else ''}}</span>
|
||||||
|
</div>
|
||||||
|
{%- endfor -%}
|
||||||
|
{%- if threads | length > 0 -%}
|
||||||
|
<div class="plank secondary-bg">
|
||||||
|
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||||
|
<legend>Page</legend>
|
||||||
|
{{- pager(page, page_count, args=request.args) -}}
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endblock -%}
|
||||||
32
app/templates/topics/topics.html
Normal file
32
app/templates/topics/topics.html
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{% from 'common/macros.html' import timestamp, subheader %}
|
||||||
|
{%- extends 'base.html' -%}
|
||||||
|
{%- block content -%}
|
||||||
|
{%- call() subheader('All topics') -%}
|
||||||
|
{%- if is_mod() -%}
|
||||||
|
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||||
|
<legend>Moderation actions</legend>
|
||||||
|
<a href="{{url_for('mod.new_topic')}}" class="linkbutton">New topic</a>
|
||||||
|
<a href="{{url_for('mod.sort_topics')}}" class="linkbutton">Sort topics</a>
|
||||||
|
</fieldset>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endcall -%}
|
||||||
|
{%- for topic in topics -%}
|
||||||
|
<div class="topic-info plank">
|
||||||
|
<div class="title-container">
|
||||||
|
<a class="info" href="{{url_for('topics.topic', slug=topic.slug)}}">{{topic.name}}</a>
|
||||||
|
</div>
|
||||||
|
<div>{{topic.description}}</div>
|
||||||
|
<ul class="horizontal">
|
||||||
|
<li>{{topic.threads_count}} {{"thread" | pluralize(topic.threads_count)}}</li>
|
||||||
|
<li>{{topic.posts_count}} {{"post" | pluralize(topic.posts_count)}}</li>
|
||||||
|
</ul>
|
||||||
|
<div>
|
||||||
|
{%- if topic.latest_post_timestamp -%}
|
||||||
|
Latest post at: {{timestamp(topic.latest_post_timestamp)}}
|
||||||
|
{%- else -%}
|
||||||
|
No posts yet
|
||||||
|
{%- endif -%}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{%- endfor -%}
|
||||||
|
{%- endblock -%}
|
||||||
22
app/templates/users/log_in.html
Normal file
22
app/templates/users/log_in.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{% from 'common/macros.html' import subheader %}
|
||||||
|
{%- extends 'base.html' -%}
|
||||||
|
{%- block title -%}log in{%- endblock -%}
|
||||||
|
{%- block content -%}
|
||||||
|
{%- set welcome -%}
|
||||||
|
Welcome back! No account yet? <a href="{{url_for('users.sign_up')}}">Sign up</a>
|
||||||
|
{%- endset -%}
|
||||||
|
{{ subheader('Log in', welcome)}}
|
||||||
|
{%- if request.args.get('error') -%}
|
||||||
|
<div class="infobox plank critical">
|
||||||
|
{{request.args.get('error')}}
|
||||||
|
</div>
|
||||||
|
{%- endif -%}
|
||||||
|
<form class="plank primary-bg full-width" method="POST">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<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>
|
||||||
|
<input type="submit" value="Log in">
|
||||||
|
</form>
|
||||||
|
{%- endblock -%}
|
||||||
24
app/templates/users/sign_up.html
Normal file
24
app/templates/users/sign_up.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{% from 'common/macros.html' import subheader %}
|
||||||
|
{%- extends 'base.html' -%}
|
||||||
|
{%- block title -%}sign up{%- endblock -%}
|
||||||
|
{%- block content -%}
|
||||||
|
{%- set welcome -%}
|
||||||
|
Please read the rules etc. stub
|
||||||
|
{%- endset -%}
|
||||||
|
{{ subheader('Sign up', welcome)}}
|
||||||
|
{%- if request.args.get('error') -%}
|
||||||
|
<div class="infobox plank critical">
|
||||||
|
{{request.args.get('error')}}
|
||||||
|
</div>
|
||||||
|
{%- endif -%}
|
||||||
|
<form class="plank primary-bg full-width" method="POST">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" id="username" name="username" pattern="[a-zA-Z0-9_\-]{3,24}" title="3-24 characters. Only upper and lowercase letters, digits, hyphens, and underscores" autocomplete="username" required>
|
||||||
|
<label for="password">Create password</label>
|
||||||
|
<input type="password" id="password" name="password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,255}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" autocomplete="new-password" required>
|
||||||
|
<label for="password2">Confirm password</label>
|
||||||
|
<input type="password" id="password2" name="password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,255}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" autocomplete="new-password" required>
|
||||||
|
<span><input type="checkbox" name="remember" id="remember"> <label for="remember">Remember me</label></span>
|
||||||
|
<input type="submit" value="Sign up">
|
||||||
|
</form>
|
||||||
|
{%- endblock -%}
|
||||||
98
app/templates/users/user_page.html
Normal file
98
app/templates/users/user_page.html
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
{%- from 'common/macros.html' import subheader, timestamp, pager -%}
|
||||||
|
{%- extends 'base.html' -%}
|
||||||
|
{%- block title -%}{{ target_user.get_readable_name() }}'s profile{%- 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">
|
||||||
|
<legend>Actions</legend>
|
||||||
|
<form action="{{url_for('users.log_out')}}" method="POST">
|
||||||
|
<input type="submit" class="warn" value="Log out">
|
||||||
|
</form>
|
||||||
|
</fieldset>
|
||||||
|
{%- endif -%}
|
||||||
|
|
||||||
|
{%- if get_active_user().is_mod() and target_user.id != get_active_user().id -%}
|
||||||
|
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||||
|
<legend>Moderation actions</legend>
|
||||||
|
<form 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)}}">
|
||||||
|
{%- else -%}
|
||||||
|
<input class="warn" type="submit" value="Demote to guest (soft ban)" formaction="{{url_for('mod.make_user_guest', user_id=target_user.id)}}">
|
||||||
|
{%- if get_active_user().is_admin() -%}
|
||||||
|
{%- if not target_user.is_mod_only() -%}
|
||||||
|
<input class="warn" type="submit" value="Promote to moderator" formaction="{{url_for('mod.make_user_mod', user_id=target_user.id)}}">
|
||||||
|
{%- else -%}
|
||||||
|
<input class="warn" type="submit" value="Demote from moderator" formaction="{{url_for('mod.make_user_regular', user_id=target_user.id)}}">
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endif -%}
|
||||||
|
</form>
|
||||||
|
</fieldset>
|
||||||
|
{%- endif -%}
|
||||||
|
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endcall -%}
|
||||||
|
<div class="userpage-usercard">
|
||||||
|
<div class="usercard plank even contrast-bg minimal no-shadow">
|
||||||
|
<div class="usercard-inner">
|
||||||
|
<img src="{{target_user.get_avatar_url()}}" class="avatar">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="plank even minimal no-shadow user-stats">
|
||||||
|
<h3 class="info">{{target_user.get_readable_name()}}</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 -%}
|
||||||
|
{%- 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>
|
||||||
|
{%- set badges = target_user.get_badges() -%}
|
||||||
|
|
||||||
|
{%- if badges -%}
|
||||||
|
<div class="badges-container nocenter">
|
||||||
|
Badges:
|
||||||
|
{%- for badge in badges -%}
|
||||||
|
{%- if badge.link -%}<a href="{{badge.link}}">{%- endif -%}
|
||||||
|
<img src="{{badge.get_image_url()}}" alt="{{badge.label}}" title="{{badge.label}}" class="badge-button">
|
||||||
|
{%- if badge.link -%}</a>{%- endif -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
</div>
|
||||||
|
{%- endif -%}
|
||||||
|
<fieldset class="plank secondary-bg minimal even no-shadow">
|
||||||
|
<legend>About me</legend>
|
||||||
|
<p>stub</p>
|
||||||
|
</fieldset>
|
||||||
|
{%- if target_user.signature_rendered -%}
|
||||||
|
<fieldset class="plank secondary-bg minimal even no-shadow">
|
||||||
|
<legend>Signature</legend>
|
||||||
|
{{target_user.signature_rendered | safe}}
|
||||||
|
</fieldset>
|
||||||
|
{%- endif -%}
|
||||||
|
{#
|
||||||
|
<fieldset class="plank secondary-bg minimal even no-shadow">
|
||||||
|
<legend>Profile comments</legend>
|
||||||
|
<fieldset class="plank minimal even no-shadow">
|
||||||
|
<legend>Page</legend>
|
||||||
|
{{pager(0, 3, url=url_for('users.log_in'))}}
|
||||||
|
</fieldset>
|
||||||
|
<div class="post plank">
|
||||||
|
<p>stub</p>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
#}
|
||||||
|
{%- endif -%}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{%- endblock -%}
|
||||||
26
app/util.py
Normal file
26
app/util.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from flask import url_for, session
|
||||||
|
from .models import Posts, Threads
|
||||||
|
from .auth import is_logged_in
|
||||||
|
|
||||||
|
def get_post_url(post_id, _anchor=False, external=False):
|
||||||
|
post = Posts.find({'id': post_id})
|
||||||
|
if not post:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
thread = Threads.find({'id': post.thread_id})
|
||||||
|
|
||||||
|
anchor = None if not _anchor else f'post-{post_id}'
|
||||||
|
|
||||||
|
return url_for('threads.thread', slug=thread.slug, after=post_id, _external=external, _anchor=anchor)
|
||||||
|
|
||||||
|
def dict_to_query_string(d) -> str:
|
||||||
|
return '?' + '&'.join([f'{key}={str(value)}' for key, value in d.items()])
|
||||||
|
|
||||||
|
def get_csrf_token():
|
||||||
|
if not is_logged_in():
|
||||||
|
return ''
|
||||||
|
|
||||||
|
return session.get('csrf', '')
|
||||||
|
|
||||||
|
def csrf_input():
|
||||||
|
return f'<input type="hidden" name="csrf" value="{get_csrf_token()}">'
|
||||||
223
data/static/css/normalize.css
vendored
Normal file
223
data/static/css/normalize.css
vendored
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
/*! modern-normalize v3.0.1 | MIT License | https://github.com/sindresorhus/modern-normalize */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Document
|
||||||
|
* ========
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use a better box model (opinionated).
|
||||||
|
*/
|
||||||
|
|
||||||
|
*,
|
||||||
|
::before,
|
||||||
|
::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3)
|
||||||
|
* 2. Correct the line height in all browsers.
|
||||||
|
* 3. Prevent adjustments of font size after orientation changes in iOS.
|
||||||
|
* 4. Use a more readable tab size (opinionated).
|
||||||
|
*/
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
Helvetica,
|
||||||
|
Arial,
|
||||||
|
sans-serif,
|
||||||
|
'Apple Color Emoji',
|
||||||
|
'Segoe UI Emoji'; /* 1 */
|
||||||
|
line-height: 1.15; /* 2 */
|
||||||
|
-webkit-text-size-adjust: 100%; /* 3 */
|
||||||
|
tab-size: 4; /* 4 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Sections
|
||||||
|
* ========
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the margin in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Text-level semantics
|
||||||
|
* ====================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the correct font weight in Chrome and Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
b,
|
||||||
|
strong {
|
||||||
|
font-weight: bolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3)
|
||||||
|
* 2. Correct the odd 'em' font sizing in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
code,
|
||||||
|
kbd,
|
||||||
|
samp,
|
||||||
|
pre {
|
||||||
|
font-family:
|
||||||
|
ui-monospace,
|
||||||
|
SFMono-Regular,
|
||||||
|
Consolas,
|
||||||
|
'Liberation Mono',
|
||||||
|
Menlo,
|
||||||
|
monospace; /* 1 */
|
||||||
|
font-size: 1em; /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the correct font size in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prevent 'sub' and 'sup' elements from affecting the line height in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
sub,
|
||||||
|
sup {
|
||||||
|
font-size: 75%;
|
||||||
|
line-height: 0;
|
||||||
|
position: relative;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub {
|
||||||
|
bottom: -0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
sup {
|
||||||
|
top: -0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Tabular data
|
||||||
|
* ============
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Correct table border color inheritance in Chrome and Safari. (https://issues.chromium.org/issues/40615503, https://bugs.webkit.org/show_bug.cgi?id=195016)
|
||||||
|
*/
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-color: currentcolor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Forms
|
||||||
|
* =====
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Change the font styles in all browsers.
|
||||||
|
* 2. Remove the margin in Firefox and Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
optgroup,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-family: inherit; /* 1 */
|
||||||
|
font-size: 100%; /* 1 */
|
||||||
|
line-height: normal; /* 1 */
|
||||||
|
margin: 0; /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Correct the inability to style clickable types in iOS and Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
button,
|
||||||
|
[type='button'],
|
||||||
|
[type='reset'],
|
||||||
|
[type='submit'] {
|
||||||
|
-webkit-appearance: button;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the padding so developers are not caught out when they zero out 'fieldset' elements in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
legend {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the correct vertical alignment in Chrome and Firefox.
|
||||||
|
*/
|
||||||
|
|
||||||
|
progress {
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Correct the cursor style of increment and decrement buttons in Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
::-webkit-inner-spin-button,
|
||||||
|
::-webkit-outer-spin-button {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Correct the odd appearance in Chrome and Safari.
|
||||||
|
* 2. Correct the outline style in Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
[type='search'] {
|
||||||
|
-webkit-appearance: textfield; /* 1 */
|
||||||
|
outline-offset: -2px; /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the inner padding in Chrome and Safari on macOS.
|
||||||
|
*/
|
||||||
|
|
||||||
|
::-webkit-search-decoration {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Correct the inability to style clickable types in iOS and Safari.
|
||||||
|
* 2. Change font properties to 'inherit' in Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
::-webkit-file-upload-button {
|
||||||
|
-webkit-appearance: button; /* 1 */
|
||||||
|
font: inherit; /* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Interactive
|
||||||
|
* ===========
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Add the correct display in Chrome and Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
summary {
|
||||||
|
display: list-item;
|
||||||
|
}
|
||||||
758
data/static/css/style.css
Normal file
758
data/static/css/style.css
Normal file
@@ -0,0 +1,758 @@
|
|||||||
|
@import url("/static/css/normalize.css");
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Cadman";
|
||||||
|
src: url("/static/fonts/Cadman_Roman.woff2");
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Cadman";
|
||||||
|
src: url("/static/fonts/Cadman_Bold.woff2");;
|
||||||
|
font-weight: bold;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Cadman";
|
||||||
|
src: url("/static/fonts/Cadman_Italic.woff2");
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Cadman";
|
||||||
|
src: url("/static/fonts/Cadman_BoldItalic.woff2");
|
||||||
|
font-weight: bold;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Atkinson Hyperlegible Mono";
|
||||||
|
src: url("/static/fonts/AtkinsonHyperlegibleMono-VariableFont_wght.ttf");
|
||||||
|
font-weight: 125 950;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Atkinson Hyperlegible Mono";
|
||||||
|
src: url("/static/fonts/AtkinsonHyperlegibleMono-Italic-VariableFont_wght.ttf");
|
||||||
|
font-weight: 125 950;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "site-title";
|
||||||
|
src: url("/static/fonts/ChicagoFLF.woff2");
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--base-padding: 6px;
|
||||||
|
--border-radius: 3px;
|
||||||
|
--border-thickness: 1px;
|
||||||
|
--wrapper-side-margin: 36px;
|
||||||
|
|
||||||
|
/* colors */
|
||||||
|
--bg-color-primary: #c1ceb1;
|
||||||
|
--bg-color-secondary: #aeb8a1;
|
||||||
|
--bg-color-tertiary: #797976;
|
||||||
|
--bg-color-contrast: #bfb1ce;
|
||||||
|
|
||||||
|
--font-color-main: black;
|
||||||
|
--font-color-anti: white;
|
||||||
|
--font-color-link: #c11c1c;
|
||||||
|
--font-color-link-visited: hsl(from var(--font-color-link) h calc(s * 0.5) calc(l * 0.7));
|
||||||
|
|
||||||
|
--critical-color: #f73030;
|
||||||
|
--warn-color: #dfdf61;
|
||||||
|
--infobox-color: #97b3ec;
|
||||||
|
|
||||||
|
--button-color-primary: #b1cecd;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
--small-padding: calc(var(--base-padding) / 2);
|
||||||
|
--medium-padding: calc(var(--base-padding) * 2);
|
||||||
|
--big-padding: calc(var(--base-padding) * 3);
|
||||||
|
--huge-padding: calc(var(--base-padding) * 4);
|
||||||
|
|
||||||
|
--code-bg-color: hsl(from var(--bg-color-primary) h calc(s * 0.2) calc(l * 0.2));
|
||||||
|
|
||||||
|
background-color: var(--bg-color-tertiary);
|
||||||
|
font-family: Cadman;
|
||||||
|
color: var(--font-color-main);
|
||||||
|
margin: var(--big-padding) var(--wrapper-side-margin);
|
||||||
|
}
|
||||||
|
|
||||||
|
button, .linkbutton, input[type="submit"] {
|
||||||
|
--main-color: var(--button-color-primary);
|
||||||
|
--font-color: var(--font-color-main);
|
||||||
|
--border-color: hsl(from var(--main-color) h calc(s * 1.3) 25);
|
||||||
|
--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));
|
||||||
|
--top-color2: hsl(from var(--main-color) h s calc(l * 1.1));
|
||||||
|
--inset-color: #fff7;
|
||||||
|
/* position: relative; */
|
||||||
|
/* display: inline-block; */
|
||||||
|
padding: var(--small-padding) var(--big-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%);
|
||||||
|
box-shadow: inset 0px 2px 5px 3px var(--inset-color);
|
||||||
|
color: var(--font-color);
|
||||||
|
text-decoration: none;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
line-height: normal;
|
||||||
|
display: inline flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&.minimal {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.critical {
|
||||||
|
--main-color: var(--critical-color);
|
||||||
|
--font-color: var(--font-color-anti);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.warn {
|
||||||
|
--main-color: var(--warn-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.rss {
|
||||||
|
--main-color: #fba668;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.alt {
|
||||||
|
--main-color: var(--bg-color-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: linear-gradient(var(--top-color) 0%, var(--top-color2) 25%, var(--hover-color) 26%, var(--hover-color) 80%, var(--bottom-color) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:is(:active, .active, [aria-selected='true']) {
|
||||||
|
background: linear-gradient(var(--active-color) 0%, var(--active-color) 50%, var(--main-color) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: var(--disabled-color);
|
||||||
|
--inset-color: #fff3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button {
|
||||||
|
border-bottom: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
|
||||||
|
&[aria-selected='true'] {
|
||||||
|
padding-top: calc(var(--base-padding) * 1.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-container {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
|
||||||
|
&> * {
|
||||||
|
margin-top: auto;
|
||||||
|
position: relative;
|
||||||
|
bottom: -2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.babycode-editor {
|
||||||
|
width: 100%;
|
||||||
|
height: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-edit-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"], input[type="password"], 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;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
background-color: var(--active-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
font-family: 'Atkinson Hyperlegible Mono'
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(a:link) {
|
||||||
|
color: var(--font-color-link);
|
||||||
|
}
|
||||||
|
:where(a:visited) {
|
||||||
|
color: var(--font-color-link-visited);
|
||||||
|
}
|
||||||
|
|
||||||
|
a.site-title {
|
||||||
|
font-family: site-title;
|
||||||
|
font-size: 3em;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--font-color-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
#header {
|
||||||
|
background-color: var(--bg-color-primary);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
&>.site-title {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&>ul {
|
||||||
|
margin-top: var(--base-padding);
|
||||||
|
margin-bottom: var(--base-padding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.plank {
|
||||||
|
--main-color: var(--bg-color-primary);
|
||||||
|
--lighter-color: hsl(from var(--main-color) h s calc(l*1.1));
|
||||||
|
--darker-color: hsl(from var(--main-color) h s calc(l*0.9));
|
||||||
|
--border-color: hsl(from var(--main-color) h s 90);
|
||||||
|
--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-color: var(--main-color);
|
||||||
|
|
||||||
|
border: 2px groove var(--border-color);
|
||||||
|
|
||||||
|
&:not(.no-shadow) {
|
||||||
|
box-shadow: 0px 6px 3px 0px #0004;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.minimal {
|
||||||
|
padding: var(--small-padding) var(--big-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.even){
|
||||||
|
margin-bottom: var(--small-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.top {
|
||||||
|
border-top-left-radius: var(--border-radius);
|
||||||
|
border-top-right-radius: var(--border-radius);
|
||||||
|
|
||||||
|
&:not(.even){
|
||||||
|
margin-bottom: var(--medium-padding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bottom {
|
||||||
|
border-bottom-left-radius: var(--border-radius);
|
||||||
|
border-bottom-right-radius: var(--border-radius);
|
||||||
|
|
||||||
|
&:not(.even){
|
||||||
|
margin-top: var(--medium-padding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.horizontal {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&.wrap {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&> fieldset {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.horizontal, ol.horizontal {
|
||||||
|
display: inline flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
|
||||||
|
& li:not(.visible) {
|
||||||
|
list-style-type: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
& li.visible {
|
||||||
|
margin-left: var(--big-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.wrap {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bullet li::before {
|
||||||
|
content: '\2022';
|
||||||
|
}
|
||||||
|
|
||||||
|
& li > button, li > .linkbutton {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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(--base-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contain-svg {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
&.horizontal {
|
||||||
|
flex-direction: row;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.infobox {
|
||||||
|
--main-color: var(--infobox-color);
|
||||||
|
justify-content: start;
|
||||||
|
|
||||||
|
&.critical {
|
||||||
|
--main-color: hsl(from var(--critical-color) h 50% calc(l * 0.7));
|
||||||
|
color: var(--font-color-anti);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.warn {
|
||||||
|
--main-color: hsl(from var(--warn-color) h 50% calc(l * 1.2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
align-items: center;
|
||||||
|
justify-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
&> * {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
min-height: 32px;
|
||||||
|
width: auto;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-container {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
justify-content: start;
|
||||||
|
align-items: end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.motd-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 75%
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
--grid-item-base-width: 600px;
|
||||||
|
--grid-item-max-width: calc((100% - var(--grid-item-base-width)) / 2);
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(max(var(--grid-item-base-width), var(--grid-item-max-width)), 1fr));
|
||||||
|
|
||||||
|
&> * {
|
||||||
|
height: fit-content;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-last {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-grow {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post {
|
||||||
|
padding: var(--base-padding);
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: min(230px, 20vw) 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userpage-usercard {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: min(300px, 30vw) 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usercard-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
top: var(--big-padding);
|
||||||
|
position: sticky;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: min-content 1fr min-content;
|
||||||
|
&> * {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 54px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-signature {
|
||||||
|
margin-top: auto;
|
||||||
|
border-top: 2px dotted gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-button {
|
||||||
|
min-width: 88px;
|
||||||
|
min-height: 31px;
|
||||||
|
max-width: 88px;
|
||||||
|
max-height: 31px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badges-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--small-padding);
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&.nocenter {
|
||||||
|
justify-content: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.usercard-rest {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-info {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
#new-post-toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 80px;
|
||||||
|
right: 80px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--big-padding);
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.babycode-editor-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.full-width {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: start;
|
||||||
|
&> textarea, &> select, &> input[type="text"], &> input[type="password"] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* babycode tags */
|
||||||
|
.inline-code {
|
||||||
|
background-color: var(--code-bg-color);
|
||||||
|
color: var(--font-color-anti);
|
||||||
|
padding: var(--base-padding);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.babycode-big {
|
||||||
|
font-size: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.babycode-small {
|
||||||
|
font-size: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.babycode-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.babycode-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-image {
|
||||||
|
max-height: 400px;
|
||||||
|
max-width: 400px;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
&> button {
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
code, kbd {
|
||||||
|
font-family: "Atkinson Hyperlegible Mono";
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
font-family: unset;
|
||||||
|
margin: 0;
|
||||||
|
margin-bottom: var(--base-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
display: block;
|
||||||
|
background-color: var(--code-bg-color);
|
||||||
|
color: var(--font-color-anti);
|
||||||
|
padding: var(--base-padding);
|
||||||
|
overflow: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
padding-left: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
margin-left: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji {
|
||||||
|
max-width: 15px;
|
||||||
|
max-height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mention {
|
||||||
|
--mention-color: var(--bg-color-contrast);
|
||||||
|
--hover-color: hsl(from var(--mention-color) h calc(s * 0.7) calc(l * 1.1));
|
||||||
|
|
||||||
|
display: inline-block;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: var(--base-padding);
|
||||||
|
background-color: var(--mention-color);
|
||||||
|
color: black;
|
||||||
|
border: 1px dashed;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.me {
|
||||||
|
--mention-color: hsl(from var(--bg-color-contrast) calc(h + 90) s l);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
body {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid {
|
||||||
|
--grid-item-base-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-fill-flex {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post, .userpage-usercard {
|
||||||
|
grid-template-columns: unset;
|
||||||
|
grid-template-rows: min-content 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
max-width: 180px;
|
||||||
|
max-height: 180px;
|
||||||
|
min-width: 140px;
|
||||||
|
min-height: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usercard-inner {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-title-counter {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--base-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-container {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
#new-post-toast {
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-image {
|
||||||
|
max-width: min(75vw, 400px);
|
||||||
|
max-height: 50vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
let ta = document.getElementById("babycode-content");
|
|
||||||
|
|
||||||
ta.addEventListener("keydown", (e) => {
|
|
||||||
if(e.key === "Enter" && e.ctrlKey) {
|
|
||||||
if (inThread()) {
|
|
||||||
localStorage.removeItem(window.location.pathname);
|
|
||||||
}
|
|
||||||
e.target.form?.submit();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const inThread = () => {
|
|
||||||
const scheme = window.location.pathname.split("/");
|
|
||||||
return scheme[1] === "threads" && scheme[2] !== "create";
|
|
||||||
}
|
|
||||||
|
|
||||||
ta.addEventListener("input", () => {
|
|
||||||
if (!inThread()) return;
|
|
||||||
|
|
||||||
localStorage.setItem(window.location.pathname, ta.value);
|
|
||||||
})
|
|
||||||
|
|
||||||
if (inThread()) {
|
|
||||||
const form = ta.closest('.post-edit-form');
|
|
||||||
if (form){
|
|
||||||
form.addEventListener("submit", () => {
|
|
||||||
localStorage.removeItem(window.location.pathname);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
|
||||||
if (!inThread()) return;
|
|
||||||
const prevContent = localStorage.getItem(window.location.pathname);
|
|
||||||
if (!prevContent) return;
|
|
||||||
ta.value = prevContent;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,543 +0,0 @@
|
|||||||
const bookmarkMenuHrefTemplate = '/hyperapi/bookmarks-dropdown';
|
|
||||||
const badgeEditorEndpoint = '/hyperapi/badge-editor';
|
|
||||||
const previewEndpoint = '/api/babycode-preview';
|
|
||||||
const userEndpoint = '/api/current-user';
|
|
||||||
|
|
||||||
const delay = ms => {return new Promise(resolve => setTimeout(resolve, ms))}
|
|
||||||
|
|
||||||
export default class {
|
|
||||||
async showBookmarkMenu(ev, el) {
|
|
||||||
if ((ev.sender.dataset.bookmarkId === el.prop('bookmarkId')) && el.childElementCount === 0) {
|
|
||||||
const searchParams = new URLSearchParams({
|
|
||||||
'id': ev.sender.dataset.conceptId,
|
|
||||||
'require_reload': el.dataset.requireReload,
|
|
||||||
});
|
|
||||||
const bookmarkMenuHref = `${bookmarkMenuHrefTemplate}/${ev.sender.dataset.bookmarkType}?${searchParams}`;
|
|
||||||
const res = await this.api.getHTML(bookmarkMenuHref);
|
|
||||||
if (res.error) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const frag = res.value;
|
|
||||||
el.appendChild(frag);
|
|
||||||
const menu = el.childNodes[0];
|
|
||||||
menu.showPopover();
|
|
||||||
|
|
||||||
const bRect = el.getBoundingClientRect();
|
|
||||||
const menuRect = menu.getBoundingClientRect();
|
|
||||||
const preferredLeft = bRect.right - menuRect.width;
|
|
||||||
const preferredRight = bRect.right;
|
|
||||||
const enoughSpace = preferredLeft >= 0;
|
|
||||||
const scrollY = window.scrollY || window.pageYOffset;
|
|
||||||
if (enoughSpace) {
|
|
||||||
menu.style.left = `${preferredLeft}px`;
|
|
||||||
} else {
|
|
||||||
menu.style.left = `${bRect.left}px`;
|
|
||||||
}
|
|
||||||
menu.style.top = `${bRect.bottom + scrollY}px`;
|
|
||||||
|
|
||||||
menu.addEventListener('beforetoggle', (e) => {
|
|
||||||
if (e.newState === 'closed') {
|
|
||||||
// if it's still in the tree, remove it
|
|
||||||
// the delay is required to make sure its removed instantly when
|
|
||||||
// clicking the button when the menu is open
|
|
||||||
setTimeout(() => {menu.remove()}, 100);
|
|
||||||
};
|
|
||||||
}, { once: true });
|
|
||||||
|
|
||||||
} else if (el.childElementCount > 0) {
|
|
||||||
el.removeChild(el.childNodes[0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
selectBookmarkCollection(ev, el) {
|
|
||||||
const clicked = ev.sender;
|
|
||||||
|
|
||||||
if (ev.sender === el) {
|
|
||||||
if (clicked.classList.contains('selected')) {
|
|
||||||
clicked.classList.remove('selected');
|
|
||||||
} else {
|
|
||||||
clicked.classList.add('selected');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
el.classList.remove('selected');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveBookmarks(ev, el) {
|
|
||||||
const bookmarkHref = el.prop('bookmarkEndpoint');
|
|
||||||
const collection = el.querySelector('.bookmark-dropdown-item.selected');
|
|
||||||
let data = {};
|
|
||||||
if (collection) {
|
|
||||||
data['operation'] = 'move';
|
|
||||||
data['collection_id'] = collection.dataset.collectionId;
|
|
||||||
data['memo'] = el.querySelector('.bookmark-memo-input').value;
|
|
||||||
} else {
|
|
||||||
data['operation'] = 'remove';
|
|
||||||
data['collection_id'] = el.prop('originallyContainedIn');
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const requireReload = el.propToInt('requireReload') !== 0;
|
|
||||||
el.remove();
|
|
||||||
await fetch(bookmarkHref, options);
|
|
||||||
if (requireReload) {
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async copyCode(ev, el) {
|
|
||||||
if (!el.isSender) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await navigator.clipboard.writeText(el.value);
|
|
||||||
el.textContent = 'Copied!'
|
|
||||||
await delay(1000);
|
|
||||||
el.textContent = 'Copy';
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleAccordion(ev, el) {
|
|
||||||
const accordion = el;
|
|
||||||
const header = accordion.querySelector('.accordion-header');
|
|
||||||
if (!header.contains(ev.sender)){
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const btn = ev.sender;
|
|
||||||
const content = el.querySelector('.accordion-content');
|
|
||||||
// these are all meant to be in sync
|
|
||||||
accordion.classList.toggle('hidden');
|
|
||||||
content.classList.toggle('hidden');
|
|
||||||
btn.textContent = accordion.classList.contains('hidden') ? '+' : '-';
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleTab(ev, el) {
|
|
||||||
const tabButtonsContainer = el.querySelector('.tab-buttons');
|
|
||||||
if (!el.contains(ev.sender)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ev.sender.classList.contains('active')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetId = ev.sender.prop('targetId');
|
|
||||||
const contents = el.querySelectorAll('.tab-content');
|
|
||||||
for (let content of contents) {
|
|
||||||
if (content.id === targetId) {
|
|
||||||
content.classList.add('active');
|
|
||||||
} else {
|
|
||||||
content.classList.remove('active');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let button of tabButtonsContainer.children) {
|
|
||||||
if (button.dataset.targetId === targetId) {
|
|
||||||
button.classList.add('active');
|
|
||||||
} else {
|
|
||||||
button.classList.remove('active');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#previousMarkup = null;
|
|
||||||
async babycodePreview(ev, el) {
|
|
||||||
if (ev.sender.classList.contains('active')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const previewErrorsContainer = el.querySelector('#babycode-preview-errors-container');
|
|
||||||
const previewContainer = el.querySelector('#babycode-preview-container');
|
|
||||||
const ta = document.getElementById('babycode-content');
|
|
||||||
const markup = ta.value.trim();
|
|
||||||
if (markup === '') {
|
|
||||||
previewErrorsContainer.textContent = 'Type something!';
|
|
||||||
previewContainer.textContent = '';
|
|
||||||
this.#previousMarkup = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (markup === this.#previousMarkup) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bannedTags = JSON.parse(document.getElementById('babycode-banned-tags').value);
|
|
||||||
this.#previousMarkup = markup;
|
|
||||||
|
|
||||||
const res = await this.api.getJSON(previewEndpoint, [], {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
markup: markup,
|
|
||||||
banned_tags: bannedTags,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (res.error) {
|
|
||||||
switch (res.error.status) {
|
|
||||||
case 429:
|
|
||||||
previewErrorsContainer.textContent = '(Old preview, try again in a few seconds.)'
|
|
||||||
this.#previousMarkup = '';
|
|
||||||
break;
|
|
||||||
case 400:
|
|
||||||
previewErrorsContainer.textContent = '(Request got malformed.)'
|
|
||||||
break;
|
|
||||||
case 401:
|
|
||||||
previewErrorsContainer.textContent = '(You are not logged in.)'
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
previewErrorsContainer.textContent = '(Error. Check console.)'
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
previewErrorsContainer.textContent = '';
|
|
||||||
previewContainer.innerHTML = res.value.html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
insertBabycodeTag(ev, el) {
|
|
||||||
const tagStart = ev.sender.prop('tag');
|
|
||||||
const breakLine = 'breakLine' in ev.sender.dataset;
|
|
||||||
const prefill = 'prefill' in ev.sender.dataset ? ev.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.substr(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();
|
|
||||||
}
|
|
||||||
|
|
||||||
addQuote(ev, el) {
|
|
||||||
el.value += ev.sender.value;
|
|
||||||
el.scrollIntoView();
|
|
||||||
el.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
convertTimestamps(ev, el) {
|
|
||||||
const timestamp = el.propToInt('utc');
|
|
||||||
if (!isNaN(timestamp)) {
|
|
||||||
const date = new Date(timestamp * 1000);
|
|
||||||
el.textContent = date.toLocaleString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#currentUsername = undefined;
|
|
||||||
async highlightMentions(ev, el) {
|
|
||||||
if (this.#currentUsername === undefined) {
|
|
||||||
const userInfo = await this.api.getJSON(userEndpoint);
|
|
||||||
if (!userInfo.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.#currentUsername = userInfo.value.user.username;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (el.prop('username') === this.#currentUsername) {
|
|
||||||
el.classList.add('me');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BadgeEditorForm {
|
|
||||||
#badgeTemplate = undefined;
|
|
||||||
async loadBadgeEditor(ev, el) {
|
|
||||||
const badges = await this.api.getHTML(badgeEditorEndpoint);
|
|
||||||
if (!badges.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.#badgeTemplate === undefined){
|
|
||||||
this.#badgeTemplate = document.getElementById('badge-editor-template').content.firstElementChild.outerHTML;
|
|
||||||
}
|
|
||||||
el.replaceChildren();
|
|
||||||
const addButton = `<button data-disable-if-max="1" data-receive="updateBadgeCount" DISABLE_IF_MAX type="button" data-send="addBadge">Add badge</button>`;
|
|
||||||
const submitButton = `<input data-receive="updateBadgeCount" type="submit" value="Save badges">`;
|
|
||||||
const controls = `<span>${addButton} ${submitButton} <span data-count="1" data-receive="updateBadgeCount">BADGECOUNT/10</span></span>`
|
|
||||||
const badgeCount = badges.value.querySelectorAll('.settings-badge-container').length;
|
|
||||||
const subs = [
|
|
||||||
['BADGECOUNT', badgeCount],
|
|
||||||
['DISABLE_IF_MAX', badgeCount === 10 ? 'disabled' : ''],
|
|
||||||
];
|
|
||||||
el.appendChild(this.api.makeHTML(controls, subs));
|
|
||||||
|
|
||||||
const listTemplate = document.getElementById('badges-list-template').content.firstElementChild.outerHTML;
|
|
||||||
const list = this.api.makeHTML(listTemplate).firstElementChild;
|
|
||||||
list.appendChild(badges.value);
|
|
||||||
el.appendChild(list);
|
|
||||||
}
|
|
||||||
|
|
||||||
addBadge(ev, el) {
|
|
||||||
if (this.#badgeTemplate === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const badge = this.api.makeHTML(this.#badgeTemplate).firstElementChild;
|
|
||||||
el.appendChild(badge);
|
|
||||||
this.api.localTrigger('updateBadgeCount');
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteBadge(ev, el) {
|
|
||||||
if (!el.contains(ev.sender)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
el.remove();
|
|
||||||
this.api.localTrigger('updateBadgeCount');
|
|
||||||
}
|
|
||||||
|
|
||||||
updateBadgeCount(ev, el) {
|
|
||||||
const badgeCount = el.parentNode.parentNode.querySelectorAll('.settings-badge-container').length;
|
|
||||||
if (el.propToInt('disableIfMax') === 1) {
|
|
||||||
el.disabled = badgeCount === 10;
|
|
||||||
} else if (el.propToInt('count') === 1) {
|
|
||||||
el.textContent = `${badgeCount}/10`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
badgeEditorPrepareSubmit(ev, el) {
|
|
||||||
if (ev.type !== 'submit') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ev.preventDefault();
|
|
||||||
|
|
||||||
const badges = el.querySelectorAll('.settings-badge-container').length;
|
|
||||||
|
|
||||||
const noUploads = el.querySelectorAll('.settings-badge-file-picker.hidden input[type=file]');
|
|
||||||
noUploads.forEach(e => {
|
|
||||||
e.value = null;
|
|
||||||
})
|
|
||||||
el.submit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const validateBase64Img = dataURL => new Promise(resolve => {
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => {
|
|
||||||
resolve(img.width === 88 && img.height === 31);
|
|
||||||
};
|
|
||||||
img.src = dataURL;
|
|
||||||
});
|
|
||||||
|
|
||||||
export class BadgeEditorBadge {
|
|
||||||
#badgeCustomImageData = null;
|
|
||||||
badgeUpdatePreview(ev, el) {
|
|
||||||
if (ev.type !== 'change') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// TODO: ev.sender doesn't have a bittyParent
|
|
||||||
const selectBittyParent = ev.sender.closest('bitty-7-0');
|
|
||||||
if (el.bittyParent !== selectBittyParent) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ev.value === 'custom') {
|
|
||||||
if (this.#badgeCustomImageData) {
|
|
||||||
el.src = this.#badgeCustomImageData;
|
|
||||||
} else {
|
|
||||||
el.removeAttribute('src');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const option = ev.sender.selectedOptions[0];
|
|
||||||
el.src = option.dataset.filePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
async badgeUpdatePreviewCustom(ev, el) {
|
|
||||||
if (ev.type !== 'change') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (el.bittyParent !== ev.sender.bittyParent) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = ev.target.files[0];
|
|
||||||
if (file.size >= 1000 * 500) {
|
|
||||||
this.api.localTrigger('badgeErrorSize');
|
|
||||||
this.#badgeCustomImageData = null;
|
|
||||||
el.removeAttribute('src');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
|
|
||||||
reader.onload = async e => {
|
|
||||||
const dimsValid = await validateBase64Img(e.target.result);
|
|
||||||
if (!dimsValid) {
|
|
||||||
this.api.localTrigger('badgeErrorDim');
|
|
||||||
this.#badgeCustomImageData = null;
|
|
||||||
el.removeAttribute('src');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.#badgeCustomImageData = e.target.result;
|
|
||||||
el.src = this.#badgeCustomImageData;
|
|
||||||
this.api.localTrigger('badgeHideErrors');
|
|
||||||
}
|
|
||||||
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
badgeToggleFilePicker(ev, el) {
|
|
||||||
if (ev.type !== 'change') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// TODO: ev.sender doesn't have a bittyParent
|
|
||||||
const selectBittyParent = ev.sender.closest('bitty-7-0');
|
|
||||||
if (el.bittyParent !== selectBittyParent) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const filePicker = el.querySelector('input[type=file]');
|
|
||||||
if (ev.value === 'custom') {
|
|
||||||
el.classList.remove('hidden');
|
|
||||||
if (filePicker.dataset.validity) {
|
|
||||||
filePicker.setCustomValidity(filePicker.dataset.validity);
|
|
||||||
}
|
|
||||||
filePicker.required = true;
|
|
||||||
} else {
|
|
||||||
el.classList.add('hidden');
|
|
||||||
filePicker.setCustomValidity('');
|
|
||||||
filePicker.required = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
openBadgeFilePicker(ev, el) {
|
|
||||||
// TODO: ev.sender doesn't have a bittyParent
|
|
||||||
if (ev.sender.parentNode !== el.parentNode) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
el.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
badgeErrorSize(ev, el) {
|
|
||||||
const validity = "Image can't be over 500KB."
|
|
||||||
el.dataset.validity = validity;
|
|
||||||
el.setCustomValidity(validity);
|
|
||||||
el.reportValidity();
|
|
||||||
}
|
|
||||||
|
|
||||||
badgeErrorDim(ev, el) {
|
|
||||||
const validity = "Image must be exactly 88x31 pixels."
|
|
||||||
el.dataset.validity = validity;
|
|
||||||
el.setCustomValidity(validity);
|
|
||||||
el.reportValidity();
|
|
||||||
}
|
|
||||||
|
|
||||||
badgeHideErrors(ev, el) {
|
|
||||||
delete el.dataset.validity;
|
|
||||||
el.setCustomValidity('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getCollectionDataForEl = el => {
|
|
||||||
const nameInput = el.querySelector(".collection-name");
|
|
||||||
const collectionId = el.dataset.collectionId;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: collectionId,
|
|
||||||
name: nameInput.value,
|
|
||||||
is_new: !('collectionId' in el.dataset),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CollectionsEditor {
|
|
||||||
#collectionTemplate = undefined;
|
|
||||||
#collectionsData = [];
|
|
||||||
#removedCollections = [];
|
|
||||||
#valid = true;
|
|
||||||
|
|
||||||
addCollection(ev, el) {
|
|
||||||
if (this.#collectionTemplate === undefined) {
|
|
||||||
this.#collectionTemplate = document.getElementById('new-collection-template').content;
|
|
||||||
}
|
|
||||||
// interesting
|
|
||||||
const newCollection = this.api.makeHTML(this.#collectionTemplate.firstElementChild.outerHTML);
|
|
||||||
el.appendChild(newCollection);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteCollection(ev, el) {
|
|
||||||
if (!el.contains(ev.sender)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if ('collectionId' in el.dataset) {
|
|
||||||
this.#removedCollections.push(el.dataset.collectionId);
|
|
||||||
}
|
|
||||||
el.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveCollections(ev, el) {
|
|
||||||
this.#valid = true;
|
|
||||||
this.api.localTrigger('testValidity');
|
|
||||||
if (!this.#valid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.#collectionsData = [];
|
|
||||||
this.api.localTrigger('getCollectionData');
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
collections: this.#collectionsData,
|
|
||||||
removed_collections: this.#removedCollections,
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await this.api.getJSON(el.prop('submitHref'), [], {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
if (res.error) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
getCollectionData(ev, el) {
|
|
||||||
this.#collectionsData.push(getCollectionDataForEl(el));
|
|
||||||
}
|
|
||||||
|
|
||||||
testValidity(ev, el) {
|
|
||||||
const input = el.querySelector('input');
|
|
||||||
if (!input.validity.valid) {
|
|
||||||
input.reportValidity();
|
|
||||||
this.#valid = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,360 +0,0 @@
|
|||||||
{
|
|
||||||
const ta = document.getElementById("babycode-content");
|
|
||||||
|
|
||||||
function supportsPopover() {
|
|
||||||
return Object.hasOwn(HTMLElement.prototype, "popover");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (supportsPopover()){
|
|
||||||
let quotedPostContainer = null;
|
|
||||||
function isQuoteSelectionValid() {
|
|
||||||
const selection = document.getSelection();
|
|
||||||
|
|
||||||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const range = selection.getRangeAt(0);
|
|
||||||
const commonAncestor = range.commonAncestorContainer;
|
|
||||||
|
|
||||||
const ancestorElement = commonAncestor.nodeType === Node.TEXT_NODE
|
|
||||||
? commonAncestor.parentNode
|
|
||||||
: commonAncestor;
|
|
||||||
|
|
||||||
const container = ancestorElement.closest(".post-inner");
|
|
||||||
if (!container) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const success = container.contains(ancestorElement);
|
|
||||||
if (success) {
|
|
||||||
quotedPostContainer = container;
|
|
||||||
}
|
|
||||||
return success;
|
|
||||||
}
|
|
||||||
|
|
||||||
let quotePopover = null;
|
|
||||||
let isSelecting = false;
|
|
||||||
|
|
||||||
document.addEventListener("mousedown", () => {
|
|
||||||
isSelecting = true;
|
|
||||||
})
|
|
||||||
|
|
||||||
document.addEventListener("mouseup", () => {
|
|
||||||
isSelecting = false;
|
|
||||||
handlePossibleSelection();
|
|
||||||
})
|
|
||||||
|
|
||||||
document.addEventListener("keyup", (e) => {
|
|
||||||
if (e.shiftKey && (e.key.startsWith('Arrow') || e.key === 'Home' || e.key === 'End')) {
|
|
||||||
handlePossibleSelection();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function handlePossibleSelection() {
|
|
||||||
setTimeout(() => {
|
|
||||||
const valid = isQuoteSelectionValid();
|
|
||||||
if (isSelecting || !valid) {
|
|
||||||
removeQuotePopover();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selection = document.getSelection();
|
|
||||||
const selectionStr = selection.toString().trim();
|
|
||||||
if (selection.isCollapsed || selectionStr === "") {
|
|
||||||
removeQuotePopover();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
showQuotePopover();
|
|
||||||
}, 50)
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeQuotePopover() {
|
|
||||||
quotePopover?.hidePopover();
|
|
||||||
}
|
|
||||||
|
|
||||||
function createQuotePopover() {
|
|
||||||
quotePopover = document.createElement("div");
|
|
||||||
quotePopover.popover = "auto";
|
|
||||||
quotePopover.className = "quote-popover";
|
|
||||||
|
|
||||||
const quoteButton = document.createElement("button");
|
|
||||||
quoteButton.textContent = "Quote fragment"
|
|
||||||
quoteButton.className = "reduced"
|
|
||||||
quotePopover.appendChild(quoteButton);
|
|
||||||
document.body.appendChild(quotePopover);
|
|
||||||
return quoteButton;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showQuotePopover() {
|
|
||||||
if (!quotePopover) {
|
|
||||||
const quoteButton = createQuotePopover();
|
|
||||||
quoteButton.addEventListener("click", () => {
|
|
||||||
console.log("Quoting:", document.getSelection().toString());
|
|
||||||
const postPermalink = quotedPostContainer.dataset.postPermalink;
|
|
||||||
const authorUsername = quotedPostContainer.dataset.authorUsername;
|
|
||||||
console.log(postPermalink, authorUsername);
|
|
||||||
if (ta.value.trim() !== "") {
|
|
||||||
ta.value += "\n"
|
|
||||||
}
|
|
||||||
ta.value += `@${authorUsername} [url=${postPermalink}]said:[/url]\n[quote]<:scissors:> ${document.getSelection().toString()} <:scissors:>[/quote]\n`;
|
|
||||||
ta.scrollIntoView()
|
|
||||||
ta.focus();
|
|
||||||
|
|
||||||
document.getSelection().empty();
|
|
||||||
removeQuotePopover();
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const range = document.getSelection().getRangeAt(0);
|
|
||||||
const rect = range.getBoundingClientRect();
|
|
||||||
const scrollY = window.scrollY || window.pageYOffset;
|
|
||||||
|
|
||||||
quotePopover.style.setProperty("top", `${rect.top + scrollY - 55}px`)
|
|
||||||
quotePopover.style.setProperty("left", `${rect.left + rect.width/2}px`)
|
|
||||||
|
|
||||||
if (!quotePopover.matches(':popover-open')) {
|
|
||||||
quotePopover.showPopover();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteDialog = document.getElementById("delete-dialog");
|
|
||||||
const deleteDialogCloseButton = document.getElementById("post-delete-dialog-close");
|
|
||||||
let deletionTargetPostContainer;
|
|
||||||
|
|
||||||
function closeDeleteDialog() {
|
|
||||||
deletionTargetPostContainer.style.removeProperty("background-color");
|
|
||||||
deleteDialog.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteDialogCloseButton.addEventListener("click", (e) => {
|
|
||||||
closeDeleteDialog();
|
|
||||||
})
|
|
||||||
deleteDialog.addEventListener("click", (e) => {
|
|
||||||
if (e.target === deleteDialog) {
|
|
||||||
closeDeleteDialog();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
for (let button of document.querySelectorAll(".post-delete-button")) {
|
|
||||||
button.addEventListener("click", (e) => {
|
|
||||||
deleteDialog.showModal();
|
|
||||||
const postId = button.value;
|
|
||||||
deletionTargetPostContainer = document.getElementById("post-" + postId).querySelector(".post-content-container");
|
|
||||||
deletionTargetPostContainer.style.setProperty("background-color", "#fff");
|
|
||||||
const form = document.getElementById("post-delete-form");
|
|
||||||
form.action = `/post/${postId}/delete`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const threadEndpoint = document.getElementById("thread-subscribe-endpoint").value;
|
|
||||||
let now = Math.floor(new Date() / 1000);
|
|
||||||
function hideNotification() {
|
|
||||||
const notification = document.getElementById('new-post-notification');
|
|
||||||
notification.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function showNewPostNotification(url) {
|
|
||||||
const notification = document.getElementById("new-post-notification");
|
|
||||||
|
|
||||||
notification.classList.remove("hidden");
|
|
||||||
|
|
||||||
document.getElementById("dismiss-new-post-button").onclick = () => {
|
|
||||||
now = Math.floor(new Date() / 1000);
|
|
||||||
hideNotification();
|
|
||||||
tryFetchUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById("go-to-new-post-button").href = url;
|
|
||||||
|
|
||||||
document.getElementById("unsub-new-post-button").onclick = () => {
|
|
||||||
hideNotification();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function tryFetchUpdate() {
|
|
||||||
if (!threadEndpoint) return;
|
|
||||||
const body = JSON.stringify({'since': now});
|
|
||||||
fetch(threadEndpoint, {method: "POST", headers: {"Content-Type": "application/json"}, body: body})
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(json => {
|
|
||||||
if (json.status === "none") {
|
|
||||||
setTimeout(tryFetchUpdate, 5000);
|
|
||||||
} else if (json.status === "new_post") {
|
|
||||||
showNewPostNotification(json.url);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => console.log(error))
|
|
||||||
}
|
|
||||||
tryFetchUpdate();
|
|
||||||
|
|
||||||
if (supportsPopover()){
|
|
||||||
const reactionEmoji = document.getElementById("allowed-reaction-emoji").value.split(" ");
|
|
||||||
let reactionPopover = null;
|
|
||||||
let reactionTargetPostId = null;
|
|
||||||
|
|
||||||
function tryAddReaction(emoji, postId = reactionTargetPostId) {
|
|
||||||
const body = JSON.stringify({
|
|
||||||
"emoji": emoji,
|
|
||||||
});
|
|
||||||
fetch(`/api/add-reaction/${postId}`, {method: "POST", headers: {"Content-Type": "application/json"}, body: body})
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(json => {
|
|
||||||
if (json.status === "added") {
|
|
||||||
const post = document.getElementById(`post-${postId}`);
|
|
||||||
const spans = Array.from(post.querySelectorAll(".reaction-count")).filter((span) => {
|
|
||||||
return span.dataset.emoji === emoji
|
|
||||||
});
|
|
||||||
if (spans.length > 0) {
|
|
||||||
const currentValue = spans[0].textContent;
|
|
||||||
spans[0].textContent = `${parseInt(currentValue) + 1}`;
|
|
||||||
const button = spans[0].closest(".reaction-button");
|
|
||||||
button.classList.add("active");
|
|
||||||
} else {
|
|
||||||
const span = document.createElement("span");
|
|
||||||
span.classList = "reaction-container";
|
|
||||||
span.dataset.emoji = emoji;
|
|
||||||
|
|
||||||
const button = document.createElement("button");
|
|
||||||
button.type = "button";
|
|
||||||
button.className = "reduced reaction-button active";
|
|
||||||
|
|
||||||
button.addEventListener("click", () => {
|
|
||||||
tryAddReaction(emoji, postId);
|
|
||||||
})
|
|
||||||
|
|
||||||
const img = document.createElement("img");
|
|
||||||
img.src = `/static/emoji/${emoji}.png`;
|
|
||||||
|
|
||||||
button.textContent = " x";
|
|
||||||
|
|
||||||
const reactionCountSpan = document.createElement("span")
|
|
||||||
reactionCountSpan.className = "reaction-count"
|
|
||||||
reactionCountSpan.textContent = "1"
|
|
||||||
|
|
||||||
button.insertAdjacentElement("afterbegin", img);
|
|
||||||
button.appendChild(reactionCountSpan);
|
|
||||||
|
|
||||||
span.appendChild(button);
|
|
||||||
|
|
||||||
const post = document.getElementById(`post-${postId}`);
|
|
||||||
post.querySelector(".post-reactions").insertBefore(span, post.querySelector(".add-reaction-button"));
|
|
||||||
}
|
|
||||||
} else if (json.error_code === 409) {
|
|
||||||
console.log("reaction exists, gonna try and remove");
|
|
||||||
tryRemoveReaction(emoji, postId);
|
|
||||||
} else {
|
|
||||||
console.warn(json)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => console.error(error));
|
|
||||||
}
|
|
||||||
|
|
||||||
function tryRemoveReaction(emoji, postId = reactionTargetPostId) {
|
|
||||||
const body = JSON.stringify({
|
|
||||||
"emoji": emoji,
|
|
||||||
});
|
|
||||||
|
|
||||||
fetch(`/api/remove-reaction/${postId}`, {method: "POST", headers: {"Content-Type": "application/json"}, body: body})
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(json => {
|
|
||||||
if (json.status === "removed") {
|
|
||||||
const post = document.getElementById(`post-${postId}`);
|
|
||||||
const spans = Array.from(post.querySelectorAll(".reaction-container")).filter((span) => {
|
|
||||||
return span.dataset.emoji === emoji
|
|
||||||
});
|
|
||||||
if (spans.length > 0) {
|
|
||||||
const reactionCountSpan = spans[0].querySelector(".reaction-count");
|
|
||||||
const currentValue = parseInt(reactionCountSpan.textContent);
|
|
||||||
if (currentValue - 1 === 0) {
|
|
||||||
spans[0].remove();
|
|
||||||
} else {
|
|
||||||
reactionCountSpan.textContent = `${parseInt(currentValue) - 1}`;
|
|
||||||
const button = reactionCountSpan.closest(".reaction-button");
|
|
||||||
button.classList.remove("active");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn(json)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => console.error(error));
|
|
||||||
}
|
|
||||||
|
|
||||||
function createReactionPopover() {
|
|
||||||
reactionPopover = document.createElement("div");
|
|
||||||
reactionPopover.className = "reaction-popover";
|
|
||||||
reactionPopover.popover = "auto";
|
|
||||||
|
|
||||||
const inner = document.createElement("div");
|
|
||||||
inner.className = "reaction-popover-inner";
|
|
||||||
|
|
||||||
reactionPopover.appendChild(inner);
|
|
||||||
|
|
||||||
for (let emoji of reactionEmoji) {
|
|
||||||
const img = document.createElement("img");
|
|
||||||
img.src = `/static/emoji/${emoji}.png`;
|
|
||||||
|
|
||||||
const button = document.createElement("button");
|
|
||||||
button.type = "button";
|
|
||||||
button.className = "reduced";
|
|
||||||
button.appendChild(img);
|
|
||||||
button.addEventListener("click", () => {
|
|
||||||
tryAddReaction(emoji);
|
|
||||||
})
|
|
||||||
|
|
||||||
button.dataset.emojiName = emoji;
|
|
||||||
|
|
||||||
inner.appendChild(button);
|
|
||||||
}
|
|
||||||
|
|
||||||
reactionPopover.addEventListener("beforetoggle", (e) => {
|
|
||||||
if (e.newState === "closed") {
|
|
||||||
reactionTargetPostId = null;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
document.body.appendChild(reactionPopover);
|
|
||||||
}
|
|
||||||
|
|
||||||
function showReactionPopover() {
|
|
||||||
if (!reactionPopover) {
|
|
||||||
createReactionPopover();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!reactionPopover.matches(':popover-open')) {
|
|
||||||
reactionPopover.showPopover();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let button of document.querySelectorAll(".add-reaction-button")) {
|
|
||||||
button.addEventListener("click", (e) => {
|
|
||||||
showReactionPopover();
|
|
||||||
reactionTargetPostId = e.target.dataset.postId;
|
|
||||||
|
|
||||||
const rect = e.target.getBoundingClientRect();
|
|
||||||
const popoverRect = reactionPopover.getBoundingClientRect();
|
|
||||||
const scrollY = window.scrollY || window.pageYOffset;
|
|
||||||
|
|
||||||
reactionPopover.style.setProperty("top", `${rect.top + scrollY + rect.height}px`)
|
|
||||||
reactionPopover.style.setProperty("left", `${rect.left + rect.width/2 - popoverRect.width/2}px`)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let button of document.querySelectorAll(".reaction-button")) {
|
|
||||||
button.addEventListener("click", () => {
|
|
||||||
const reactionContainer = button.closest(".reaction-container")
|
|
||||||
const emoji = reactionContainer.dataset.emoji;
|
|
||||||
const postId = reactionContainer.dataset.postId;
|
|
||||||
console.log(reactionContainer);
|
|
||||||
tryAddReaction(emoji, postId);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
} else {
|
|
||||||
for (let button of document.querySelectorAll(".add-reaction-button")) {
|
|
||||||
button.disabled = true;
|
|
||||||
button.title = "Enable JS to add reactions."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
const deleteDialog = document.getElementById("delete-dialog");
|
|
||||||
const deleteDialogOpenButton = document.getElementById("topic-delete-dialog-open");
|
|
||||||
deleteDialogOpenButton.addEventListener("click", (e) => {
|
|
||||||
deleteDialog.showModal();
|
|
||||||
});
|
|
||||||
const deleteDialogCloseButton = document.getElementById("topic-delete-dialog-close");
|
|
||||||
deleteDialogCloseButton.addEventListener("click", (e) => {
|
|
||||||
deleteDialog.close();
|
|
||||||
})
|
|
||||||
deleteDialog.addEventListener("click", (e) => {
|
|
||||||
if (e.target === deleteDialog) {
|
|
||||||
deleteDialog.close();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
function openLightbox(post, idx) {
|
|
||||||
lightboxCurrentPost = post;
|
|
||||||
lightboxCurrentIdx = idx;
|
|
||||||
lightboxObj.img.src = lightboxImages.get(post)[idx].src;
|
|
||||||
lightboxObj.openOriginalAnchor.href = lightboxImages.get(post)[idx].src
|
|
||||||
lightboxObj.prevButton.disabled = lightboxImages.get(post).length === 1
|
|
||||||
lightboxObj.nextButton.disabled = lightboxImages.get(post).length === 1
|
|
||||||
lightboxObj.imageCount.textContent = `Image ${idx + 1} of ${lightboxImages.get(post).length}`
|
|
||||||
|
|
||||||
if (!lightboxObj.dialog.open) {
|
|
||||||
lightboxObj.dialog.showModal();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const modulo = (n, m) => ((n % m) + m) % m
|
|
||||||
|
|
||||||
function lightboxNext() {
|
|
||||||
const l = lightboxImages.get(lightboxCurrentPost).length;
|
|
||||||
const target = modulo(lightboxCurrentIdx + 1, l);
|
|
||||||
openLightbox(lightboxCurrentPost, target);
|
|
||||||
}
|
|
||||||
|
|
||||||
function lightboxPrev() {
|
|
||||||
const l = lightboxImages.get(lightboxCurrentPost).length;
|
|
||||||
const target = modulo(lightboxCurrentIdx - 1, l);
|
|
||||||
openLightbox(lightboxCurrentPost, target);
|
|
||||||
}
|
|
||||||
|
|
||||||
function constructLightbox() {
|
|
||||||
const dialog = document.createElement("dialog");
|
|
||||||
dialog.classList.add("lightbox-dialog");
|
|
||||||
dialog.addEventListener("click", (e) => {
|
|
||||||
if (e.target === dialog) {
|
|
||||||
dialog.close();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const dialogInner = document.createElement("div");
|
|
||||||
dialogInner.classList.add("lightbox-inner");
|
|
||||||
dialog.appendChild(dialogInner);
|
|
||||||
const img = document.createElement("img");
|
|
||||||
img.classList.add("lightbox-image")
|
|
||||||
dialogInner.appendChild(img);
|
|
||||||
const openOriginalAnchor = document.createElement("a")
|
|
||||||
openOriginalAnchor.text = "Open original in new window"
|
|
||||||
openOriginalAnchor.target = "_blank"
|
|
||||||
openOriginalAnchor.rel = "noopener noreferrer nofollow"
|
|
||||||
dialogInner.appendChild(openOriginalAnchor);
|
|
||||||
|
|
||||||
const navSpan = document.createElement("span");
|
|
||||||
navSpan.classList.add("lightbox-nav");
|
|
||||||
const prevButton = document.createElement("button");
|
|
||||||
prevButton.type = "button";
|
|
||||||
prevButton.textContent = "Previous";
|
|
||||||
prevButton.addEventListener("click", lightboxPrev);
|
|
||||||
const nextButton = document.createElement("button");
|
|
||||||
nextButton.type = "button";
|
|
||||||
nextButton.textContent = "Next";
|
|
||||||
nextButton.addEventListener("click", lightboxNext);
|
|
||||||
const imageCount = document.createElement("span");
|
|
||||||
imageCount.textContent = "Image of ";
|
|
||||||
navSpan.appendChild(prevButton);
|
|
||||||
navSpan.appendChild(imageCount);
|
|
||||||
navSpan.appendChild(nextButton);
|
|
||||||
|
|
||||||
dialogInner.appendChild(navSpan);
|
|
||||||
return {
|
|
||||||
img: img,
|
|
||||||
dialog: dialog,
|
|
||||||
openOriginalAnchor: openOriginalAnchor,
|
|
||||||
prevButton: prevButton,
|
|
||||||
nextButton: nextButton,
|
|
||||||
imageCount: imageCount,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let lightboxImages = new Map(); //.post-inner : Array<Object>
|
|
||||||
let lightboxObj = null;
|
|
||||||
let lightboxCurrentPost = null;
|
|
||||||
let lightboxCurrentIdx = -1;
|
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
|
||||||
//lightboxes
|
|
||||||
lightboxObj = constructLightbox();
|
|
||||||
document.body.appendChild(lightboxObj.dialog);
|
|
||||||
|
|
||||||
function setImageMaxSize(img) {
|
|
||||||
const {
|
|
||||||
maxWidth: origMaxWidth,
|
|
||||||
maxHeight: origMaxHeight,
|
|
||||||
minWidth: origMinWidth,
|
|
||||||
minHeight: origMinHeight,
|
|
||||||
} = getComputedStyle(img);
|
|
||||||
if (img.naturalWidth < parseInt(origMinWidth)) {
|
|
||||||
img.style.minWidth = img.naturalWidth + "px";
|
|
||||||
}
|
|
||||||
if (img.naturalHeight < parseInt(origMinHeight)) {
|
|
||||||
img.style.minHeight = img.naturalHeight + "px";
|
|
||||||
}
|
|
||||||
if (img.naturalWidth < parseInt(origMaxWidth)) {
|
|
||||||
img.style.maxWidth = img.naturalWidth + "px";
|
|
||||||
}
|
|
||||||
if (img.naturalHeight < parseInt(origMaxHeight)) {
|
|
||||||
img.style.maxHeight = img.naturalHeight + "px";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const postImages = document.querySelectorAll(".post-inner img.post-image");
|
|
||||||
postImages.forEach(postImage => {
|
|
||||||
const belongingTo = postImage.closest(".post-inner");
|
|
||||||
const images = lightboxImages.get(belongingTo) ?? [];
|
|
||||||
images.push({
|
|
||||||
src: postImage.src,
|
|
||||||
alt: postImage.alt,
|
|
||||||
});
|
|
||||||
const idx = images.length - 1;
|
|
||||||
lightboxImages.set(belongingTo, images);
|
|
||||||
postImage.style.cursor = "pointer";
|
|
||||||
postImage.addEventListener("click", () => {
|
|
||||||
openLightbox(belongingTo, idx);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const postAndSigImages = document.querySelectorAll("img.post-image");
|
|
||||||
postAndSigImages.forEach(image => {
|
|
||||||
if (image.complete) {
|
|
||||||
setImageMaxSize(image);
|
|
||||||
} else {
|
|
||||||
image.addEventListener("load", () => setImageMaxSize(image));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
{
|
|
||||||
function isBefore(el1, el2) {
|
|
||||||
if (el2.parentNode === el1.parentNode) {
|
|
||||||
for (let cur = el1.previousSibling; cur; cur = cur.previousSibling) {
|
|
||||||
if (cur === el2) return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let draggedItem = null;
|
|
||||||
|
|
||||||
function sortableItemDragStart(e, item) {
|
|
||||||
const box = item.getBoundingClientRect();
|
|
||||||
const oX = e.clientX - box.left;
|
|
||||||
const oY = e.clientY - box.top;
|
|
||||||
draggedItem = item;
|
|
||||||
item.classList.add('dragged');
|
|
||||||
e.dataTransfer.setDragImage(item, oX, oY);
|
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortableItemDragEnd(e, item) {
|
|
||||||
draggedItem = null;
|
|
||||||
item.classList.remove('dragged');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortableItemDragOver(e, item) {
|
|
||||||
const target = e.target.closest('.sortable-item');
|
|
||||||
if (!target || target === draggedItem) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const inSameList = draggedItem.dataset.sortableListKey === target.dataset.sortableListKey;
|
|
||||||
if (!inSameList) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const targetList = draggedItem.closest('.sortable-list');
|
|
||||||
if (isBefore(draggedItem, target)) {
|
|
||||||
targetList.insertBefore(draggedItem, target);
|
|
||||||
} else {
|
|
||||||
targetList.insertBefore(draggedItem, target.nextSibling);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const listItemsHandled = new Map();
|
|
||||||
|
|
||||||
const getListItemsHandled = (list) => {
|
|
||||||
return listItemsHandled.get(list) || new Set();
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerSortableList(list) {
|
|
||||||
list.querySelectorAll('li:not(.immovable)').forEach(item => {
|
|
||||||
const listItems = getListItemsHandled(list);
|
|
||||||
listItems.add(item);
|
|
||||||
listItemsHandled.set(list, listItems);
|
|
||||||
const dragger = item.querySelector('.dragger');
|
|
||||||
dragger.addEventListener('dragstart', e => {sortableItemDragStart(e, item)});
|
|
||||||
dragger.addEventListener('dragend', e => {sortableItemDragEnd(e, item)});
|
|
||||||
item.addEventListener('dragover', e => {sortableItemDragOver(e, item)});
|
|
||||||
});
|
|
||||||
|
|
||||||
const obs = new MutationObserver(records => {
|
|
||||||
for (const mutation of records) {
|
|
||||||
mutation.addedNodes.forEach(node => {
|
|
||||||
if (!(node instanceof HTMLElement)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!node.classList.contains('sortable-item')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const listItems = getListItemsHandled(list)
|
|
||||||
if (listItems.has(node)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const dragger = node.querySelector('.dragger');
|
|
||||||
dragger.addEventListener('dragstart', e => {sortableItemDragStart(e, node)});
|
|
||||||
dragger.addEventListener('dragend', e => {sortableItemDragEnd(e, node)});
|
|
||||||
node.addEventListener('dragover', e => {sortableItemDragOver(e, node)});
|
|
||||||
listItems.add(node);
|
|
||||||
listItemsHandled.set(list, listItems);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
obs.observe(list, {childList: true});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.querySelectorAll('.sortable-list').forEach(registerSortableList);
|
|
||||||
|
|
||||||
listsObs = new MutationObserver(records => {
|
|
||||||
for (const mutation of records) {
|
|
||||||
mutation.addedNodes.forEach(node => {
|
|
||||||
if (!(node instanceof HTMLElement)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!node.classList.contains('sortable-list')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
registerSortableList(node);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
listsObs.observe(document.body, {childList: true, subtree: true});
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user