Compare commits

...

7 Commits

Author SHA1 Message Date
05cbc03e82
start porting some topics stuff 2025-06-30 16:13:23 +03:00
9126ce4f61
port babycode parser 2025-06-30 16:12:40 +03:00
fd257e701f
start user page port 2025-06-30 12:47:24 +03:00
bd56310067
sign up 2025-06-29 23:54:29 +03:00
fb2a96e94d
log in 2025-06-29 23:14:23 +03:00
dfb662c646
import static files 2025-06-29 21:11:19 +03:00
21b62a12f9
remove loose print from db 2025-06-29 21:10:47 +03:00
40 changed files with 2958 additions and 8 deletions

View File

@ -1,8 +1,12 @@
from flask import Flask
from flask import Flask, session
from dotenv import load_dotenv
from .models import Avatars, Users
from .auth import digest
from .constants import PermissionLevel
from .routes.users import is_logged_in, get_active_user
from .constants import (
PermissionLevel,
InfoboxKind, InfoboxIcons, InfoboxHTMLClass
)
import os
import time
import secrets
@ -64,6 +68,23 @@ def create_app():
create_deleted_user()
from app.routes.app import bp as app_bp
from app.routes.topics import bp as topics_bp
from app.routes.users import bp as users_bp
app.register_blueprint(app_bp)
app.register_blueprint(topics_bp)
app.register_blueprint(users_bp)
@app.context_processor
def inject_constants():
return {
"InfoboxIcons": InfoboxIcons,
"InfoboxHTMLClass": InfoboxHTMLClass,
"InfoboxKind": InfoboxKind,
}
@app.context_processor
def inject_auth():
return {"is_logged_in": is_logged_in, "get_active_user": get_active_user, "active_user": get_active_user()}
return app

View File

@ -1,4 +1,4 @@
from enum import Enum
from enum import Enum, IntEnum
class PermissionLevel(Enum):
GUEST = 0
@ -6,3 +6,23 @@ class PermissionLevel(Enum):
MODERATOR = 2
SYSTEM = 3
ADMIN = 4
class InfoboxKind(IntEnum):
INFO = 0
LOCK = 1
WARN = 2
ERROR = 3
InfoboxIcons = {
InfoboxKind.INFO: "/static/misc/info.svg",
InfoboxKind.LOCK: "/static/misc/lock.svg",
InfoboxKind.WARN: "/static/misc/warn.svg",
InfoboxKind.ERROR: "/static/misc/error.svg",
}
InfoboxHTMLClass = {
InfoboxKind.INFO: "",
InfoboxKind.LOCK: "warn",
InfoboxKind.WARN: "warn",
InfoboxKind.ERROR: "critical",
}

View File

@ -147,7 +147,6 @@ class DB:
def first(self):
sql, params = self.build_select()
print(sql, params)
return db.fetch_one(f"{sql} LIMIT 1", *params)

241
app/lib/babycode_parser.py Normal file
View File

@ -0,0 +1,241 @@
# originally written in lua by kaesa
import re
PAT_EMOTE = r"[^\s:]"
PAT_BBCODE_TAG = r"\w"
PAT_BBCODE_ATTR = r"[^\s\]]"
PAT_LINK = r"https?:\/\/[\w\-_.?:\/=&~@#%]+[\w\-\/]"
class Parser:
def __init__(self, src_str):
self.valid_bbcode_tags = []
self.valid_emotes = []
self.bbcode_tags_only_text_children = [],
self.source = src_str
self.position = 0
self.position_stack = []
def advance(self, count = 1):
self.position += count
def is_end_of_source(self, offset = 0):
return self.position + offset >= len(self.source)
def save_position(self):
self.position_stack.append(self.position)
def restore_position(self):
self.position = self.position_stack.pop()
def forget_position(self):
self.position_stack.pop()
def peek_char(self, offset = 0):
if self.is_end_of_source(offset):
return ""
return self.source[self.position + offset]
def get_char(self):
char = self.peek_char()
self.advance()
return char
def check_char(self, wanted):
char = self.peek_char()
if char == wanted:
self.advance()
return True
return False
def check_str(self, wanted):
self.save_position()
# for each char in wanted
for i in range(len(wanted)):
if not self.check_char(wanted[i]):
self.restore_position()
return False
self.forget_position()
return True
def match_pattern(self, pattern):
buf = ""
while not self.is_end_of_source():
ch = self.peek_char()
if not re.match(pattern, ch):
break
self.advance()
buf = buf + ch
return buf
def parse_emote(self):
self.save_position()
if not self.check_char(":"):
self.restore_position()
return None
name = self.match_pattern(PAT_EMOTE)
if not self.check_char(":"):
self.restore_position()
return None
if not name in self.valid_emotes:
self.restore_position()
return None
self.forget_position()
return {
"type": "emote",
"name": name
}
def parse_bbcode_open(self):
self.save_position()
if not self.check_char("["):
self.restore_position()
return None, None
name = self.match_pattern(PAT_BBCODE_TAG)
if name == "":
self.restore_position()
return None, None
attr = None
if self.check_char("="):
attr = self.match_pattern(PAT_BBCODE_ATTR)
if not self.check_char("]"):
self.restore_position()
return None, None
if not name in self.valid_bbcode_tags:
self.restore_position()
return None, None
self.forget_position()
return name, attr
def parse_bbcode(self):
self.save_position()
name, attr = self.parse_bbcode_open()
if name is None:
self.restore_position()
return None
children = []
while not self.is_end_of_source():
if self.check_str(f"[/{name}]"):
break
if name in self.bbcode_tags_only_text_children:
ch = self.get_char()
if len(children) == 0:
children.append(ch)
else:
children[1] = children[1] + ch
else:
element = self.parse_element(children)
if element is None:
self.restore_position()
return None
children.append(element)
self.forget_position()
return {
"type": "bbcode",
"name": name,
"attr": attr,
"children": children,
}
def parse_rule(self):
if not self.check_str("---"):
return None
return {
"type": "rule"
}
def parse_link(self):
self.save_position()
# extract printable chars (extreme hack edition)
word = self.match_pattern(r'[ -~]')
if not re.match(PAT_LINK, word):
self.restore_position()
return None
self.forget_position()
return {
"type": "link",
"url": word
}
def parse_element(self, siblings):
if self.is_end_of_source():
return None
element = self.parse_emote() \
or self.parse_bbcode() \
or self.parse_rule() \
or self.parse_link()
if element is None:
if len(siblings) > 0:
last = siblings[-1]
if isinstance(last, str):
siblings.pop()
return last + self.get_char()
return self.get_char()
return element
def parse(self):
elements = []
while True:
element = self.parse_element(elements)
if element is None:
break
elements.append(element)
return elements

View File

@ -1,11 +1,165 @@
from .db import Model
from .db import Model, db
from .constants import PermissionLevel
class Users(Model):
table = "users"
def is_guest(self):
return self.permission == PermissionLevel.GUEST.value
def is_mod(self):
return self.permission >= PermissionLevel.MODERATOR.value
def is_admin(self):
return self.permission == PermissionLevel.ADMIN.value
def is_system(self):
return self.permission == PermissionLevel.SYSTEM.value
def is_default_avatar(self):
return self.avatar_id == 1
def get_latest_posts(self):
q = """SELECT
posts.id, posts.created_at, post_history.content, post_history.edited_at, threads.title AS thread_title, topics.name as topic_name, threads.slug as thread_slug
FROM
posts
JOIN
post_history ON posts.current_revision_id = post_history.id
JOIN
threads ON posts.thread_id = threads.id
JOIN
topics ON threads.topic_id = topics.id
WHERE
posts.user_id = ?
ORDER BY posts.created_at DESC
LIMIT 10"""
return db.query(q, self.id)
def get_post_stats(self):
q = """SELECT
COUNT(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
FROM users
LEFT JOIN posts ON posts.user_id = users.id
LEFT JOIN threads ON threads.user_id = users.id
LEFT JOIN (
SELECT user_id, MAX(created_at) AS created_at
FROM threads
GROUP BY user_id
) latest ON latest.user_id = users.id
WHERE users.id = ?"""
return db.fetch_one(q, self.id)
class Topics(Model):
table = "topics"
@classmethod
def get_list(_cls):
q = """
SELECT
topics.id, topics.name, topics.slug, topics.description, topics.is_locked,
users.username AS latest_thread_username,
threads.title AS latest_thread_title,
threads.slug AS latest_thread_slug,
threads.created_at AS latest_thread_created_at
FROM
topics
LEFT JOIN (
SELECT
*,
row_number() OVER (PARTITION BY threads.topic_id ORDER BY threads.created_at DESC) as rn
FROM
threads
) threads ON threads.topic_id = topics.id AND threads.rn = 1
LEFT JOIN
users on users.id = threads.user_id
ORDER BY
topics.sort_order ASC"""
return db.query(q)
@classmethod
def get_active_threads(cls):
q = """
WITH ranked_threads AS (
SELECT
threads.topic_id, threads.id AS thread_id, threads.title AS thread_title, threads.slug AS thread_slug,
posts.id AS post_id, posts.created_at AS post_created_at,
users.username,
ROW_NUMBER() OVER (PARTITION BY threads.topic_id ORDER BY posts.created_at DESC) AS rn
FROM
threads
JOIN
posts ON threads.id = posts.thread_id
LEFT JOIN
users ON posts.user_id = users.id
)
SELECT
topic_id,
thread_id, thread_title, thread_slug,
post_id, post_created_at,
username
FROM
ranked_threads
WHERE
rn = 1
ORDER BY
topic_id"""
active_threads_raw = db.query(q)
active_threads = {}
for thread in active_threads_raw:
active_threads[int(thread['topic_id'])] = {
'thread_title': thread['thread_title'],
'thread_slug': thread['thread_slug'],
'post_id': thread['post_id'],
'username': thread['username'],
'post_created_at': thread['post_created_at']
}
return active_threads
def get_threads(self, per_page, page, sort_by = "activity"):
order_clause = ""
if sort_by == "thread":
order_clause = "ORDER BY threads.is_stickied DESC, threads.created_at DESC"
else:
order_clause = "ORDER BY threads.is_stickied DESC, latest_post_created_at DESC"
q = """
SELECT
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,
posts.created_at AS latest_post_created_at,
posts.id AS latest_post_id
FROM
threads
JOIN users ON users.id = threads.user_id
JOIN (
SELECT
posts.thread_id,
posts.id,
posts.user_id,
posts.created_at,
posts.current_revision_id,
ROW_NUMBER() OVER (PARTITION BY posts.thread_id ORDER BY posts.created_at DESC) AS rn
FROM
posts
) posts ON posts.thread_id = threads.id AND posts.rn = 1
JOIN
post_history ph ON ph.id = posts.current_revision_id
JOIN
users u ON u.id = posts.user_id
WHERE
threads.topic_id = ?
""" + order_clause + " LIMIT ? OFFSET ?"
return db.query(q, self.id, per_page, (page - 1) * per_page)
class Threads(Model):
table = "threads"

View File

@ -1,7 +1,7 @@
from flask import Blueprint
from flask import Blueprint, redirect, url_for
bp = Blueprint("app", __name__, url_prefix = "/")
@bp.route("/")
def hello_world():
return f"<img src='static/avatars/default.webp'></img>"
def index():
return redirect(url_for("topics.all_topics"))

7
app/routes/threads.py Normal file
View File

@ -0,0 +1,7 @@
from flask import Blueprint, render_template
bp = Blueprint("topics", __name__, url_prefix = "/threads/")
@bp.get("/<slug>")
def thread(slug):
return slug

71
app/routes/topics.py Normal file
View File

@ -0,0 +1,71 @@
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 ..constants import InfoboxKind
from slugify import slugify
import time
import math
bp = Blueprint("topics", __name__, url_prefix = "/topics/")
@bp.get("/")
def all_topics():
admin = Users.find({"id": 1})
return render_template("topics/topics.html", topic_list = Topics.get_list(), active_threads = Topics.get_active_threads())
@bp.get("/create")
@login_required
@mod_only(".all_topics")
def create():
return render_template("topics/create.html")
@bp.post("/create")
@login_required
@mod_only(".all_topics")
def create_post():
topic_name = request.form['name'].strip()
now = int(time.time())
slug = f"{slugify(topic_name)}-{now}"
topic_count = Topics.count()
topic = Topics.create({
"name": topic_name,
"description": request.form['description'],
"slug": slug,
"sort_order": topic_count + 1,
})
flash("Topic created.", InfoboxKind.INFO)
return redirect(url_for("topics.topic", slug = slug))
@bp.get("/<slug>")
def topic(slug):
THREADS_PER_PAGE = 10
target_topic = Topics.find({
"slug": slug
})
if not target_topic:
return "no"
threads_count = Threads.count({
"topic_id": target_topic.id
})
sort_by = session.get('sort_by', default="activity")
page_count = max(math.ceil(threads_count / THREADS_PER_PAGE), 1)
page = max(1, min(int(request.args.get('page', default=1)), page_count))
return render_template(
"topics/topic.html",
threads_list = target_topic.get_threads(THREADS_PER_PAGE, page, sort_by),
topic = target_topic,
current_page = page,
page_count = page_count
)

198
app/routes/users.py Normal file
View File

@ -0,0 +1,198 @@
from flask import (
Blueprint, render_template, request, redirect, url_for, flash, session, current_app
)
from functools import wraps
from ..models import Users, Sessions
from ..constants import InfoboxKind, PermissionLevel
from ..auth import digest, verify
import secrets
import time
import re
bp = Blueprint("users", __name__, url_prefix = "/users/")
def is_logged_in():
return "pyrom_session_key" in session
def get_active_user():
if not is_logged_in():
return None
sess = Sessions.find({"key": session["pyrom_session_key"]})
if not sess:
return None
return Users.find({"id": sess.user_id})
def create_session(user_id):
return Sessions.create({
"key": secrets.token_hex(16),
"user_id": user_id,
"expires_at": int(time.time()) + 30 * 24 * 60 * 60,
})
def validate_password(password):
pattern = r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,255}$'
return bool(re.fullmatch(pattern, password))
def validate_username(username):
pattern = r'^[a-zA-Z0-9_-]{3,20}$'
return bool(re.fullmatch(pattern, username))
def redirect_if_logged_in(*args, **kwargs):
def decorator(view_func):
@wraps(view_func)
def wrapper(*view_args, **view_kwargs):
if is_logged_in():
# resolve callables
processed_kwargs = {
k: v() if callable(v) else v
for k, v in kwargs.items()
}
endpoint = args[0] if args else processed_kwargs.get("endpoint")
if endpoint.startswith("."):
blueprint = current_app.blueprints.get(view_func.__name__.split(".")[0])
if blueprint:
endpoint = endpoint.lstrip(".")
return redirect(url_for(f"{blueprint.name}.{endpoint}", **processed_kwargs))
return redirect(url_for(*args, **processed_kwargs))
return view_func(*view_args, **view_kwargs)
return wrapper
return decorator
def login_required(view_func):
@wraps(view_func)
def wrapper(*args, **kwargs):
if not is_logged_in():
return redirect(url_for("users.log_in"))
return view_func(*args, **kwargs)
return wrapper
def mod_only(*args, **kwargs):
def decorator(view_func):
@wraps(view_func)
def wrapper(*view_args, **view_kwargs):
if not get_active_user().is_mod():
# resolve callables
processed_kwargs = {
k: v() if callable(v) else v
for k, v in kwargs.items()
}
endpoint = args[0] if args else processed_kwargs.get("endpoint")
if endpoint.startswith("."):
blueprint = current_app.blueprints.get(view_func.__name__.split(".")[0])
if blueprint:
endpoint = endpoint.lstrip(".")
return redirect(url_for(f"{blueprint.name}.{endpoint}", **processed_kwargs))
return redirect(url_for(*args, **processed_kwargs))
return view_func(*view_args, **view_kwargs)
return wrapper
return decorator
@bp.get("/log_in")
@redirect_if_logged_in(".page", username = lambda: get_active_user().username)
def log_in():
return render_template("users/log_in.html")
@bp.post("/log_in")
@redirect_if_logged_in(".page", username = lambda: get_active_user().username)
def log_in_post():
target_user = Users.find({
"username": request.form['username']
})
if not target_user:
flash("Incorrect username or password.", InfoboxKind.ERROR)
return redirect(url_for("users.log_in"))
if not verify(target_user.password_hash, request.form['password']):
flash("Incorrect username or password.", InfoboxKind.ERROR)
return redirect(url_for("users.log_in"))
session_obj = create_session(target_user.id)
session['pyrom_session_key'] = session_obj.key
flash("Logged in!", InfoboxKind.INFO)
return redirect(url_for("users.log_in"))
@bp.get("/sign_up")
@redirect_if_logged_in(".page", username = lambda: get_active_user().username)
def sign_up():
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():
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"))
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"))
if not validate_password(password):
flash("Invalid password.", InfoboxKind.ERROR)
return redirect(url_for("users.sign_up"))
if password != password_confirm:
flash("Passwords do not match.", InfoboxKind.ERROR)
return redirect(url_for("users.sign_up"))
hashed = digest(password)
new_user = Users.create({
"username": username,
"password_hash": hashed,
"permission": PermissionLevel.GUEST.value,
})
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"))
@bp.get("/<username>")
def page(username):
target_user = Users.find({"username": username})
return render_template("users/user.html", target_user = target_user)
@bp.get("/<username>/setings")
@login_required
def settings(username):
return "stub"
@bp.get("/<username>/inbox")
@login_required
def inbox(username):
return "stub"
@bp.get("/list")
@login_required
@mod_only(".page", username = lambda: get_active_user().username)
def user_list():
return "stub"
@bp.post("/log_out")
def log_out():
pass

28
app/templates/base.html Normal file
View File

@ -0,0 +1,28 @@
{% from 'common/infobox.html' import infobox with context %}
<!DOCTYPE HTML>
<html lang="en">
<head>
<meta charset="UTF-8">
{% if self.title() %}
<title>Porom - {% block title %}{% endblock %}</title>
{% else %}
<title>Porom</title>
{% endif %}
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
{% include 'common/topnav.html' %}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
{{ infobox(message, category) }}
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
<footer class="darkbg">
<span>Pyrom commit</span>
</footer>
<script src="/static/js/copy-code.js"></script>
<script src="/static/js/ui.js"></script>
</body>

View File

@ -0,0 +1,10 @@
{% macro infobox(message, kind=InfoboxKind.INFO) %}
<div class="{{ "infobox " + InfoboxHTMLClass[kind] }}">
<span>
<div class="infobox-icon-container">
<img src="{{ InfoboxIcons[kind] }}">
</div>
{{ message }}
</span>
</div>
{% endmacro %}

View File

@ -0,0 +1,29 @@
{% macro pager(current_page, page_count) %}
{% set left_start = (1, current_page - 5) | max %}
{% set right_end = (page_count, current_page + 5) | min %}
<div class="pager">
<span>Page:</span>
{% if current_page > 5 %}
<a href="?page=1" class="pagebutton">1</a>
{% if left_start > 2 %}
<span class="currentpage">&hellip;</span>
{% endif %}
{% endif %}
{% for i in range(left_start, current_page - 1) %}
<a href="?page={{i}}" class="pagebutton">{{i}}</a>
{% endfor %}
{% if page_count > 0 %}
<span class="currentpage">{{current_page}}</span>
{% endif %}
{% for i in range(current_page + 1, right_end) %}
<a href="?page={{i}}" class="pagebutton">{{i}}</a>
{% endfor %}
{% if right_end < page_count %}
{% if right_end < page_count - 1 %}
<span class="currentpage">&hellip;</span>
{% endif %}
<a href="?page={{page_count}}" class="pagebutton">{{page_count}}</a>
{% endif %}
</div>
{% endmacro %}

View File

@ -0,0 +1,22 @@
<nav id="topnav">
<span>
<a class="site-title" href="{{url_for('topics.all_topics')}}">Porom</a>
</span>
<span>
{% if not is_logged_in() %}
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 %}
{% with user = get_active_user() %}
Welcome, <a href="{{ url_for("users.page", username = user.username) }}">{{user.username}}</a>
&bullet;
<a href="{{ url_for("users.settings", username = user.username) }}">Settings</a>
&bullet;
<a href="{{ url_for("users.inbox", username = user.username) }}">Inbox</a>
{% if user.is_mod() %}
&bullet;
<a href="{{ url_for("users.user_list") }}">User list</a>
{% endif %}
{% endwith %}
{% endif %}
</span>
</nav>

View File

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}creating a topic{% endblock %}
{% block content %}
<div class="darkbg settings-container">
<h1>Create topic</h1>
<form method="post">
<label for=name>Name</label>
<input type="text" name="name" id="name" required><br>
<label for="description">Description</label>
<textarea id="description" name="description" required rows=5></textarea><br>
<input type="submit" value="Create topic">
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,55 @@
{% from 'common/pager.html' import pager %}
{% extends "base.html" %}
{% block title %}browsing topic {{ topic['name'] }}{% endblock %}
{% block content %}
<nav class="darkbg">
<h1 class="thread-title">All threads in "{{topic['name']}}"</h1>
<span>{{topic['description']}}</span>
<div>
{% if active_user and active_user.is_mod() %}
{% endif %}
</div>
</nav>
{% if topic['is_locked'] %}
{{ infobox("This topic is locked. Only moderators can create new threads.", InfoboxKind.INFO) }}
{% endif %}
{% if threads_list | length == 0 %}
<p>There are no threads in this topic.</p>
{% else %}
{% for thread in threads_list %}
<div class="thread">
<div class="thread-sticky-container contain-svg">
{% if thread['is_stickied'] %}
<img src="/static/misc/sticky.svg">
<i>Stickied</i>
{% endif %}
</div>
<div class="thread-info-container">
<span>
<span class="thread-title"><a href="{{ url_for("threads.thread", slug=thread['slug']) }}">{{thread['title']}}</a></span>
&bullet;
Started by <a href="{{ url_for("users.page", username=thread['started_by']) }}">{{ thread['started_by'] }}</a> on ...
</span>
<span>
Latest post by <a href="{{ url_for("users.page", username=thread['latest_post_username']) }}">{{ thread['latest_post_username'] }}</a>
on ...
</span>
<span class="thread-info-post-preview">
{{ thread['latest_post_content'] }}
</span>
</div>
<div class="thread-locked-container contain-svg">
{% if thread['is_locked'] %}
<img src="/static/misc/lock.svg">
<i>Locked</i>
{% endif %}
</div>
</div>
{% endfor %}
{% endif %}
<nav id="bottomnav">
{{ pager(current_page = current_page, page_count = page_count) }}
</nav>
{% endblock %}

View File

@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block content %}
<nav class="darkbg">
<h1 class="thread-title">All topics</h1>
{% if active_user and active_user.is_mod() %}
<a class="linkbutton" href={{ url_for("topics.create") }}>Create new topic</a>
{% endif %}
</nav>
{% if topic_list | length == 0 %}
<p>There are no topics.</p>
{% else %}
{% for topic in topic_list %}
<div class="topic">
<div class="topic-info-container">
<a class="thread-title" href="{{ url_for("topics.topic", slug=topic['slug']) }}">{{ topic['name'] }}</a>
{{ topic['description'] }}
{% if topic['latest_thread_username'] %}
<span>
Latest thread: <a href="{{ url_for("threads.thread", slug=topic['latest_thread_slug'])}}">{{topic['latest_thread_title']}}</a> by <a href="{{url_for("users.page", username=topic['latest_thread_username'])}}">{{topic['latest_thread_username']}}</a> on ...
</span>
{% 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> on ...
</span>
{% endwith %}
{% endif %}
{% else %}
<i>No threads yet.</i>
{% endif %}
</div>
<div class="topic-locked-container contain-svg">
{% if topic['is_locked'] %}
<img src="/static/misc/lock.svg"></img>
<i>Locked</i>
{% endif %}
</div>
</div>
{% endfor %}
{% endif %}
{% endblock %}

View File

@ -0,0 +1,14 @@
{% extends 'base.html' %}
{% block title %}Log in{% endblock %}
{% block content %}
<div class="darkbg login-container">
<h1>Log in</h1>
<form method="post">
<label for="username">Username</label><br>
<input type="text" id="username" name="username" required autocomplete="username"><br>
<label for="password">Password</label><br>
<input type="password" id="password" name="password" required autocomplete="current-password"><br>
<input type="submit" value="Log in">
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,17 @@
{% extends 'base.html' %}
{% block title %}Sign up{% endblock %}
{% block content %}
<div class="darkbg login-container">
<h1>Sign up</h1>
<form method="post">
<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>
<input type="password" id="password" name="password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,255}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required autocomplete="new-password"><br>
<label for="password-confirm">Confirm Password</label>
<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>
<span>After you sign up, a moderator will need to confirm your account before you will be allowed to post.</span>
</div>
{% endblock %}

View File

@ -0,0 +1,35 @@
{% extends 'base.html' %}
{% block title %}{{ target_user.username }}'s profile{% endblock %}
{% block content %}
<div class="darkbg">
<h1 class="thread-title"><i>{{ target_user.username }}</i>'s profile</h1>
{% if active_user.id == target_user.id %}
<div class="user-actions">
<a class="linkbutton" href="{{ url_for("users.settings", username = active_user.username) }}">Settings</a>
<form method="post" action="{{ url_for("users.log_out") }}">
<input class="warn" type="submit" value="Log out">
</form>
</div>
{% if active_user.is_guest() %}
<h2>You are a guest. A Moderator needs to approve your account before you will be able to post.</h2>
{% endif %}
{% endif %}
{% if active_user and active_user.is_mod() and not target_user.is_system() %}
<h1 class="thread-title">Moderation controls</h1>
{% if target_user.is_guest() %}
<p>This user is a guest. They signed up on ... </p>
{% else %}
<p>This user signed up on ... and was confirmed on ...</p>
{% endif %}
{% endif %}
</div>
<div>
{% with stats = target_user.get_post_stats() %}
<ul>
<li>Posts created: {{ stats.post_count }}</li>
<li>Threads started: {{ stats.thread_count }}</li>
<li>Latest started thread: {{ stats.latest_thread_title }}
</ul>
{% endwith %}
</div>
{% endblock %}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,159 @@
{
let ta = document.getElementById("babycode-content");
ta.addEventListener("keydown", (e) => {
if(e.key === "Enter" && e.ctrlKey) {
// console.log(e.target.form)
e.target.form?.submit();
}
})
const inThread = () => {
const scheme = window.location.pathname.split("/");
return scheme[1] === "threads" && scheme[2] !== "create";
}
ta.addEventListener("input", () => {
if (!inThread()) return;
localStorage.setItem(window.location.pathname, ta.value);
})
document.addEventListener("DOMContentLoaded", () => {
if (!inThread()) return;
const prevContent = localStorage.getItem(window.location.pathname);
if (!prevContent) return;
ta.value = prevContent;
})
const buttonBold = document.getElementById("post-editor-bold");
const buttonItalics = document.getElementById("post-editor-italics");
const buttonStrike = document.getElementById("post-editor-strike");
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");
function insertTag(tagStart, newline = false, prefill = "") {
const hasAttr = tagStart[tagStart.length - 1] === "=";
let tagEnd = tagStart;
let tagInsertStart = `[${tagStart}]${newline ? "\n" : ""}`;
if (hasAttr) {
tagEnd = tagEnd.slice(0, -1);
}
const tagInsertEnd = `${newline ? "\n" : ""}[/${tagEnd}]`;
const hasSelection = ta.selectionStart !== ta.selectionEnd;
const text = ta.value;
if (hasSelection) {
const realStart = Math.min(ta.selectionStart, ta.selectionEnd);
const realEnd = Math.max(ta.selectionStart, ta.selectionEnd);
const selectionLength = realEnd - realStart;
const strStart = text.slice(0, realStart);
const strEnd = text.substring(realEnd);
const frag = `${tagInsertStart}${text.slice(realStart, realEnd)}${tagInsertEnd}`;
const reconst = `${strStart}${frag}${strEnd}`;
ta.value = reconst;
if (!hasAttr){
ta.setSelectionRange(realStart + tagInsertStart.length, realStart + tagInsertStart.length + selectionLength);
} else {
ta.setSelectionRange(realStart + tagInsertEnd.length - 1, realStart + tagInsertEnd.length - 1); // cursor on attr
}
ta.focus()
} else {
if (hasAttr) {
tagInsertStart += prefill;
}
const cursor = ta.selectionStart;
const strStart = text.slice(0, cursor);
const strEnd = text.substr(cursor);
let newCursor = strStart.length + tagInsertStart.length;
if (hasAttr) {
newCursor = cursor + tagInsertStart.length - prefill.length - 1;
}
const reconst = `${strStart}${tagInsertStart}${tagInsertEnd}${strEnd}`;
ta.value = reconst;
ta.setSelectionRange(newCursor, newCursor);
ta.focus()
}
}
buttonBold.addEventListener("click", (e) => {
e.preventDefault();
insertTag("b")
})
buttonItalics.addEventListener("click", (e) => {
e.preventDefault();
insertTag("i")
})
buttonStrike.addEventListener("click", (e) => {
e.preventDefault();
insertTag("s")
})
buttonUrl.addEventListener("click", (e) => {
e.preventDefault();
insertTag("url=", false, "link label");
})
buttonCode.addEventListener("click", (e) => {
e.preventDefault();
insertTag("code", true)
})
buttonImg.addEventListener("click", (e) => {
e.preventDefault();
insertTag("img=", false, "alt text");
})
buttonOl.addEventListener("click", (e) => {
e.preventDefault();
insertTag("ol", true);
})
buttonUl.addEventListener("click", (e) => {
e.preventDefault();
insertTag("ul", true);
})
const previewEndpoint = "/api/babycode-preview";
let previousMarkup = "";
const previewTab = document.getElementById("tab-preview");
previewTab.addEventListener("tab-activated", async () => {
const previewContainer = document.getElementById("babycode-preview-container");
const previewErrorsContainer = document.getElementById("babycode-preview-errors-container");
// previewErrorsContainer.textContent = "";
const markup = ta.value.trim();
if (markup === "" || markup === previousMarkup) {
return;
}
previousMarkup = markup;
const req = await fetch(previewEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({markup: markup})
})
if (!req.ok) {
switch (req.status) {
case 429:
previewErrorsContainer.textContent = "(Old preview, try again in a few seconds.)"
previousMarkup = "";
break;
case 400:
previewErrorsContainer.textContent = "(Request got malformed.)"
break;
case 401:
previewErrorsContainer.textContent = "(You are not logged in.)"
break;
default:
previewErrorsContainer.textContent = "(Error. Check console.)"
console.error(req.error);
break;
}
return;
}
const json_resp = await req.json();
previewContainer.innerHTML = json_resp.html;
previewErrorsContainer.textContent = "";
});
}

View File

@ -0,0 +1,7 @@
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,10 @@
document.addEventListener("DOMContentLoaded", () => {
const timestampSpans = document.getElementsByClassName("timestamp");
for (let timestampSpan of timestampSpans) {
const timestamp = parseInt(timestampSpan.dataset.utc);
if (!isNaN(timestamp)) {
const date = new Date(timestamp * 1000);
timestampSpan.textContent = date.toLocaleString();
}
}
})

View File

@ -0,0 +1,45 @@
// https://codepen.io/crouchingtigerhiddenadam/pen/qKXgap
let selected = null;
let container = document.getElementById("topics-container")
function isBefore(el1, el2) {
let cur
if (el2.parentNode === el1.parentNode) {
for (cur = el1.previousSibling; cur; cur = cur.previousSibling) {
if (cur === el2) return true
}
}
return false;
}
function dragOver(e) {
let target = e.target.closest(".draggable-topic")
if (!target || target === selected) {
return;
}
if (isBefore(selected, target)) {
container.insertBefore(selected, target)
} else {
container.insertBefore(selected, target.nextSibling)
}
}
function dragEnd() {
if (!selected) return;
selected.classList.remove("dragged")
selected = null;
for (let i = 0; i < container.childElementCount - 1; i++) {
let input = container.children[i].querySelector(".topic-input");
input.value = i + 1;
}
}
function dragStart(e) {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', null)
selected = e.target
selected.classList.add("dragged")
}

80
data/static/js/thread.js Normal file
View File

@ -0,0 +1,80 @@
{
const ta = document.getElementById("babycode-content");
for (let button of document.querySelectorAll(".reply-button")) {
button.addEventListener("click", (e) => {
ta.value += button.value;
ta.scrollIntoView()
ta.focus();
})
}
const deleteDialog = document.getElementById("delete-dialog");
const deleteDialogCloseButton = document.getElementById("post-delete-dialog-close");
let deletionTargetPostContainer;
function closeDeleteDialog() {
deletionTargetPostContainer.style.removeProperty("background-color");
deleteDialog.close();
}
deleteDialogCloseButton.addEventListener("click", (e) => {
closeDeleteDialog();
})
deleteDialog.addEventListener("click", (e) => {
if (e.target === deleteDialog) {
closeDeleteDialog();
}
})
for (let button of document.querySelectorAll(".post-delete-button")) {
button.addEventListener("click", (e) => {
deleteDialog.showModal();
const postId = button.value;
deletionTargetPostContainer = document.getElementById("post-" + postId).querySelector(".post-content-container");
deletionTargetPostContainer.style.setProperty("background-color", "#fff");
const form = document.getElementById("post-delete-form");
form.action = `/post/${postId}/delete`
})
}
const threadEndpoint = document.getElementById("thread-subscribe-endpoint").value;
let now = Math.floor(new Date() / 1000);
function hideNotification() {
const notification = document.getElementById('new-post-notification');
notification.classList.add('hidden');
}
function showNewPostNotification(url) {
const notification = document.getElementById("new-post-notification");
notification.classList.remove("hidden");
document.getElementById("dismiss-new-post-button").onclick = () => {
now = Math.floor(new Date() / 1000);
hideNotification();
tryFetchUpdate();
}
document.getElementById("go-to-new-post-button").href = url;
document.getElementById("unsub-new-post-button").onclick = () => {
hideNotification();
}
}
function tryFetchUpdate() {
if (!threadEndpoint) return;
const body = JSON.stringify({since: now});
fetch(threadEndpoint, {method: "POST", headers: {"Content-Type": "application/json"}, body: body})
.then(res => res.json())
.then(json => {
if (json.status === "none") {
setTimeout(tryFetchUpdate, 5000);
} else if (json.status === "new_post") {
showNewPostNotification(json.url);
}
})
.catch(error => console.log(error))
}
tryFetchUpdate();
}

16
data/static/js/topic.js Normal file
View File

@ -0,0 +1,16 @@
{
const deleteDialog = document.getElementById("delete-dialog");
const deleteDialogOpenButton = document.getElementById("topic-delete-dialog-open");
deleteDialogOpenButton.addEventListener("click", (e) => {
deleteDialog.showModal();
});
const deleteDialogCloseButton = document.getElementById("topic-delete-dialog-close");
deleteDialogCloseButton.addEventListener("click", (e) => {
deleteDialog.close();
})
deleteDialog.addEventListener("click", (e) => {
if (e.target === deleteDialog) {
deleteDialog.close();
}
})
}

147
data/static/js/ui.js Normal file
View File

@ -0,0 +1,147 @@
function activateSelfDeactivateSibs(button) {
if (button.classList.contains("active")) return;
Array.from(button.parentNode.children).forEach(s => {
if (s === button){
button.classList.add('active');
} else {
s.classList.remove('active');
}
const targetId = s.dataset.targetId;
const target = document.getElementById(targetId);
if (!target) return;
if (s.classList.contains('active')) {
target.classList.add('active');
target.dispatchEvent(new CustomEvent("tab-activated", {bubbles: false}))
} else {
target.classList.remove('active');
}
});
}
function openLightbox(post, idx) {
lightboxCurrentPost = post;
lightboxCurrentIdx = idx;
lightboxObj.img.src = lightboxImages.get(post)[idx].src;
lightboxObj.openOriginalAnchor.href = lightboxImages.get(post)[idx].src
lightboxObj.prevButton.disabled = lightboxImages.get(post).length === 1
lightboxObj.nextButton.disabled = lightboxImages.get(post).length === 1
lightboxObj.imageCount.textContent = `Image ${idx + 1} of ${lightboxImages.get(post).length}`
if (!lightboxObj.dialog.open) {
lightboxObj.dialog.showModal();
}
}
const modulo = (n, m) => ((n % m) + m) % m
function lightboxNext() {
const l = lightboxImages.get(lightboxCurrentPost).length;
const target = modulo(lightboxCurrentIdx + 1, l);
openLightbox(lightboxCurrentPost, target);
}
function lightboxPrev() {
const l = lightboxImages.get(lightboxCurrentPost).length;
const target = modulo(lightboxCurrentIdx - 1, l);
openLightbox(lightboxCurrentPost, target);
}
function constructLightbox() {
const dialog = document.createElement("dialog");
dialog.classList.add("lightbox-dialog");
dialog.addEventListener("click", (e) => {
if (e.target === dialog) {
dialog.close();
}
})
const dialogInner = document.createElement("div");
dialogInner.classList.add("lightbox-inner");
dialog.appendChild(dialogInner);
const img = document.createElement("img");
img.classList.add("lightbox-image")
dialogInner.appendChild(img);
const openOriginalAnchor = document.createElement("a")
openOriginalAnchor.text = "Open original in new window"
openOriginalAnchor.target = "_blank"
openOriginalAnchor.rel = "noopener noreferrer nofollow"
dialogInner.appendChild(openOriginalAnchor);
const navSpan = document.createElement("span");
navSpan.classList.add("lightbox-nav");
const prevButton = document.createElement("button");
prevButton.type = "button";
prevButton.textContent = "Previous";
prevButton.addEventListener("click", lightboxPrev);
const nextButton = document.createElement("button");
nextButton.type = "button";
nextButton.textContent = "Next";
nextButton.addEventListener("click", lightboxNext);
const imageCount = document.createElement("span");
imageCount.textContent = "Image of ";
navSpan.appendChild(prevButton);
navSpan.appendChild(imageCount);
navSpan.appendChild(nextButton);
dialogInner.appendChild(navSpan);
return {
img: img,
dialog: dialog,
openOriginalAnchor: openOriginalAnchor,
prevButton: prevButton,
nextButton: nextButton,
imageCount: imageCount,
}
}
let lightboxImages = new Map(); //.post-inner : Array<Object>
let lightboxObj = null;
let lightboxCurrentPost = null;
let lightboxCurrentIdx = -1;
document.addEventListener("DOMContentLoaded", () => {
// tabs
document.querySelectorAll(".tab-button").forEach(button => {
button.addEventListener("click", () => {
activateSelfDeactivateSibs(button);
});
});
// accordions
const accordions = document.querySelectorAll(".accordion");
accordions.forEach(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.addEventListener("click", toggle);
});
//lightboxes
lightboxObj = constructLightbox();
document.body.appendChild(lightboxObj.dialog);
const postImages = document.querySelectorAll(".post-inner img.block-img");
postImages.forEach(postImage => {
const belongingTo = postImage.closest(".post-inner");
const images = lightboxImages.get(belongingTo) ?? [];
images.push({
src: postImage.src,
alt: postImage.alt,
});
const idx = images.length - 1;
lightboxImages.set(belongingTo, images);
postImage.style.cursor = "pointer";
postImage.addEventListener("click", () => {
openLightbox(belongingTo, idx);
});
});
});

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="60px" height="60px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.364 5.63604C19.9926 7.26472 21 9.51472 21 12C21 16.9706 16.9706 21 12 21C9.51472 21 7.26472 19.9926 5.63604 18.364M18.364 5.63604C16.7353 4.00736 14.4853 3 12 3C7.02944 3 3 7.02944 3 12C3 14.4853 4.00736 16.7353 5.63604 18.364M18.364 5.63604L5.63604 18.364" 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: 609 B

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 17L7.58959 13.7694C8.38025 13.0578 9.58958 13.0896 10.3417 13.8417L11.5 15L15.0858 11.4142C15.8668 10.6332 17.1332 10.6332 17.9142 11.4142L20 13.5M11 9C11 9.55228 10.5523 10 10 10C9.44772 10 9 9.55228 9 9C9 8.44772 9.44772 8 10 8C10.5523 8 11 8.44772 11 9ZM6 20H18C19.1046 20 20 19.1046 20 18V6C20 4.89543 19.1046 4 18 4H6C4.89543 4 4 4.89543 4 6V18C4 19.1046 4.89543 20 6 20Z" 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: 728 B

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="60px" height="60px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 8V8.5M12 12V16M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21Z" 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: 480 B

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="60px" height="60px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 14V16M8 9V6C8 3.79086 9.79086 2 12 2C14.2091 2 16 3.79086 16 6V9M7 21H17C18.1046 21 19 20.1046 19 19V11C19 9.89543 18.1046 9 17 9H7C5.89543 9 5 9.89543 5 11V19C5 20.1046 5.89543 21 7 21Z" 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: 539 B

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="M13 20H6C4.89543 20 4 19.1046 4 18V6C4 4.89543 4.89543 4 6 4H18C19.1046 4 20 4.89543 20 6V13M13 20L20 13M13 20V14C13 13.4477 13.4477 13 14 13H20" 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: 498 B

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="60px" height="60px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 15H12.01M12 12V9M4.98207 19H19.0179C20.5615 19 21.5233 17.3256 20.7455 15.9923L13.7276 3.96153C12.9558 2.63852 11.0442 2.63852 10.2724 3.96153L3.25452 15.9923C2.47675 17.3256 3.43849 19 4.98207 19Z" 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: 550 B

735
data/static/style.css Normal file
View File

@ -0,0 +1,735 @@
@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;
}
.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;
padding: 5px 20px;
margin: 10px 0;
}
body {
font-family: "Cadman";
margin: 20px 100px;
background-color: rgb(173.5214173228, 183.6737007874, 161.0262992126);
}
.big {
font-size: 1.8rem;
}
#topnav {
padding: 10px;
display: flex;
justify-content: end;
background-color: #c1ceb1;
justify-content: space-between;
align-items: baseline;
}
#bottomnav {
padding: 10px;
display: flex;
justify-content: end;
background-color: rgb(143.7039271654, 144.3879625984, 142.8620374016);
}
.darkbg {
padding-bottom: 10px;
padding-left: 10px;
padding-right: 10px;
background-color: rgb(143.7039271654, 144.3879625984, 142.8620374016);
}
.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 rgb(135.1928346457, 145.0974015748, 123.0025984252);
}
.usercard {
grid-area: usercard;
padding: 20px 10px;
border: 4px outset rgb(217.26, 220.38, 213.42);
background-color: rgb(143.7039271654, 144.3879625984, 142.8620374016);
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: rgb(38.5714173228, 40.9237007874, 35.6762992126);
font-size: 1rem;
color: white;
border-bottom-right-radius: 8px;
border-bottom-left-radius: 8px;
border-left: 10px solid rgb(229.84, 231.92, 227.28);
padding: 20px;
overflow: scroll;
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);
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: #c1ceb1;
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;
}
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);
}
.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 rgb(217.26, 220.38, 213.42);
background-color: rgb(143.7039271654, 144.3879625984, 142.8620374016);
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: #c1ceb1;
}
.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: rgb(177, 206, 204.5);
}
button:hover, input[type=submit]:hover, .linkbutton:hover {
background-color: rgb(192.6, 215.8, 214.6);
}
button:active, input[type=submit]:active, .linkbutton:active {
background-color: rgb(166.6881496063, 178.0118503937, 177.4261417323);
}
button:disabled, input[type=submit]:disabled, .linkbutton:disabled {
background-color: rgb(209.535, 211.565, 211.46);
}
button.critical, input[type=submit].critical, .linkbutton.critical {
color: white;
background-color: red;
}
button.critical:hover, input[type=submit].critical:hover, .linkbutton.critical:hover {
background-color: #ff3333;
}
button.critical:active, input[type=submit].critical:active, .linkbutton.critical:active {
background-color: rgb(149.175, 80.325, 80.325);
}
button.critical:disabled, input[type=submit].critical:disabled, .linkbutton.critical:disabled {
background-color: rgb(174.675, 156.825, 156.825);
}
button.warn, input[type=submit].warn, .linkbutton.warn {
background-color: #fbfb8d;
}
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);
}
input[type=file]::file-selector-button {
background-color: rgb(177, 206, 204.5);
margin: 10px 10px;
}
input[type=file]::file-selector-button:hover {
background-color: rgb(192.6, 215.8, 214.6);
}
input[type=file]::file-selector-button:active {
background-color: rgb(166.6881496063, 178.0118503937, 177.4261417323);
}
input[type=file]::file-selector-button:disabled {
background-color: rgb(209.535, 211.565, 211.46);
}
p {
margin: 15px 0;
}
.pagebutton {
background-color: rgb(177, 206, 204.5);
padding: 5px 5px;
margin: 0;
display: inline-block;
min-width: 20px;
text-align: center;
}
.pagebutton:hover {
background-color: rgb(192.6, 215.8, 214.6);
}
.pagebutton:active {
background-color: rgb(166.6881496063, 178.0118503937, 177.4261417323);
}
.pagebutton:disabled {
background-color: rgb(209.535, 211.565, 211.46);
}
.currentpage {
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: rgb(217.8, 225.6, 208.2);
}
input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus {
background-color: rgb(230.2, 235.4, 223.8);
}
.infobox {
border: 2px solid black;
background-color: #81a3e6;
padding: 20px 15px;
}
.infobox.critical {
background-color: rgb(237, 129, 129);
}
.infobox.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 rgb(217.26, 220.38, 213.42);
}
.thread-locked-container {
grid-area: thread-locked-container;
border: 2px outset rgb(217.26, 220.38, 213.42);
}
.contain-svg {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.contain-svg:not(.full) > svg, .contain-svg img {
height: 50%;
width: 50%;
}
.block-img {
object-fit: contain;
max-width: 400px;
max-height: 400px;
}
.thread-info-container {
grid-area: thread-info-container;
background-color: #c1ceb1;
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: #c1ceb1;
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-bottom-right-radius: 8px;
background-color: rgb(177, 206, 204.5);
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 64px;
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: #c1ceb1;
padding: 5px 20px;
border: 1px solid black;
display: flex;
flex-direction: column;
}
.topic-locked-container {
grid-area: topic-locked-container;
border: 2px outset rgb(217.26, 220.38, 213.42);
}
.draggable-topic {
cursor: pointer;
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);
}
.draggable-topic.dragged {
background-color: rgb(177, 206, 204.5);
}
.editing {
background-color: rgb(217.26, 220.38, 213.42);
}
.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: rgb(177, 206, 204.5);
border-bottom: none;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
margin-bottom: 0;
}
.tab-button:hover {
background-color: rgb(192.6, 215.8, 214.6);
}
.tab-button:active {
background-color: rgb(166.6881496063, 178.0118503937, 177.4261417323);
}
.tab-button:disabled {
background-color: rgb(209.535, 211.565, 211.46);
}
.tab-button.active {
background-color: #beb1ce;
padding-top: 8px;
}
.tab-content {
display: none;
}
.tab-content.active {
min-height: 250px;
display: block;
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;
}
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: rgb(159.0271653543, 162.0727712915, 172.9728346457);
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;
}
.babycode-button > * {
font-size: 1rem;
}

View File

@ -2,3 +2,4 @@ flask
argon2-cffi
wand
dotenv
python-slugify

744
sass/style.scss Normal file
View File

@ -0,0 +1,744 @@
@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%);
}
}
@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, img {
height: 50%;
width: 50%;
}
}
.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 64px;
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;
}
}