Compare commits

...

13 Commits

31 changed files with 936 additions and 353 deletions

View File

@@ -4,5 +4,7 @@
data/db/*
data/static/avatars/*
!data/static/avatars/default.webp
data/static/badges/user
data/_cached
.local/

1
.gitignore vendored
View File

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

View File

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

View File

@@ -3,15 +3,15 @@ from dotenv import load_dotenv
from .models import Avatars, Users, PostHistory, Posts, MOTD, BadgeUploads
from .auth import digest
from .routes.users import is_logged_in, get_active_user, get_prefers_theme
from .routes.threads import get_post_url
from .constants import (
PermissionLevel, permission_level_string,
InfoboxKind, InfoboxHTMLClass,
REACTION_EMOJI, MOTD_BANNED_TAGS,
SIG_BANNED_TAGS, STRICT_BANNED_TAGS,
)
from .lib.babycode import babycode_to_html, EMOJI, BABYCODE_VERSION
from datetime import datetime
from .lib.babycode import babycode_to_html, babycode_to_rssxml, EMOJI, BABYCODE_VERSION
from datetime import datetime, timezone
from flask_caching import Cache
import os
import time
import secrets
@@ -55,6 +55,18 @@ def reparse_babycode():
print('Re-parsing babycode, this may take a while...')
from .db import db
from .constants import MOTD_BANNED_TAGS
post_histories_without_rss = PostHistory.findall([
('markup_language', '=', 'babycode'),
('content_rss', 'IS', None),
])
with db.transaction():
for ph in post_histories_without_rss:
ph.update({
'content_rss': babycode_to_rssxml(ph['original_markup']),
})
post_histories = PostHistory.findall([
('markup_language', '=', 'babycode'),
('format_version', 'IS NOT', BABYCODE_VERSION)
@@ -65,6 +77,7 @@ def reparse_babycode():
for ph in post_histories:
ph.update({
'content': babycode_to_html(ph['original_markup']).result,
'content_rss': babycode_to_rssxml(ph['original_markup']),
'format_version': BABYCODE_VERSION,
})
print('Re-parsing posts done.')
@@ -125,6 +138,8 @@ def bind_default_badges(path):
})
cache = Cache()
def create_app():
app = Flask(__name__)
app.config['SITE_NAME'] = 'Pyrom'
@@ -133,6 +148,10 @@ def create_app():
app.config['USERS_CAN_INVITE'] = False
app.config['ADMIN_CONTACT_INFO'] = ''
app.config['GUIDE_DESCRIPTION'] = ''
app.config['CACHE_TYPE'] = 'FileSystemCache'
app.config['CACHE_DEFAULT_TIMEOUT'] = 300
try:
app.config.from_file('../config/pyrom_config.toml', load=tomllib.load, text=False)
except FileNotFoundError:
@@ -142,6 +161,7 @@ def create_app():
app.static_folder = os.path.join(os.path.dirname(__file__), "../data/static")
app.debug = True
app.config["DB_PATH"] = "data/db/db.dev.sqlite"
app.config["SERVER_NAME"] = "localhost:8080"
load_dotenv()
else:
app.config["DB_PATH"] = "data/db/db.prod.sqlite"
@@ -156,6 +176,13 @@ def create_app():
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)
if app.config['CACHE_TYPE'] == 'FileSystemCache':
cache_dir = app.config.get('CACHE_DIR', 'data/_cached')
os.makedirs(cache_dir, exist_ok = True)
app.config['CACHE_DIR'] = cache_dir
cache.init_app(app)
css_dir = 'data/static/css/'
allowed_themes = []
for f in os.listdir(css_dir):
@@ -167,20 +194,6 @@ def create_app():
allowed_themes.sort(key=(lambda x: (x != 'style', x)))
app.config['allowed_themes'] = allowed_themes
with app.app_context():
from .schema import create as create_tables
from .migrations import run_migrations
create_tables()
run_migrations()
create_default_avatar()
create_admin()
create_deleted_user()
reparse_babycode()
bind_default_badges(app.config['BADGES_PATH'])
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
@@ -200,6 +213,20 @@ def create_app():
app.register_blueprint(hyperapi_bp)
app.register_blueprint(guides_bp)
with app.app_context():
from .schema import create as create_tables
from .migrations import run_migrations
create_tables()
run_migrations()
create_default_avatar()
create_admin()
create_deleted_user()
reparse_babycode()
bind_default_badges(app.config['BADGES_PATH'])
app.config['SESSION_COOKIE_SECURE'] = True
@app.before_request
@@ -229,10 +256,12 @@ def create_app():
@app.context_processor
def inject_funcs():
from .routes.threads import get_post_url
return {
'get_post_url': get_post_url,
'get_prefers_theme': get_prefers_theme,
'get_motds': MOTD.get_all,
'get_time_now': lambda: int(time.time()),
}
@app.template_filter("ts_datetime")
@@ -251,12 +280,12 @@ def create_app():
return permission_level_string(term)
@app.template_filter('babycode')
def babycode_filter(markup):
return babycode_to_html(markup).result
def babycode_filter(markup, nofrag=False):
return babycode_to_html(markup, fragment=not nofrag).result
@app.template_filter('babycode_strict')
def babycode_strict_filter(markup):
return babycode_to_html(markup, STRICT_BANNED_TAGS).result
def babycode_strict_filter(markup, nofrag=False):
return babycode_to_html(markup, banned_tags=STRICT_BANNED_TAGS, fragment=not nofrag).result
@app.template_filter('extract_h2')
def extract_h2(content):
@@ -308,4 +337,8 @@ def create_app():
def fromjson(subject: str):
return json.loads(subject)
@app.template_filter('iso8601')
def unix_to_iso8601(subject: str):
return datetime.fromtimestamp(int(subject), timezone.utc).isoformat()
return app

View File

@@ -6,17 +6,204 @@ from pygments.lexers import get_lexer_by_name
from pygments.util import ClassNotFound as PygmentsClassNotFound
import re
class BabycodeParseResult:
BABYCODE_VERSION = 8
class BabycodeError(Exception):
pass
class BabycodeRenderError(BabycodeError):
pass
class UnknownASTElementError(BabycodeRenderError):
def __init__(self, element_type, element=None):
self.element_type = element_type
self.element = element
message = f'Unknown AST element: {element_type}'
if element:
message += f' (element: {element})'
super().__init__(message)
class BabycodeRenderResult:
def __init__(self, result, mentions=[]):
self.result = result
self.mentions = mentions
def __str__(self):
return self.result
BABYCODE_VERSION = 5
class BabycodeRenderer:
def __init__(self, tag_map, void_tag_map, emote_map, fragment=False):
self.tag_map = tag_map
self.void_tag_map = void_tag_map
self.emote_map = emote_map
self.fragment = fragment
def make_mention(self, element):
raise NotImplementedError
def transform_para_whitespace(self, text):
# markdown rules:
# two spaces at end of line -> <br>
text = re.sub(r' +\n', '<br>', text)
# single newlines -> space (collapsed)
text = re.sub(r'\n', ' ', text)
return text
def wrap_in_paragraphs(self, nodes, context_is_block=True, is_root=False):
result = []
current_paragraph = []
is_first_para = is_root and self.fragment
def flush_paragraph():
# TIL nonlocal exists
nonlocal result, current_paragraph, is_first_para
if not current_paragraph:
return
para_content = ''.join(current_paragraph)
if para_content.strip(): # skip empty paragraphs
if is_first_para:
result.append(para_content)
is_first_para = False
else:
result.append(f"<p>{para_content}</p>")
current_paragraph.clear()
for node in nodes:
if isinstance(node, str):
paras = re.split(r'\n\n+', node)
for i, para in enumerate(paras):
if i > 0 and context_is_block:
flush_paragraph()
if para:
processed = self.transform_para_whitespace(para)
current_paragraph.append(processed)
else:
inline = is_inline(node)
if inline and context_is_block:
# inline child within a paragraph context
current_paragraph.append(self.fold(node))
elif not inline and context_is_block:
# block child within a block context
flush_paragraph()
if is_root:
# this is relevant for fragment.
# fragment only applies to the first inline node(s).
# if the first element is a block, reset "fragment mode".
is_first_para = False
result.append(self.fold(node))
else:
# either inline in inline context, or block in inline context
current_paragraph.append(self.fold(node))
if context_is_block:
# flush final para if we're in a block context
flush_paragraph()
elif current_paragraph:
# inline context - just append whatever we collected
result.append(''.join(current_paragraph))
return ''.join(result)
def fold(self, element):
if isinstance(element, str):
return element
match element['type']:
case 'bbcode':
tag_name = element['name']
if is_inline(element):
# inline tag
# since its inline, all children should be processed inline
content = "".join(self.fold(child) for child in element['children'])
return self.tag_map[tag_name](content, element['attr'])
else:
# block tag
if tag_name in {'ul', 'ol', 'code', 'img'}:
# these handle their own internal structure
content = ''.join(
child if isinstance(child, str) else self.fold(child)
for child in element['children']
)
return self.tag_map[tag_name](content, element['attr'])
else:
# block elements that can contain paragraphs
content = self.wrap_in_paragraphs(element['children'], context_is_block=True, is_root=False)
return self.tag_map[tag_name](content, element['attr'])
case 'bbcode_void':
return self.void_tag_map[element['name']](element['attr'])
case 'link':
return f"<a href=\"{element['url']}\">{element['url']}</a>"
case 'emote':
return self.emote_map[element['name']]
case 'rule':
return '<hr>'
case 'mention':
return self.make_mention(element)
case _:
raise UnknownASTElementError(
element_type=element['type'],
element=element
)
def render(self, ast):
out = self.wrap_in_paragraphs(ast, context_is_block=True, is_root=True)
return out
class HTMLRenderer(BabycodeRenderer):
def __init__(self, fragment=False):
super().__init__(TAGS, VOID_TAGS, EMOJI, fragment)
self.mentions = []
def make_mention(self, e):
from ..models import Users
from flask import url_for, current_app
with current_app.test_request_context('/'):
target_user = Users.find({'username': e['name'].lower()})
if not target_user:
return f"@{e['name']}"
mention_data = {
'mention_text': f"@{e['name']}",
'mentioned_user_id': int(target_user.id),
"start": e['start'],
"end": e['end'],
}
if mention_data not in self.mentions:
self.mentions.append(mention_data)
return f"<a class='mention{' display' if target_user.has_display_name() else ''}' href='{url_for('users.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):
out = super().render(ast)
return BabycodeRenderResult(out, self.mentions)
class RSSXMLRenderer(BabycodeRenderer):
def __init__(self, fragment=False):
super().__init__(RSS_TAGS, VOID_TAGS, RSS_EMOJI, fragment)
def make_mention(self, e):
from ..models import Users
from flask import url_for, current_app
with current_app.test_request_context('/'):
target_user = Users.find({'username': e['name'].lower()})
if not target_user:
return f"@{e['name']}"
return f'<a href="{url_for('users.page', username=target_user.username, _external=True)}" title="@{target_user.username}">{target_user.get_readable_name()}</a>'
NAMED_COLORS = [
'black', 'silver', 'gray', 'white', 'maroon', 'red',
@@ -49,114 +236,11 @@ NAMED_COLORS = [
'violet', 'wheat', 'white', 'whitesmoke', 'yellow', 'yellowgreen',
]
def is_tag(e, tag=None):
if e is None:
return False
if isinstance(e, str):
return False
if e['type'] != 'bbcode':
return False
if tag is None:
return True
return e['name'] == tag
def is_text(e):
return isinstance(e, str)
def tag_code(children, attr, surrounding):
is_inline = children.find('\n') == -1
if is_inline:
return f"<code class=\"inline-code\">{children}</code>"
else:
input_code = children.strip()
button = f"<button type=button class=\"copy-code\" value=\"{input_code}\" data-send=\"copyCode\" data-receive=\"copyCode\">Copy</button>"
unhighlighted = f"<pre><span class=\"copy-code-container\"><span class=\"code-language-identifier\">code block</span>{button}</span><code>{input_code}</code></pre>"
if not attr:
return unhighlighted
try:
lexer = get_lexer_by_name(attr.strip())
formatter = HtmlFormatter(nowrap=True)
return f"<pre><span class=\"copy-code-container\"><span class=\"code-language-identifier\">{lexer.name}</span>{button}</span><code>{highlight(input_code.unescape(), lexer, formatter)}</code></pre>"
except PygmentsClassNotFound:
return unhighlighted
def tag_list(children):
list_body = re.sub(r" +\n", "<br>", children.strip())
list_body = re.sub(r"\n\n+", "\1", list_body)
return " ".join([f"<li>{x}</li>" for x in list_body.split("\1") if x])
def tag_color(children, attr, surrounding):
if not attr:
return f"[color]{children}[/color]"
hex_re = r"^#?([0-9a-f]{6}|[0-9a-f]{3})$"
potential_color = attr.lower().strip()
if potential_color in NAMED_COLORS:
return f"<span style='color: {potential_color};'>{children}</span>"
m = re.match(hex_re, potential_color)
if m:
return f"<span style='color: #{m.group(1)};'>{children}</span>"
# return just the way it was if we can't parse it
return f"[color={attr}]{children}[/color]"
def tag_spoiler(children, attr, surrounding):
spoiler_name = attr if attr else "Spoiler"
content = f"<div class='accordion-content post-accordion-content 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>"""
return container
def tag_image(children, attr, surrounding):
img = f"<img class=\"post-image\" src=\"{attr}\" alt=\"{children}\">"
if not is_tag(surrounding[0], 'img'):
img = f"<div class=post-img-container>{img}"
if not is_tag(surrounding[1], 'img'):
img = f"{img}</div>"
return img
TAGS = {
"b": lambda children, attr, _: f"<strong>{children}</strong>",
"i": lambda children, attr, _: f"<em>{children}</em>",
"s": lambda children, attr, _: f"<del>{children}</del>",
"u": lambda children, attr, _: f"<u>{children}</u>",
"img": tag_image,
"url": lambda children, attr, _: f"<a href={attr}>{children}</a>",
"quote": lambda children, attr, _: f"<blockquote>{children}</blockquote>",
"code": tag_code,
"ul": lambda children, attr, _: f"<ul>{tag_list(children)}</ul>",
"ol": lambda children, attr, _: f"<ol>{tag_list(children)}</ol>",
"big": lambda children, attr, _: f"<span style='font-size: 2rem;'>{children}</span>",
"small": lambda children, attr, _: f"<span style='font-size: 0.75rem;'>{children}</span>",
"color": tag_color,
"center": lambda children, attr, _: f"<div style='text-align: center;'>{children}</div>",
"right": lambda children, attr, _: f"<div style='text-align: right;'>{children}</div>",
"spoiler": tag_spoiler,
}
VOID_TAGS = {
'lb': lambda attr: '[',
'rb': lambda attr: ']',
'@': lambda attr: '@',
}
# [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 = {
'b', 'i', 's', 'u', 'color', 'big', 'small', 'url'
}
def make_emoji(name, code):
return f' <img class=emoji src="/static/emoji/{name}.png" alt="{name}" title=":{code}:">'
EMOJI = {
'angry': make_emoji('angry', 'angry'),
@@ -203,12 +287,208 @@ EMOJI = {
'wink': make_emoji('wink', 'wink'),
}
RSS_EMOJI = {
**EMOJI,
'angry': '😡',
'(': '🙁',
'D': '😃',
'imp': '😈',
'angryimp': '👿',
'impangry': '👿',
'lobster': '🦞',
'|': '😐',
'pensive': '😔',
'scissors': '✂️',
')': '🙂',
'smiletear': '🥲',
'crytear': '🥲',
',': '😭',
'T': '😭',
'cry': '😭',
'sob': '😭',
'o': '😮',
'O': '😮',
'hmm': '🤔',
'think': '🤔',
'thinking': '🤔',
'P': '😛',
'p': '😛',
'weary': '😩',
';': '😉',
'wink': '😉',
}
TEXT_ONLY = ["code"]
def break_lines(text):
text = re.sub(r" +\n", "<br>", text)
text = re.sub(r"\n\n+", "<br><br>", text)
return text
def tag_code(children, attr):
is_inline = children.find('\n') == -1
if is_inline:
return f"<code class=\"inline-code\">{children}</code>"
else:
input_code = children.strip()
button = f"<button type=button class=\"copy-code\" value=\"{input_code}\" data-send=\"copyCode\" data-receive=\"copyCode\">Copy</button>"
unhighlighted = f"<pre><span class=\"copy-code-container\"><span class=\"code-language-identifier\">code block</span>{button}</span><code>{input_code}</code></pre>"
if not attr:
return unhighlighted
try:
lexer = get_lexer_by_name(attr.strip())
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>"
except PygmentsClassNotFound:
return unhighlighted
def tag_list(children):
list_body = re.sub(r" +\n", "<br>", children.strip())
list_body = re.sub(r"\n\n+", "\1", list_body)
return " ".join([f"<li>{x}</li>" for x in list_body.split("\1") if x])
def tag_color(children, attr):
if not attr:
return f"[color]{children}[/color]"
hex_re = r"^#?([0-9a-f]{6}|[0-9a-f]{3})$"
potential_color = attr.lower().strip()
if potential_color in NAMED_COLORS:
return f"<span style='color: {potential_color};'>{children}</span>"
m = re.match(hex_re, potential_color)
if m:
return f"<span style='color: #{m.group(1)};'>{children}</span>"
# return just the way it was if we can't parse it
return f"[color={attr}]{children}[/color]"
def tag_spoiler(children, attr):
spoiler_name = attr if attr else "Spoiler"
content = f"<div class='accordion-content post-accordion-content 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>"""
return container
def tag_image(children, attr):
img = f"<img class=\"post-image\" src=\"{attr}\" alt=\"{children}\">"
return f"<div class=post-img-container>{img}</div>"
TAGS = {
"b": lambda children, attr: f"<strong>{children}</strong>",
"i": lambda children, attr: f"<em>{children}</em>",
"s": lambda children, attr: f"<del>{children}</del>",
"u": lambda children, attr: f"<u>{children}</u>",
"img": tag_image,
"url": lambda children, attr: f"<a href={attr}>{children}</a>",
"quote": lambda children, attr: f"<blockquote>{children}</blockquote>",
"code": tag_code,
"ul": lambda children, attr: f"<ul>{tag_list(children)}</ul>",
"ol": lambda children, attr: f"<ol>{tag_list(children)}</ol>",
"big": lambda children, attr: f"<span style='font-size: 2rem;'>{children}</span>",
"small": lambda children, attr: f"<span style='font-size: 0.75rem;'>{children}</span>",
"color": tag_color,
"center": lambda children, attr: f"<div style='text-align: center;'>{children}</div>",
"right": lambda children, attr: f"<div style='text-align: right;'>{children}</div>",
"spoiler": tag_spoiler,
}
def tag_code_rss(children, attr):
is_inline = children.find('\n') == -1
if is_inline:
return f'<code>{children}</code>'
else:
return f'<pre><code>{children}</code></pre>'
def tag_url_rss(children, attr):
if attr.startswith('/'):
from flask import current_app
uri = f"{current_app.config['PREFERRED_URL_SCHEME']}://{current_app.config['SERVER_NAME']}{attr}"
return f"<a href={uri}>{children}</a>"
return f"<a href={attr}>{children}</a>"
def tag_image_rss(children, attr):
if attr.startswith('/'):
from flask import current_app
uri = f"{current_app.config['PREFERRED_URL_SCHEME']}://{current_app.config['SERVER_NAME']}{attr}"
return f'<img src="{uri}" alt={children} />'
return f'<img src="{attr}" alt={children} />'
RSS_TAGS = {
**TAGS,
'img': tag_image_rss,
'url': tag_url_rss,
'spoiler': lambda children, attr: f'<details><summary>{attr or "Spoiler"} (click to reveal)</summary>{children}</details>',
'code': tag_code_rss,
'big': lambda children, attr: f'<span style="font-size: 1.2em">{children}</span>',
'small': lambda children, attr: f'<small>{children}</small>'
}
VOID_TAGS = {
'lb': lambda attr: '[',
'rb': lambda attr: ']',
'at': lambda attr: '@',
'd': lambda attr: '-',
}
# [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 = {
'b', 'i', 's', 'u', 'color', 'big', 'small', 'url', 'lb', 'rb', 'at', 'd'
}
def is_tag(e, tag=None):
if e is None:
return False
if isinstance(e, str):
return False
if e['type'] != 'bbcode' and e['type'] != 'bbcode_void':
return False
if tag is None:
return True
return e['name'] == tag
def is_text(e):
return isinstance(e, str)
def is_inline(e):
if e is None:
@@ -219,29 +499,12 @@ def is_inline(e):
if is_tag(e):
if is_tag(e, 'code'): # special case, since [code] can be inline OR block
return '\n' not in e['children']
return '\n' not in e['children'][0]
return e['name'] in INLINE_TAGS
return e['type'] != 'rule'
def make_mention(e, mentions):
from ..models import Users
from flask import url_for
target_user = Users.find({'username': e['name'].lower()})
if not target_user:
return f"@{e['name']}"
mention_data = {
'mention_text': f"@{e['name']}",
'mentioned_user_id': int(target_user.id),
"start": e['start'],
"end": e['end'],
}
if mention_data not in mentions:
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>"
def should_collapse(text, surrounding):
if not isinstance(text, str):
@@ -255,10 +518,30 @@ def should_collapse(text, surrounding):
return False
def sanitize(s):
return escape(s.strip().replace('\r\n', '\n').replace('\r', '\n'))
def babycode_to_html(s, banned_tags=[]):
def babycode_ast(s: str, banned_tags=[]):
"""
transforms a string of babycode into an AST.
the AST is a list of strings or dicts.
a string element is plain unformatted text.
a dict element is a node that contains at least the key `type`.
possible types are:
- bbcode
- bbcode_void
- link
- emote
- rule
- mention
bbcode type elements have a children key that is a list of children of that node. the children are themselves elements (string or dict).
"""
allowed_tags = set(TAGS.keys())
if banned_tags is not None:
for tag in banned_tags:
@@ -281,44 +564,38 @@ def babycode_to_html(s, banned_tags=[]):
)
if not should_collapse(e, surrounding):
elements.append(e)
return elements
out = ""
mentions = []
def fold(element, nobr, surrounding):
if isinstance(element, str):
if nobr:
return element
return break_lines(element)
match element['type']:
case "bbcode":
c = ""
for i in range(len(element['children'])):
child = element['children'][i]
_surrounding = (
element['children'][i - 1] if i-1 >= 0 else None,
element['children'][i + 1] if i+1 < len(element['children']) else None
)
_nobr = element['name'] == "code" or element['name'] == "ul" or element['name'] == "ol"
c = c + Markup(fold(child, _nobr, _surrounding))
res = TAGS[element['name']](c, element['attr'], surrounding)
return res
case "bbcode_void":
return VOID_TAGS[element['name']](element['attr'])
case "link":
return f"<a href=\"{element['url']}\">{element['url']}</a>"
case 'emote':
return EMOJI[element['name']]
case "rule":
return "<hr>"
case "mention":
return make_mention(element, mentions)
def babycode_to_html(s: str, banned_tags=[], fragment=False) -> BabycodeRenderResult:
"""
transforms a string of babycode into html.
for i in range(len(elements)):
e = elements[i]
surrounding = (
elements[i - 1] if i-1 >= 0 else None,
elements[i + 1] if i+1 < len(elements) else None
)
out = out + fold(e, False, surrounding)
return BabycodeParseResult(out, mentions)
parameters:
s (str) - babycode string
banned_tags (list) - babycode tags to exclude from being parsed. they will remain as plain text in the transformation.
fragment (bool) - skip adding an html p tag to the first element if it is inline.
"""
ast = babycode_ast(s, banned_tags)
r = HTMLRenderer(fragment=fragment)
return r.render(ast)
def babycode_to_rssxml(s: str, banned_tags=[], fragment=False) -> str:
"""
transforms a string of babycode into rss-compatible x/html.
parameters:
s (str) - babycode string
banned_tags (list) - babycode tags to exclude from being parsed. they will remain as plain text in the transformation.
fragment (bool) - skip adding an html p tag to the first element if it is inline.
"""
ast = babycode_ast(s, banned_tags)
r = RSSXMLRenderer(fragment=fragment)
return r.render(ast)

10
app/lib/render_atom.py Normal file
View File

@@ -0,0 +1,10 @@
from flask import make_response, render_template, request
def render_atom_template(template, *args, **kwargs):
injects = {
**kwargs,
'__current_page': request.url,
}
r = make_response(render_template(template, *args, **injects))
r.mimetype = 'application/xml'
return r

View File

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

View File

@@ -230,6 +230,38 @@ class Topics(Model):
return db.query(q, self.id, per_page, (page - 1) * per_page)
def get_threads_with_op_rss(self):
q = """
SELECT
threads.id, threads.title, threads.slug, threads.created_at, threads.is_locked, threads.is_stickied,
users.username AS started_by,
users.display_name AS started_by_display_name,
ph.content_rss AS original_post_content,
posts.id AS original_post_id
FROM
threads
JOIN users ON users.id = threads.user_id
JOIN (
SELECT
posts.thread_id,
posts.id,
posts.user_id,
posts.created_at,
posts.current_revision_id,
ROW_NUMBER() OVER (PARTITION BY posts.thread_id ORDER BY posts.created_at ASC) AS rn
FROM
posts
) posts ON posts.thread_id = threads.id AND posts.rn = 1
JOIN
post_history ph ON ph.id = posts.current_revision_id
JOIN
users u ON u.id = posts.user_id
WHERE
threads.topic_id = ?
ORDER BY threads.created_at DESC"""
return db.query(q, self.id)
class Threads(Model):
table = "threads"
@@ -238,6 +270,10 @@ class Threads(Model):
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)
def get_posts_rss(self):
q = Posts.FULL_POSTS_QUERY + ' WHERE posts.thread_id = ?'
return db.query(q, self.id)
def locked(self):
return bool(self.is_locked)
@@ -265,7 +301,7 @@ class Posts(Model):
SELECT
posts.id, posts.created_at,
post_history.content, post_history.edited_at,
post_history.content, post_history.edited_at, post_history.content_rss,
users.username, users.display_name, users.status,
avatars.file_path AS avatar_path, posts.thread_id,
users.id AS user_id, post_history.original_markup,

View File

@@ -1,7 +1,18 @@
from flask import Blueprint, redirect, url_for, render_template
from app import cache
from datetime import datetime
bp = Blueprint("app", __name__, url_prefix = "/")
@bp.route("/")
def index():
return redirect(url_for("topics.all_topics"))
@bp.route("/cache-test")
def cache_test():
test_value = cache.get('test')
if test_value is None:
test_value = 'cached_value_' + str(datetime.now())
cache.set('test', test_value, timeout=10)
return f"set cache: {test_value}"
return f"cached: {test_value}"

View File

@@ -2,7 +2,7 @@ from flask import (
Blueprint, redirect, url_for, flash, render_template, request
)
from .users import login_required, get_active_user
from ..lib.babycode import babycode_to_html, BABYCODE_VERSION
from ..lib.babycode import babycode_to_html, babycode_to_rssxml, BABYCODE_VERSION
from ..constants import InfoboxKind
from ..db import db
from ..models import Posts, PostHistory, Threads, Topics, Mentions
@@ -12,6 +12,7 @@ bp = Blueprint("posts", __name__, url_prefix = "/post")
def create_post(thread_id, user_id, content, markup_language="babycode"):
parsed_content = babycode_to_html(content)
parsed_rss = babycode_to_rssxml(content)
with db.transaction():
post = Posts.create({
"thread_id": thread_id,
@@ -22,6 +23,7 @@ def create_post(thread_id, user_id, content, markup_language="babycode"):
revision = PostHistory.create({
"post_id": post.id,
"content": parsed_content.result,
"content_rss": parsed_rss,
"is_initial_revision": True,
"original_markup": content,
"markup_language": markup_language,
@@ -43,11 +45,13 @@ def create_post(thread_id, user_id, content, markup_language="babycode"):
def update_post(post_id, new_content, markup_language='babycode'):
parsed_content = babycode_to_html(new_content)
parsed_rss = babycode_to_rssxml(new_content)
with db.transaction():
post = Posts.find({'id': post_id})
new_revision = PostHistory.create({
'post_id': post.id,
'content': parsed_content.result,
"content_rss": parsed_rss,
'is_initial_revision': False,
'original_markup': new_content,
'markup_language': markup_language,

View File

@@ -1,31 +1,35 @@
from flask import (
Blueprint, render_template, request, redirect, url_for, flash,
abort,
abort, current_app,
)
from .users import login_required, mod_only, get_active_user, is_logged_in
from ..db import db
from ..models import Threads, Topics, Posts, Subscriptions, Reactions
from ..constants import InfoboxKind
from ..lib.render_atom import render_atom_template
from .posts import create_post
from slugify import slugify
from app import cache
import math
import time
bp = Blueprint("threads", __name__, url_prefix = "/threads/")
def get_post_url(post_id, _anchor=False):
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})
res = url_for('threads.thread', slug=thread.slug, after=post_id)
if not _anchor:
return res
anchor = None if not _anchor else f'post-{post_id}'
return f"{res}#post-{post_id}"
return url_for('threads.thread', slug=thread.slug, after=post_id, _external=external, _anchor=anchor)
# if not _anchor:
# return res
# return f"{res}#post-{post_id}"
@bp.get("/<slug>")
@@ -80,9 +84,25 @@ def thread(slug):
is_subscribed = is_subscribed,
Reactions = Reactions,
unread_count = unread_count,
__feedlink = url_for('.thread_atom', slug=slug, _external=True),
__feedtitle = f'replies to {thread.title}',
)
@bp.get("/<slug>/feed.atom")
@cache.cached(timeout=5 * 60, unless=lambda: current_app.config.get('DEBUG', False))
def thread_atom(slug):
thread = Threads.find({"slug": slug})
if not thread:
abort(404) # TODO throw an atom friendly 404
return
topic = Topics.find({'id': thread.topic_id})
posts = thread.get_posts_rss()
return render_atom_template('threads/thread.atom', thread=thread, topic=topic, posts=posts, get_post_url=get_post_url)
@bp.post("/<slug>")
@login_required
def reply(slug):

View File

@@ -1,11 +1,13 @@
from flask import (
Blueprint, render_template, request, redirect, url_for, flash, session,
abort,
abort, current_app
)
from .users import login_required, mod_only, get_active_user, is_logged_in
from ..models import Users, Topics, Threads, Subscriptions
from ..constants import InfoboxKind
from ..lib.render_atom import render_atom_template
from slugify import slugify
from app import cache
import time
import math
@@ -80,10 +82,27 @@ def topic(slug):
subscriptions = subscriptions,
topic = target_topic,
current_page = page,
page_count = page_count
page_count = page_count,
__feedlink = url_for('.topic_atom', slug=slug, _external=True),
__feedtitle = f'latest threads in {target_topic.name}',
)
@bp.get('/<slug>/feed.atom')
@cache.cached(timeout=10 * 60, unless=lambda: current_app.config.get('DEBUG', False))
def topic_atom(slug):
target_topic = Topics.find({
"slug": slug
})
if not target_topic:
abort(404) # TODO throw an atom friendly 404
return
threads_list = target_topic.get_threads_with_op_rss()
return render_atom_template('topics/topic.atom', threads_list=threads_list, target_topic=target_topic)
@bp.get("/<slug>/edit")
@login_required
@mod_only(".topic", slug = lambda slug: slug)

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

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

View File

@@ -10,7 +10,10 @@
{% endif %}
<link rel="stylesheet" href="{{ ("/static/css/%s.css" % get_prefers_theme()) | cachebust }}">
<link rel="icon" type="image/png" href="/static/favicon.png">
<script src="{{ '/static/js/vnd/bitty-7.0.0-rc1.min.js' | cachebust }}" type="module"></script>
<script src="{{ '/static/js/vnd/bitty-7.0.0.js' | cachebust }}" type="module"></script>
{% if __feedlink %}
<link rel="alternate" type="application/atom+xml" href="{{ __feedlink }}" title="{{ __feedtitle }}">
{% endif %}
</head>
<body>
<bitty-7-0 data-connect="{{ '/static/js/bitties/pyrom-bitty.js' | cachebust }}">

View File

@@ -53,3 +53,9 @@
<path d="M6 18V14M6 14H8L13 17V7L8 10H5C3.89543 10 3 10.8954 3 12V12C3 13.1046 3.89543 14 5 14H6ZM17 7L19 5M17 17L19 19M19 12H21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{%- endmacro %}
{% macro icn_rss(width=24) %}
<svg width="{{width}}px" height="{{width}}px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 11C9.41828 11 13 14.5817 13 19M5 5C12.732 5 19 11.268 19 19M7 18C7 18.5523 6.55228 19 6 19C5.44772 19 5 18.5523 5 18C5 17.4477 5.44772 17 6 17C6.55228 17 7 17.4477 7 18Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{% endmacro %}

View File

@@ -1,4 +1,4 @@
{% from 'common/icons.html' import icn_image, icn_spoiler, icn_info, icn_lock, icn_warn, icn_error, icn_bookmark, icn_megaphone %}
{% from 'common/icons.html' import icn_image, icn_spoiler, icn_info, icn_lock, icn_warn, icn_error, icn_bookmark, icn_megaphone, icn_rss %}
{% macro pager(current_page, page_count) %}
{% set left_start = [1, current_page - 5] | max %}
{% set right_end = [page_count, current_page + 5] | min %}
@@ -359,3 +359,11 @@
</div>
</bitty-7-0>
{% endmacro %}
{% macro rss_html_content(html) %}
<content type="html">{{ html }}</content>
{% endmacro %}
{% macro rss_button(feed) %}
<a class="linkbutton contain-svg inline icon rss-button" href="{{feed}}" title="it&#39;s actually atom, don&#39;t tell anyone &#59;&#41;">{{ icn_rss(20) }} Subscribe via RSS</a>
{% endmacro %}

View File

@@ -95,12 +95,12 @@
<h2 id="paragraph-rules">Paragraph rules</h2>
<p>Line breaks in babycode work like Markdown: to start a new paragraph, use two line breaks:</p>
{{ '[code]paragraph 1\n\nparagraph 2[/code]' | babycode | safe }}
Will produce:<br>
{{ 'paragraph 1\n\nparagraph 2' | babycode | safe }}
Will produce:
{{ 'paragraph 1\n\nparagraph 2' | babycode(true) | safe }}
<p>To break a line without starting a new paragraph, end a line with two spaces:</p>
{{ '[code]paragraph 1 \nstill paragraph 1[/code]' | babycode | safe }}
That will produce:<br>
{{ 'paragraph 1 \nstill paragraph 1' | babycode | safe }}
{{ 'paragraph 1 \nstill paragraph 1' | babycode(true) | safe }}
<p>Additionally, the following tags will break into a new paragraph:</p>
<ul>
<li><code class="inline-code">[code]</code> (code block, not inline);</li>
@@ -113,21 +113,20 @@
</section>
<section class="guide-section">
<h2 id="links">Links</h2>
<p>Loose links (starting with http:// or https://) will automatically get converted to clickable links. To add a label to a link, use<br><code class="inline-code">[url=https://example.com]Link label[/url]</code>:<br>
<p>Loose links (starting with http:// or https://) will automatically get converted to clickable links. To add a label to a link, use <code class="inline-code">[url=https://example.com]Link label[/url]</code>:<br>
<a href="https://example.com">Link label</a></p>
</section>
<section class="guide-section">
<h2 id="attaching-an-image">Attaching an image</h2>
<p>To add an image to your post, use the <code class="inline-code">[img]</code> tag:<br>
<code class="inline-code">[img=https://forum.poto.cafe/avatars/default.webp]the Python logo with a cowboy hat[/img]</code>
<code class="inline-code">[img=https://forum.poto.cafe/avatars/default.webp]the Python logo with a cowboy hat[/img]</code></p>
{{ '[img=/static/avatars/default.webp]the Python logo with a cowboy hat[/img]' | babycode | safe }}
</p>
<p>The attribute is the image URL. The text inside the tag will become the image's alt text.</p>
<p>Images will always break up a paragraph and will get scaled down to a maximum of 400px. However, consecutive image tags will try to stay in one line, wrapping if necessary. Break the paragraph if you wish to keep images on their own paragraph.</p>
<p>Multiple images attached to a post can be clicked to open a dialog to view them.</p>
</section>
<section class="guide-section">
<h2 id="adding-code-blocks">Adding code blocks</h2>
<h2 id="adding-code-blocks">Adding code blocūs</h2>
{% set code = 'func _ready() -> void:\n\tprint("hello world!")' %}
<p>There are two kinds of code blocks recognized by babycode: inline and block. Inline code blocks do not break a paragraph. They can be added with <code class="inline-code">[code]your code here[/code]</code>. As long as there are no line breaks inside the code block, it is considered inline. If there are any, it will produce this:</p>
{{ ('[code]%s[/code]' % code) | babycode | safe }}
@@ -168,9 +167,18 @@
<a class="mention display me" href="#mentions" title="@your-username">Your display name</a>
<p>Mentioning a user does not notify them. It is simply a way to link to their profile in your posts.</p>
</section>
<section class="guide-section">
{% set hr_example = "some section\n---\nanother section" %}
<h2 id="rule">Horizontal rules</h2>
<p>The special <code class="inline-code">---</code> markup inserts a horizontal separator, also known as a horizontal rule:</p>
{{ ("[code]%s[/code]" % hr_example) | babycode | safe }}
Will become
{{ hr_example | babycode(true) | safe}}
<p>Horizontal rules will always break the current paragraph.</p>
</section>
<section class="guide-section">
<h2 id="void-tags">Void tags</h2>
<p>The special void tags <code class="inline-code">[lb]</code>, <code class="inline-code">[rb]</code>, and <code class="inline-code">[@]</code> will appear as the literal characters <code class="inline-code">[</code>, <code class="inline-code">]</code>, and <code class="inline-code">@</code> respectively. Unlike other tags, they are self-contained and have no closing equivalent.</p>
<p>The special void tags <code class="inline-code">[lb]</code>, <code class="inline-code">[rb]</code>, <code class="inline-code">[d]</code> and <code class="inline-code">[at]</code> will appear as the literal characters <code class="inline-code">[</code>, <code class="inline-code">]</code>, <code class="inline-code">-</code>, and <code class="inline-code">@</code> respectively. Unlike other tags, they are self-contained and have no closing equivalent.</p>
<ul class="guide-list">
{% set lbrb = "[color=red]This text will be red[/color]\n\n[lb]color=red[rb]This text won't be red[lb]/color[rb]" %}
<li><code class="inline-code">[lb]</code> and <code class="inline-code">[rb]</code> allow you to use square brackets without them being interpreted as Babycode:
@@ -178,7 +186,8 @@
Will result in:<br>
{{ lbrb | babycode | safe }}
</li>
<li>The <code class="inline-code">[@]</code> tag allows you to use the @ symbol without it being turned into a mention.</li>
<li>The <code class="inline-code">[at]</code> tag allows you to use the @ symbol without it being turned into a mention.</li>
<li>The <code class="inline-code">[d]</code> tag allows you to use the - (dash) symbol without it being turned into a rule.</li>
</ul>
</section>
{% endblock %}

View File

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

View File

@@ -1,4 +1,4 @@
{% from 'common/macros.html' import pager, babycode_editor_form, full_post, bookmark_button %}
{% from 'common/macros.html' import pager, babycode_editor_form, full_post, bookmark_button, rss_button %}
{% from 'common/icons.html' import icn_bookmark %}
{% extends "base.html" %}
{% block title %}{{ thread.title }}{% endblock %}
@@ -53,6 +53,7 @@
<input class="warn" type="submit" value="Move thread">
</form>
{% endif %}
{{ rss_button(url_for('threads.thread_atom', slug=thread.slug)) }}
</div>
</nav>
{% for post in posts %}

View File

@@ -0,0 +1,20 @@
{% extends 'base.atom' %}
{% from 'common/macros.html' import rss_html_content %}
{% block title %}latest threads in {{target_topic.name}}{% endblock %}
{% block canonical_link %}{{url_for('topics.topic', slug=target_topic.slug, _external=true)}}{% endblock %}
{% block content %}
<subtitle>{{ target_topic.description }}</subtitle>
{% for thread in threads_list %}
<entry>
<title>[new thread] {{ thread.title | escape }}</title>
<link href="{{ url_for('threads.thread', slug=thread.slug, _external=true)}}" />
<link rel="replies" type="application/atom+xml" href="{{ url_for('threads.thread_atom', slug=thread.slug, _external=true)}}" />
<id>{{ url_for('threads.thread', slug=thread.slug, _external=true)}}</id>
<updated>{{ thread.created_at | iso8601 }}</updated>
{{rss_html_content(thread.original_post_content)}}
<author>
<name>{{thread.started_by_display_name}} @{{ thread.started_by }}</name>
</author>
</entry>
{% endfor %}
{% endblock %}

View File

@@ -1,4 +1,4 @@
{% from 'common/macros.html' import pager, timestamp, motd %}
{% from 'common/macros.html' import pager, timestamp, motd, rss_button %}
{% from 'common/icons.html' import icn_lock, icn_sticky %}
{% extends "base.html" %}
{% block title %}browsing topic {{ topic['name'] }}{% endblock %}
@@ -6,7 +6,7 @@
<nav class="darkbg">
<h1 class="thread-title">All threads in "{{topic['name']}}"</h1>
<span>{{topic['description']}}</span>
<div>
<div class="thread-actions">
{% if active_user %}
{% if not (topic['is_locked']) | int or active_user.is_mod() %}
<a class="linkbutton" href="{{ url_for("threads.create", topic_id=topic['id']) }}">New thread</a>
@@ -18,6 +18,7 @@
<input class="warn" type="submit" id="lock" value="{{"Unlock topic" if topic['is_locked'] else "Lock topic"}}">
</form>
<button type="button" class="critical" id="topic-delete-dialog-open">Delete</button>
{{ rss_button(url_for('topics.topic_atom', slug=topic.slug)) }}
{% endif %}
{% endif %}
</div>

View File

@@ -18,6 +18,16 @@
<span>1MB maximum size. Avatar will be cropped to square.</span>
</form>
</fieldset>
<fieldset class="hfc">
<legend>Change password</legend>
<form method='post' action='{{ url_for('users.change_password', username=active_user.username) }}'>
<label for="new_password">New password</label><br>
<input type="password" id="new_password" name="new_password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required autocomplete="new-password"><br>
<label for="new_password2">Confirm new password</label><br>
<input type="password" id="new_password2" name="new_password2" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required autocomplete="new-password"><br>
<input class="warn" type="submit" value="Change password">
</form>
</fieldset>
<fieldset class="hfc">
<legend>Personalization</legend>
<form method='post'>
@@ -44,16 +54,6 @@
</form>
</fieldset>
<fieldset class="hfc">
<legend>Change password</legend>
<form method='post' action='{{ url_for('users.change_password', username=active_user.username) }}'>
<label for="new_password">New password</label><br>
<input type="password" id="new_password" name="new_password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required autocomplete="new-password"><br>
<label for="new_password2">Confirm new password</label><br>
<input type="password" id="new_password2" name="new_password2" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required autocomplete="new-password"><br>
<input class="warn" type="submit" value="Change password">
</form>
</fieldset>
<fieldset>
<legend>Badges</legend>
<a href="{{ url_for('guides.guide_page', category='user-guides', slug='settings', _anchor='badges')}}">Badges help</a>
<bitty-7-0 data-connect="{{ '/static/js/bitties/pyrom-bitty.js' | cachebust }} BadgeEditorForm" data-listeners="click input submit change">

View File

@@ -44,13 +44,13 @@
.reaction-button.active, .tab-button, .currentpage, .pagebutton, input[type=file]::file-selector-button, button.warn, input[type=submit].warn, .linkbutton.warn, button.critical, input[type=submit].critical, .linkbutton.critical, button, input[type=submit], .linkbutton {
cursor: default;
font-size: 1rem;
font-size: 1em;
font-family: "Cadman", sans-serif;
text-decoration: none;
border: 1px solid black;
border-radius: 4px;
padding: 5px 20px;
margin: 10px 0;
margin: 5px 0;
}
body {
@@ -60,6 +60,11 @@ body {
color: black;
}
@media (orientation: portrait) {
body {
margin: 20px 0;
}
}
:where(a:link) {
color: #c11c1c;
}
@@ -69,7 +74,7 @@ body {
}
.big {
font-size: 1.8rem;
font-size: 1.8em;
}
#topnav {
@@ -114,7 +119,7 @@ body {
.site-title {
font-family: "site-title";
font-size: 3rem;
font-size: 3em;
margin: 0 20px;
text-decoration: none;
color: black;
@@ -122,14 +127,15 @@ body {
.thread-title {
margin: 0;
font-size: 1.5rem;
font-size: 1.5em;
font-weight: bold;
}
.thread-actions {
display: flex;
align-items: center;
gap: 5px;
gap: 0 5px;
flex-wrap: wrap;
}
.post {
@@ -222,7 +228,7 @@ code {
pre code {
display: block;
background-color: rgb(38.5714173228, 40.9237007874, 35.6762992126);
font-size: 1rem;
font-size: 1em;
color: white;
border-bottom-right-radius: 8px;
border-bottom-left-radius: 8px;
@@ -606,7 +612,7 @@ pre code { /* Literal.Number.Integer.Long */ }
display: inline-block;
margin: 4px;
border-radius: 4px;
font-size: 1rem;
font-size: 1em;
white-space: pre;
}
@@ -799,7 +805,7 @@ input[type=file]::file-selector-button {
}
p {
margin: 15px 0;
margin: 10px 0;
}
.pagebutton {
@@ -859,7 +865,7 @@ input[type=text], input[type=password], textarea, select {
resize: vertical;
color: black;
background-color: rgb(217.8, 225.6, 208.2);
font-size: 100%;
font-size: 1em;
font-family: inherit;
}
input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus {
@@ -1123,7 +1129,7 @@ textarea {
}
.babycode-preview-errors-container {
font-size: 0.8rem;
font-size: 0.8em;
}
.tab-button {
@@ -1269,9 +1275,6 @@ ul.horizontal li, ol.horizontal li {
padding: 5px 10px;
min-width: 36px;
}
.babycode-button > * {
font-size: 1rem;
}
.quote-popover {
position: absolute;
@@ -1455,7 +1458,7 @@ a.mention:hover, a.mention:visited:hover {
display: grid;
gap: 10px;
--grid-item-max-width: calc((100% - 10px) / 2);
grid-template-columns: repeat(auto-fill, minmax(max(400px, var(--grid-item-max-width)), 1fr));
grid-template-columns: repeat(auto-fill, minmax(max(600px, var(--grid-item-max-width)), 1fr));
}
.settings-grid fieldset {
border: 1px solid white;
@@ -1523,3 +1526,16 @@ img.badge-button {
justify-content: center;
gap: 5px;
}
.rss-button {
background-color: #fba668;
color: black;
}
.rss-button:hover {
background-color: rgb(251.8, 183.8, 134.2);
color: black;
}
.rss-button:active {
background-color: rgb(186.8501612903, 155.5098387097, 132.6498387097);
color: black;
}

View File

@@ -44,13 +44,13 @@
.reaction-button.active, .tab-button, .currentpage, .pagebutton, input[type=file]::file-selector-button, button.warn, input[type=submit].warn, .linkbutton.warn, button.critical, input[type=submit].critical, .linkbutton.critical, button, input[type=submit], .linkbutton {
cursor: default;
font-size: 1rem;
font-size: 1em;
font-family: "Cadman", sans-serif;
text-decoration: none;
border: 1px solid black;
border-radius: 8px;
padding: 5px 20px;
margin: 10px 0;
margin: 5px 0;
}
body {
@@ -60,6 +60,11 @@ body {
color: #e6e6e6;
}
@media (orientation: portrait) {
body {
margin: 20px 0;
}
}
:where(a:link) {
color: #e87fe1;
}
@@ -69,7 +74,7 @@ body {
}
.big {
font-size: 1.8rem;
font-size: 1.8em;
}
#topnav {
@@ -114,7 +119,7 @@ body {
.site-title {
font-family: "site-title";
font-size: 3rem;
font-size: 3em;
margin: 0 20px;
text-decoration: none;
color: white;
@@ -122,14 +127,15 @@ body {
.thread-title {
margin: 0;
font-size: 1.5rem;
font-size: 1.5em;
font-weight: bold;
}
.thread-actions {
display: flex;
align-items: center;
gap: 5px;
gap: 0 5px;
flex-wrap: wrap;
}
.post {
@@ -222,7 +228,7 @@ code {
pre code {
display: block;
background-color: #302731;
font-size: 1rem;
font-size: 1em;
color: white;
border-bottom-right-radius: 8px;
border-bottom-left-radius: 8px;
@@ -606,7 +612,7 @@ pre code { /* Literal.Number.Integer.Long */ }
display: inline-block;
margin: 4px;
border-radius: 8px;
font-size: 1rem;
font-size: 1em;
white-space: pre;
}
@@ -799,7 +805,7 @@ input[type=file]::file-selector-button {
}
p {
margin: 15px 0;
margin: 10px 0;
}
.pagebutton {
@@ -859,7 +865,7 @@ input[type=text], input[type=password], textarea, select {
resize: vertical;
color: #e6e6e6;
background-color: #371e37;
font-size: 100%;
font-size: 1em;
font-family: inherit;
}
input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus {
@@ -1123,7 +1129,7 @@ textarea {
}
.babycode-preview-errors-container {
font-size: 0.8rem;
font-size: 0.8em;
}
.tab-button {
@@ -1269,9 +1275,6 @@ ul.horizontal li, ol.horizontal li {
padding: 5px 10px;
min-width: 36px;
}
.babycode-button > * {
font-size: 1rem;
}
.quote-popover {
position: absolute;
@@ -1455,7 +1458,7 @@ a.mention:hover, a.mention:visited:hover {
display: grid;
gap: 10px;
--grid-item-max-width: calc((100% - 10px) / 2);
grid-template-columns: repeat(auto-fill, minmax(max(400px, var(--grid-item-max-width)), 1fr));
grid-template-columns: repeat(auto-fill, minmax(max(600px, var(--grid-item-max-width)), 1fr));
}
.settings-grid fieldset {
border: 1px solid black;
@@ -1524,6 +1527,19 @@ img.badge-button {
gap: 5px;
}
.rss-button {
background-color: #fba668;
color: black;
}
.rss-button:hover {
background-color: rgb(251.8, 183.8, 134.2);
color: black;
}
.rss-button:active {
background-color: rgb(186.8501612903, 155.5098387097, 132.6498387097);
color: black;
}
#topnav {
margin-bottom: 10px;
border: 10px solid rgb(40, 40, 40);

View File

@@ -44,13 +44,13 @@
.reaction-button.active, .tab-button, .currentpage, .pagebutton, input[type=file]::file-selector-button, button.warn, input[type=submit].warn, .linkbutton.warn, button.critical, input[type=submit].critical, .linkbutton.critical, button, input[type=submit], .linkbutton {
cursor: default;
font-size: 1rem;
font-size: 1em;
font-family: "Cadman", sans-serif;
text-decoration: none;
border: 1px solid black;
border-radius: 16px;
padding: 8px 12px;
margin: 6px 0;
margin: 3px 0;
}
body {
@@ -60,6 +60,11 @@ body {
color: black;
}
@media (orientation: portrait) {
body {
margin: 12px 0;
}
}
:where(a:link) {
color: black;
}
@@ -69,7 +74,7 @@ body {
}
.big {
font-size: 1.8rem;
font-size: 1.8em;
}
#topnav {
@@ -114,7 +119,7 @@ body {
.site-title {
font-family: "site-title";
font-size: 3rem;
font-size: 3em;
margin: 0 12px;
text-decoration: none;
color: black;
@@ -122,14 +127,15 @@ body {
.thread-title {
margin: 0;
font-size: 1.5rem;
font-size: 1.5em;
font-weight: bold;
}
.thread-actions {
display: flex;
align-items: center;
gap: 3px;
gap: 0 3px;
flex-wrap: wrap;
}
.post {
@@ -222,7 +228,7 @@ code {
pre code {
display: block;
background-color: rgb(41.7051685393, 28.2759550562, 24.6948314607);
font-size: 1rem;
font-size: 1em;
color: white;
border-bottom-right-radius: 16px;
border-bottom-left-radius: 16px;
@@ -606,7 +612,7 @@ pre code { /* Literal.Number.Integer.Long */ }
display: inline-block;
margin: 4px;
border-radius: 16px;
font-size: 1rem;
font-size: 1em;
white-space: pre;
}
@@ -799,7 +805,7 @@ input[type=file]::file-selector-button {
}
p {
margin: 8px 0;
margin: 6px 0;
}
.pagebutton {
@@ -859,7 +865,7 @@ input[type=text], input[type=password], textarea, select {
resize: vertical;
color: black;
background-color: rgb(247.2, 175.2, 156);
font-size: 100%;
font-size: 1em;
font-family: inherit;
}
input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus {
@@ -1123,7 +1129,7 @@ textarea {
}
.babycode-preview-errors-container {
font-size: 0.8rem;
font-size: 0.8em;
}
.tab-button {
@@ -1269,9 +1275,6 @@ ul.horizontal li, ol.horizontal li {
padding: 3px 6px;
min-width: 36px;
}
.babycode-button > * {
font-size: 1rem;
}
.quote-popover {
position: absolute;
@@ -1455,7 +1458,7 @@ a.mention:hover, a.mention:visited:hover {
display: grid;
gap: 6px;
--grid-item-max-width: calc((100% - 6px) / 2);
grid-template-columns: repeat(auto-fill, minmax(max(400px, var(--grid-item-max-width)), 1fr));
grid-template-columns: repeat(auto-fill, minmax(max(600px, var(--grid-item-max-width)), 1fr));
}
.settings-grid fieldset {
border: 1px solid white;
@@ -1524,6 +1527,19 @@ img.badge-button {
gap: 3px;
}
.rss-button {
background-color: #fba668;
color: black;
}
.rss-button:hover {
background-color: rgb(251.8, 183.8, 134.2);
color: black;
}
.rss-button:active {
background-color: rgb(186.8501612903, 155.5098387097, 132.6498387097);
color: black;
}
#topnav {
border-top-left-radius: 16px;
border-top-right-radius: 16px;

View File

@@ -44,13 +44,13 @@
.reaction-button.active, .tab-button, .currentpage, .pagebutton, input[type=file]::file-selector-button, button.warn, input[type=submit].warn, .linkbutton.warn, button.critical, input[type=submit].critical, .linkbutton.critical, button, input[type=submit], .linkbutton {
cursor: default;
font-size: 1rem;
font-size: 1em;
font-family: "Cadman", sans-serif;
text-decoration: none;
border: 1px solid black;
border-radius: 4px;
padding: 5px 20px;
margin: 10px 0;
margin: 5px 0;
}
body {
@@ -60,6 +60,11 @@ body {
color: black;
}
@media (orientation: portrait) {
body {
margin: 20px 0;
}
}
:where(a:link) {
color: #711579;
}
@@ -69,7 +74,7 @@ body {
}
.big {
font-size: 1.8rem;
font-size: 1.8em;
}
#topnav {
@@ -114,7 +119,7 @@ body {
.site-title {
font-family: "site-title";
font-size: 3rem;
font-size: 3em;
margin: 0 20px;
text-decoration: none;
color: black;
@@ -122,14 +127,15 @@ body {
.thread-title {
margin: 0;
font-size: 1.5rem;
font-size: 1.5em;
font-weight: bold;
}
.thread-actions {
display: flex;
align-items: center;
gap: 5px;
gap: 0 5px;
flex-wrap: wrap;
}
.post {
@@ -222,7 +228,7 @@ code {
pre code {
display: block;
background-color: rgb(37.9418181818, 42.3818181818, 50.8581818182);
font-size: 1rem;
font-size: 1em;
color: white;
border-bottom-right-radius: 8px;
border-bottom-left-radius: 8px;
@@ -606,7 +612,7 @@ pre code { /* Literal.Number.Integer.Long */ }
display: inline-block;
margin: 4px;
border-radius: 4px;
font-size: 1rem;
font-size: 1em;
white-space: pre;
}
@@ -799,7 +805,7 @@ input[type=file]::file-selector-button {
}
p {
margin: 15px 0;
margin: 10px 0;
}
.pagebutton {
@@ -859,7 +865,7 @@ input[type=text], input[type=password], textarea, select {
resize: vertical;
color: black;
background-color: rgb(225.6, 232.2, 244.8);
font-size: 100%;
font-size: 1em;
font-family: inherit;
}
input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus {
@@ -1123,7 +1129,7 @@ textarea {
}
.babycode-preview-errors-container {
font-size: 0.8rem;
font-size: 0.8em;
}
.tab-button {
@@ -1269,9 +1275,6 @@ ul.horizontal li, ol.horizontal li {
padding: 5px 10px;
min-width: 36px;
}
.babycode-button > * {
font-size: 1rem;
}
.quote-popover {
position: absolute;
@@ -1455,7 +1458,7 @@ a.mention:hover, a.mention:visited:hover {
display: grid;
gap: 10px;
--grid-item-max-width: calc((100% - 10px) / 2);
grid-template-columns: repeat(auto-fill, minmax(max(400px, var(--grid-item-max-width)), 1fr));
grid-template-columns: repeat(auto-fill, minmax(max(600px, var(--grid-item-max-width)), 1fr));
}
.settings-grid fieldset {
border: 1px solid white;
@@ -1523,3 +1526,16 @@ img.badge-button {
justify-content: center;
gap: 5px;
}
.rss-button {
background-color: #fba668;
color: black;
}
.rss-button:hover {
background-color: rgb(251.8, 183.8, 134.2);
color: black;
}
.rss-button:active {
background-color: rgb(186.8501612903, 155.5098387097, 132.6498387097);
color: black;
}

View File

@@ -7,12 +7,12 @@ const delay = ms => {return new Promise(resolve => setTimeout(resolve, ms))}
export default class {
async showBookmarkMenu(ev, el) {
if ((el.sender.dataset.bookmarkId === el.ds('bookmarkId')) && el.childElementCount === 0) {
if ((ev.sender.dataset.bookmarkId === el.prop('bookmarkId')) && el.childElementCount === 0) {
const searchParams = new URLSearchParams({
'id': el.sender.dataset.conceptId,
'id': ev.sender.dataset.conceptId,
'require_reload': el.dataset.requireReload,
});
const bookmarkMenuHref = `${bookmarkMenuHrefTemplate}/${el.sender.dataset.bookmarkType}?${searchParams}`;
const bookmarkMenuHref = `${bookmarkMenuHrefTemplate}/${ev.sender.dataset.bookmarkType}?${searchParams}`;
const res = await this.api.getHTML(bookmarkMenuHref);
if (res.error) {
return;
@@ -50,9 +50,9 @@ export default class {
}
selectBookmarkCollection(ev, el) {
const clicked = el.sender;
const clicked = ev.sender;
if (el.sender === el) {
if (ev.sender === el) {
if (clicked.classList.contains('selected')) {
clicked.classList.remove('selected');
} else {
@@ -64,7 +64,7 @@ export default class {
}
async saveBookmarks(ev, el) {
const bookmarkHref = el.ds('bookmarkEndpoint');
const bookmarkHref = el.prop('bookmarkEndpoint');
const collection = el.querySelector('.bookmark-dropdown-item.selected');
let data = {};
if (collection) {
@@ -73,7 +73,7 @@ export default class {
data['memo'] = el.querySelector('.bookmark-memo-input').value;
} else {
data['operation'] = 'remove';
data['collection_id'] = el.ds('originallyContainedIn');
data['collection_id'] = el.prop('originallyContainedIn');
}
const options = {
@@ -83,7 +83,7 @@ export default class {
'Content-Type': 'application/json',
},
}
const requireReload = el.dsInt('requireReload') !== 0;
const requireReload = el.propToInt('requireReload') !== 0;
el.remove();
await fetch(bookmarkHref, options);
if (requireReload) {
@@ -104,10 +104,10 @@ export default class {
toggleAccordion(ev, el) {
const accordion = el;
const header = accordion.querySelector('.accordion-header');
if (!header.contains(el.sender)){
if (!header.contains(ev.sender)){
return;
}
const btn = el.sender;
const btn = ev.sender;
const content = el.querySelector('.accordion-content');
// these are all meant to be in sync
accordion.classList.toggle('hidden');
@@ -117,15 +117,15 @@ export default class {
toggleTab(ev, el) {
const tabButtonsContainer = el.querySelector('.tab-buttons');
if (!el.contains(el.sender)) {
if (!el.contains(ev.sender)) {
return;
}
if (el.sender.classList.contains('active')) {
if (ev.sender.classList.contains('active')) {
return;
}
const targetId = el.senderDs('targetId');
const targetId = ev.sender.prop('targetId');
const contents = el.querySelectorAll('.tab-content');
for (let content of contents) {
if (content.id === targetId) {
@@ -145,7 +145,7 @@ export default class {
#previousMarkup = null;
async babycodePreview(ev, el) {
if (el.sender.classList.contains('active')) {
if (ev.sender.classList.contains('active')) {
return;
}
@@ -200,9 +200,9 @@ export default class {
}
insertBabycodeTag(ev, el) {
const tagStart = el.senderDs('tag');
const breakLine = 'breakLine' in el.sender.dataset;
const prefill = 'prefill' in el.sender.dataset ? el.sender.dataset.prefill : '';
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;
@@ -250,13 +250,13 @@ export default class {
}
addQuote(ev, el) {
el.value += el.sender.value;
el.value += ev.sender.value;
el.scrollIntoView();
el.focus();
}
convertTimestamps(ev, el) {
const timestamp = el.dsInt('utc');
const timestamp = el.propToInt('utc');
if (!isNaN(timestamp)) {
const date = new Date(timestamp * 1000);
el.textContent = date.toLocaleString();
@@ -273,7 +273,7 @@ export default class {
this.#currentUsername = userInfo.value.user.username;
}
if (el.ds('username') === this.#currentUsername) {
if (el.prop('username') === this.#currentUsername) {
el.classList.add('me');
}
}
@@ -316,18 +316,18 @@ export class BadgeEditorForm {
}
deleteBadge(ev, el) {
if (!el.contains(el.sender)) {
if (!el.contains(ev.sender)) {
return;
}
el.remove();
this.api.localTrigger('updateBadgeCount');
}
updateBadgeCount(_ev, el) {
updateBadgeCount(ev, el) {
const badgeCount = el.parentNode.parentNode.querySelectorAll('.settings-badge-container').length;
if (el.dsInt('disableIfMax') === 1) {
if (el.propToInt('disableIfMax') === 1) {
el.disabled = badgeCount === 10;
} else if (el.dsInt('count') === 1) {
} else if (el.propToInt('count') === 1) {
el.textContent = `${badgeCount}/10`;
}
}
@@ -364,13 +364,13 @@ export class BadgeEditorBadge {
if (ev.type !== 'change') {
return;
}
// TODO: el.sender doesn't have a bittyParentBittyId
const selectBittyParent = el.sender.closest('bitty-7-0');
if (el.bittyParentBittyId !== selectBittyParent.dataset.bittyid) {
// TODO: ev.sender doesn't have a bittyParent
const selectBittyParent = ev.sender.closest('bitty-7-0');
if (el.bittyParent !== selectBittyParent) {
return;
}
if (ev.val === 'custom') {
if (ev.value === 'custom') {
if (this.#badgeCustomImageData) {
el.src = this.#badgeCustomImageData;
} else {
@@ -378,7 +378,7 @@ export class BadgeEditorBadge {
}
return;
}
const option = el.sender.selectedOptions[0];
const option = ev.sender.selectedOptions[0];
el.src = option.dataset.filePath;
}
@@ -386,13 +386,13 @@ export class BadgeEditorBadge {
if (ev.type !== 'change') {
return;
}
if (el.bittyParentBittyId !== el.sender.bittyParentBittyId) {
if (el.bittyParent !== ev.sender.bittyParent) {
return;
}
const file = ev.target.files[0];
if (file.size >= 1000 * 500) {
this.api.trigger('badgeErrorSize');
this.api.localTrigger('badgeErrorSize');
this.#badgeCustomImageData = null;
el.removeAttribute('src');
return;
@@ -403,14 +403,14 @@ export class BadgeEditorBadge {
reader.onload = async e => {
const dimsValid = await validateBase64Img(e.target.result);
if (!dimsValid) {
this.api.trigger('badgeErrorDim');
this.api.localTrigger('badgeErrorDim');
this.#badgeCustomImageData = null;
el.removeAttribute('src');
return;
}
this.#badgeCustomImageData = e.target.result;
el.src = this.#badgeCustomImageData;
this.api.trigger('badgeHideErrors');
this.api.localTrigger('badgeHideErrors');
}
reader.readAsDataURL(file);
@@ -420,13 +420,13 @@ export class BadgeEditorBadge {
if (ev.type !== 'change') {
return;
}
// TODO: el.sender doesn't have a bittyParentBittyId
const selectBittyParent = el.sender.closest('bitty-7-0');
if (el.bittyParentBittyId !== selectBittyParent.dataset.bittyid) {
// 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.val === 'custom') {
if (ev.value === 'custom') {
el.classList.remove('hidden');
if (filePicker.dataset.validity) {
filePicker.setCustomValidity(filePicker.dataset.validity);
@@ -440,37 +440,28 @@ export class BadgeEditorBadge {
}
openBadgeFilePicker(ev, el) {
// TODO: el.sender doesn't have a bittyParentBittyId
if (el.sender.parentNode !== el.parentNode) {
// TODO: ev.sender doesn't have a bittyParent
if (ev.sender.parentNode !== el.parentNode) {
return;
}
el.click();
}
badgeErrorSize(_ev, el) {
if (el.sender !== el.bittyParent) {
return;
}
badgeErrorSize(ev, el) {
const validity = "Image can't be over 500KB."
el.dataset.validity = validity;
el.setCustomValidity(validity);
el.reportValidity();
}
badgeErrorDim(_ev, el) {
if (el.sender !== el.bittyParent) {
return;
}
badgeErrorDim(ev, el) {
const validity = "Image must be exactly 88x31 pixels."
el.dataset.validity = validity;
el.setCustomValidity(validity);
el.reportValidity();
}
badgeHideErrors(_ev, el) {
if (el.sender !== el.bittyParent) {
return;
}
badgeHideErrors(ev, el) {
delete el.dataset.validity;
el.setCustomValidity('');
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,9 +1,11 @@
argon2-cffi==25.1.0
argon2-cffi-bindings==21.2.0
blinker==1.9.0
cachelib==0.13.0
cffi==1.17.1
click==8.2.1
Flask==3.1.1
Flask-Caching==2.3.1
itsdangerous==2.2.0
Jinja2==3.1.6
MarkupSafe==3.0.2

View File

@@ -115,10 +115,10 @@ $DEFAULT_BORDER_RADIUS: 4px !default;
$button_border: $DEFAULT_BORDER !default;
$button_padding: $SMALL_PADDING $BIG_PADDING !default;
$button_border_radius: $DEFAULT_BORDER_RADIUS !default;
$button_margin: $MEDIUM_PADDING $ZERO_PADDING !default;
$button_margin: $SMALL_PADDING $ZERO_PADDING !default;
%button-base {
cursor: default;
font-size: 1rem;
font-size: 1em;
font-family: "Cadman", sans-serif;
text-decoration: none;
border: $button_border;
@@ -178,6 +178,13 @@ body {
color: $DEFAULT_FONT_COLOR;
}
$body_portrait_margin: $BIG_PADDING $ZERO_PADDING !default;
@media (orientation: portrait) {
body {
margin: $body_portrait_margin;
}
}
$link_color: #c11c1c !default;
$link_color_visited: #730c0c !default;
:where(a:link){
@@ -188,7 +195,7 @@ $link_color_visited: #730c0c !default;
}
.big {
font-size: 1.8rem;
font-size: 1.8em;
}
$topnav_color: $ACCENT_COLOR !default;
@@ -224,7 +231,7 @@ $user_actions_gap: $MEDIUM_BIG_PADDING !default;
}
$site_title_margin: $ZERO_PADDING $BIG_PADDING !default;
$site_title_size: 3rem !default;
$site_title_size: 3em !default;
$site_title_color: $DEFAULT_FONT_COLOR !default;
.site-title {
font-family: "site-title";
@@ -235,18 +242,19 @@ $site_title_color: $DEFAULT_FONT_COLOR !default;
}
$thread_title_margin: $ZERO_PADDING !default;
$thread_title_size: 1.5rem !default;
$thread_title_size: 1.5em !default;
.thread-title {
margin: $thread_title_margin;
font-size: $thread_title_size;
font-weight: bold;
}
$thread_actions_gap: $SMALL_PADDING !default;
$thread_actions_gap: $ZERO_PADDING $SMALL_PADDING !default;
.thread-actions {
display: flex;
align-items: center;
gap: $thread_actions_gap;
flex-wrap: wrap;
}
$post_usercard_width: 230px !default;
@@ -372,7 +380,7 @@ $code_border_left: $MEDIUM_PADDING solid $LIGHT_2 !default;
pre code {
display: block;
background-color: $code_background_color;
font-size: 1rem;
font-size: 1em;
color: $code_font_color;
border-bottom-right-radius: $code_border_radius;
border-bottom-left-radius: $code_border_radius;
@@ -496,7 +504,7 @@ $inline_code_padding: $SMALL_PADDING $MEDIUM_PADDING !default;
display: inline-block;
margin: $inline_code_margin;
border-radius: $inline_code_border_radius;
font-size: 1rem;
font-size: 1em;
white-space: pre;
}
@@ -647,7 +655,7 @@ input[type="file"]::file-selector-button {
margin: $MEDIUM_PADDING;
}
$para_margin: $MEDIUM_BIG_PADDING $ZERO_PADDING !default;
$para_margin: $MEDIUM_PADDING $ZERO_PADDING !default;
p {
margin: $para_margin;
}
@@ -700,7 +708,7 @@ input[type="text"], input[type="password"], textarea, select {
resize: vertical;
color: $text_input_font_color;
background-color: $text_input_background;
font-size: 100%;
font-size: 1em;
font-family: inherit;
&:focus {
@@ -1041,7 +1049,7 @@ $post_editing_context_margin: $BIG_PADDING $ZERO_PADDING !default;
}
.babycode-preview-errors-container {
font-size: 0.8rem;
font-size: 0.8em;
}
$tab_button_color: $BUTTON_COLOR !default;
@@ -1199,10 +1207,6 @@ $babycode_button_min_width: $accordion_button_size !default;
.babycode-button {
padding: $babycode_button_padding;
min-width: $babycode_button_min_width;
&> * {
font-size: 1rem;
}
}
$quote_fragment_background_color: #00000080 !default;
@@ -1415,7 +1419,7 @@ a.mention, a.mention:visited {
}
$settings_grid_gap: $MEDIUM_PADDING !default;
$settings_grid_item_min_width: 400px !default;
$settings_grid_item_min_width: 600px !default;
$settings_grid_fieldset_border: 1px solid $DEFAULT_FONT_COLOR_INVERSE !default;
$settings_grid_fieldset_border_radius: $DEFAULT_BORDER_RADIUS !default;
.settings-grid {
@@ -1511,3 +1515,24 @@ $badges_container_gap: $SMALL_PADDING !default;
justify-content: center;
gap: $badges_container_gap;
}
$rss_button_color: #fba668 !default;
$rss_button_color_hover: color.scale($rss_button_color, $lightness: 20%) !default;
$rss_button_color_active: color.scale($rss_button_color, $lightness: -10%, $saturation: -70%) !default;
$rss_button_font_color: black !default;
$rss_button_font_color_hover: black !default;
$rss_button_font_color_active: black !default;
.rss-button {
background-color: $rss_button_color;
color: $rss_button_font_color;
&:hover {
background-color: $rss_button_color_hover;
color: $rss_button_font_color_hover;
}
&:active {
background-color: $rss_button_color_active;
color: $rss_button_font_color_active;
}
}