Compare commits

...

52 Commits

Author SHA1 Message Date
6cfc862d63 fix subscription unread count calculation 2025-08-17 20:52:19 +03:00
70646ba381 some edits for otomotone theme 2025-08-17 20:33:26 +03:00
f04f0fb51b new theme: peachy 2025-08-17 20:33:12 +03:00
317182ae12 build available theme list dynamically 2025-08-17 20:32:17 +03:00
751be27b52 unescape children of babycode elements 2025-08-17 15:15:49 +03:00
6dd9f5bf65 add unread count to thread title in topic view and thread view 2025-08-17 15:15:06 +03:00
1f80ed7ca5 fix oto's sig by setting min width and height on post images as well 2025-08-17 02:36:09 +03:00
89817340c9 add barebones theme switcher 2025-08-17 01:36:55 +03:00
fc80823713 add otomotone theme 2025-08-17 01:36:34 +03:00
184472726e set unbound in build-themes 2025-08-17 00:11:34 +03:00
68cf5f7d57 remove contents properly 2025-08-17 00:07:10 +03:00
4ef7b0ba1e add watch arg to build-themes.sh 2025-08-16 23:58:43 +03:00
aaeb3a524b make the sass file extendable 2025-08-16 23:44:02 +03:00
f1f62fa2c8 add spoiler button to babycode editor 2025-08-16 17:50:33 +03:00
8c917f6ae2 bump babycode version 2025-08-16 17:38:15 +03:00
4f88d14b45 babycode: handle code tag being inline or block explicitly in is_inline 2025-08-16 17:37:05 +03:00
9238385244 collapse whitespace between block babycode tags 2025-08-16 16:59:44 +03:00
cf89070639 put images in their own lane 2025-08-16 15:50:44 +03:00
4a8f87d64a fix wrong variable in edit post route 2025-08-16 06:08:08 +03:00
2b1f52a99d mention lightboxes in babycode guide 2025-08-16 05:46:44 +03:00
d0b702e1e8 allow spaces in babycode tag attrs 2025-08-16 05:40:59 +03:00
14b96bf37e add spoiler tag to babycode 2025-08-16 05:40:00 +03:00
cf4bf3caa3 add versioning to markup languages and reparse old format version posts 2025-08-16 05:28:44 +03:00
382080ceaa up to python 3.13 2025-08-16 02:22:04 +03:00
304a862931 try to bump libargon deb ver? 2025-08-16 01:26:02 +03:00
348b782350 fix loose links consuming spaces 2025-08-16 00:55:25 +03:00
aec4724e2f restore shadowban functionality to mods 2025-08-15 23:45:23 +03:00
53d39d5a36 properly redirect in set avatar 2025-08-15 23:34:45 +03:00
05bd034b23 redirect on post delete if target post cant be found 2025-08-15 23:23:55 +03:00
033df03c49 quote column names in db insert 2025-08-15 21:59:42 +03:00
a0c86f33b4 finish that a tag in topics view 2025-08-11 17:46:22 +03:00
712782bc1c add invite system 2025-08-11 17:26:15 +03:00
1c80777fe4 add config file 2025-08-10 19:31:00 +03:00
4c2877403d add a way for mods to create a password reset link for users 2025-08-10 19:00:47 +03:00
cf2d605077 delete thread when no more posts remain 2025-08-10 06:30:24 +03:00
c68ead85c0 add line breaks around [quote] in reply button reply markup 2025-08-09 12:48:04 +03:00
b0fd2a4f0c add [u], [big], [small], [color], [center], [right] tags to babycode 2025-08-05 01:25:38 +03:00
a529c1db65 add reactions support to thread 2025-08-04 21:10:23 +03:00
acac6ed778 make sure thread slug is accessible to post macro in inbox 2025-08-04 06:18:05 +03:00
3699daa44a add scissors in quoted fragment for fun 2025-08-04 04:10:50 +03:00
4bdd01569c add scissors emoji (homegrown) 2025-08-04 04:07:06 +03:00
33dc52342a acknowledge forumoji in THIRDPARTY.md 2025-08-04 04:05:47 +03:00
d3f63c4120 fix babycode code block generation 2025-08-04 03:29:56 +03:00
d36e94127e move copy-code.js into ui.js 2025-08-04 03:25:41 +03:00
e33d26c6dc make nested quotes stack opacity 2025-08-04 03:17:07 +03:00
7702384c40 cachebust style and js at build time 2025-08-04 03:14:33 +03:00
f08c60de75 fix undeclared get_active_user in mod app 2025-08-04 03:00:14 +03:00
2e8fd9a22e new inbox view 2025-08-04 02:57:51 +03:00
6e86832211 add border to footer 2025-08-04 02:17:51 +03:00
c7f29c1cd4 add accordion macro, new user list view 2025-08-04 02:17:27 +03:00
abcc10654b potentially fix logged out users getting an internal server error 2025-07-30 21:45:35 +03:00
3c1797afef properly escape alt in img tag 2025-07-30 18:47:58 +03:00
46 changed files with 4682 additions and 1006 deletions

View File

@ -1,11 +1,11 @@
FROM python:3.11-slim
FROM python:3.13-slim
RUN apt-get update && apt-get install -y \
nginx \
uwsgi \
uwsgi-plugin-python3 \
sqlite3 \
libargon2-0 \
libargon2-1 \
imagemagick \
&& rm -rf /var/lib/apt/lists/*

View File

@ -25,9 +25,16 @@ Designers: Paul James Miller
## ICONCINO
Affected files: [`data/static/misc/error.svg`](./data/static/misc/error.svg) [`data/static/misc/image.svg`](./data/static/misc/image.svg) [`data/static/misc/info.svg`](./data/static/misc/info.svg) [`data/static/misc/lock.svg`](./data/static/misc/lock.svg) [`data/static/misc/sticky.svg`](./data/static/misc/sticky.svg) [`data/static/misc/warn.svg`](./data/static/misc/warn.svg)
Affected files: [`data/static/misc/error.svg`](./data/static/misc/error.svg) [`data/static/misc/image.svg`](./data/static/misc/image.svg) [`data/static/misc/info.svg`](./data/static/misc/info.svg) [`data/static/misc/lock.svg`](./data/static/misc/lock.svg) [`data/static/misc/spoiler.svg`](./data/static/misc/spoiler.svg) [`data/static/misc/sticky.svg`](./data/static/misc/sticky.svg) [`data/static/misc/warn.svg`](./data/static/misc/warn.svg)
URL: https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license
Copyright: Gabriele Malaspina
Designers: Gabriele Malaspina
License: CC0 1.0/CC BY 4.0
CC BY 4.0 compliance: Modified to indicate the URL. Modified size.
## Forumoji
Affected files: everything in [`data/static/emoji`](./data/static/emoji) except [`data/static/emoji/scissors.png`](data/static/emoji/scissors.png)
URL: https://gh.vercte.net/forumoji/
License: CC0 1.0
Designers: lolecksdeehaha; Scratch137; 64lu; stickfiregames; mybearworld (the project has many more contributors, but these are the people whose designs were reproduced here)

View File

@ -1,17 +1,20 @@
from flask import Flask, session
from dotenv import load_dotenv
from .models import Avatars, Users
from .models import Avatars, Users, PostHistory, Posts
from .auth import digest
from .routes.users import is_logged_in, get_active_user
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, InfoboxIcons, InfoboxHTMLClass
InfoboxKind, InfoboxIcons, InfoboxHTMLClass,
REACTION_EMOJI,
)
from .lib.babycode import babycode_to_html, EMOJI
from .lib.babycode import babycode_to_html, EMOJI, BABYCODE_VERSION
from datetime import datetime
import os
import time
import secrets
import tomllib
def create_default_avatar():
if Avatars.count() == 0:
@ -45,8 +48,26 @@ def create_deleted_user():
"permission": PermissionLevel.SYSTEM.value,
})
def reparse_posts():
from .db import db
post_histories = PostHistory.findall([
('markup_language', '=', 'babycode'),
('format_version', 'IS NOT', BABYCODE_VERSION)
])
if len(post_histories) == 0:
return
print('Re-parsing babycode, this may take a while...')
with db.transaction():
for ph in post_histories:
ph.update({
'content': babycode_to_html(ph['original_markup']),
'format_version': BABYCODE_VERSION,
})
print('Re-parsing done.')
def create_app():
app = Flask(__name__)
app.config.from_file('../config/pyrom_config.toml', load=tomllib.load, text=False)
if os.getenv("PYROM_PROD") is None:
app.static_folder = os.path.join(os.path.dirname(__file__), "../data/static")
@ -62,6 +83,18 @@ def create_app():
app.config['MAX_CONTENT_LENGTH'] = 1000 * 1000
os.makedirs(os.path.dirname(app.config["DB_PATH"]), exist_ok = True)
css_dir = 'data/static/css/'
allowed_themes = []
for f in os.listdir(css_dir):
if not os.path.isfile(os.path.join(css_dir, f)):
continue
theme_name = os.path.splitext(os.path.basename(f))[0]
allowed_themes.append(theme_name)
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
@ -72,6 +105,8 @@ def create_app():
create_admin()
create_deleted_user()
reparse_posts()
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
@ -103,24 +138,33 @@ def create_app():
"InfoboxIcons": InfoboxIcons,
"InfoboxHTMLClass": InfoboxHTMLClass,
"InfoboxKind": InfoboxKind,
"PermissionLevel": PermissionLevel,
"__commit": commit,
"__emoji": EMOJI,
"REACTION_EMOJI": REACTION_EMOJI,
}
@app.context_processor
def inject_auth():
return {"is_logged_in": is_logged_in, "get_active_user": get_active_user, "active_user": get_active_user()}
@app.context_processor
def inject_funcs():
return {
'get_post_url': get_post_url,
'get_prefers_theme': get_prefers_theme,
}
@app.template_filter("ts_datetime")
def ts_datetime(ts, format):
return datetime.utcfromtimestamp(ts or int(time.time())).strftime(format)
@app.template_filter("pluralize")
def pluralize(number, singular = "", plural = "s"):
if number == 1:
return singular
def pluralize(subject, num=1, singular = "", plural = "s"):
if int(num) == 1:
return subject + singular
return plural
return subject + plural
@app.template_filter("permission_string")
def permission_string(term):
@ -140,4 +184,18 @@ def create_app():
for id_, text in matches
]
# this only happens at build time but
# build time is when updates are done anyway
# sooo... /shrug
@app.template_filter('cachebust')
def cachebust(subject):
return f"{subject}?v={str(int(time.time()))}"
@app.template_filter('theme_name')
def get_theme_name(subject: str):
if subject == 'style':
return 'Default'
return f'{subject.removeprefix('theme-').capitalize()} (beta)'
return app

View File

@ -15,6 +15,38 @@ PermissionLevelString = {
PermissionLevel.ADMIN: 'Administrator',
}
REACTION_EMOJI = [
'smile',
'grin',
'neutral',
'wink',
'frown',
'angry',
'think',
'sob',
'surprised',
'smiletear',
'tongue',
'pensive',
'weary',
'imp',
'impangry',
'lobster',
'scissors',
]
def permission_level_string(perm):
return PermissionLevelString[PermissionLevel(int(perm))]

View File

@ -55,7 +55,7 @@ class DB:
def insert(self, table, columns, *values):
if isinstance(columns, (list, tuple)):
columns = ", ".join(columns)
columns = ", ".join([f'"{column}"' for column in columns])
placeholders = ", ".join(["?"] * len(values))
sql = f"""
@ -89,6 +89,9 @@ class DB:
self.table = table
self._where = [] # list of tuples
self._select = "*"
self._group_by = ""
self._order_by = ""
self._order_asc = True
def _build_where(self):
@ -104,6 +107,17 @@ class DB:
return " WHERE " + " AND ".join(conditions), params
def group_by(self, stmt):
self._group_by = stmt
return self
def order_by(self, stmt, asc = True):
self._order_by = stmt
self._order_asc = asc
return self
def select(self, columns = "*"):
self._select = columns
return self
@ -122,7 +136,16 @@ class DB:
def build_select(self):
sql = f"SELECT {self._select} FROM {self.table}"
where_clause, params = self._build_where()
return sql + where_clause, params
stmt = sql + where_clause
if self._group_by:
stmt += " GROUP BY " + self._group_by
if self._order_by:
stmt += " ORDER BY " + self._order_by + (" ASC" if self._order_asc else " DESC")
return stmt, params
def build_update(self, data):
@ -178,6 +201,19 @@ class Model:
return instance
@classmethod
def findall(cls, condition, operator='='):
rows = db.QueryBuilder(cls.table)\
.where(condition, operator)\
.all()
res = []
for row in rows:
instance = cls(cls.table)
instance._data = dict(row)
res.append(instance)
return res
@classmethod
def create(cls, values):
if not values:

View File

@ -1,14 +1,63 @@
from .babycode_parser import Parser
from markupsafe import escape
from markupsafe import Markup, escape
import re
def tag_code(children, attr):
BABYCODE_VERSION = 3
NAMED_COLORS = [
'black', 'silver', 'gray', 'white', 'maroon', 'red',
'purple', 'fuchsia', 'green', 'lime', 'olive', 'yellow',
'navy', 'blue', 'teal', 'aqua', 'aliceblue', 'antiquewhite',
'aqua', 'aquamarine', 'azure', 'beige', 'bisque', 'black',
'blanchedalmond', 'blue', 'blueviolet', 'brown', 'burlywood', 'cadetblue',
'chartreuse', 'chocolate', 'coral', 'cornflowerblue', 'cornsilk', 'crimson',
'cyan', 'aqua', 'darkblue', 'darkcyan', 'darkgoldenrod', 'darkgray',
'darkgreen', 'darkgrey', 'darkkhaki', 'darkmagenta', 'darkolivegreen', 'darkorange',
'darkorchid', 'darkred', 'darksalmon', 'darkseagreen', 'darkslateblue', 'darkslategray',
'darkslategrey', 'darkturquoise', 'darkviolet', 'deeppink', 'deepskyblue', 'dimgray',
'dimgrey', 'dodgerblue', 'firebrick', 'floralwhite', 'forestgreen', 'fuchsia',
'gainsboro', 'ghostwhite', 'gold', 'goldenrod', 'gray', 'green',
'greenyellow', 'grey', 'gray', 'honeydew', 'hotpink', 'indianred',
'indigo', 'ivory', 'khaki', 'lavender', 'lavenderblush', 'lawngreen',
'lemonchiffon', 'lightblue', 'lightcoral', 'lightcyan', 'lightgoldenrodyellow', 'lightgray',
'lightgreen', 'lightgrey', 'lightpink', 'lightsalmon', 'lightseagreen', 'lightskyblue',
'lightslategray', 'lightslategrey', 'lightsteelblue', 'lightyellow', 'lime', 'limegreen',
'linen', 'magenta', 'fuchsia', 'maroon', 'mediumaquamarine', 'mediumblue',
'mediumorchid', 'mediumpurple', 'mediumseagreen', 'mediumslateblue', 'mediumspringgreen', 'mediumturquoise',
'mediumvioletred', 'midnightblue', 'mintcream', 'mistyrose', 'moccasin', 'navajowhite',
'navy', 'oldlace', 'olive', 'olivedrab', 'orange', 'orangered',
'orchid', 'palegoldenrod', 'palegreen', 'paleturquoise', 'palevioletred', 'papayawhip',
'peachpuff', 'peru', 'pink', 'plum', 'powderblue', 'purple',
'rebeccapurple', 'red', 'rosybrown', 'royalblue', 'saddlebrown', 'salmon',
'sandybrown', 'seagreen', 'seashell', 'sienna', 'silver', 'skyblue',
'slateblue', 'slategray', 'slategrey', 'snow', 'springgreen', 'steelblue',
'tan', 'teal', 'thistle', 'tomato', 'transparent', 'turquoise',
'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:
t = children.strip()
button = f"<button type=button class=\"copy-code\" value={t}>Copy</button>"
button = f"<button type=button class=\"copy-code\" value=\"{t}\">Copy</button>"
return f"<pre><span class=\"copy-code-container\">{button}</span><code>{t}</code></pre>"
def tag_list(children):
@ -16,16 +65,62 @@ def tag_list(children):
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):
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'><div class='accordion-header'><button type='button' class='accordion-toggle'>+</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>",
"img": lambda children, attr: f"<div class=\"post-img-container\"><img class=\"block-img\" src={attr} alt={children}></div>",
"url": lambda children, attr: f"<a href={attr}>{children}</a>",
"quote": lambda children, attr: f"<blockquote>{children}</blockquote>",
"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>",
"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,
}
# [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):
@ -49,6 +144,8 @@ EMOJI = {
'pensive': make_emoji('pensive', 'pensive'),
'scissors': make_emoji('scissors', 'scissors'),
')': make_emoji('smile', ')'),
'smiletear': make_emoji('smiletear', 'smiletear'),
@ -82,6 +179,33 @@ def break_lines(text):
text = re.sub(r"\n\n+", "<br><br>", text)
return text
def is_inline(e):
if e is None:
return False # i think
if is_text(e):
return True
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 e['name'] in INLINE_TAGS
return e['type'] != 'rule'
def should_collapse(text, surrounding):
if not isinstance(text, str):
return False
if not text:
return True
if not text.strip() and '\n' not in text:
return not is_inline(surrounding[0]) and not is_inline(surrounding[1])
return False
def babycode_to_html(s):
subj = escape(s.strip().replace('\r\n', '\n').replace('\r', '\n'))
parser = Parser(subj)
@ -89,10 +213,19 @@ def babycode_to_html(s):
parser.bbcode_tags_only_text_children = TEXT_ONLY
parser.valid_emotes = EMOJI.keys()
elements = parser.parse()
print(elements)
uncollapsed = parser.parse()
elements = []
for i in range(len(uncollapsed)):
e = uncollapsed[i]
surrounding = (
uncollapsed[i - 1] if i-1 >= 0 else None,
uncollapsed[i + 1] if i+1 < len(uncollapsed) else None
)
if not should_collapse(e, surrounding):
elements.append(e)
out = ""
def fold(element, nobr):
def fold(element, nobr, surrounding):
if isinstance(element, str):
if nobr:
return element
@ -101,10 +234,15 @@ def babycode_to_html(s):
match element['type']:
case "bbcode":
c = ""
for child in element['children']:
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 + fold(child, _nobr)
res = TAGS[element['name']](c, element['attr'])
c = c + Markup(fold(child, _nobr, _surrounding))
res = TAGS[element['name']](c, element['attr'], surrounding)
return res
case "link":
return f"<a href=\"{element['url']}\">{element['url']}</a>"
@ -112,6 +250,12 @@ def babycode_to_html(s):
return EMOJI[element['name']]
case "rule":
return "<hr>"
for e in elements:
out = out + fold(e, False)
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 out

View File

@ -4,7 +4,7 @@ import re
PAT_EMOTE = r"[^\s:]"
PAT_BBCODE_TAG = r"\w"
PAT_BBCODE_ATTR = r"[^\s\]]"
PAT_BBCODE_ATTR = r"[^\]]"
PAT_LINK = r"https?:\/\/[\w\-_.?:\/=&~@#%]+[\w\-\/]"
class Parser:
@ -193,9 +193,10 @@ class Parser:
self.save_position()
# extract printable chars (extreme hack edition)
word = self.match_pattern(r'[ -~]')
word = self.match_pattern(r'[!-~]')
if not re.match(PAT_LINK, word):
match = re.match(PAT_LINK, word)
if not match:
self.restore_position()
return None

View File

@ -9,6 +9,8 @@ def migrate_old_avatars():
MIGRATIONS = [
migrate_old_avatars,
'DELETE FROM sessions', # delete old lua porom sessions
'ALTER TABLE "users" ADD COLUMN "invited_by" INTEGER REFERENCES users(id)', # invitation system
'ALTER TABLE "post_history" ADD COLUMN "format_version" INTEGER DEFAULT NULL',
]
def run_migrations():

View File

@ -1,5 +1,6 @@
from .db import Model, db
from .constants import PermissionLevel
from flask import current_app
import time
class Users(Model):
@ -17,6 +18,9 @@ class Users(Model):
def is_mod(self):
return self.permission >= PermissionLevel.MODERATOR.value
def is_mod_only(self):
return self.permission == PermissionLevel.MODERATOR.value
def is_admin(self):
return self.permission == PermissionLevel.ADMIN.value
@ -48,7 +52,8 @@ class Users(Model):
COUNT(DISTINCT posts.id) AS post_count,
COUNT(DISTINCT threads.id) AS thread_count,
MAX(threads.title) FILTER (WHERE threads.created_at = latest.created_at) AS latest_thread_title,
MAX(threads.slug) FILTER (WHERE threads.created_at = latest.created_at) AS latest_thread_slug
MAX(threads.slug) FILTER (WHERE threads.created_at = latest.created_at) AS latest_thread_slug,
inviter.username AS inviter_username
FROM users
LEFT JOIN posts ON posts.user_id = users.id
LEFT JOIN threads ON threads.user_id = users.id
@ -57,6 +62,7 @@ class Users(Model):
FROM threads
GROUP BY user_id
) latest ON latest.user_id = users.id
LEFT JOIN users AS inviter ON inviter.id = users.invited_by
WHERE users.id = ?"""
return db.fetch_one(q, self.id)
@ -83,6 +89,18 @@ class Users(Model):
return True
def can_invite(self):
if not current_app.config['DISABLE_SIGNUP']:
return True
if current_app.config['MODS_CAN_INVITE'] and self.is_mod():
return True
if current_app.config['USERS_CAN_INVITE'] and not self.is_guest():
return True
return False
class Topics(Model):
table = "topics"
@ -160,7 +178,7 @@ class Topics(Model):
q = """
SELECT
threads.title, threads.slug, threads.created_at, threads.is_locked, threads.is_stickied,
threads.id, threads.title, threads.slug, threads.created_at, threads.is_locked, threads.is_stickied,
users.username AS started_by,
u.username AS latest_post_username,
ph.content AS latest_post_content,
@ -233,6 +251,16 @@ class Avatars(Model):
class Subscriptions(Model):
table = "subscriptions"
def get_unread_count(self):
q = """SELECT COUNT(*) AS unread_count
FROM posts
LEFT JOIN subscriptions ON subscriptions.thread_id = posts.thread_id
WHERE subscriptions.user_id = ? AND posts.created_at > subscriptions.last_seen AND posts.thread_id = ?"""
res = db.fetch_one(q, self.user_id, self.thread_id)
if res:
return res['unread_count']
return None
class APIRateLimits(Model):
table = 'api_rate_limits'
@ -256,3 +284,36 @@ class APIRateLimits(Model):
return True
else:
return False
class Reactions(Model):
table = "reactions"
@classmethod
def for_post(cls, post_id):
qb = db.QueryBuilder(cls.table)\
.select("reaction_text, COUNT(*) as c")\
.where({"post_id": post_id})\
.group_by("reaction_text")\
.order_by("c", False)
result = qb.all()
return result if result else []
@classmethod
def get_users(cls, post_id, reaction_text):
q = """
SELECT user_id, username FROM reactions
JOIN
users ON users.id = user_id
WHERE
post_id = ? AND reaction_text = ?
"""
return db.query(q, post_id, reaction_text)
class PasswordResetLinks(Model):
table = "password_reset_links"
class InviteKeys(Model):
table = 'invite_keys'

View File

@ -1,7 +1,8 @@
from flask import Blueprint, request, url_for
from ..lib.babycode import babycode_to_html
from ..constants import REACTION_EMOJI
from .users import is_logged_in, get_active_user
from ..models import APIRateLimits, Threads
from ..models import APIRateLimits, Threads, Reactions
from ..db import db
bp = Blueprint("api", __name__, url_prefix="/api/")
@ -41,3 +42,57 @@ def babycode_preview():
return {'error': 'markup field missing or invalid type'}, 400
rendered = babycode_to_html(markup)
return {'html': rendered}
@bp.post('/add-reaction/<post_id>')
def add_reaction(post_id):
if not is_logged_in():
return {'error': 'not authorized', 'error_code': 401}, 401
user = get_active_user()
reaction_text = request.json.get('emoji')
if not reaction_text or not isinstance(reaction_text, str):
return {'error': 'emoji field missing or invalid type', 'error_code': 400}, 400
if reaction_text not in REACTION_EMOJI:
return {'error': 'unsupported reaction', 'error_code': 400}, 400
reaction = Reactions.find({
'user_id': user.id,
'post_id': int(post_id),
'reaction_text': reaction_text,
})
if reaction:
return {'error': 'reaction already exists', 'error_code': 409}, 409
reaction = Reactions.create({
'user_id': user.id,
'post_id': int(post_id),
'reaction_text': reaction_text,
})
return {'status': 'added'}
@bp.post('/remove-reaction/<post_id>')
def remove_reaction(post_id):
if not is_logged_in():
return {'error': 'not authorized'}, 401
user = get_active_user()
reaction_text = request.json.get('emoji')
if not reaction_text or not isinstance(reaction_text, str):
return {'error': 'emoji field missing or invalid type'}, 400
if reaction_text not in REACTION_EMOJI:
return {'error': 'unsupported reaction'}, 400
reaction = Reactions.find({
'user_id': user.id,
'post_id': int(post_id),
'reaction_text': reaction_text,
})
if not reaction:
return {'error': 'reaction does not exist'}, 404
reaction.delete()
return {'status': 'removed'}

View File

@ -1,9 +1,12 @@
from flask import (
Blueprint, render_template, request, redirect, url_for
)
from .users import login_required, mod_only
from ..models import Users
from .users import login_required, mod_only, get_active_user, admin_only
from ..models import Users, PasswordResetLinks
from ..db import db, DB
import secrets
import time
bp = Blueprint("mod", __name__, url_prefix = "/mod/")
@bp.get("/sort-topics")
@ -31,3 +34,18 @@ def sort_topics_post():
def user_list():
users = Users.select()
return render_template("mod/user-list.html", users = users)
@bp.post("/reset-pass/<user_id>")
@login_required
@mod_only("topics.all_topics")
def create_reset_pass(user_id):
now = int(time.time())
key = secrets.token_urlsafe(20)
reset_link = PasswordResetLinks.create({
'user_id': int(user_id),
'expires_at': now + 24 * 60 * 60,
'key': key,
})
return redirect(url_for('users.reset_link_login', key=key))

View File

@ -2,15 +2,16 @@ 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
from ..lib.babycode import babycode_to_html, BABYCODE_VERSION
from ..constants import InfoboxKind
from ..db import db
from ..models import Posts, PostHistory, Threads
from ..models import Posts, PostHistory, Threads, Topics
bp = Blueprint("posts", __name__, url_prefix = "/post")
def create_post(thread_id, user_id, content, markup_language="babycode"):
parsed_content = babycode_to_html(content)
with db.transaction():
post = Posts.create({
"thread_id": thread_id,
@ -20,10 +21,11 @@ def create_post(thread_id, user_id, content, markup_language="babycode"):
revision = PostHistory.create({
"post_id": post.id,
"content": babycode_to_html(content),
"content": parsed_content,
"is_initial_revision": True,
"original_markup": content,
"markup_language": markup_language,
"format_version": BABYCODE_VERSION,
})
post.update({"current_revision_id": revision.id})
@ -31,14 +33,16 @@ 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)
with db.transaction():
post = Posts.find({'id': post_id})
new_revision = PostHistory.create({
'post_id': post.id,
'content': babycode_to_html(new_content),
'content': parsed_content,
'is_initial_revision': False,
'original_markup': new_content,
'markup_language': markup_language,
'format_version': BABYCODE_VERSION,
})
post.update({'current_revision_id': new_revision.id})
@ -48,12 +52,30 @@ def update_post(post_id, new_content, markup_language='babycode'):
@login_required
def delete(post_id):
post = Posts.find({'id': post_id})
if not post:
return redirect(url_for('topics.all_topics'))
thread = Threads.find({'id': post.thread_id})
user = get_active_user()
if not user:
return redirect(url_for('threads.thread', slug=thread.slug))
if user.is_mod() or post.user_id == user.id:
post.delete()
flash("Post deleted.", InfoboxKind.INFO)
post_count = Posts.count({
'thread_id': thread.id,
})
if post_count == 0:
topic = Topics.find({
'id': thread.topic_id,
})
thread.delete()
flash('Thread deleted.', InfoboxKind.INFO)
return redirect(url_for('topics.topic', slug=topic.slug))
flash('Post deleted.', InfoboxKind.INFO)
return redirect(url_for('threads.thread', slug=thread.slug))
@ -61,6 +83,10 @@ def delete(post_id):
@bp.get("/<post_id>/edit")
@login_required
def edit(post_id):
post = Posts.find({'id': post_id})
if not post:
return redirect(url_for('topics.all_topics'))
user = get_active_user()
q = f"{Posts.FULL_POSTS_QUERY} WHERE posts.id = ?"
editing_post = db.fetch_one(q, post_id)
@ -91,6 +117,8 @@ def edit(post_id):
def edit_form(post_id):
user = get_active_user()
post = Posts.find({'id': post_id})
if not post:
return redirect(url_for('topics.all_topics'))
if post.user_id != user.id:
return redirect(url_for('topics.all_topics'))

View File

@ -3,7 +3,7 @@ from flask import (
)
from .users import login_required, mod_only, get_active_user, is_logged_in
from ..db import db
from ..models import Threads, Topics, Posts, Subscriptions
from ..models import Threads, Topics, Posts, Subscriptions, Reactions
from ..constants import InfoboxKind
from .posts import create_post
from slugify import slugify
@ -13,6 +13,20 @@ import time
bp = Blueprint("threads", __name__, url_prefix = "/threads/")
def get_post_url(post_id, _anchor=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
return f"{res}#post-{post_id}"
@bp.get("/<slug>")
def thread(slug):
POSTS_PER_PAGE = 10
@ -40,12 +54,14 @@ def thread(slug):
other_topics = Topics.select()
is_subscribed = False
unread_count = None
if is_logged_in():
subscription = Subscriptions.find({
'thread_id': thread.id,
'user_id': get_active_user().id,
})
if subscription:
unread_count = subscription.get_unread_count()
if int(posts[-1]['created_at']) > int(subscription.last_seen):
subscription.update({
'last_seen': int(posts[-1]['created_at'])
@ -61,6 +77,8 @@ def thread(slug):
topic = topic,
topics = other_topics,
is_subscribed = is_subscribed,
Reactions = Reactions,
unread_count = unread_count,
)

View File

@ -1,8 +1,8 @@
from flask import (
Blueprint, render_template, request, redirect, url_for, flash, session
)
from .users import login_required, mod_only
from ..models import Users, Topics, Threads
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 slugify import slugify
import time
@ -62,9 +62,21 @@ def topic(slug):
page_count = max(math.ceil(threads_count / THREADS_PER_PAGE), 1)
page = max(1, min(int(request.args.get('page', default=1)), page_count))
threads_list = target_topic.get_threads(THREADS_PER_PAGE, page, sort_by)
subscriptions = {}
if is_logged_in():
for thread in threads_list:
subscription = Subscriptions.find({
'user_id': get_active_user().id,
'thread_id': thread['id'],
})
if subscription:
subscriptions[thread['id']] = subscription.get_unread_count()
return render_template(
"topics/topic.html",
threads_list = target_topic.get_threads(THREADS_PER_PAGE, page, sort_by),
threads_list = threads_list,
subscriptions = subscriptions,
topic = target_topic,
current_page = page,
page_count = page_count

View File

@ -4,7 +4,7 @@ from flask import (
from functools import wraps
from ..db import db
from ..lib.babycode import babycode_to_html
from ..models import Users, Sessions, Subscriptions, Avatars
from ..models import Users, Sessions, Subscriptions, Avatars, PasswordResetLinks, InviteKeys
from ..constants import InfoboxKind, PermissionLevel
from ..auth import digest, verify
from wand.image import Image
@ -165,6 +165,15 @@ def admin_only(*args, **kwargs):
return decorator
def get_prefers_theme():
if not 'theme' in session:
return 'style'
if session['theme'] not in current_app.config['allowed_themes']:
return 'style'
return session['theme']
@bp.get("/log_in")
@redirect_if_logged_in(".page", username = lambda: get_active_user().username)
def log_in():
@ -195,32 +204,53 @@ def log_in_post():
@bp.get("/sign_up")
@redirect_if_logged_in(".page", username = lambda: get_active_user().username)
def sign_up():
if current_app.config['DISABLE_SIGNUP']:
key = request.args.get('key', default=None)
if key is None:
return redirect(url_for('topics.all_topics'))
invite = InviteKeys.find({'key': key})
if not invite:
return redirect(url_for('topics.all_topics'))
inviter = Users.find({'id': invite.created_by})
return render_template("users/sign_up.html", inviter=inviter, key=key)
return render_template("users/sign_up.html")
@bp.post("/sign_up")
@redirect_if_logged_in(".page", username = lambda: get_active_user().username)
def sign_up_post():
key = request.form.get('key', default=None)
if current_app.config['DISABLE_SIGNUP']:
if not key:
return redirect(url_for("topics.all_topics"))
invite_key = InviteKeys.find({'key': key})
if not invite_key:
return redirect(url_for("topics.all_topics"))
username = request.form['username']
password = request.form['password']
password_confirm = request.form['password-confirm']
if not validate_username(username):
flash("Invalid username.", InfoboxKind.ERROR)
return redirect(url_for("users.sign_up"))
return redirect(url_for("users.sign_up", key=key))
user_exists = Users.count({"username": username}) > 0
if user_exists:
flash(f"Username '{username}' is already taken.", InfoboxKind.ERROR)
return redirect(url_for("users.sign_up"))
return redirect(url_for("users.sign_up", key=key))
if not validate_password(password):
flash("Invalid password.", InfoboxKind.ERROR)
return redirect(url_for("users.sign_up"))
return redirect(url_for("users.sign_up", key=key))
if password != password_confirm:
flash("Passwords do not match.", InfoboxKind.ERROR)
return redirect(url_for("users.sign_up"))
return redirect(url_for("users.sign_up", key=key))
hashed = digest(password)
@ -230,11 +260,19 @@ def sign_up_post():
"permission": PermissionLevel.GUEST.value,
})
if current_app.config['DISABLE_SIGNUP']:
invite_key = InviteKeys.find({'key': key})
new_user.update({
'invited_by': invite_key.created_by,
'permission': PermissionLevel.USER.value,
})
invite_key.delete()
session_obj = create_session(new_user.id)
session['pyrom_session_key'] = session_obj.key
flash("Signed up successfully!", InfoboxKind.INFO)
return redirect(url_for("users.sign_up"))
return redirect(url_for("topics.all_topics"))
@bp.get("/<username>")
@ -259,6 +297,12 @@ def settings_form(username):
# we silently ignore the passed username
# and grab the correct user from the session
user = get_active_user()
theme = request.form.get('theme', default='style')
if theme == 'style':
if 'theme' in session:
session.pop('theme')
else:
session['theme'] = theme
topic_sort_by = request.form.get('topic_sort_by', default='activity')
if topic_sort_by == 'activity' or topic_sort_by == 'thread':
sort_by = session['sort_by'] = topic_sort_by
@ -281,17 +325,17 @@ def settings_form(username):
def set_avatar(username):
user = get_active_user()
if user.is_guest():
flash('You must be logged in to perform this action.', InfoboxKind.ERROR)
return redirect(url_for('.settings', user.username))
flash('You are a guest. Your account must be confirmed by a moderator to perform this action.', InfoboxKind.ERROR)
return redirect(url_for('.settings', username=user.username))
if 'avatar' not in request.files:
flash('Avatar missing.', InfoboxKind.ERROR)
return redirect(url_for('.settings', user.username))
return redirect(url_for('.settings', username=user.username))
file = request.files['avatar']
if file.filename == '':
flash('Avatar missing.', InfoboxKind.ERROR)
return redirect(url_for('.settings', user.username))
return redirect(url_for('.settings', username=user.username))
file_bytes = file.read()
@ -316,7 +360,7 @@ def set_avatar(username):
return redirect(url_for('.settings', username=user.username))
else:
flash('Something went wrong. Please try again later.', InfoboxKind.WARN)
return redirect(url_for('.settings', user.username))
return redirect(url_for('.settings', username=user.username))
@bp.post('/<username>/change_password')
@ -419,12 +463,12 @@ def demod_user(user_id):
@bp.post("/guest_user/<user_id>")
@login_required
@admin_only("topics.all_topics")
@mod_only("topics.all_topics")
def guest_user(user_id):
target_user = Users.find({"id": user_id})
if not target_user:
return redirect(url_for('.all_topics'))
if target_user.is_mod():
if get_active_user().is_mod_only() and target_user.is_mod():
return redirect(url_for('.page', username=target_user.username))
target_user.update({
@ -510,7 +554,132 @@ def inbox(username):
'thread_id': row['thread_id'],
'user_id': row['user_id'],
'original_markup': row['original_markup'],
'signature_rendered': row['signature_rendered']
'signature_rendered': row['signature_rendered'],
'thread_slug': row['thread_slug'],
})
return render_template("users/inbox.html", new_posts = new_posts, total_unreads_count = total_unreads_count, all_subscriptions = all_subscriptions)
@bp.get('/reset-link/<key>')
def reset_link_login(key):
reset_link = PasswordResetLinks.find({
'key': key
})
if not reset_link:
return redirect(url_for('topics.all_topics'))
if int(time.time()) > int(reset_link.expires_at):
reset_link.delete()
return redirect(url_for('topics.all_topics'))
target_user = Users.find({
'id': reset_link.user_id
})
return render_template('users/reset_link_login.html', username = target_user.username)
@bp.post('/reset-link/<key>')
def reset_link_login_form(key):
reset_link = PasswordResetLinks.find({
'key': key
})
if not reset_link:
return redirect('topics.all_topics')
if int(time.time()) > int(reset_link.expires_at):
reset_link.delete()
return redirect('topics.all_topics')
password = request.form.get('password')
password2 = request.form.get('password2')
if not validate_password(password):
flash("Invalid password.", InfoboxKind.ERROR)
return redirect(url_for('.reset_link_login', key=key))
if password != password2:
flash("Passwords do not match.", InfoboxKind.ERROR)
return redirect(url_for('.reset_link_login', key=key))
target_user = Users.find({
'id': reset_link.user_id
})
reset_link.delete()
hashed = digest(password)
target_user.update({'password_hash': hashed})
session_obj = create_session(target_user.id)
session['pyrom_session_key'] = session_obj.key
flash("Logged in!", InfoboxKind.INFO)
return redirect(url_for('.page', username=target_user.username))
@bp.get('/<username>/invite-links/')
@login_required
def invite_links(username):
target_user = Users.find({
'username': username
})
if not target_user or not target_user.can_invite():
return redirect(url_for('.page', username=username))
if target_user.username != get_active_user().username:
return redirect(url_for('.invite_links', username=target_user.username))
invites = InviteKeys.findall({
'created_by': target_user.id
})
return render_template('users/invite_links.html', invites=invites)
@bp.post('/<username>/invite-links/create')
@login_required
def create_invite_link(username):
target_user = Users.find({
'username': username
})
if not target_user or not target_user.can_invite():
return redirect(url_for('.page', username=username))
if target_user.username != get_active_user().username:
return redirect(url_for('.invite_links', username=target_user.username))
invite = InviteKeys.create({
'created_by': target_user.id,
'key': secrets.token_urlsafe(20),
})
return redirect(url_for('.invite_links', username=target_user.username))
@bp.post('/<username>/invite-links/revoke')
@login_required
def revoke_invite_link(username):
target_user = Users.find({
'username': username
})
if not target_user or not target_user.can_invite():
return redirect(url_for('.page', username=username))
if target_user.username != get_active_user().username:
return redirect(url_for('.invite_links', username=target_user.username))
invite = InviteKeys.find({
'key': request.form.get('key'),
})
if not invite:
return redirect(url_for('.invite_links', username=target_user.username))
if invite.created_by != target_user.id:
return redirect(url_for('.invite_links', username=target_user.username))
invite.delete()
return redirect(url_for('.invite_links', username=target_user.username))

View File

@ -76,6 +76,26 @@ SCHEMA = [
"signature_rendered" TEXT NOT NULL DEFAULT ''
)""",
"""CREATE TABLE IF NOT EXISTS "reactions" (
"id" INTEGER NOT NULL PRIMARY KEY,
"user_id" REFERENCES users(id) ON DELETE CASCADE,
"post_id" REFERENCES posts(id) ON DELETE CASCADE,
"reaction_text" TEXT NOT NULL DEFAULT ''
)""",
"""CREATE TABLE IF NOT EXISTS "password_reset_links" (
"id" INTEGER NOT NULL PRIMARY KEY,
"user_id" REFERENCES users(id) ON DELETE CASCADE,
"expires_at" INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP)),
"key" TEXT NOT NULL UNIQUE
)""",
"""CREATE TABLE IF NOT EXISTS "invite_keys" (
"id" INTEGER NOT NULL PRIMARY KEY,
"created_by" REFERENCES users(id) ON DELETE CASCADE,
"key" TEXT NOT NULL UNIQUE
)""",
# INDEXES
"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)",
@ -87,6 +107,9 @@ SCHEMA = [
"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 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_user_post_text ON reactions(user_id, post_id, reaction_text)",
]
def create():

View File

@ -14,23 +14,68 @@
</section>
<section class="babycode-guide-section">
<h2 id="text-formatting-tags">Text formatting tags</h2>
<ul>
<ul class='babycode-guide-list'>
<li>To make some text <strong>bold</strong>, enclose it in <code class="inline-code">[b][/b]</code>:<br>
[b]Hello World[/b]<br>
Will become<br>
<strong>Hello World</strong>
</li>
</ul>
<ul>
<ul class='babycode-guide-list'>
<li>To <em>italicize</em> text, enclose it in <code class="inline-code">[i][/i]</code>:<br>
[i]Hello World[/i]<br>
Will become<br>
<em>Hello World</em>
</li>
</ul>
<ul>
<ul class='babycode-guide-list'>
<li>To make some text <del>strikethrough</del>, enclose it in <code class="inline-code">[s][/s]</code>:<br>
[s]Hello World[/s]<br>
Will become<br>
<del>Hello World</del>
</li>
</ul>
<ul class='babycode-guide-list'>
<li>To <u>underline</u> some text, enclose it in <code class="inline-code">[u][/u]</code>:<br>
[u]Hello World[/u]<br>
Will become<br>
<u>Hello World</u>
</li>
</ul>
<ul class='babycode-guide-list'>
<li>To make some text {{ "[big]big[/big]" | babycode | safe }}, enclose it in <code class="inline-code">[big][/big]</code>:<br>
[big]Hello World[/big]<br>
Will become<br>
{{ "[big]Hello World[/big]" | babycode | safe }}
<li>Similarly, you can make text {{ "[small]small[/small]" | babycode | safe }} with <code class="inline-code">[small][/small]</code>:<br>
[small]Hello World[/small]<br>
Will become<br>
{{ "[small]Hello World[/small]" | babycode | safe }}
</li>
</ul>
<ul class='babycode-guide-list'>
<li>You can change the text color by using <code class="inline-code">[color][/color]</code>:<br>
[color=red]Red text[/color]<br>
[color=white]White text[/color]<br>
[color=#3b08f0]Blueish text[/color]<br>
Will become<br>
{{ "[color=red]Red text[/color]" | babycode | safe }}<br>
{{ "[color=white]White text[/color]" | babycode | safe }}<br>
{{ "[color=#3b08f0]Blueish text[/color]" | babycode | safe }}<br>
</li>
</ul>
<ul class='babycode-guide-list'>
<li>You can center text by enclosing it in <code class="inline-code">[center][/center]</code>:<br>
[center]Hello World[/center]<br>
Will become<br>
{{ "[center]Hello World[/center]" | babycode | safe }}
</li>
<li>You can right-align text by enclosing it in <code class="inline-code">[right][/right]</code>:<br>
[right]Hello World[/right]<br>
Will become<br>
{{ "[right]Hello World[/right]" | babycode | safe }}
</li>
Note: the center and right tags will break the paragraph. See <a href="#paragraph-rules">Paragraph rules</a> for more details.
</ul>
</section>
<section class="babycode-guide-section">
@ -60,6 +105,15 @@
{{ '[code]paragraph 1 \nstill paragraph 1[/code]' | babycode | safe }}
That will produce:<br>
{{ 'paragraph 1 \nstill paragraph 1' | babycode | 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>
<li><code class="inline-code">[img]</code>;</li>
<li><code class="inline-code">[center]</code>;</li>
<li><code class="inline-code">[right]</code>;</li>
<li><code class="inline-code">[ul]</code> and <code class="inline-code">[ol]</code>;</li>
<li><code class="inline-code">[quote]</code>.</li>
</ul>
</section>
<section class="babycode-guide-section">
<h2 id="links">Links</h2>
@ -72,8 +126,9 @@
<code class="inline-code">[img=https://forum.poto.cafe/avatars/default.webp]the Python logo with a cowboy hat[/img]</code>
{{ '[img=/static/avatars/default.webp]the Python logo with a cowboy hat[/img]' | babycode | safe }}
</p>
<p>Text inside the tag becomes the alt text. The attribute is the image URL.</p>
<p>Images will always break up a paragraph and will get scaled down to a maximum of 400px. The text inside the tag will become the image's alt text.</p>
<p>Text inside the tag becomes the alt text. 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="babycode-guide-section">
<h2 id="adding-code-blocks">Adding code blocks</h2>
@ -97,6 +152,15 @@
Will produce the following list:
{{ list | babycode | safe }}
</section>
<section class="babycode-guide-section">
<h2 id="spoilers">Spoilers</h2>
{% set spoiler = "[spoiler=Major Metal Gear Spoilers]Snake dies[/spoiler]" %}
<p>You can make a section collapsible by using the <code class="inline-code">[spoiler]</code> tag:</p>
{{ ("[code]\n%s[/code]" % spoiler) | babycode | safe }}
Will produce:
{{ spoiler | babycode | safe }}
All other tags are supported inside spoilers.
</section>
{% endset %}
{{ sections | safe }}
</div>

View File

@ -4,11 +4,11 @@
<head>
<meta charset="UTF-8">
{% if self.title() %}
<title>Porom - {% block title %}{% endblock %}</title>
<title>{{config.SITE_NAME}} - {% block title %}{% endblock %}</title>
{% else %}
<title>Porom</title>
<title>{{config.SITE_NAME}}</title>
{% endif %}
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="{{ ("/static/css/%s.css" % get_prefers_theme()) | cachebust }}">
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>
@ -24,7 +24,6 @@
<footer class="darkbg">
<span>Pyrom commit <a href="{{ "https://git.poto.cafe/yagich/pyrom/commit/" + __commit }}">{{ __commit[:8] }}</a></span>
</footer>
<script src="/static/js/copy-code.js"></script>
<script src="/static/js/ui.js"></script>
<script src="/static/js/date-fmt.js"></script>
<script src="{{ "/static/js/ui.js" | cachebust }}"></script>
<script src="{{ "/static/js/date-fmt.js" | cachebust }}"></script>
</body>

View File

@ -53,11 +53,13 @@
<button class="babycode-button" type=button id="post-editor-bold" title="Insert Bold"><strong>B</strong></button>
<button class="babycode-button" type=button id="post-editor-italics" title="Insert Italics"><em>I</em></button>
<button class="babycode-button" type=button id="post-editor-strike" title="Insert Strikethrough"><del>S</del></button>
<button class="babycode-button" type=button id="post-editor-underline" title="Insert Underline"><u>U</u></button>
<button class="babycode-button" type=button id="post-editor-url" title="Insert Link"><code>://</code></button>
<button class="babycode-button" type=button id="post-editor-code" title="Insert Code block"><code>&lt;/&gt;</code></button>
<button class="babycode-button contain-svg full" type=button id="post-editor-img" title="Insert Image"><img src="/static/misc/image.svg"></button>
<button class="babycode-button" type=button id="post-editor-ol" title="Insert Ordered list">1.</button>
<button class="babycode-button" type=button id="post-editor-ul" title="Insert Unordered list">&bullet;</button>
<button class="babycode-button contain-svg full" type=button id="post-editor-spoiler" title="Insert spoiler"><img src="/static/misc/spoiler.svg"></button>
</span>
<textarea class="babycode-editor" name="{{ ta_name }}" id="babycode-content" placeholder="{{ ta_placeholder }}" {{ "required" if not optional else "" }}>{{ prefill }}</textarea>
<a href="{{ url_for("app.babycode_guide") }}" target="_blank">babycode guide</a>
@ -67,7 +69,7 @@
<div id="babycode-preview-container"></div>
</div>
</div>
<script src="/static/js/babycode-editor.js?v=2"></script>
<script src="{{ "/static/js/babycode-editor.js" | cachebust }}"></script>
{% endmacro %}
{% macro babycode_editor_form(ta_name, prefill = "", cancel_url="", endpoint="") %}
@ -89,13 +91,13 @@
</form>
{% endmacro %}
{% macro full_post(post, render_sig = True, is_latest = False, editing = False, active_user = None, no_reply = false) %}
{% macro full_post(post, render_sig = True, is_latest = False, editing = False, active_user = None, no_reply = false, Reactions = none) %}
{% set postclass = "post" %}
{% if editing %}
{% set postclass = postclass + " editing" %}
{% endif %}
{% set post_permalink = url_for("threads.thread", slug = post['thread_slug'], after = post['id'], _anchor = ("post-" + (post['id'] | string))) %}
<div class=" {{ postclass }}" id="post-{{ post['id'] }}">
<div class=" {{ postclass }}" id="post-{{ post['id'] }}" data-post-id="{{ post['id'] }}">
<div class="usercard">
<div class="usercard-inner">
<a href="{{ url_for("users.page", username=post['username']) }}" style="display: contents;">
@ -128,9 +130,11 @@
{% set show_reply = true %}
{% if active_user and post['thread_is_locked'] and not active_user.is_mod() %}
{% if not active_user %}
{% set show_reply = false %}
{% elif active_user and active_user.is_guest() %}
{% elif post['thread_is_locked'] and not active_user.is_mod() %}
{% set show_reply = false %}
{% elif active_user.is_guest() %}
{% set show_reply = false %}
{% elif editing %}
{% set show_reply = false %}
@ -140,7 +144,7 @@
{% if show_reply %}
{% set qtext = "[url=%s]%s said:[/url]" | format(post_permalink, post['username']) %}
{% set reply_text = "%s\n[quote]%s[/quote]\n" | format(qtext, post['original_markup']) %}
{% set reply_text = "%s\n[quote]\n%s\n[/quote]\n" | format(qtext, post['original_markup']) %}
<button value="{{ reply_text }}" class="reply-button">Quote</button>
{% endif %}
@ -160,7 +164,6 @@
<div class="post-inner" data-post-permalink="{{ post_permalink }}" data-author-username="{{ post.username }}">{{ post['content'] | safe }}</div>
{% if render_sig and post['signature_rendered'] %}
<div class="signature-container">
<hr>
{{ post['signature_rendered'] | safe }}
</div>
{% endif %}
@ -168,6 +171,52 @@
{{ babycode_editor_form(cancel_url = post_permalink, prefill = post['original_markup'], ta_name = "new_content") }}
{% endif %}
</div>
{% if Reactions -%}
{% set can_react = true -%}
{% if not active_user -%}
{% set can_react = false -%}
{% elif post['thread_is_locked'] and not active_user.is_mod() -%}
{% set can_react = false -%}
{% elif active_user.is_guest() -%}
{% set can_react = false -%}
{% elif editing -%}
{% set can_react = false -%}
{% endif -%}
{% set reactions = Reactions.for_post(post.id) -%}
<div class="post-reactions">
{% for reaction in reactions %}
{% 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 = active_user is not none and active_user.username in reactors %}
<span class="reaction-container" data-emoji="{{ reaction.reaction_text }}" data-post-id="{{ post.id }}"><button type="button" class="reduced reaction-button {{"active" if has_reacted else ""}}" {{ "disabled" if not can_react else ""}} title="{{reactors_str}}"><img class=emoji src="/static/emoji/{{reaction.reaction_text}}.png"> x<span class="reaction-count" data-emoji="{{ reaction.reaction_text }}">{{reaction.c}}</span></button>
</span>
{% endfor %}
{% if can_react %}
<button type="button" class="reduced add-reaction-button" data-post-id="{{ post.id }}">Add reaction</button>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endmacro %}
{% macro accordion(hidden=false, style="", disabled=false) %}
{% if disabled %}
{% set hidden = true %}
{% endif %}
<div class="accordion {{ "hidden" if hidden else ""}}" style="{{style}}">
<div class="accordion-header">
<button type="button" class="accordion-toggle" {{"disabled" if disabled else ""}}>{{ "+" if hidden else "-" }}</button>
{{ caller('header') }}
</div>
<div class="accordion-content {{ "hidden" if hidden else "" }}">
{{ caller('content') }}
</div>
</div>
{% endmacro %}

View File

@ -1,10 +1,14 @@
<nav id="topnav">
<span>
<a class="site-title" href="{{url_for('topics.all_topics')}}">Porom</a>
<a class="site-title" href="{{url_for('topics.all_topics')}}">{{config.SITE_NAME}}</a>
</span>
<span>
{% if not is_logged_in() %}
{% if not config.DISABLE_SIGNUP %}
Welcome, guest. Please <a href="{{url_for('users.sign_up')}}">sign up</a> or <a href="{{url_for('users.log_in')}}">log in</a>
{% else %}
Welcome, guest. Please <a href="{{url_for('users.log_in')}}">log in</a>
{% endif %}
{% else %}
{% with user = get_active_user() %}
Welcome, <a href="{{ url_for("users.page", username = user.username) }}">{{user.username}}</a>
@ -12,6 +16,10 @@
<a href="{{ url_for("users.settings", username = user.username) }}">Settings</a>
&bullet;
<a href="{{ url_for("users.inbox", username = user.username) }}">Inbox</a>
{% if config.DISABLE_SIGNUP and user.can_invite() %}
&bullet;
<a href="{{ url_for('users.invite_links', username=user.username )}}">Invite to {{ config.SITE_NAME }}</a>
{% endif %}
{% if user.is_mod() %}
&bullet;
<a href="{{ url_for("mod.user_list") }}">User list</a>

View File

@ -14,5 +14,5 @@
<input type=submit value="Save order">
</form>
</div>
<script src="/static/js/sort-topics.js"></script>
<script src="{{ "/static/js/sort-topics.js" | cachebust }}"></script>
{% endblock %}

View File

@ -1,10 +1,69 @@
{% from "common/macros.html" import timestamp, accordion %}
{% extends "base.html" %}
{% block content %}
<div class="darkbg settings-container">
<ul>
{% for user in users %}
<li><a href="{{url_for("users.page", username=user['username'])}}">{{user['username']}}</a>
{% endfor %}
</ul>
<div class="darkbg inbox-container">
{% set guests = (users | selectattr('permission', 'eq', PermissionLevel.GUEST.value) | list) %}
{% set not_guests = (users | selectattr('permission', 'gt', PermissionLevel.GUEST.value) | list) %}
{% call(section) accordion(disabled=(guests | count==0)) %}
{% if section == "header" %}
<span>Unconfirmed guests</span>
{% elif section == "content" %}
<table class="colorful-table">
<thead>
<th>Username</th>
<th class="small">Signed up on</th>
</thead>
{% for user in guests %}
<tr>
<td>
<a href="{{url_for("users.page", username=user['username'])}}">{{user['username']}}
</a>
</td>
<td>
{{ timestamp(user.created_at) }}
</td>
</tr>
{% endfor %}
</table>
{% endif %}
{% endcall %}
{% call(section) accordion() %}
{% if section == "header" %}
<span>Other users</span>
{% elif section == "content" %}
<table class="colorful-table">
<thead>
<th>Username</th>
<th class="small">Permission</th>
<th class="small">Signed up on</th>
{% if active_user.is_admin() %}
<th class="small">Create password reset link</th>
{% endif %}
</thead>
{% for user in not_guests %}
<tr>
<td>
<a href="{{url_for("users.page", username=user['username'])}}">{{user['username']}}
</a>
</td>
<td>
{{ user.permission | permission_string }}
</td>
<td>
{{ timestamp(user.created_at) }}
</td>
{% if active_user.is_admin() %}
<td>
<form method="post" action="{{url_for('mod.create_reset_pass', user_id=user.id)}}">
<input type="submit" class="warn" value="Create password reset link">
</form>
</td>
{% endif %}
</tr>
{% endfor %}
</table>
{% endif %}
{% endcall %}
</div>
{% endblock %}

View File

@ -12,7 +12,7 @@
{% endif %}
<main>
<nav class="darkbg">
<h1 class="thread-title">{{ thread.title }}</h1>
<h1 class="thread-title">{{ thread.title }}{% if unread_count is not none %} ({{ unread_count }} unread){% endif %}</h1>
<span>Posted in <a href="{{ url_for("topics.topic", slug=topic.slug) }}">{{ topic.name }}</a>
{% if thread.is_stickied %}
&bullet; <i>stickied, so it's probably important</i>
@ -32,7 +32,7 @@
<input class="warn" type="submit" value="{{"Unlock thread" if thread.is_locked else "Lock thread"}}">
</form>
{% endif %}
{% if active_user.is_mod() %}
{% if active_user and active_user.is_mod() %}
<form class="modform" action="{{ url_for("threads.sticky", slug=thread.slug) }}" method="post">
<input type=hidden name='target_op' value="{{ (not thread.is_stickied) | int }}">
<input class="warn" type="submit" value="{{"Unsticky thread" if thread.is_stickied else "Sticky thread"}}">
@ -50,7 +50,7 @@
</div>
</nav>
{% for post in posts %}
{{ full_post(post = post, active_user = active_user, is_latest = loop.index == (posts | length)) }}
{{ full_post(post = post, active_user = active_user, is_latest = loop.index == (posts | length), Reactions = Reactions) }}
{% endfor %}
</main>
@ -72,6 +72,7 @@
</span>
</div>
</dialog>
<input type='hidden' id='allowed-reaction-emoji' value='{{ REACTION_EMOJI | join(' ') }}'>
<input type='hidden' id='thread-subscribe-endpoint' value='{{ url_for('api.thread_updates', thread_id=thread.id) }}'>
<div id="new-post-notification" class="new-concept-notification hidden">
<div class="new-notification-content">
@ -83,5 +84,5 @@
</span>
</div>
</div>
<script src="/static/js/thread.js?v=1"></script>
<script src="{{ "/static/js/thread.js" | cachebust }}"></script>
{% endblock %}

View File

@ -40,6 +40,9 @@
<div class="thread-info-container">
<span>
<span class="thread-title"><a href="{{ url_for("threads.thread", slug=thread['slug']) }}">{{thread['title']}}</a></span>
{% if thread['id'] in subscriptions %}
({{ subscriptions[thread['id']] }} unread)
{% endif %}
&bullet;
Started by <a href="{{ url_for("users.page", username=thread['started_by']) }}">{{ thread['started_by'] }}</a> on {{ timestamp(thread['created_at'])}}
</span>
@ -75,5 +78,5 @@
</div>
</dialog>
<script src="/static/js/topic.js"></script>
<script src="{{ "/static/js/topic.js" | cachebust }}"></script>
{% endblock %}

View File

@ -23,7 +23,7 @@
{% if topic['id'] in active_threads %}
{% with thread=active_threads[topic['id']] %}
<span>
Latest post in: <a href="{{ url_for("threads.thread", slug=thread['thread_slug'])}}">{{ thread['thread_title'] }}</a> by <a href="{{ url_for("users.page", username=thread['username'])}}">{{ thread['username'] }}</a> at <a href="">{{ timestamp(thread['post_created_at']) }}</a>
Latest post in: <a href="{{ url_for("threads.thread", slug=thread['thread_slug'])}}">{{ thread['thread_title'] }}</a> by <a href="{{ url_for("users.page", username=thread['username'])}}">{{ thread['username'] }}</a> at <a href="{{ get_post_url(thread.post_id, _anchor=true) }}">{{ timestamp(thread['post_created_at']) }}</a>
</span>
{% endwith %}
{% endif %}

View File

@ -1,51 +1,65 @@
{% from "common/macros.html" import timestamp, full_post %}
{% from "common/macros.html" import timestamp, full_post, accordion %}
{% extends "base.html" %}
{% block title %}inbox{% endblock %}
{% block content %}
<div class="inbox-container">
{% if all_subscriptions is none %}
You have no subscriptions.<br>
{% else %}
Your subscriptions:
<ul>
{% for sub in all_subscriptions %}
<li>
<a href=" {{ url_for("threads.thread", slug=sub.thread_slug) }} ">{{ sub.thread_title }}</a>
<form class="modform" method="post" action="{{ url_for("threads.subscribe", slug = sub.thread_slug) }}">
<input type="hidden" name="subscribe" value="unsubscribe">
<input class="warn" type="submit" value="Unsubscribe">
</form>
</li>
<div class="darkbg inbox-container">
{% set has_subscriptions = all_subscriptions is not none %}
{% call(section) accordion(disabled=not has_subscriptions) %}
{% if section == "header" %}
{% if not has_subscriptions %}
(You have no subscriptions)
{% else %}
Your subscriptions
{% endif %}
{% elif section == "content" and has_subscriptions %}
<table class="colorful-table">
<thead>
<th>Thread</th>
<th class="small">Unsubscribe</th>
</thead>
{% for sub in all_subscriptions %}
<tr>
<td>
<a href=" {{ url_for("threads.thread", slug=sub.thread_slug) }} ">{{ sub.thread_title }}</a>
</td>
<td>
<form class="modform" method="post" action="{{ url_for("threads.subscribe", slug = sub.thread_slug) }}">
<input type="hidden" name="subscribe" value="unsubscribe">
<input class="warn" type="submit" value="Unsubscribe">
</form>
</td>
</tr>
{% endfor %}
</table>
{% endif %}
{% endcall %}
{% if has_subscriptions %}
{% if not new_posts %}
You have no unread posts.
{% else %}
You have {{ total_unreads_count }} total unread {{("post" | pluralize(num=total_unreads_count))}}:
{% for thread in new_posts %}
{% call(section) accordion() %}
{% if section == "header" %}
{% set latest_post_id = thread.posts[-1].id %}
{% set unread_posts_text = " (" + (thread.unread_count | string) + (" unread post" | pluralize(num=thread.unread_count)) %}
<a class="accordion-title" href="{{ url_for("threads.thread", slug=thread.thread_slug, after=latest_post_id, _anchor="post-" + (latest_post_id | string)) }}" title="Jump to latest post">{{thread.thread_title + unread_posts_text}}, latest at {{ timestamp(thread.newest_post_time) }})</a>
<form class="modform" method="post" action="{{ url_for("threads.subscribe", slug = thread.thread_slug) }}">
<input type="hidden" name="subscribe" value="read">
<input type="submit" value="Mark thread as Read">
</form>
<form class="modform" method="post" action="{{ url_for("threads.subscribe", slug = thread.thread_slug) }}">
<input type="hidden" name="subscribe" value="unsubscribe">
<input class="warn" type="submit" value="Unsubscribe">
</form>
{% elif section == "content" %}
{% for post in thread.posts %}
{{ full_post(post, no_reply = true) }}
{% endfor %}
{% endif %}
{% endcall %}
{% endfor %}
</ul>
{% endif %}
{% if not new_posts %}
You have no unread posts.
{% else %}
You have {{ total_unreads_count }} unread post{{(total_unreads_count | int) | pluralize }}:
{% for thread in new_posts %}
<div class="accordion">
<div class="accordion-header">
<button type="button" class="accordion-toggle"></button>
{% set latest_post_id = thread.posts[-1].id %}
{% set unread_posts_text = " (" + (thread.unread_count | string) + (" unread post" | pluralize) %}
<a class="accordion-title" href="{{ url_for("threads.thread", slug=latest_post_slug, after=latest_post_id, _anchor="post-" + (latest_post_id | string)) }}" title="Jump to latest post">{{thread.thread_title + unread_posts_text}}, latest at {{ timestamp(thread.newest_post_time) }})</a>
<form class="modform" method="post" action="{{ url_for("threads.subscribe", slug = thread.thread_slug) }}">
<input type="hidden" name="subscribe" value="read">
<input type="submit" value="Mark thread as Read">
</form>
<form class="modform" method="post" action="{{ url_for("threads.subscribe", slug = thread.thread_slug) }}">
<input type="hidden" name="subscribe" value="unsubscribe">
<input class="warn" type="submit" value="Unsubscribe">
</form>
</div>
<div class="accordion-content">
{% for post in thread.posts %}
{{ full_post(post, no_reply = true) }}
{% endfor %}
</div>
</div>
{% endfor %}
{% endif %}
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,36 @@
{% from 'common/macros.html' import accordion %}
{% extends 'base.html' %}
{% block title %}invites{% endblock %}
{% block content %}
<div class="darkbg inbox-container">
<p>To manage growth, {{ config.SITE_NAME }} disallows direct sign ups. Instead, users already with an account may invite people they know. You can create invite links here. Once an invite link is used to sign up, it can no longer be used.</p>
{% call(section) accordion(disabled=invites | length == 0) %}
{% if section == 'header' %}
Your invites
{% else %}
{% if invites %}
<table class="colorful-table">
<thead>
<th class='small'>Link</th>
<th class='small'>Revoke</th>
</thead>
{% for invite in invites %}
<tr>
<td><a href="{{url_for('users.sign_up', key=invite.key)}}">Link</a></td>
<td>
<form method="post" action="{{ url_for('users.revoke_invite_link', username=active_user.username) }}">
<input type=hidden value="{{ invite.key }}" name="key">
<input type=submit class=warn value="Revoke">
</form>
</td>
</tr>
{% endfor %}
</table>
{% endif %}
{% endif %}
{% endcall %}
<form method="post" action="{{ url_for('users.create_invite_link', username=active_user.username) }}">
<input type=submit value="Create new invite">
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,15 @@
{% extends 'base.html' %}
{% block title %}Reset password{% endblock %}
{% block content %}
<div class="darkbg login-container">
<h1>Reset password for {{username}}</h1>
<p>Send this link to {{username}} to allow them to reset their password.</p>
<form method="post">
<label for="password">New password</label><br>
<input type="password" id="password" name="password" autocomplete="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><br>
<label for="password2">Confirm password</label><br>
<input type="password" id="password2" name="password2" autocomplete="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><br>
<input type="submit" value="Reset password">
</form>
</div>
{% endblock %}

View File

@ -15,6 +15,12 @@
</div>
</form>
<form method='post'>
<label for='theme'>Theme (beta)</label>
<select autocomplete='off' id='theme' name='theme'>
{% for theme in config.allowed_themes %}
<option value="{{ theme }}" {{ 'selected' if get_prefers_theme() == theme }}>{{ theme | theme_name }}</option>
{% endfor %}
</select>
<label for='topic_sort_by'>Sort threads by:</label>
<select id='topic_sort_by' name='topic_sort_by'>
<option value='activity' {{ 'selected' if session['sort_by'] == 'activity' else '' }}>Latest activity</option>

View File

@ -3,7 +3,13 @@
{% block content %}
<div class="darkbg login-container">
<h1>Sign up</h1>
{% if inviter %}
<p>You have been invited by <a href="{{ url_for('users.page', username=inviter.username) }}">{{ inviter.username }}</a> to join {{ config.SITE_NAME }}. Create an identity below.</p>
{% endif %}
<form method="post">
{% if key %}
<input type="hidden" value={{key}} name="key">
{% endif %}
<label for="username">Username</label><br>
<input type="text" id="username" name="username" pattern="[a-zA-Z0-9_-]{3,20}" title="3-20 characters. Only upper and lowercase letters, digits, hyphens, and underscores" required autocomplete="username"><br>
<label for="password">Password</label>
@ -12,6 +18,8 @@
<input type="password" id="password-confirm" name="password-confirm" 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" required autocomplete="new-password"><br>
<input type="submit" value="Sign up">
</form>
{% if not inviter %}
<span>After you sign up, a moderator will need to confirm your account before you will be allowed to post.</span>
{% endif %}
</div>
{% endblock %}

View File

@ -64,6 +64,9 @@
{% if stats.latest_thread_title %}
<li>Latest started thread: <a href="{{ url_for("threads.thread", slug = stats.latest_thread_slug) }}">{{ stats.latest_thread_title }}</a>
{% endif %}
{% if stats.inviter_username %}
<li>Invited by <a href="{{ url_for('users.page', username=stats.inviter_username) }}">{{ stats.inviter_username }}</a></li>
{% endif %}
</ul>
{% endwith %}
Latest posts:
@ -80,8 +83,8 @@
{% endif %}
</i></a>
</div>
<div class="post-content wider user-page-post-preview">
<div class="post-inner">{{ post.content | safe }}</div>
<div class="post-content user-page-post-preview">
<div class="post-inner wider">{{ post.content | safe }}</div>
</div>
</div>
{% endfor %}

20
build-themes.sh Executable file
View File

@ -0,0 +1,20 @@
#!/bin/bash
set -e
sass_dir="sass"
css_dir="data/static/css"
if [[ "$1" == "--watch" && -n "$2" ]]; then
file="$2"
[[ $(basename "$file") = _* ]] && exit 1
sass --no-source-map --watch "$file" "$css_dir/theme-$(basename "$file" .scss).css"
else
set -u
rm -r "$css_dir/"
#build default first
sass --no-source-map "$sass_dir/_default.scss" "$css_dir/style.css"
for file in "$sass_dir"/*.scss; do
[[ $(basename "$file") = _* ]] && continue
sass --no-source-map "$file" "$css_dir/theme-$(basename "$file" .scss).css"
done
fi

8
config/pyrom_config.toml Normal file
View File

@ -0,0 +1,8 @@
SITE_NAME = "Porom"
DISABLE_SIGNUP = false # if true, no one can sign up.
# if neither of the following two options is true,
# no one can sign up. this may be useful later when/if LDAP is implemented.
MODS_CAN_INVITE = true # if true, allows moderators to create invite links. useless unless DISABLE_SIGNUP to be true.
USERS_CAN_INVITE = false # if true, allows users to create invite links. useless unless DISABLE_SIGNUP to be true.

View File

@ -26,14 +26,13 @@
font-weight: bold;
font-style: italic;
}
.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 {
.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;
color: black;
font-size: 0.9em;
font-family: "Cadman";
text-decoration: none;
border: 1px solid black;
border-radius: 3px;
border-radius: 4px;
padding: 5px 20px;
margin: 10px 0;
}
@ -42,6 +41,14 @@ body {
font-family: "Cadman";
margin: 20px 100px;
background-color: rgb(173.5214173228, 183.6737007874, 161.0262992126);
color: black;
}
a:link {
color: #c11c1c;
}
a:visited {
color: #730c0c;
}
.big {
@ -50,6 +57,7 @@ body {
#topnav {
padding: 10px;
margin: 0;
display: flex;
justify-content: end;
background-color: #c1ceb1;
@ -59,6 +67,7 @@ body {
#bottomnav {
padding: 10px;
margin: 0;
display: flex;
justify-content: end;
background-color: rgb(143.7039271654, 144.3879625984, 142.8620374016);
@ -81,7 +90,7 @@ body {
font-size: 3rem;
margin: 0 20px;
text-decoration: none;
color: black;
color: black !important;
}
.thread-title {
@ -119,38 +128,58 @@ body {
.post-content-container {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 70px 2.5fr;
gap: 0px 0px;
grid-template-rows: min-content 1fr min-content;
gap: 0;
grid-auto-flow: row;
grid-template-areas: "post-info" "post-content";
grid-template-areas: "post-info" "post-content" "post-reactions";
grid-area: post-content-container;
min-height: 100%;
}
.post-info {
grid-area: post-info;
display: flex;
min-height: 70px;
justify-content: space-between;
padding: 5px 20px;
align-items: center;
border-top: 1px solid black;
border-bottom: 1px solid black;
background-color: rgb(173.5214173228, 183.6737007874, 161.0262992126);
}
.post-content {
grid-area: post-content;
padding: 20px;
margin-right: 25%;
padding: 20px 20px 0 20px;
display: flex;
flex-direction: column;
overflow: hidden;
background-color: #c1ceb1;
}
.post-content.wider {
margin-right: 12.5%;
.post-reactions {
grid-area: post-reactions;
min-height: 50px;
display: flex;
padding: 5px 20px;
align-items: center;
flex-wrap: wrap;
gap: 5px;
background-color: rgb(173.5214173228, 183.6737007874, 161.0262992126);
border-top: 2px dotted gray;
}
.post-inner {
height: 100%;
padding-right: 25%;
}
.post-inner.wider {
padding-right: 12.5%;
}
.signature-container {
border-top: 2px dotted gray;
padding: 10px 0;
}
pre code {
@ -166,51 +195,6 @@ pre code {
tab-size: 4;
}
.inline-code {
background-color: rgb(38.5714173228, 40.9237007874, 35.6762992126);
color: white;
padding: 5px 10px;
display: inline-block;
margin: 4px;
border-radius: 4px;
font-size: 1rem;
}
#delete-dialog, .lightbox-dialog {
padding: 0;
border-radius: 4px;
border: 2px solid black;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.25);
}
.delete-dialog-inner {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
}
.lightbox-inner {
display: flex;
flex-direction: column;
padding: 20px;
min-width: 400px;
background-color: #c1ceb1;
gap: 10px;
}
.lightbox-image {
max-width: 70vw;
max-height: 70vh;
object-fit: scale-down;
}
.lightbox-nav {
display: flex;
justify-content: space-between;
align-items: center;
}
.copy-code-container {
position: sticky;
width: calc(100% - 4px);
@ -235,12 +219,59 @@ pre code {
margin-right: 10px;
}
.inline-code {
background-color: rgb(38.5714173228, 40.9237007874, 35.6762992126);
color: white;
padding: 5px 10px;
display: inline-block;
margin: 4px;
border-radius: 4px;
font-size: 1rem;
white-space: pre;
}
#delete-dialog, .lightbox-dialog {
padding: 0;
border-radius: 4px;
border: 2px solid black;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.25);
}
.delete-dialog-inner {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
}
.lightbox-inner {
display: flex;
flex-direction: column;
padding: 20px;
min-width: 400px;
background-color: #c1ceb1;
color: black;
gap: 10px;
}
.lightbox-image {
max-width: 70vw;
max-height: 70vh;
object-fit: scale-down;
}
.lightbox-nav {
display: flex;
justify-content: space-between;
align-items: center;
}
blockquote {
padding: 10px 20px;
margin: 10px;
border-radius: 4px;
border-left: 10px solid rgb(229.84, 231.92, 227.28);
background-color: rgb(135.1928346457, 145.0974015748, 123.0025984252);
background-color: rgba(0, 0, 0, 0.1490196078);
}
.user-info {
@ -271,9 +302,9 @@ blockquote {
}
.user-page-posts {
border-left: solid 1px black;
border-right: solid 1px black;
border-bottom: solid 1px black;
border-left: 1px solid black;
border-right: 1px solid black;
border-bottom: 1px solid black;
background-color: #c1ceb1;
}
@ -300,6 +331,7 @@ blockquote {
button, input[type=submit], .linkbutton {
display: inline-block;
background-color: rgb(177, 206, 204.5);
color: black !important;
}
button:hover, input[type=submit]:hover, .linkbutton:hover {
background-color: rgb(192.6, 215.8, 214.6);
@ -315,8 +347,8 @@ button.reduced, input[type=submit].reduced, .linkbutton.reduced {
padding: 5px;
}
button.critical, input[type=submit].critical, .linkbutton.critical {
color: white;
background-color: red;
color: white !important;
}
button.critical:hover, input[type=submit].critical:hover, .linkbutton.critical:hover {
background-color: #ff3333;
@ -333,6 +365,7 @@ button.critical.reduced, input[type=submit].critical.reduced, .linkbutton.critic
}
button.warn, input[type=submit].warn, .linkbutton.warn {
background-color: #fbfb8d;
color: black !important;
}
button.warn:hover, input[type=submit].warn:hover, .linkbutton.warn:hover {
background-color: rgb(251.8, 251.8, 163.8);
@ -350,7 +383,8 @@ button.warn.reduced, input[type=submit].warn.reduced, .linkbutton.warn.reduced {
input[type=file]::file-selector-button {
background-color: rgb(177, 206, 204.5);
margin: 10px 10px;
color: black !important;
margin: 10px;
}
input[type=file]::file-selector-button:hover {
background-color: rgb(192.6, 215.8, 214.6);
@ -372,6 +406,7 @@ p {
.pagebutton {
background-color: rgb(177, 206, 204.5);
color: black !important;
padding: 5px 5px;
margin: 0;
display: inline-block;
@ -406,7 +441,7 @@ p {
}
.login-container > * {
width: 25%;
width: 40%;
margin: auto;
}
@ -424,11 +459,12 @@ p {
input[type=text], input[type=password], textarea, select {
border: 1px solid black;
border-radius: 3px;
border-radius: 4px;
padding: 7px 10px;
width: 100%;
box-sizing: border-box;
resize: vertical;
color: black;
background-color: rgb(217.8, 225.6, 208.2);
}
input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus {
@ -439,12 +475,15 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
border: 2px solid black;
background-color: #81a3e6;
padding: 20px 15px;
color: black;
}
.infobox.critical {
background-color: rgb(237, 129, 129);
background-color: #ed8181;
color: black;
}
.infobox.warn {
background-color: #fbfb8d;
color: black;
}
.infobox > span {
@ -461,7 +500,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
display: grid;
grid-template-columns: 96px 1.6fr 96px;
grid-template-rows: 1fr;
gap: 0px 0px;
gap: 0;
grid-auto-flow: row;
min-height: 96px;
grid-template-areas: "thread-sticky-container thread-info-container thread-locked-container";
@ -470,11 +509,13 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
.thread-sticky-container {
grid-area: thread-sticky-container;
border: 2px outset rgb(217.26, 220.38, 213.42);
background-color: none;
}
.thread-locked-container {
grid-area: thread-locked-container;
border: 2px outset rgb(217.26, 220.38, 213.42);
background-color: none;
}
.contain-svg {
@ -492,10 +533,21 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
width: 100%;
}
.block-img {
.post-img-container {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.post-image {
object-fit: contain;
max-width: 400px;
max-height: 400px;
min-width: 200px;
min-height: 200px;
flex: 1 1 0%;
width: auto;
height: auto;
}
.thread-info-container {
@ -529,7 +581,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
display: grid;
grid-template-columns: 1.5fr 300px;
grid-template-rows: 1fr;
gap: 0px 0px;
gap: 0;
grid-auto-flow: row;
grid-template-areas: "guide-topics guide-toc";
}
@ -570,11 +622,33 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
border-collapse: collapse;
}
.colorful-table {
border-collapse: collapse;
width: 100%;
margin: 10px 0;
overflow: hidden;
}
.colorful-table tr th {
background-color: #beb1ce;
padding: 5px 0;
}
.colorful-table tr td {
background-color: rgb(177, 206, 204.5);
padding: 5px 0;
text-align: center;
}
.colorful-table .small {
width: 250px;
}
.topic {
display: grid;
grid-template-columns: 1.5fr 96px;
grid-template-rows: 1fr;
gap: 0px 0px;
gap: 0;
grid-auto-flow: row;
grid-template-areas: "topic-info-container topic-locked-container";
}
@ -591,6 +665,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
.topic-locked-container {
grid-area: topic-locked-container;
border: 2px outset rgb(217.26, 220.38, 213.42);
background-color: none;
}
.draggable-topic {
@ -598,9 +673,9 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
user-select: none;
background-color: #c1ceb1;
padding: 20px;
margin: 12px 0;
border-top: 6px outset rgb(217.26, 220.38, 213.42);
border-bottom: 6px outset rgb(135.1928346457, 145.0974015748, 123.0025984252);
margin: 15px 0;
border-top: 5px outset rgb(217.26, 220.38, 213.42);
border-bottom: 5px outset rgb(135.1928346457, 145.0974015748, 123.0025984252);
}
.draggable-topic.dragged {
background-color: rgb(177, 206, 204.5);
@ -638,6 +713,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
.tab-button {
background-color: rgb(177, 206, 204.5);
color: black !important;
border-bottom: none;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
@ -670,9 +746,9 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
background-color: rgb(191.3137931034, 189.7, 193.3);
border: 1px solid black;
padding: 10px;
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
border-bottom-left-radius: 3px;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom-left-radius: 4px;
}
ul, ol {
@ -688,7 +764,7 @@ ul, ol {
position: fixed;
bottom: 80px;
right: 80px;
border: 2px solid black;
border: 1px solid black;
background-color: #81a3e6;
padding: 20px 15px;
border-radius: 4px;
@ -701,8 +777,8 @@ ul, ol {
}
.accordion {
border-top-right-radius: 3px;
border-top-left-radius: 3px;
border-top-right-radius: 4px;
border-top-left-radius: 4px;
box-sizing: border-box;
border: 1px solid black;
margin: 10px 5px;
@ -745,13 +821,20 @@ ul, ol {
display: none;
}
.post-accordion-content {
padding-top: 10px;
padding-bottom: 10px;
background-color: rgb(173.5214173228, 183.6737007874, 161.0262992126);
}
.inbox-container {
padding: 10px;
}
.babycode-button-container {
display: flex;
gap: 10px;
gap: 5px;
flex-wrap: wrap;
}
.babycode-button {
@ -771,3 +854,47 @@ ul, ol {
background-color: rgba(0, 0, 0, 0.5019607843);
padding: 5px 10px;
}
footer {
border-top: 1px solid black;
}
.reaction-button.active {
background-color: #beb1ce;
color: black !important;
}
.reaction-button.active:hover {
background-color: rgb(203, 192.6, 215.8);
}
.reaction-button.active:active {
background-color: rgb(171.7642913386, 166.6881496063, 178.0118503937);
}
.reaction-button.active:disabled {
background-color: rgb(210.445, 209.535, 211.565);
}
.reaction-button.active.reduced {
margin: 0;
padding: 5px;
}
.reaction-popover {
position: relative;
margin: 0;
border: none;
border-radius: 4px;
background-color: rgba(0, 0, 0, 0.5019607843);
padding: 5px 10px;
width: 250px;
}
.reaction-popover-inner {
display: flex;
flex-wrap: wrap;
overflow: scroll;
margin: auto;
justify-content: center;
}
.babycode-guide-list {
border-bottom: 1px dashed;
}

View File

@ -0,0 +1,909 @@
@font-face {
font-family: "site-title";
src: url("/static/fonts/ChicagoFLF.woff2");
}
@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;
}
.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: 0.9em;
font-family: "Cadman";
text-decoration: none;
border: 1px solid black;
border-radius: 4px;
padding: 5px 20px;
margin: 10px 0;
}
body {
font-family: "Cadman";
margin: 20px 100px;
background-color: #220d16;
color: #e6e6e6;
}
a:link {
color: #e87fe1;
}
a:visited {
color: #ed4fb1;
}
.big {
font-size: 1.8rem;
}
#topnav {
padding: 10px;
margin: 0;
display: flex;
justify-content: end;
background-color: #303030;
justify-content: space-between;
align-items: baseline;
}
#bottomnav {
padding: 10px;
margin: 0;
display: flex;
justify-content: end;
background-color: #231c23;
}
.darkbg {
padding-bottom: 10px;
padding-left: 10px;
padding-right: 10px;
background-color: #502d50;
}
.user-actions {
display: flex;
column-gap: 15px;
}
.site-title {
font-family: "site-title";
font-size: 3rem;
margin: 0 20px;
text-decoration: none;
color: white !important;
}
.thread-title {
margin: 0;
font-size: 1.5rem;
font-weight: bold;
}
.post {
display: grid;
grid-template-columns: 200px 1fr;
grid-template-rows: 1fr;
gap: 0;
grid-auto-flow: row;
grid-template-areas: "usercard post-content-container";
border: 2px outset rgb(96.95, 81.55, 96.95);
}
.usercard {
grid-area: usercard;
padding: 20px 10px;
border: 4px outset #503250;
background-color: #502d50;
border-right: solid 2px;
}
.usercard-inner {
display: flex;
flex-direction: column;
align-items: center;
top: 10px;
position: sticky;
}
.post-content-container {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: min-content 1fr min-content;
gap: 0;
grid-auto-flow: row;
grid-template-areas: "post-info" "post-content" "post-reactions";
grid-area: post-content-container;
min-height: 100%;
}
.post-info {
grid-area: post-info;
display: flex;
min-height: 70px;
justify-content: space-between;
padding: 5px 20px;
align-items: center;
border-top: 1px solid black;
border-bottom: 1px solid black;
background-color: #412841;
}
.post-content {
grid-area: post-content;
padding: 20px 20px 0 20px;
display: flex;
flex-direction: column;
overflow: hidden;
background-color: #231c23;
}
.post-reactions {
grid-area: post-reactions;
min-height: 50px;
display: flex;
padding: 5px 20px;
align-items: center;
flex-wrap: wrap;
gap: 5px;
background-color: #503250;
border-top: 2px dotted gray;
}
.post-inner {
height: 100%;
padding-right: 25%;
}
.post-inner.wider {
padding-right: 12.5%;
}
.signature-container {
border-top: 2px dotted gray;
padding: 10px 0;
}
pre code {
display: block;
background-color: #302731;
font-size: 1rem;
color: white;
border-bottom-right-radius: 8px;
border-bottom-left-radius: 8px;
border-left: 10px solid #ae6bae;
padding: 20px;
overflow: scroll;
tab-size: 4;
}
.copy-code-container {
position: sticky;
width: calc(100% - 4px);
display: flex;
justify-content: space-between;
align-items: last baseline;
font-family: "Cadman";
border-top-right-radius: 8px;
border-top-left-radius: 8px;
background-color: #9b649b;
border-left: 2px solid black;
border-right: 2px solid black;
border-top: 2px solid black;
}
.copy-code-container::before {
content: "code block";
font-style: italic;
margin-left: 10px;
}
.copy-code {
margin-right: 10px;
}
.inline-code {
background-color: #302731;
color: white;
padding: 5px 10px;
display: inline-block;
margin: 4px;
border-radius: 4px;
font-size: 1rem;
white-space: pre;
}
#delete-dialog, .lightbox-dialog {
padding: 0;
border-radius: 4px;
border: 2px solid black;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.25);
}
.delete-dialog-inner {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
}
.lightbox-inner {
display: flex;
flex-direction: column;
padding: 20px;
min-width: 400px;
background-color: #503250;
color: #e6e6e6;
gap: 10px;
}
.lightbox-image {
max-width: 70vw;
max-height: 70vh;
object-fit: scale-down;
}
.lightbox-nav {
display: flex;
justify-content: space-between;
align-items: center;
}
blockquote {
padding: 10px 20px;
margin: 10px;
border-radius: 4px;
border-left: 10px solid #ae6bae;
background-color: rgba(251, 175, 207, 0.0392156863);
}
.user-info {
display: grid;
grid-template-columns: 300px 1fr;
grid-template-rows: 1fr;
gap: 0;
grid-template-areas: "user-page-usercard user-page-stats";
}
.user-page-usercard {
grid-area: user-page-usercard;
padding: 20px 10px;
border: 4px outset #503250;
background-color: #502d50;
border-right: solid 2px;
}
.user-page-stats {
grid-area: user-page-stats;
padding: 20px 30px;
border: 1px solid black;
}
.user-stats-list {
list-style: none;
margin: 0 0 10px 0;
}
.user-page-posts {
border-left: 1px solid black;
border-right: 1px solid black;
border-bottom: 1px solid black;
background-color: #9b649b;
}
.user-page-post-preview {
max-height: 200px;
mask-image: linear-gradient(180deg, #000 60%, transparent);
}
.avatar {
width: 90%;
height: 90%;
object-fit: contain;
margin-bottom: 10px;
}
.username-link {
overflow-wrap: anywhere;
}
.user-status {
text-align: center;
}
button, input[type=submit], .linkbutton {
display: inline-block;
background-color: #3c283c;
color: #e6e6e6 !important;
}
button:hover, input[type=submit]:hover, .linkbutton:hover {
background-color: rgb(109.2, 72.8, 109.2);
}
button:active, input[type=submit]:active, .linkbutton:active {
background-color: rgb(47.7, 42.3, 47.7);
}
button:disabled, input[type=submit]:disabled, .linkbutton:disabled {
background-color: rgb(113.73, 109.27, 113.73);
}
button.reduced, input[type=submit].reduced, .linkbutton.reduced {
margin: 0;
padding: 5px;
}
button.critical, input[type=submit].critical, .linkbutton.critical {
background-color: #d53232;
color: #e6e6e6 !important;
}
button.critical:hover, input[type=submit].critical:hover, .linkbutton.critical:hover {
background-color: rgb(221.4, 91, 91);
}
button.critical:active, input[type=submit].critical:active, .linkbutton.critical:active {
background-color: rgb(141.7804251012, 94.9195748988, 94.9195748988);
}
button.critical:disabled, input[type=submit].critical:disabled, .linkbutton.critical:disabled {
background-color: rgb(174.255, 162.845, 162.845);
}
button.critical.reduced, input[type=submit].critical.reduced, .linkbutton.critical.reduced {
margin: 0;
padding: 5px;
}
button.warn, input[type=submit].warn, .linkbutton.warn {
background-color: #eaea6a;
color: black !important;
}
button.warn:hover, input[type=submit].warn:hover, .linkbutton.warn:hover {
background-color: rgb(238.2, 238.2, 135.8);
}
button.warn:active, input[type=submit].warn:active, .linkbutton.warn:active {
background-color: rgb(176.04, 176.04, 129.96);
}
button.warn:disabled, input[type=submit].warn:disabled, .linkbutton.warn:disabled {
background-color: rgb(199.98, 199.98, 191.02);
}
button.warn.reduced, input[type=submit].warn.reduced, .linkbutton.warn.reduced {
margin: 0;
padding: 5px;
}
input[type=file]::file-selector-button {
background-color: #3c283c;
color: #e6e6e6 !important;
margin: 10px;
}
input[type=file]::file-selector-button:hover {
background-color: rgb(109.2, 72.8, 109.2);
}
input[type=file]::file-selector-button:active {
background-color: rgb(47.7, 42.3, 47.7);
}
input[type=file]::file-selector-button:disabled {
background-color: rgb(113.73, 109.27, 113.73);
}
input[type=file]::file-selector-button.reduced {
margin: 0;
padding: 5px;
}
p {
margin: 15px 0;
}
.pagebutton {
background-color: #3c283c;
color: #e6e6e6 !important;
padding: 5px 5px;
margin: 0;
display: inline-block;
min-width: 20px;
text-align: center;
}
.pagebutton:hover {
background-color: rgb(109.2, 72.8, 109.2);
}
.pagebutton:active {
background-color: rgb(47.7, 42.3, 47.7);
}
.pagebutton:disabled {
background-color: rgb(113.73, 109.27, 113.73);
}
.pagebutton.reduced {
margin: 0;
padding: 5px;
}
.currentpage {
border: none;
padding: 5px 5px;
margin: 0;
display: inline-block;
min-width: 20px;
text-align: center;
}
.modform {
display: inline;
}
.login-container > * {
width: 40%;
margin: auto;
}
.settings-container > * {
width: 40%;
margin: auto;
}
.avatar-form {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 0;
}
input[type=text], input[type=password], textarea, select {
border: 1px solid black;
border-radius: 4px;
padding: 7px 10px;
width: 100%;
box-sizing: border-box;
resize: vertical;
color: #e6e6e6;
background-color: #371e37;
}
input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus {
background-color: #514151;
}
.infobox {
border: 2px solid black;
background-color: #775891;
padding: 20px 15px;
color: #e6e6e6;
}
.infobox.critical {
background-color: #d53232;
color: #e6e6e6;
}
.infobox.warn {
background-color: #eaea6a;
color: black;
}
.infobox > span {
display: flex;
align-items: center;
}
.infobox-icon-container {
min-width: 60px;
padding-right: 15px;
}
.thread {
display: grid;
grid-template-columns: 96px 1.6fr 96px;
grid-template-rows: 1fr;
gap: 0;
grid-auto-flow: row;
min-height: 96px;
grid-template-areas: "thread-sticky-container thread-info-container thread-locked-container";
}
.thread-sticky-container {
grid-area: thread-sticky-container;
border: 2px outset #231c23;
background-color: #503250;
}
.thread-locked-container {
grid-area: thread-locked-container;
border: 2px outset #231c23;
background-color: #503250;
}
.contain-svg {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.contain-svg:not(.full) > svg, .contain-svg:not(.full) > img {
height: 50%;
width: 50%;
}
.contain-svg.full > svg, .contain-svg.full > img {
height: 100%;
width: 100%;
}
.post-img-container {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.post-image {
object-fit: contain;
max-width: 400px;
max-height: 400px;
min-width: 200px;
min-height: 200px;
flex: 1 1 0%;
width: auto;
height: auto;
}
.thread-info-container {
grid-area: thread-info-container;
background-color: #231c23;
padding: 5px 20px;
border-top: 1px solid black;
border-bottom: 1px solid black;
display: flex;
flex-direction: column;
overflow: hidden;
max-height: 110px;
mask-image: linear-gradient(180deg, #000 60%, transparent);
}
.thread-info-post-preview {
overflow: hidden;
text-overflow: ellipsis;
display: inline;
margin-right: 25%;
}
.babycode-guide-section {
background-color: #231c23;
padding: 5px 20px;
border: 1px solid black;
padding-right: 25%;
}
.babycode-guide-container {
display: grid;
grid-template-columns: 1.5fr 300px;
grid-template-rows: 1fr;
gap: 0;
grid-auto-flow: row;
grid-template-areas: "guide-topics guide-toc";
}
.guide-topics {
grid-area: guide-topics;
overflow: hidden;
}
.guide-toc {
grid-area: guide-toc;
position: sticky;
top: 100px;
align-self: start;
padding: 10px;
border-bottom-right-radius: 8px;
background-color: #3c233c;
border-right: 1px solid black;
border-top: 1px solid black;
border-bottom: 1px solid black;
}
.emoji-table tr td {
text-align: center;
}
.emoji-table tr th {
padding-left: 50px;
padding-right: 50px;
}
.emoji-table {
margin: auto;
}
.emoji-table, th, td {
border: 1px solid black;
border-collapse: collapse;
}
.colorful-table {
border-collapse: collapse;
width: 100%;
margin: 10px 0;
overflow: hidden;
}
.colorful-table tr th {
background-color: #503250;
padding: 5px 0;
}
.colorful-table tr td {
background-color: #231c23;
padding: 5px 0;
text-align: center;
}
.colorful-table .small {
width: 250px;
}
.topic {
display: grid;
grid-template-columns: 1.5fr 96px;
grid-template-rows: 1fr;
gap: 0;
grid-auto-flow: row;
grid-template-areas: "topic-info-container topic-locked-container";
}
.topic-info-container {
grid-area: topic-info-container;
background-color: #231c23;
padding: 5px 20px;
border: 1px solid black;
display: flex;
flex-direction: column;
}
.topic-locked-container {
grid-area: topic-locked-container;
border: 2px outset #231c23;
background-color: #503250;
}
.draggable-topic {
cursor: pointer;
user-select: none;
background-color: #9b649b;
padding: 20px;
margin: 15px 0;
border-top: 5px outset #503250;
border-bottom: 5px outset rgb(96.95, 81.55, 96.95);
}
.draggable-topic.dragged {
background-color: #3c283c;
}
.editing {
background-color: #503250;
}
.context-explain {
margin: 20px 0;
display: flex;
justify-content: space-evenly;
}
.post-edit-form {
display: flex;
flex-direction: column;
align-items: baseline;
height: 100%;
}
.babycode-editor {
height: 150px;
font-size: 1rem;
}
.babycode-editor-container {
width: 100%;
}
.babycode-preview-errors-container {
font-size: 0.8rem;
}
.tab-button {
background-color: #3c283c;
color: #e6e6e6 !important;
border-bottom: none;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
margin-bottom: 0;
}
.tab-button:hover {
background-color: rgb(109.2, 72.8, 109.2);
}
.tab-button:active {
background-color: rgb(47.7, 42.3, 47.7);
}
.tab-button:disabled {
background-color: rgb(113.73, 109.27, 113.73);
}
.tab-button.reduced {
margin: 0;
padding: 5px;
}
.tab-button.active {
background-color: #8a5584;
padding-top: 8px;
}
.tab-content {
display: none;
}
.tab-content.active {
min-height: 250px;
display: block;
background-color: #503250;
border: 1px solid black;
padding: 10px;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom-left-radius: 4px;
}
ul, ol {
margin: 10px 0 10px 30px;
padding: 0;
}
.new-concept-notification.hidden {
display: none;
}
.new-concept-notification {
position: fixed;
bottom: 80px;
right: 80px;
border: 1px solid black;
background-color: #775891;
padding: 20px 15px;
border-radius: 4px;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.25);
}
.emoji {
max-width: 15px;
max-height: 15px;
}
.accordion {
border-top-right-radius: 4px;
border-top-left-radius: 4px;
box-sizing: border-box;
border: 1px solid black;
margin: 10px 5px;
overflow: hidden;
}
.accordion.hidden {
border-bottom: none;
}
.accordion-header {
display: flex;
align-items: center;
background-color: #7d467d;
padding: 0 10px;
gap: 10px;
border-bottom: 1px solid black;
}
.accordion-toggle {
padding: 0;
width: 36px;
height: 36px;
min-width: 36px;
min-height: 36px;
}
.accordion-title {
margin-right: auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.accordion-content {
padding: 0 15px;
}
.accordion-content.hidden {
display: none;
}
.post-accordion-content {
padding-top: 10px;
padding-bottom: 10px;
background-color: #2d212d;
}
.inbox-container {
padding: 10px;
}
.babycode-button-container {
display: flex;
gap: 5px;
flex-wrap: wrap;
}
.babycode-button {
padding: 5px 10px;
min-width: 36px;
}
.babycode-button > * {
font-size: 1rem;
}
.quote-popover {
position: absolute;
transform: translateX(-50%);
margin: 0;
border: none;
border-radius: 4px;
background-color: rgba(0, 0, 0, 0.5019607843);
padding: 5px 10px;
}
footer {
border-top: 1px solid black;
}
.reaction-button.active {
background-color: #8a5584;
color: #e6e6e6 !important;
}
.reaction-button.active:hover {
background-color: rgb(167.4843049327, 112.9156950673, 161.3067264574);
}
.reaction-button.active:active {
background-color: rgb(107.505, 93.195, 105.885);
}
.reaction-button.active:disabled {
background-color: rgb(156.9373766816, 152.1626233184, 156.396838565);
}
.reaction-button.active.reduced {
margin: 0;
padding: 5px;
}
.reaction-popover {
position: relative;
margin: 0;
border: none;
border-radius: 4px;
background-color: rgba(0, 0, 0, 0.5019607843);
padding: 5px 10px;
width: 250px;
}
.reaction-popover-inner {
display: flex;
flex-wrap: wrap;
overflow: scroll;
margin: auto;
justify-content: center;
}
.babycode-guide-list {
border-bottom: 1px dashed;
}
#topnav {
margin-bottom: 10px;
border: 10px solid rgb(40, 40, 40);
}
footer {
margin-top: 10px;
}

View File

@ -0,0 +1,929 @@
@font-face {
font-family: "site-title";
src: url("/static/fonts/ChicagoFLF.woff2");
}
@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;
}
.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: 0.9em;
font-family: "Cadman";
text-decoration: none;
border: 1px solid black;
border-radius: 16px;
padding: 8px 12px;
margin: 6px 0;
}
body {
font-family: "Cadman";
margin: 12px 50px;
background-color: #c85d45;
color: black;
}
a:link {
color: black;
}
a:visited {
color: black;
}
.big {
font-size: 1.8rem;
}
#topnav {
padding: 6px;
margin: 0;
display: flex;
justify-content: end;
background-color: #f27a5a;
justify-content: space-between;
align-items: baseline;
}
#bottomnav {
padding: 6px;
margin: 0;
display: flex;
justify-content: end;
background-color: #88486d;
}
.darkbg {
padding-bottom: 6px;
padding-left: 6px;
padding-right: 6px;
background-color: #88486d;
}
.user-actions {
display: flex;
column-gap: 8px;
}
.site-title {
font-family: "site-title";
font-size: 3rem;
margin: 0 12px;
text-decoration: none;
color: black !important;
}
.thread-title {
margin: 0;
font-size: 1.5rem;
font-weight: bold;
}
.post {
display: grid;
grid-template-columns: 200px 1fr;
grid-template-rows: 1fr;
gap: 0;
grid-auto-flow: row;
grid-template-areas: "usercard post-content-container";
border: 2px outset rgb(155.8907865169, 93.2211235955, 76.5092134831);
}
.usercard {
grid-area: usercard;
padding: 12px 6px;
border: none;
background-color: #88486d;
border-right: none;
}
.usercard-inner {
display: flex;
flex-direction: column;
align-items: center;
top: 6px;
position: sticky;
}
.post-content-container {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: min-content 1fr min-content;
gap: 0;
grid-auto-flow: row;
grid-template-areas: "post-info" "post-content" "post-reactions";
grid-area: post-content-container;
min-height: 100%;
}
.post-info {
grid-area: post-info;
display: flex;
min-height: 35px;
justify-content: space-between;
padding: 3px 12px;
align-items: center;
border-top: 1px solid black;
border-bottom: 1px solid black;
background-color: #c85d45;
}
.post-content {
grid-area: post-content;
padding: 12px 12px 0 12px;
display: flex;
flex-direction: column;
overflow: hidden;
background-color: #f27a5a;
}
.post-reactions {
grid-area: post-reactions;
min-height: 50px;
display: flex;
padding: 6px 12px;
align-items: center;
flex-wrap: wrap;
gap: 6px;
background-color: #c85d45;
border-top: 2px dotted #f7bfdf;
}
.post-inner {
height: 100%;
padding-right: 25%;
}
.post-inner.wider {
padding-right: 12.5%;
}
.signature-container {
border-top: 2px dotted #f7bfdf;
padding: 6px 0;
}
pre code {
display: block;
background-color: rgb(41.7051685393, 28.2759550562, 24.6948314607);
font-size: 1rem;
color: white;
border-bottom-right-radius: 16px;
border-bottom-left-radius: 16px;
border-left: 6px solid rgb(231.56, 212.36, 207.24);
padding: 12px;
overflow: scroll;
tab-size: 4;
}
.copy-code-container {
position: sticky;
width: calc(100% - 4px);
display: flex;
justify-content: space-between;
align-items: last baseline;
font-family: "Cadman";
border-top-right-radius: 16px;
border-top-left-radius: 16px;
background-color: #f27a5a;
border-left: 2px solid black;
border-right: 2px solid black;
border-top: 2px solid black;
}
.copy-code-container::before {
content: "code block";
font-style: italic;
margin-left: 6px;
}
.copy-code {
margin-right: 6px;
}
.inline-code {
background-color: rgb(41.7051685393, 28.2759550562, 24.6948314607);
color: white;
padding: 3px 6px;
display: inline-block;
margin: 4px;
border-radius: 16px;
font-size: 1rem;
white-space: pre;
}
#delete-dialog, .lightbox-dialog {
padding: 0;
border-radius: 16px;
border: 2px solid black;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.25);
}
.delete-dialog-inner {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px;
}
.lightbox-inner {
display: flex;
flex-direction: column;
padding: 12px;
min-width: 400px;
background-color: #f27a5a;
color: black;
gap: 6px;
}
.lightbox-image {
max-width: 70vw;
max-height: 70vh;
object-fit: scale-down;
}
.lightbox-nav {
display: flex;
justify-content: space-between;
align-items: center;
}
blockquote {
padding: 6px 12px;
margin: 6px;
border-radius: 16px;
border-left: 6px solid rgb(231.56, 212.36, 207.24);
background-color: rgba(0, 0, 0, 0.1333333333);
}
.user-info {
display: grid;
grid-template-columns: 300px 1fr;
grid-template-rows: 1fr;
gap: 0;
grid-template-areas: "user-page-usercard user-page-stats";
}
.user-page-usercard {
grid-area: user-page-usercard;
padding: 12px 6px;
border: none;
background-color: #88486d;
border-right: none;
}
.user-page-stats {
grid-area: user-page-stats;
padding: 12px 16px;
border: 1px solid black;
}
.user-stats-list {
list-style: none;
margin: 0 0 6px 0;
}
.user-page-posts {
border-left: 1px solid black;
border-right: 1px solid black;
border-bottom: 1px solid black;
background-color: #f27a5a;
}
.user-page-post-preview {
max-height: 200px;
mask-image: linear-gradient(180deg, #000 60%, transparent);
}
.avatar {
width: 90%;
height: 90%;
object-fit: contain;
margin-bottom: 6px;
}
.username-link {
overflow-wrap: anywhere;
}
.user-status {
text-align: center;
}
button, input[type=submit], .linkbutton {
display: inline-block;
background-color: #f27a5a;
color: black !important;
}
button:hover, input[type=submit]:hover, .linkbutton:hover {
background-color: rgb(244.6, 148.6, 123);
}
button:active, input[type=submit]:active, .linkbutton:active {
background-color: rgb(176.4525842697, 133.7379775281, 122.3474157303);
}
button:disabled, input[type=submit]:disabled, .linkbutton:disabled {
background-color: rgb(198.02, 189.62, 187.38);
}
button.reduced, input[type=submit].reduced, .linkbutton.reduced {
margin: 0;
padding: 6px;
}
button.critical, input[type=submit].critical, .linkbutton.critical {
background-color: #f73030;
color: white !important;
}
button.critical:hover, input[type=submit].critical:hover, .linkbutton.critical:hover {
background-color: rgb(248.6, 89.4, 89.4);
}
button.critical:active, input[type=submit].critical:active, .linkbutton.critical:active {
background-color: rgb(166.6956976744, 98.8043023256, 98.8043023256);
}
button.critical:disabled, input[type=submit].critical:disabled, .linkbutton.critical:disabled {
background-color: rgb(186.715, 172.785, 172.785);
}
button.critical.reduced, input[type=submit].critical.reduced, .linkbutton.critical.reduced {
margin: 0;
padding: 6px;
}
button.warn, input[type=submit].warn, .linkbutton.warn {
background-color: #fbfb8d;
color: black !important;
}
button.warn:hover, input[type=submit].warn:hover, .linkbutton.warn:hover {
background-color: rgb(251.8, 251.8, 163.8);
}
button.warn:active, input[type=submit].warn:active, .linkbutton.warn:active {
background-color: rgb(198.3813559322, 198.3813559322, 154.4186440678);
}
button.warn:disabled, input[type=submit].warn:disabled, .linkbutton.warn:disabled {
background-color: rgb(217.55, 217.55, 209.85);
}
button.warn.reduced, input[type=submit].warn.reduced, .linkbutton.warn.reduced {
margin: 0;
padding: 6px;
}
input[type=file]::file-selector-button {
background-color: #f27a5a;
color: black !important;
margin: 6px;
}
input[type=file]::file-selector-button:hover {
background-color: rgb(244.6, 148.6, 123);
}
input[type=file]::file-selector-button:active {
background-color: rgb(176.4525842697, 133.7379775281, 122.3474157303);
}
input[type=file]::file-selector-button:disabled {
background-color: rgb(198.02, 189.62, 187.38);
}
input[type=file]::file-selector-button.reduced {
margin: 0;
padding: 6px;
}
p {
margin: 8px 0;
}
.pagebutton {
background-color: #f27a5a;
color: black !important;
padding: 3px 3px;
margin: 0;
display: inline-block;
min-width: 36px;
text-align: center;
}
.pagebutton:hover {
background-color: rgb(244.6, 148.6, 123);
}
.pagebutton:active {
background-color: rgb(176.4525842697, 133.7379775281, 122.3474157303);
}
.pagebutton:disabled {
background-color: rgb(198.02, 189.62, 187.38);
}
.pagebutton.reduced {
margin: 0;
padding: 6px;
}
.currentpage {
border: none;
padding: 3px 3px;
margin: 0;
display: inline-block;
min-width: 36px;
text-align: center;
}
.modform {
display: inline;
}
.login-container > * {
width: 60%;
margin: auto;
}
.settings-container > * {
width: 60%;
margin: auto;
}
.avatar-form {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 0;
}
input[type=text], input[type=password], textarea, select {
border: 1px solid black;
border-radius: 16px;
padding: 8px;
width: 100%;
box-sizing: border-box;
resize: vertical;
color: black;
background-color: rgb(247.2, 175.2, 156);
}
input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus {
background-color: rgb(249.8, 201.8, 189);
}
.infobox {
border: 2px solid black;
background-color: #81a3e6;
padding: 12px 8px;
color: black;
}
.infobox.critical {
background-color: #f73030;
color: white;
}
.infobox.warn {
background-color: #fbfb8d;
color: black;
}
.infobox > span {
display: flex;
align-items: center;
}
.infobox-icon-container {
min-width: 60px;
padding-right: 8px;
}
.thread {
display: grid;
grid-template-columns: 96px 1.6fr 96px;
grid-template-rows: 1fr;
gap: 0;
grid-auto-flow: row;
min-height: 96px;
grid-template-areas: "thread-sticky-container thread-info-container thread-locked-container";
}
.thread-sticky-container {
grid-area: thread-sticky-container;
border: 1px solid black;
background-color: #f27a5a;
}
.thread-locked-container {
grid-area: thread-locked-container;
border: 1px solid black;
background-color: #f27a5a;
}
.contain-svg {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.contain-svg:not(.full) > svg, .contain-svg:not(.full) > img {
height: 50%;
width: 50%;
}
.contain-svg.full > svg, .contain-svg.full > img {
height: 100%;
width: 100%;
}
.post-img-container {
display: flex;
flex-wrap: wrap;
gap: 3px;
}
.post-image {
object-fit: contain;
max-width: 400px;
max-height: 400px;
min-width: 200px;
min-height: 200px;
flex: 1 1 0%;
width: auto;
height: auto;
}
.thread-info-container {
grid-area: thread-info-container;
background-color: #f27a5a;
padding: 3px 12px;
border-top: 1px solid black;
border-bottom: 1px solid black;
display: flex;
flex-direction: column;
overflow: hidden;
max-height: 110px;
mask-image: linear-gradient(180deg, #000 60%, transparent);
}
.thread-info-post-preview {
overflow: hidden;
text-overflow: ellipsis;
display: inline;
margin-right: 25%;
}
.babycode-guide-section {
background-color: #f27a5a;
padding: 3px 12px;
border: 1px solid black;
padding-right: 25%;
}
.babycode-guide-container {
display: grid;
grid-template-columns: 1.5fr 300px;
grid-template-rows: 1fr;
gap: 0;
grid-auto-flow: row;
grid-template-areas: "guide-topics guide-toc";
}
.guide-topics {
grid-area: guide-topics;
overflow: hidden;
}
.guide-toc {
grid-area: guide-toc;
position: sticky;
top: 100px;
align-self: start;
padding: 6px;
border-bottom-right-radius: 8px;
background-color: #f27a5a;
border-right: 1px solid black;
border-top: 1px solid black;
border-bottom: 1px solid black;
}
.emoji-table tr td {
text-align: center;
}
.emoji-table tr th {
padding-left: 50px;
padding-right: 50px;
}
.emoji-table {
margin: auto;
}
.emoji-table, th, td {
border: 1px solid black;
border-collapse: collapse;
}
.colorful-table {
border-collapse: collapse;
width: 100%;
margin: 6px 0;
overflow: hidden;
}
.colorful-table tr th {
background-color: #b54444;
padding: 3px 0;
}
.colorful-table tr td {
background-color: #f27a5a;
padding: 3px 0;
text-align: center;
}
.colorful-table .small {
width: 250px;
}
.topic {
display: grid;
grid-template-columns: 1.5fr 96px;
grid-template-rows: 1fr;
gap: 0;
grid-auto-flow: row;
grid-template-areas: "topic-info-container topic-locked-container";
}
.topic-info-container {
grid-area: topic-info-container;
background-color: #f27a5a;
padding: 3px 12px;
border: 1px solid black;
display: flex;
flex-direction: column;
}
.topic-locked-container {
grid-area: topic-locked-container;
border: 1px solid black;
background-color: #f27a5a;
}
.draggable-topic {
cursor: pointer;
user-select: none;
background-color: #f27a5a;
padding: 12px;
margin: 8px 0;
border-top: 5px outset rgb(219.84, 191.04, 183.36);
border-bottom: 5px outset rgb(155.8907865169, 93.2211235955, 76.5092134831);
}
.draggable-topic.dragged {
background-color: #f27a5a;
}
.editing {
background-color: rgb(219.84, 191.04, 183.36);
}
.context-explain {
margin: 12px 0;
display: flex;
justify-content: space-evenly;
}
.post-edit-form {
display: flex;
flex-direction: column;
align-items: baseline;
height: 100%;
}
.babycode-editor {
height: 150px;
font-size: 1rem;
}
.babycode-editor-container {
width: 100%;
}
.babycode-preview-errors-container {
font-size: 0.8rem;
}
.tab-button {
background-color: #f27a5a;
color: black !important;
border-bottom: none;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
margin-bottom: 0;
}
.tab-button:hover {
background-color: rgb(244.6, 148.6, 123);
}
.tab-button:active {
background-color: rgb(176.4525842697, 133.7379775281, 122.3474157303);
}
.tab-button:disabled {
background-color: rgb(198.02, 189.62, 187.38);
}
.tab-button.reduced {
margin: 0;
padding: 6px;
}
.tab-button.active {
background-color: #b54444;
padding-top: 8px;
}
.tab-content {
display: none;
}
.tab-content.active {
min-height: 250px;
display: block;
background-color: rgb(156.1, 92.9, 92.9);
border: 1px solid black;
padding: 6px;
border-top-right-radius: 16px;
border-bottom-right-radius: 16px;
border-bottom-left-radius: 16px;
}
ul, ol {
margin: 6px 0 6px 16px;
padding: 0;
}
.new-concept-notification.hidden {
display: none;
}
.new-concept-notification {
position: fixed;
bottom: 80px;
right: 80px;
border: 1px solid black;
background-color: #81a3e6;
padding: 12px 8px;
border-radius: 16px;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.25);
}
.emoji {
max-width: 15px;
max-height: 15px;
}
.accordion {
border-top-right-radius: 16px;
border-top-left-radius: 16px;
box-sizing: border-box;
border: 1px solid black;
margin: 6px 3px;
overflow: hidden;
}
.accordion.hidden {
border-bottom: none;
}
.accordion-header {
display: flex;
align-items: center;
background-color: #c6655b;
padding: 0 6px;
gap: 6px;
border-bottom: 1px solid black;
}
.accordion-toggle {
padding: 0;
width: 36px;
height: 36px;
min-width: 36px;
min-height: 36px;
}
.accordion-title {
margin-right: auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.accordion-content {
padding: 0 8px;
}
.accordion-content.hidden {
display: none;
}
.post-accordion-content {
padding-top: 6px;
padding-bottom: 6px;
background-color: #c85d45;
}
.inbox-container {
padding: 6px;
}
.babycode-button-container {
display: flex;
gap: 3px;
flex-wrap: wrap;
}
.babycode-button {
padding: 3px 6px;
min-width: 36px;
}
.babycode-button > * {
font-size: 1rem;
}
.quote-popover {
position: absolute;
transform: translateX(-50%);
margin: 0;
border: none;
border-radius: 16px;
background-color: rgba(0, 0, 0, 0.5019607843);
padding: 3px 6px;
}
footer {
border-top: 1px solid black;
}
.reaction-button.active {
background-color: #b54444;
color: white !important;
}
.reaction-button.active:hover {
background-color: rgb(197.978313253, 103.221686747, 103.221686747);
}
.reaction-button.active:active {
background-color: rgb(127.305, 96.795, 96.795);
}
.reaction-button.active:disabled {
background-color: rgb(167.7956024096, 159.5043975904, 159.5043975904);
}
.reaction-button.active.reduced {
margin: 0;
padding: 6px;
}
.reaction-popover {
position: relative;
margin: 0;
border: none;
border-radius: 16px;
background-color: rgba(0, 0, 0, 0.5019607843);
padding: 3px 6px;
width: 250px;
}
.reaction-popover-inner {
display: flex;
flex-wrap: wrap;
overflow: scroll;
margin: auto;
justify-content: center;
}
.babycode-guide-list {
border-bottom: 1px dashed;
}
#topnav {
border-top-left-radius: 16px;
border-top-right-radius: 16px;
}
#bottomnav {
border-bottom-left-radius: 16px;
border-bottom-right-radius: 16px;
color: white;
}
textarea {
padding: 12px 16px;
}
footer {
margin-top: 10px;
border-radius: 16px;
border: none;
text-align: center;
}
.darkbg {
color: white;
}
.darkbg a {
color: white;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 B

View File

@ -42,11 +42,13 @@
const buttonBold = document.getElementById("post-editor-bold");
const buttonItalics = document.getElementById("post-editor-italics");
const buttonStrike = document.getElementById("post-editor-strike");
const buttonUnderline = document.getElementById("post-editor-underline");
const buttonUrl = document.getElementById("post-editor-url");
const buttonCode = document.getElementById("post-editor-code");
const buttonImg = document.getElementById("post-editor-img");
const buttonOl = document.getElementById("post-editor-ol");
const buttonUl = document.getElementById("post-editor-ul");
const buttonSpoiler = document.getElementById("post-editor-spoiler");
function insertTag(tagStart, newline = false, prefill = "") {
const hasAttr = tagStart[tagStart.length - 1] === "=";
@ -105,6 +107,10 @@
e.preventDefault();
insertTag("s")
})
buttonUnderline.addEventListener("click", (e) => {
e.preventDefault();
insertTag("u")
})
buttonUrl.addEventListener("click", (e) => {
e.preventDefault();
insertTag("url=", false, "link label");
@ -125,6 +131,10 @@
e.preventDefault();
insertTag("ul", true);
})
buttonSpoiler.addEventListener("click", (e) => {
e.preventDefault();
insertTag("spoiler=", true, "hidden content");
})
const previewEndpoint = "/api/babycode-preview";
let previousMarkup = "";
@ -168,5 +178,8 @@
const json_resp = await req.json();
previewContainer.innerHTML = json_resp.html;
previewErrorsContainer.textContent = "";
const accordionRefreshEvt = new CustomEvent("refresh_accordions");
document.body.dispatchEvent(accordionRefreshEvt);
});
}

View File

@ -1,7 +0,0 @@
for (let button of document.querySelectorAll(".copy-code")) {
button.addEventListener("click", async () => {
await navigator.clipboard.writeText(button.value)
button.textContent = "Copied!"
setTimeout(() => {button.textContent = "Copy"}, 1000.0)
})
}

View File

@ -62,26 +62,26 @@
setTimeout(() => {
const valid = isQuoteSelectionValid();
if (isSelecting || !valid) {
removePopover();
removeQuotePopover();
return;
}
const selection = document.getSelection();
const selectionStr = selection.toString().trim();
if (selection.isCollapsed || selectionStr === "") {
removePopover();
removeQuotePopover();
return;
}
showPopover();
showQuotePopover();
}, 50)
}
function removePopover() {
function removeQuotePopover() {
quotePopover?.hidePopover();
}
function createPopover() {
function createQuotePopover() {
quotePopover = document.createElement("div");
quotePopover.popover = "auto";
quotePopover.className = "quote-popover";
@ -95,9 +95,9 @@
return quoteButton;
}
function showPopover() {
function showQuotePopover() {
if (!quotePopover) {
const quoteButton = createPopover();
const quoteButton = createQuotePopover();
quoteButton.addEventListener("click", () => {
console.log("Quoting:", document.getSelection().toString());
const postPermalink = quotedPostContainer.dataset.postPermalink;
@ -106,12 +106,12 @@
if (ta.value.trim() !== "") {
ta.value += "\n"
}
ta.value += `[url=${postPermalink}]${authorUsername} said:[/url]\n[quote]${document.getSelection().toString()}[/quote]\n`;
ta.value += `[url=${postPermalink}]${authorUsername} said:[/url]\n[quote]< :scissors: > ${document.getSelection().toString()} < :scissors: >[/quote]\n`;
ta.scrollIntoView()
ta.focus();
document.getSelection().empty();
removePopover();
removeQuotePopover();
})
}
@ -196,4 +196,174 @@
.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."
}
}
}

View File

@ -110,26 +110,66 @@ document.addEventListener("DOMContentLoaded", () => {
});
// accordions
const accordions = document.querySelectorAll(".accordion");
accordions.forEach(accordion => {
const handledAccordions = new Set();
function attachAccordionHandlers(accordion){
if(handledAccordions.has(accordion)) {
return;
}
handledAccordions.add(accordion)
const header = accordion.querySelector(".accordion-header");
const toggleButton = header.querySelector(".accordion-toggle");
const content = accordion.querySelector(".accordion-content");
const toggle = (e) => {
e.stopPropagation();
accordion.classList.toggle("hidden");
content.classList.toggle("hidden");
toggleButton.textContent = content.classList.contains("hidden") ? "" : ""
toggleButton.textContent = content.classList.contains("hidden") ? "+" : "-"
}
toggleButton.addEventListener("click", toggle);
});
}
function refreshAccordions(){
const accordions = document.querySelectorAll(".accordion");
accordions.forEach(attachAccordionHandlers);
}
refreshAccordions()
document.body.addEventListener('refresh_accordions', refreshAccordions)
//lightboxes
lightboxObj = constructLightbox();
document.body.appendChild(lightboxObj.dialog);
const postImages = document.querySelectorAll(".post-inner img.block-img");
function setImageMaxSize(img) {
const {
maxWidth: origMaxWidth,
maxHeight: origMaxHeight,
minWidth: origMinWidth,
minHeight: origMinHeight,
} = getComputedStyle(img);
console.log(img, img.naturalWidth, img.naturalHeight, origMinWidth, origMinHeight, origMaxWidth, origMaxHeight)
if (img.naturalWidth < parseInt(origMinWidth)) {
console.log(1)
img.style.minWidth = img.naturalWidth + "px";
}
if (img.naturalHeight < parseInt(origMinHeight)) {
console.log(2)
img.style.minHeight = img.naturalHeight + "px";
}
if (img.naturalWidth < parseInt(origMaxWidth)) {
console.log(3)
img.style.maxWidth = img.naturalWidth + "px";
}
if (img.naturalHeight < parseInt(origMaxHeight)) {
console.log(4)
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) ?? [];
@ -144,4 +184,21 @@ document.addEventListener("DOMContentLoaded", () => {
openLightbox(belongingTo, idx);
});
});
const postAndSigImages = document.querySelectorAll("img.post-image");
postAndSigImages.forEach(image => {
if (image.complete) {
setImageMaxSize(image);
} else {
image.addEventListener("load", () => setImageMaxSize(image));
}
})
// copy code blocks
for (let button of document.querySelectorAll(".copy-code")) {
button.addEventListener("click", async () => {
await navigator.clipboard.writeText(button.value)
button.textContent = "Copied!"
setTimeout(() => {button.textContent = "Copy"}, 1000.0)
})
};
});

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 4L9.87868 9.87868M20 20L14.1213 14.1213M9.87868 9.87868C9.33579 10.4216 9 11.1716 9 12C9 13.6569 10.3431 15 12 15C12.8284 15 13.5784 14.6642 14.1213 14.1213M9.87868 9.87868L14.1213 14.1213M6.76821 6.76821C4.72843 8.09899 2.96378 10.026 2 11.9998C3.74646 15.5764 8.12201 19 11.9998 19C13.7376 19 15.5753 18.3124 17.2317 17.2317M9.76138 5.34717C10.5114 5.12316 11.2649 5 12.0005 5C15.8782 5 20.2531 8.42398 22 12.0002C21.448 13.1302 20.6336 14.2449 19.6554 15.2412" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<!-- https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license -->

After

Width:  |  Height:  |  Size: 814 B

1097
sass/_default.scss Normal file

File diff suppressed because it is too large Load Diff

85
sass/otomotone.scss Normal file
View File

@ -0,0 +1,85 @@
$fc: #e6e6e6;
$fci: black;
$lightish_accent: #503250;
$lightish_accent2: #502d50;
$dark_accent: #231c23;
$warn: #eaea6a;
$crit: #d53232;
@use 'default' with (
$ACCENT_COLOR: #9b649b,
$MAIN_BG: #220d16,
$DARK_1: $lightish_accent2,
$DARK_3: #302731,
$LIGHT_2: #ae6bae,
$LIGHT: $lightish_accent,
$DEFAULT_FONT_COLOR: $fc,
$DEFAULT_FONT_COLOR_INVERSE: $fci,
$BUTTON_COLOR: #3c283c,
$BUTTON_COLOR_2: #8a5584,
$BUTTON_FONT_COLOR: $fc,
$BUTTON_COLOR_WARN: $warn,
$BUTTON_WARN_FONT_COLOR: $fci,
$BUTTON_COLOR_CRITICAL: $crit,
$BUTTON_CRITICAL_FONT_COLOR: $fc,
$ACCORDION_COLOR: #7d467d,
$bottomnav_color: $dark_accent,
$topic_info_background: $dark_accent,
$topic_locked_background: $lightish_accent,
$thread_locked_background: $lightish_accent,
$thread_locked_border: 2px outset $dark_accent,
$site_title_color: white,
$topnav_color: #303030,
$quote_background_color: #fbafcf0a,
$link_color: #e87fe1,
$link_color_visited: #ed4fb1,
$post_info_background: #412841,
$post_content_background: $dark_accent,
$thread_info_background_color: $dark_accent,
$post_reactions_background: $lightish_accent,
$post_accordion_content_background: #2d212d,
$babycode_guide_toc_background: #3c233c,
$babycode_guide_section_background: $dark_accent,
$text_input_background: #371e37,
$text_input_background_focus: #514151,
$text_input_font_color: $fc,
$colorful_table_th_color: $lightish_accent,
$colorful_table_td_color: $dark_accent,
$lightbox_background: $lightish_accent,
$infobox_info_color: #775891,
$infobox_warn_color: $warn,
$infobox_warn_font_color: $fci,
$infobox_critical_color: $crit,
$tab_content_background: $lightish_accent,
$tab_button_active_color: #8a5584,
);
#topnav {
margin-bottom: 10px;
border: 10px solid rgb(40, 40, 40);
}
footer {
margin-top: 10px;
}

97
sass/peachy.scss Normal file
View File

@ -0,0 +1,97 @@
// $accent: #dd5536;
$accent: #f27a5a;
$br: 16px;
@use 'default' with (
$ACCENT_COLOR: $accent,
$thread_locked_background: $accent,
$topic_locked_background: $accent,
// $DARK_1: #e36286,
$DARK_1: #88486d,
$MAIN_BG: #c85d45,
$usercard_border: none,
$usercard_border_right: none,
$thread_locked_border: 1px solid black,
$SETTINGS_WIDTH: 60%,
$PAGE_SIDE_MARGIN: 50px,
$link_color: black,
$link_color_visited: black,
$reaction_button_active_font_color: white,
// $DEFAULT_FONT_COLOR: white,
// $DEFAULT_FONT_COLOR_INVERSE: black,
$text_input_font_color: black,
$BUTTON_COLOR: $accent,
$BUTTON_COLOR_2: #b54444,
$BUTTON_COLOR_CRITICAL: #f73030,
$ACCORDION_COLOR: #c6655b,
$BUTTON_WARN_FONT_COLOR: black,
$BUTTON_CRITICAL_FONT_COLOR: white,
$SMALL_PADDING: 3px,
$MEDIUM_PADDING: 6px,
$MEDIUM_BIG_PADDING: 8px,
$BIG_PADDING: 12px,
$BIGGER_PADDING: 16px,
$DEFAULT_BORDER_RADIUS: $br,
$code_border_radius: $br,
$button_padding: 8px 12px,
$reduced_button_padding: 6px,
$post_reactions_border_top: 2px dotted #f7bfdf,
$post_info_min_height: 35px,
$post_reactions_padding: 6px 12px,
$post_reactions_gap: 6px,
$text_input_padding: 8px,
$infobox_info_color: #81a3e6,
$infobox_critical_color: #f73030,
$infobox_warn_color: #fbfb8d,
$infobox_info_font_color: black,
$infobox_critical_font_color: white,
$infobox_warn_font_color: black,
$pagebutton_min_width: 36px,
$quote_background_color: #0002,
);
#topnav {
border-top-left-radius: $br;
border-top-right-radius: $br;
}
#bottomnav {
border-bottom-left-radius: $br;
border-bottom-right-radius: $br;
color: white;
}
textarea {
padding: 12px 16px;
}
footer {
margin-top: 10px;
border-radius: $br;
border: none;
text-align: center;
}
.darkbg {
color: white;
& a {
color: white;
}
}

View File

@ -1,763 +0,0 @@
@use "sass:color";
@font-face {
font-family: "site-title";
src: url("/static/fonts/ChicagoFLF.woff2");
}
@mixin cadman($var) {
font-family: "Cadman";
src: url("/static/fonts/Cadman_#{$var}.woff2");
}
@font-face {
@include cadman("Roman");
font-weight: normal;
font-style: normal;
}
@font-face {
@include cadman("Bold");
font-weight: bold;
font-style: normal;
}
@font-face {
@include cadman("Italic");
font-weight: normal;
font-style: italic;
}
@font-face {
@include cadman("BoldItalic");
font-weight: bold;
font-style: italic;
}
$accent_color: #c1ceb1;
$dark_bg: color.scale($accent_color, $lightness: -25%, $saturation: -97%);
$dark2: color.scale($accent_color, $lightness: -30%, $saturation: -60%);
$verydark: color.scale($accent_color, $lightness: -80%, $saturation: -70%);
$light: color.scale($accent_color, $lightness: 40%, $saturation: -60%);
$lighter: color.scale($accent_color, $lightness: 60%, $saturation: -60%);
$main_bg: color.scale($accent_color, $lightness: -10%, $saturation: -40%);
$button_color: color.adjust($accent_color, $hue: 90);
$button_color2: color.adjust($accent_color, $hue: 180);
$accordion_color: color.adjust($accent_color, $hue: 140, $lightness: -10%, $saturation: -15%);
%button-base {
cursor: default;
color: black;
font-size: 0.9em;
font-family: "Cadman";
text-decoration: none;
border: 1px solid black;
border-radius: 3px;
padding: 5px 20px;
margin: 10px 0;
}
@mixin button($color) {
@extend %button-base;
background-color: $color;
&:hover {
background-color: color.scale($color, $lightness: 20%);
}
&:active {
background-color: color.scale($color, $lightness: -10%, $saturation: -70%);
}
&:disabled {
background-color: color.scale($color, $lightness: 30%, $saturation: -90%);
}
&.reduced {
margin: 0;
padding: 5px;
}
}
@mixin navbar($color) {
padding: 10px;
display: flex;
justify-content: end;
background-color: $color;
}
body {
font-family: "Cadman";
// font-size: 18px;
margin: 20px 100px;
background-color: $main_bg;
}
.big {
font-size: 1.8rem;
}
#topnav {
@include navbar($accent_color);
justify-content: space-between;
align-items: baseline;
}
#bottomnav {
@include navbar($dark_bg);
}
.darkbg {
padding-bottom: 10px;
padding-left: 10px;
padding-right: 10px;
background-color: $dark_bg;
}
.user-actions {
display: flex;
column-gap: 15px;
}
.site-title {
font-family: "site-title";
font-size: 3rem;
margin: 0 20px;
text-decoration: none;
color: black;
}
.thread-title {
margin: 0;
font-size: 1.5rem;
font-weight: bold;
}
.post {
display: grid;
grid-template-columns: 200px 1fr;
grid-template-rows: 1fr;
gap: 0;
grid-auto-flow: row;
grid-template-areas:
"usercard post-content-container";
border: 2px outset $dark2;
}
.usercard {
grid-area: usercard;
padding: 20px 10px;
border: 4px outset $light;
background-color: $dark_bg;
border-right: solid 2px;
}
.usercard-inner {
display: flex;
flex-direction: column;
align-items: center;
top: 10px;
position: sticky;
}
.post-content-container {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 70px 2.5fr;
gap: 0px 0px;
grid-auto-flow: row;
grid-template-areas:
"post-info"
"post-content";
grid-area: post-content-container;
}
.post-info {
grid-area: post-info;
display: flex;
justify-content: space-between;
padding: 5px 20px;
align-items: center;
border-top: 1px solid black;
border-bottom: 1px solid black;
}
.post-content {
grid-area: post-content;
padding: 20px;
margin-right: 25%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.post-content.wider {
margin-right: 12.5%;
}
.post-inner {
height: 100%;
}
pre code {
display: block;
background-color: $verydark;
font-size: 1rem;
color: white;
border-bottom-right-radius: 8px;
border-bottom-left-radius: 8px;
border-left: 10px solid $lighter;
padding: 20px;
overflow: scroll;
tab-size: 4;
}
.inline-code {
background-color: $verydark;
color: white;
padding: 5px 10px;
display: inline-block;
margin: 4px;
border-radius: 4px;
font-size: 1rem;
}
#delete-dialog, .lightbox-dialog {
padding: 0;
border-radius: 4px;
border: 2px solid black;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.25);
}
.delete-dialog-inner {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
}
.lightbox-inner {
display: flex;
flex-direction: column;
padding: 20px;
min-width: 400px;
background-color: $accent_color;
gap: 10px;
}
.lightbox-image {
max-width: 70vw;
max-height: 70vh;
object-fit: scale-down;
}
.lightbox-nav {
display: flex;
justify-content: space-between;
align-items: center;
}
.copy-code-container {
position: sticky;
// width: 100%;
width: calc(100% - 4px);
display: flex;
justify-content: space-between;
align-items: last baseline;
font-family: "Cadman";
border-top-right-radius: 8px;
border-top-left-radius: 8px;
background-color: $accent_color;
border-left: 2px solid black;
border-right: 2px solid black;
border-top: 2px solid black;
&::before {
content: "code block";
font-style: italic;
margin-left: 10px;
}
}
.copy-code {
margin-right: 10px;
}
blockquote {
padding: 10px 20px;
margin: 10px;
border-radius: 4px;
border-left: 10px solid $lighter;
background-color: $dark2;
}
.user-info {
display: grid;
grid-template-columns: 300px 1fr;
grid-template-rows: 1fr;
gap: 0;
grid-template-areas:
"user-page-usercard user-page-stats";
}
.user-page-usercard {
grid-area: user-page-usercard;
padding: 20px 10px;
border: 4px outset $light;
background-color: $dark_bg;
border-right: solid 2px;
}
.user-page-stats {
grid-area: user-page-stats;
padding: 20px 30px;
border: 1px solid black;
}
.user-stats-list {
list-style: none;
margin: 0 0 10px 0;
}
.user-page-posts {
border-left: solid 1px black;
border-right: solid 1px black;
border-bottom: solid 1px black;
background-color: $accent_color;
}
.user-page-post-preview {
max-height: 200px;
mask-image: linear-gradient(180deg,#000 60%,transparent);
}
.avatar {
width: 90%;
height: 90%;
object-fit: contain;
margin-bottom: 10px;
}
.username-link {
overflow-wrap: anywhere;
}
.user-status {
text-align: center;
}
button, input[type="submit"], .linkbutton {
display: inline-block;
@include button($button_color);
&.critical {
color: white;
@include button(red);
}
&.warn {
@include button(#fbfb8d);
}
}
// not sure why this one has to be separate, but if it's included in the rule above everything breaks
input[type="file"]::file-selector-button {
@include button($button_color);
margin: 10px 10px;
}
p {
margin: 15px 0;
}
.pagebutton {
@include button($button_color);
padding: 5px 5px;
margin: 0;
display: inline-block;
min-width: 20px;
text-align: center;
}
.currentpage {
@extend %button-base;
border: none;
padding: 5px 5px;
margin: 0;
display: inline-block;
min-width: 20px;
text-align: center;
}
.modform {
display: inline;
}
.login-container > * {
width: 25%;
margin: auto;
}
.settings-container > * {
width: 40%;
margin: auto;
}
.avatar-form {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 0;
}
input[type="text"], input[type="password"], textarea, select {
border: 1px solid black;
border-radius: 3px;
padding: 7px 10px;
width: 100%;
box-sizing: border-box;
resize: vertical;
background-color: color.scale($accent_color, $lightness: 40%);
&:focus {
background-color: color.scale($accent_color, $lightness: 60%);
}
}
.infobox {
border: 2px solid black;
background-color: #81a3e6;
padding: 20px 15px;
&.critical {
background-color: rgb(237, 129, 129);
}
&.warn {
background-color: #fbfb8d;
}
}
.infobox > span {
display: flex;
align-items: center;
}
.infobox-icon-container {
min-width: 60px;
padding-right: 15px;
}
.thread {
display: grid;
grid-template-columns: 96px 1.6fr 96px;
grid-template-rows: 1fr;
gap: 0px 0px;
grid-auto-flow: row;
min-height: 96px;
grid-template-areas:
"thread-sticky-container thread-info-container thread-locked-container";
}
.thread-sticky-container {
grid-area: thread-sticky-container;
border: 2px outset $light;
}
.thread-locked-container {
grid-area: thread-locked-container;
border: 2px outset $light;
}
.contain-svg {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
&:not(.full) > svg, &:not(.full) > img {
height: 50%;
width: 50%;
}
&.full > svg, &.full > img {
height: 100%;
width: 100%;
}
}
.block-img {
object-fit: contain;
max-width: 400px;
max-height: 400px;
}
.thread-info-container {
grid-area: thread-info-container;
background-color: $accent_color;
padding: 5px 20px;
border-top: 1px solid black;
border-bottom: 1px solid black;
display: flex;
flex-direction: column;
overflow: hidden;
max-height: 110px;
mask-image: linear-gradient(180deg,#000 60%,transparent);
}
.thread-info-post-preview {
overflow: hidden;
text-overflow: ellipsis;
display: inline;
margin-right: 25%;
}
.babycode-guide-section {
background-color: $accent_color;
padding: 5px 20px;
border: 1px solid black;
padding-right: 25%;
}
.babycode-guide-container {
display: grid;
grid-template-columns: 1.5fr 300px;
grid-template-rows: 1fr;
gap: 0px 0px;
grid-auto-flow: row;
grid-template-areas:
"guide-topics guide-toc";
}
.guide-topics {
grid-area: guide-topics;
overflow: hidden;
}
.guide-toc {
grid-area: guide-toc;
position: sticky;
top: 100px;
align-self: start;
padding: 10px;
// border-top-right-radius: 16px;
border-bottom-right-radius: 8px;
background-color: $button_color;
border-right: 1px solid black;
border-top: 1px solid black;
border-bottom: 1px solid black;
}
.emoji-table tr td {
text-align: center;
}
.emoji-table tr th {
padding-left: 50px;
padding-right: 50px;
}
.emoji-table {
margin: auto;
}
.emoji-table, th, td {
border: 1px solid black;
border-collapse: collapse;
}
.topic {
display: grid;
grid-template-columns: 1.5fr 96px;
grid-template-rows: 1fr;
gap: 0px 0px;
grid-auto-flow: row;
grid-template-areas:
"topic-info-container topic-locked-container";
}
.topic-info-container {
grid-area: topic-info-container;
background-color: $accent_color;
padding: 5px 20px;
border: 1px solid black;
display: flex;
flex-direction: column;
}
.topic-locked-container {
grid-area: topic-locked-container;
border: 2px outset $light;
}
.draggable-topic {
cursor: pointer;
user-select: none;
background-color: $accent_color;
padding: 20px;
margin: 12px 0;
border-top: 6px outset $light;
border-bottom: 6px outset $dark2;
&.dragged {
background-color: $button_color;
}
}
.editing {
background-color: $light;
}
.context-explain {
margin: 20px 0;
display: flex;
justify-content: space-evenly;
}
.post-edit-form {
display: flex;
flex-direction: column;
align-items: baseline;
height: 100%;
}
.babycode-editor {
height: 150px;
font-size: 1rem;
}
.babycode-editor-container {
width: 100%;
}
.babycode-preview-errors-container {
font-size: 0.8rem;
}
.tab-button {
@include button($button_color);
border-bottom: none;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
margin-bottom: 0;
&.active {
background-color: $button_color2;
padding-top: 8px;
}
}
.tab-content {
display: none;
&.active {
min-height: 250px;
display: block;
background-color: color.adjust($button_color2, $saturation: -20%);
border: 1px solid black;
padding: 10px;
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
border-bottom-left-radius: 3px;
}
}
ul, ol {
margin: 10px 0 10px 30px;
padding: 0;
}
.new-concept-notification.hidden {
display: none;
}
.new-concept-notification {
position: fixed;
bottom: 80px;
right: 80px;
border: 2px solid black;
background-color: #81a3e6;
padding: 20px 15px;
border-radius: 4px;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.25);
}
.emoji {
max-width: 15px;
max-height: 15px;
}
.accordion {
border-top-right-radius: 3px;
border-top-left-radius: 3px;
box-sizing: border-box;
border: 1px solid black;
margin: 10px 5px;
overflow: hidden;
}
.accordion.hidden {
border-bottom: none;
}
.accordion-header {
display: flex;
align-items: center;
background-color: $accordion_color;
padding: 0 10px;
gap: 10px;
border-bottom: 1px solid black;
}
.accordion-toggle {
padding: 0;
width: 36px;
height: 36px;
min-width: 36px;
min-height: 36px;
}
.accordion-title {
margin-right: auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.accordion-content {
padding: 0 15px;
}
.accordion-content.hidden {
display: none;
}
.inbox-container {
padding: 10px;
}
.babycode-button-container {
display: flex;
gap: 10px;
}
.babycode-button {
padding: 5px 10px;
min-width: 36px;
&> * {
font-size: 1rem;
}
}
.quote-popover {
position: absolute;
transform: translateX(-50%);
margin: 0;
border: none;
border-radius: 4px;
background-color: #00000080;
padding: 5px 10px;
}