Compare commits

...

26 Commits

Author SHA1 Message Date
40219f2b54 clean stale sessions 2025-12-20 20:11:44 +03:00
4a45b62521 prevent admin from deleting their account 2025-12-20 19:05:01 +03:00
fc55aaf87a improve readme further 2025-12-20 18:52:15 +03:00
db68ef2c3d remove cache test endpoint 2025-12-20 18:42:52 +03:00
a808137e5b touch up the instructions and config example 2025-12-20 17:08:04 +03:00
a93a89f0df acknowledge Flask-Caching in THIRDPARTY.md 2025-12-20 17:04:44 +03:00
7aa3a9382e rss mention fix attempt; SITE_NAME is now required 2025-12-20 16:05:23 +03:00
46704df7d9 new sortable list implementation 2025-12-19 19:31:12 +03:00
98bf430604 fix inbox: show badges in inbox 2025-12-19 18:00:41 +03:00
21ace9299f make guide section more mobile friendly on portrait 2025-12-15 19:59:57 +03:00
122b706350 remove spaces from babycode html emoji, clean up js 2025-12-15 19:56:54 +03:00
c655caab9e use template element for badge template 2025-12-15 19:34:16 +03:00
b2d16e305d add bisexual pride badge 2025-12-15 19:25:14 +03:00
a8398cad51 make sure uwsgi is utf 8 2025-12-15 14:16:23 +03:00
f27d8eaf7e even more style changes 2025-12-15 05:40:17 +03:00
36e17c6677 clean up newlines in babycode.py 2025-12-14 08:40:38 +03:00
d7a90745f6 correct invalid void tags 2025-12-14 08:35:21 +03:00
d90b4643cb reorganize settings a bit 2025-12-14 08:16:54 +03:00
d82f25471d port to bitty 7.0.0 2025-12-14 08:16:28 +03:00
791911b416 change min settings column width to 600px 2025-12-14 08:05:14 +03:00
ba2c9132f6 and some styles 2025-12-14 07:19:49 +03:00
d4e3d7cded draw the rest of the owl 2025-12-14 07:16:10 +03:00
0898c56a51 add rss content to post history and generate it when creating or editing a post 2025-12-14 07:05:52 +03:00
96c37f9081 add flask-cache dep 2025-12-13 23:31:50 +03:00
94a4be8b97 remove erroneous dumps method from BabycodeRenderResult 2025-12-13 09:17:16 +03:00
fa1140895a use no fragment in some places in babycode guide 2025-12-13 07:37:54 +03:00
49 changed files with 1332 additions and 633 deletions

View File

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

1
.gitignore vendored
View File

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

View File

@@ -1,18 +1,70 @@
# Pyrom
python/flask port of [porom](https://git.poto.cafe/yagich/porom)
pyrom is a playful home-grown forum software for the indie web borne out of frustration with social media and modern forums imitating it.
this is now the canonical implementation of porom. it's compatible with the database of porom.
the aim is not to recreate the feeling of forums from any time period. rather, it aims to serve as a lightweight alternative to other forum software packages. pyrom is lean and "fire-and-forget"; there is little necessary configuration, making it a great fit for smaller communities (though nothing prevents it from being used in larger ones.)
# License
Released under [CNPLv7+](https://thufie.lain.haus/NPL.html).
Please read the [full terms](./LICENSE.md) for proper wording.
a live example can be seen in action over at [Porom](https://forum.poto.cafe/).
## stack & structure
on the server side, pyrom is built in Python using the Flask framework. content is rendered mostly server-side with Jinja templates. the database used is SQLite.
on the client side, JS with only one library ([Bitty](https://bitty-js.com)) is used. for CSS, pyrom uses Sass.
below is an explanation of the folder structure:
- `/`
- `app/`
- `lib/` - utility libraries
- `routes/` - each `.py` file represents a "sub-app", usually the first part of the URL
- `templates/` - Jinja templates used by the routes. each subfolder corresponds to the "sub-app" that uses that template.
- `__init__.py` - creates the app
- `auth.py` - authentication helper
- `constants.py` - constant values used throughout the forum
- `db.py` - database abstraction layer and ORM library
- `migrations.py` - database migrations
- `models.py` - ORM model definitions
- `run.py` - runner script for development
- `schema.py` - database schema definition
- `config/` - configuration for the forum
- `data/`
- `_cached/` - cached versions of certain endpoints are stored here
- `db/` - the SQLite database is stored here
- `static/` - static files
- `avatars/` - user avatar uploads
- `badges/` - user badge uploads
- `css/` - CSS files generated from Sass sources
- `emoji/` - emoji images used on the forum
- `fonts/`
- `js/`
- `sass/`
- `_default.scss` - the default theme. Sass variables that other themes modify are defined here, along with the default styles. other files define the available themes.
- `build-themes.sh` - script for building Sass files into CSS
- `nginx.conf` - nginx config (production only)
- `uwsgi.ini` - uwsgi config (production only)
# license
released under [CNPLv7+](https://thufie.lain.haus/NPL.html).
please read the [full terms](./LICENSE.md) for proper wording.
# acknowledgments
pyrom uses many open-source and otherwise free-culture components. see the [THIRDPARTY](./THIRDPARTY.md) file for full credit.
# installing & first time setup
## docker (production)
create `config/secrets.prod.env` according to `config/secrets.prod.env.example`
1. clone the repo
2. create `config/secrets.prod.env` according to `config/secrets.prod.env.example`
3. create `config/pyrom_config.toml` according to `config/pyrom_config.toml.example` and modify as needed
4. make sure the `data/` folder is writable by the app:
```bash
$ docker compose up
$ chmod -R 777 data/
```
5. bring up the container:
```bash
$ docker compose up --build
```
- opens port 8080
@@ -20,10 +72,10 @@ $ docker compose up
make sure to run it in an interactive session the first time, because it will spit out the password to the auto-created admin account.
alternatively, if you already had porom running before, put the db file (`db.prod.sqlite`) in `data/db` and it will Just Work.
6. point your favorite proxy at `localhost:8080`
## manual (development)
1. install python >= 3.11, sqlite3, libargon2, and imagemagick & clone repo
1. install python >= 3.13, sqlite3, libargon2, and imagemagick & clone repo
2. create a venv:
```bash
@@ -59,6 +111,3 @@ $ source .venv/bin/activate
$ python -m app.run
```
# acknowledgments
pyrom uses many open-source and otherwise free-culture components. see the [THIRDPARTY](./THIRDPARTY.md) file for full credit.

View File

@@ -80,8 +80,23 @@ Repo: https://github.com/emcconville/wand
## Bitty
Affected files: [`data/static/js/vnd/bitty-6.0.0-rc3.min.js`](./data/static/js/vnd/bitty-6.0.0-rc3.min.js)
URL: https://bitty.alanwsmith.com/
Affected files: [`data/static/js/vnd/bitty-7.0.0.js`](./data/static/js/vnd/bitty-7.0.0.js)
URL: https://bitty-js.com/
License: CC0 1.0
Author: alan w smith https://www.alanwsmith.com/
Repo: https://github.com/alanwsmith/bitty
## Flask-Caching
URL: https://flask-caching.readthedocs.io/
Copyright:
```
Copyright (c) 2010 by Thadeus Burgess.
Copyright (c) 2016 by Peter Justin.
Some rights reserved.
```
License: BSD-3-Clause ([see more](https://github.com/pallets-eco/flask-caching/blob/e59bc040cd47cd2b43e501d636d43d442c50b3ff/LICENSE))
Repo: https://github.com/pallets-eco/flask-caching

View File

@@ -1,17 +1,18 @@
from flask import Flask, session, request, render_template
from dotenv import load_dotenv
from .models import Avatars, Users, PostHistory, Posts, MOTD, BadgeUploads
from .models import Avatars, Users, PostHistory, Posts, MOTD, BadgeUploads, Sessions
from .auth import digest
from .routes.users import is_logged_in, get_active_user, get_prefers_theme
from .routes.threads import get_post_url
from .constants import (
PermissionLevel, permission_level_string,
InfoboxKind, InfoboxHTMLClass,
REACTION_EMOJI, MOTD_BANNED_TAGS,
SIG_BANNED_TAGS, STRICT_BANNED_TAGS,
)
from .lib.babycode import babycode_to_html, EMOJI, BABYCODE_VERSION
from datetime import datetime
from .lib.babycode import babycode_to_html, babycode_to_rssxml, EMOJI, BABYCODE_VERSION
from .lib.exceptions import SiteNameMissingException
from datetime import datetime, timezone
from flask_caching import Cache
import os
import time
import secrets
@@ -55,6 +56,18 @@ def reparse_babycode():
print('Re-parsing babycode, this may take a while...')
from .db import db
from .constants import MOTD_BANNED_TAGS
post_histories_without_rss = PostHistory.findall([
('markup_language', '=', 'babycode'),
('content_rss', 'IS', None),
])
with db.transaction():
for ph in post_histories_without_rss:
ph.update({
'content_rss': babycode_to_rssxml(ph['original_markup']),
})
post_histories = PostHistory.findall([
('markup_language', '=', 'babycode'),
('format_version', 'IS NOT', BABYCODE_VERSION)
@@ -65,6 +78,7 @@ def reparse_babycode():
for ph in post_histories:
ph.update({
'content': babycode_to_html(ph['original_markup']).result,
'content_rss': babycode_to_rssxml(ph['original_markup']),
'format_version': BABYCODE_VERSION,
})
print('Re-parsing posts done.')
@@ -124,6 +138,18 @@ def bind_default_badges(path):
'uploaded_at': int(os.path.getmtime(real_path)),
})
def clear_stale_sessions():
from .db import db
with db.transaction():
now = int(time.time())
stale_sessions = Sessions.findall([
('expires_at', '<', now)
])
for sess in stale_sessions:
sess.delete()
cache = Cache()
def create_app():
app = Flask(__name__)
@@ -133,6 +159,10 @@ def create_app():
app.config['USERS_CAN_INVITE'] = False
app.config['ADMIN_CONTACT_INFO'] = ''
app.config['GUIDE_DESCRIPTION'] = ''
app.config['CACHE_TYPE'] = 'FileSystemCache'
app.config['CACHE_DEFAULT_TIMEOUT'] = 300
try:
app.config.from_file('../config/pyrom_config.toml', load=tomllib.load, text=False)
except FileNotFoundError:
@@ -142,9 +172,12 @@ def create_app():
app.static_folder = os.path.join(os.path.dirname(__file__), "../data/static")
app.debug = True
app.config["DB_PATH"] = "data/db/db.dev.sqlite"
app.config["SERVER_NAME"] = "localhost:8080"
load_dotenv()
else:
app.config["DB_PATH"] = "data/db/db.prod.sqlite"
if not app.config["SERVER_NAME"]:
raise SiteNameMissingException()
app.config["SECRET_KEY"] = os.getenv("FLASK_SECRET_KEY")
@@ -156,6 +189,13 @@ def create_app():
os.makedirs(os.path.dirname(app.config["DB_PATH"]), exist_ok = True)
os.makedirs(os.path.dirname(app.config["BADGES_UPLOAD_PATH"]), exist_ok = True)
if app.config['CACHE_TYPE'] == 'FileSystemCache':
cache_dir = app.config.get('CACHE_DIR', 'data/_cached')
os.makedirs(cache_dir, exist_ok = True)
app.config['CACHE_DIR'] = cache_dir
cache.init_app(app)
css_dir = 'data/static/css/'
allowed_themes = []
for f in os.listdir(css_dir):
@@ -196,6 +236,8 @@ def create_app():
create_admin()
create_deleted_user()
clear_stale_sessions()
reparse_babycode()
bind_default_badges(app.config['BADGES_PATH'])
@@ -229,10 +271,12 @@ def create_app():
@app.context_processor
def inject_funcs():
from .routes.threads import get_post_url
return {
'get_post_url': get_post_url,
'get_prefers_theme': get_prefers_theme,
'get_motds': MOTD.get_all,
'get_time_now': lambda: int(time.time()),
}
@app.template_filter("ts_datetime")
@@ -308,4 +352,8 @@ def create_app():
def fromjson(subject: str):
return json.loads(subject)
@app.template_filter('iso8601')
def unix_to_iso8601(subject: str):
return datetime.fromtimestamp(int(subject), timezone.utc).isoformat()
return app

View File

@@ -6,14 +6,17 @@ from pygments.lexers import get_lexer_by_name
from pygments.util import ClassNotFound as PygmentsClassNotFound
import re
BABYCODE_VERSION = 7
BABYCODE_VERSION = 8
class BabycodeError(Exception):
pass
class BabycodeRenderError(BabycodeError):
pass
class UnknownASTElementError(BabycodeRenderError):
def __init__(self, element_type, element=None):
self.element_type = element_type
@@ -24,18 +27,15 @@ class UnknownASTElementError(BabycodeRenderError):
message += f' (element: {element})'
super().__init__(message)
class BabycodeRenderResult:
def __init__(self, result, mentions=[]):
self.result = result
self.mentions = mentions
def __str__(self):
return self.result
def dumps(self):
return self.result
class BabycodeRenderer:
def __init__(self, tag_map, void_tag_map, emote_map, fragment=False):
@@ -194,15 +194,14 @@ class RSSXMLRenderer(BabycodeRenderer):
def __init__(self, fragment=False):
super().__init__(RSS_TAGS, VOID_TAGS, RSS_EMOJI, fragment)
def make_mention(self, element):
def make_mention(self, e):
from ..models import Users
from flask import url_for, current_app
with current_app.test_request_context('/'):
target_user = Users.find({'username': e['name'].lower()})
if not target_user:
return f"@{e['name']}"
from flask import url_for
target_user = Users.find({'username': e['name'].lower()})
if not target_user:
return f"@{e['name']}"
return f'<a href="{url_for('users.page', username=target_user.username, _external=True)}" title="@{target_user.username}">{target_user.get_readable_name()}</a>'
return f'<a href="{url_for('users.page', username=target_user.username, _external=True)}" title="@{target_user.username}">{'@' if not target_user.has_display_name() else ''}{target_user.get_readable_name()}</a>'
NAMED_COLORS = [
@@ -236,8 +235,10 @@ NAMED_COLORS = [
'violet', 'wheat', 'white', 'whitesmoke', 'yellow', 'yellowgreen',
]
def make_emoji(name, code):
return f' <img class=emoji src="/static/emoji/{name}.png" alt="{name}" title=":{code}:">'
return f'<img class=emoji src="/static/emoji/{name}.png" alt="{name}" title=":{code}:">'
EMOJI = {
'angry': make_emoji('angry', 'angry'),
@@ -285,6 +286,7 @@ EMOJI = {
'wink': make_emoji('wink', 'wink'),
}
RSS_EMOJI = {
**EMOJI,
@@ -333,8 +335,10 @@ RSS_EMOJI = {
'wink': '😉',
}
TEXT_ONLY = ["code"]
def tag_code(children, attr):
is_inline = children.find('\n') == -1
if is_inline:
@@ -352,11 +356,13 @@ def tag_code(children, attr):
except PygmentsClassNotFound:
return unhighlighted
def tag_list(children):
list_body = re.sub(r" +\n", "<br>", children.strip())
list_body = re.sub(r"\n\n+", "\1", list_body)
return " ".join([f"<li>{x}</li>" for x in list_body.split("\1") if x])
def tag_color(children, attr):
if not attr:
return f"[color]{children}[/color]"
@@ -374,16 +380,19 @@ def tag_color(children, attr):
# return just the way it was if we can't parse it
return f"[color={attr}]{children}[/color]"
def tag_spoiler(children, attr):
spoiler_name = attr if attr else "Spoiler"
content = f"<div class='accordion-content post-accordion-content hidden'>{children}</div>"
container = f"""<div class='accordion hidden' data-receive='toggleAccordion'><div class='accordion-header'><button type='button' class='accordion-toggle' data-send='toggleAccordion'>+</button><span>{spoiler_name}</span></div>{content}</div>"""
return container
def tag_image(children, attr):
img = f"<img class=\"post-image\" src=\"{attr}\" alt=\"{children}\">"
return f"<div class=post-img-container>{img}</div>"
TAGS = {
"b": lambda children, attr: f"<strong>{children}</strong>",
"i": lambda children, attr: f"<em>{children}</em>",
@@ -407,6 +416,7 @@ TAGS = {
"spoiler": tag_spoiler,
}
def tag_code_rss(children, attr):
is_inline = children.find('\n') == -1
if is_inline:
@@ -414,29 +424,53 @@ def tag_code_rss(children, attr):
else:
return f'<pre><code>{children}</code></pre>'
def tag_url_rss(children, attr):
if attr.startswith('/'):
from flask import current_app
uri = f"{current_app.config['PREFERRED_URL_SCHEME']}://{current_app.config['SERVER_NAME']}{attr}"
return f"<a href={uri}>{children}</a>"
return f"<a href={attr}>{children}</a>"
def tag_image_rss(children, attr):
if attr.startswith('/'):
from flask import current_app
uri = f"{current_app.config['PREFERRED_URL_SCHEME']}://{current_app.config['SERVER_NAME']}{attr}"
return f'<img src="{uri}" alt={children} />'
return f'<img src="{attr}" alt={children} />'
RSS_TAGS = {
**TAGS,
'img': lambda children, attr: f'<img src="{attr}" alt={children} />',
'spoiler': lambda children, attr: f'<details><summary>{attr or "Spoiler"}</summary>{children}</details>',
'img': tag_image_rss,
'url': tag_url_rss,
'spoiler': lambda children, attr: f'<details><summary>{attr or "Spoiler"} (click to reveal)</summary>{children}</details>',
'code': tag_code_rss,
'big': lambda children, attr: f'<span style="font-size: 1.2em">{children}</span>',
'small': lambda children, attr: f'<small>{children}</small>'
}
VOID_TAGS = {
'lb': lambda attr: '[',
'rb': lambda attr: ']',
'@': lambda attr: '@',
'at': lambda attr: '@',
'd': lambda attr: '-',
}
# [img] is considered block for the purposes of collapsing whitespace,
# despite being potentially inline (since the resulting <img> tag is inline, but creates a block container around itself and sibling images).
# [code] has a special case in is_inline().
INLINE_TAGS = {
'b', 'i', 's', 'u', 'color', 'big', 'small', 'url', 'lb', 'rb', '@'
'b', 'i', 's', 'u', 'color', 'big', 'small', 'url', 'lb', 'rb', 'at', 'd'
}
def is_tag(e, tag=None):
if e is None:
return False
@@ -450,9 +484,11 @@ def is_tag(e, tag=None):
return e['name'] == tag
def is_text(e):
return isinstance(e, str)
def is_inline(e):
if e is None:
return False # i think
@@ -468,24 +504,6 @@ def is_inline(e):
return e['type'] != 'rule'
def make_mention(e, mentions):
from ..models import Users
from flask import url_for, current_app
with current_app.test_request_context('/'):
target_user = Users.find({'username': e['name'].lower()})
if not target_user:
return f"@{e['name']}"
mention_data = {
'mention_text': f"@{e['name']}",
'mentioned_user_id': int(target_user.id),
"start": e['start'],
"end": e['end'],
}
if mention_data not in mentions:
mentions.append(mention_data)
return f"<a class='mention{' display' if target_user.has_display_name() else ''}' href='{url_for('users.page', username=target_user.username)}' title='@{target_user.username}' data-init='highlightMentions' data-username='{target_user.username}'>{'@' if not target_user.has_display_name() else ''}{target_user.get_readable_name()}</a>"
def should_collapse(text, surrounding):
if not isinstance(text, str):
@@ -548,7 +566,7 @@ def babycode_ast(s: str, banned_tags=[]):
return elements
def babycode_to_html(s: str, banned_tags=[], fragment=False):
def babycode_to_html(s: str, banned_tags=[], fragment=False) -> BabycodeRenderResult:
"""
transforms a string of babycode into html.
@@ -565,7 +583,7 @@ def babycode_to_html(s: str, banned_tags=[], fragment=False):
return r.render(ast)
def babycode_to_rssxml(s: str, banned_tags=[], fragment=False):
def babycode_to_rssxml(s: str, banned_tags=[], fragment=False) -> str:
"""
transforms a string of babycode into rss-compatible x/html.

9
app/lib/exceptions.py Normal file
View File

@@ -0,0 +1,9 @@
class MissingConfigurationException(Exception):
def __init__(self, configuration_field: str):
message = f"Missing configuration field '{configuration_field}'"
super().__init__(message)
class SiteNameMissingException(MissingConfigurationException):
def __init__(self):
super().__init__('SITE_NAME')

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

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
from flask import Blueprint, redirect, url_for, render_template
from datetime import datetime
bp = Blueprint("app", __name__, url_prefix = "/")

View File

@@ -60,11 +60,3 @@ def get_badges():
uploads = BadgeUploads.get_for_user(get_active_user().id)
badges = sorted(Badges.findall({'user_id': int(get_active_user().id)}), key=lambda x: x['sort_order'])
return render_template('components/badge_editor_badges.html', uploads=uploads, badges=badges)
@bp.get('/badge-editor/template')
@login_required
@account_required
def get_badge_template():
uploads = BadgeUploads.get_for_user(get_active_user().id)
return render_template('components/badge_editor_template.html', uploads=uploads)

View File

@@ -29,8 +29,10 @@ def sort_topics():
@bp.post("/sort-topics")
def sort_topics_post():
topics_list = request.form.getlist('topics[]')
print(topics_list)
with db.transaction():
for topic_id, new_order in request.form.items():
for new_order, topic_id in enumerate(topics_list):
db.execute("UPDATE topics SET sort_order = ? WHERE id = ?", new_order, topic_id)
return redirect(url_for(".sort_topics"))

View File

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

View File

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

View File

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

View File

@@ -74,7 +74,17 @@ def validate_and_create_badge(input_image, filename):
return False
def is_logged_in():
return "pyrom_session_key" in session
if "pyrom_session_key" not in session:
return False
sess = Sessions.find({"key": session["pyrom_session_key"]})
if not sess:
return False
if sess.expires_at < int(time.time()):
session.clear()
sess.delete()
flash('Your session expired.;Please log in again.', InfoboxKind.INFO)
return False
return True
def get_active_user():
@@ -83,6 +93,8 @@ def get_active_user():
sess = Sessions.find({"key": session["pyrom_session_key"]})
if not sess:
return None
if sess.expires_at < int(time.time()):
return None
return Users.find({"id": sess.user_id})
@@ -394,7 +406,8 @@ def page(username):
@login_required
@redirect_to_own
def settings(username):
return render_template('users/settings.html')
uploads = BadgeUploads.get_for_user(get_active_user().id)
return render_template('users/settings.html', uploads=uploads)
@bp.post('/<username>/settings')
@@ -644,12 +657,29 @@ def inbox(username):
subscriptions ON subscriptions.thread_id = posts.thread_id
WHERE subscriptions.user_id = ? AND posts.created_at > subscriptions.last_seen
GROUP BY posts.thread_id
),
user_badges AS (
SELECT
b.user_id,
json_group_array(
json_object(
'label', b.label,
'link', b.link,
'sort_order', b.sort_order,
'file_path', bu.file_path
)
) AS badges_json
FROM badges b
LEFT JOIN badge_uploads bu ON b.upload = bu.id
GROUP BY b.user_id
ORDER BY b.sort_order
)
SELECT
tm.thread_id, tm.thread_slug, tm.thread_title, tm.unread_count, tm.newest_post_time,
posts.id, posts.created_at, post_history.content, post_history.edited_at, users.username, users.status, avatars.file_path AS avatar_path, posts.thread_id, users.id AS user_id, post_history.original_markup, users.signature_rendered
posts.id, posts.created_at, post_history.content, post_history.edited_at, users.username, users.status, avatars.file_path AS avatar_path, posts.thread_id, users.id AS user_id, post_history.original_markup, users.signature_rendered,
COALESCE(user_badges.badges_json, '[]') AS badges_json
FROM
thread_metadata tm
JOIN
@@ -664,6 +694,8 @@ def inbox(username):
avatars ON users.avatar_id = avatars.id
LEFT JOIN
subscriptions ON subscriptions.thread_id = posts.thread_id
LEFT JOIN
user_badges ON users.id = user_badges.user_id
WHERE
subscriptions.user_id = ? AND posts.created_at > subscriptions.last_seen
ORDER BY
@@ -697,6 +729,7 @@ def inbox(username):
'user_id': row['user_id'],
'original_markup': row['original_markup'],
'signature_rendered': row['signature_rendered'],
'badges_json': row['badges_json'],
'thread_slug': row['thread_slug'],
})
@@ -863,6 +896,10 @@ def delete_page_confirm(username):
flash('Incorrect password.', InfoboxKind.ERROR)
return redirect(url_for('.delete_page', username=username))
if target_user.is_admin():
flash('You cannot delete the admin account.', InfoboxKind.ERROR)
return redirect(url_for('.delete_page', username=username))
anonymize_user(target_user.id)
sessions = Sessions.findall({'user_id': int(target_user.id)})
for session_obj in sessions:

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

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

View File

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

View File

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

View File

@@ -1,4 +1,8 @@
{% from 'common/icons.html' import icn_image, icn_spoiler, icn_info, icn_lock, icn_warn, icn_error, icn_bookmark, icn_megaphone %}
{% from 'common/icons.html' import icn_image, icn_spoiler, icn_info, icn_lock, icn_warn, icn_error, icn_bookmark, icn_megaphone, icn_rss, icn_drag %}
{%- macro dict_to_attr(attrs) -%}
{%- for key, value in attrs.items() if value is not none -%}{{' '}}{{key}}="{{value}}"{%- endfor -%}
{%- endmacro -%}
{% macro pager(current_page, page_count) %}
{% set left_start = [1, current_page - 5] | max %}
{% set right_end = [page_count, current_page + 5] | min %}
@@ -331,8 +335,10 @@
{% else %}
{% set selected_href = defaults[0].file_path %}
{% endif %}
<bitty-7-0 data-connect="{{ '/static/js/bitties/pyrom-bitty.js' | cachebust }} BadgeEditorBadge" data-listeners="click input submit change">
<div class="settings-badge-container" data-receive="deleteBadge">
<li class="sortable-item" data-sortable-list-key="" data-receive="deleteBadge"> {# breaking convention on purpose since this one is special #}
<span class="dragger" draggable="true">{{ icn_drag(24) }}</span>
<bitty-7-0 class="fg" data-connect="{{ '/static/js/bitties/pyrom-bitty.js' | cachebust }} BadgeEditorBadge" data-listeners="click input submit change">
<div class="settings-badge-container">
<div class="settings-badge-select">
<select data-send="badgeUpdatePreview badgeToggleFilePicker" name="badge_choice[]" required>
<optgroup label="Default">
@@ -358,4 +364,30 @@
<button data-send="deleteBadge" type="button" class="critical" title="Delete">X</button>
</div>
</bitty-7-0>
</li>
{% endmacro %}
{% macro rss_html_content(html) %}
<content type="html">{{ html }}</content>
{% endmacro %}
{% macro rss_button(feed) %}
<a class="linkbutton contain-svg inline icon rss-button" href="{{feed}}" title="it&#39;s actually atom, don&#39;t tell anyone &#59;&#41;">{{ icn_rss(20) }} Subscribe via RSS</a>
{% endmacro %}
{% macro sortable_list(attr=none) %}
<ol class="sortable-list" {% if attr %}{{ dict_to_attr(attr) }}{% endif %}>
{% if caller %}
{{ caller() }}
{% endif %}
</ol>
{% endmacro %}
{% macro sortable_list_item(key, immovable=false, attr=none) %}
<li class="sortable-item{{' immovable' if immovable else ''}}" data-sortable-list-key="{{key}}" {% if attr %}{{ dict_to_attr(attr) }}{% endif %}>
<span class="dragger" draggable="{{ 'true' if not immovable else 'false'}}">{{ icn_drag(24) }}</span>
<div class="sortable-item-inner">
{{ caller() }}
</div>
</li>
{% endmacro %}

View File

@@ -1,4 +1,4 @@
{% from 'common/macros.html' import badge_editor_single with context %}
{% from 'common/macros.html' import badge_editor_single %}
{% for badge in badges %}
{{ badge_editor_single(options=uploads, selected=badge.upload, badge=badge) }}
{% endfor %}

View File

@@ -1,2 +1,2 @@
{% from 'common/macros.html' import badge_editor_single with context %}
{% from 'common/macros.html' import badge_editor_single %}
{{ badge_editor_single(options=uploads) }}

View File

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

View File

@@ -1,18 +1,20 @@
{% extends "base.html" %}
{% from 'common/macros.html' import sortable_list, sortable_list_item %}
{% block content %}
<div class="darkbg">
<h1>Change topics order</h1>
<p>Drag topic titles to reoder them. Press submit when done. The topics will appear to users in the order set here.</p>
<form method="post" id=topics-container>
<p>Drag topic titles to reoder them. Press "Save order" when done. The topics will appear to users in the order set here.</p>
<form method="post">
<input type=submit value="Save order">
{% call() sortable_list() %}
{% for topic in topics %}
<div draggable="true" class="draggable-topic" ondragover="dragOver(event)" ondragstart="dragStart(event)" ondragend="dragEnd()">
<div class="thread-title">{{ topic['name'] }}</div>
{% call() sortable_list_item(key="topics") %}
<div class="thread-title">{{ topic.name }}</div>
<div>{{ topic.description }}</div>
<input type="hidden" name="{{ topic['id'] }}" value="{{ topic['sort-order'] }}" class="topic-input">
</div>
<input type="hidden" name="topics[]" value="{{ topic.id }}" class="topic-input">
{% endcall %}
{% endfor %}
<input type=submit value="Save order">
</form>
{% endcall %}
</form>
</div>
<script src="{{ "/static/js/sort-topics.js" | cachebust }}"></script>
{% endblock %}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,35 +1,41 @@
{% extends "base.html" %}
{% from 'common/macros.html' import sortable_list, sortable_list_item %}
{% block title %}managing bookmark collections{% endblock %}
{% block content %}
<div class="darkbg">
<h1>Manage bookmark collections</h1>
<p>Drag collections to reoder them. You cannot move or remove the default collection, but you can rename it.</p>
<div>
<button type="button" id="add-collection-button">Add new collection</button>
<div id="collections-container">
{% for collection in collections | sort(attribute='sort_order') %}
<div class="draggable-collection {{ "default" if collection.is_default else ""}}"
{% if not collection.is_default %}
draggable="true"
ondragover="dragOver(event)"
ondragstart="dragStart(event)"
ondragend="dragEnd()"
{% else %}
id="default-collection"
{% endif %}
data-collection-id="{{ collection.id }}">
<input type="text" class="collection-name" value="{{ collection.name }}" placeholder="Collection name" required autocomplete="off" maxlength="60"><br>
<div>{{ collection.get_threads_count() }} {{ "thread" | pluralize(num=collection.get_threads_count()) }}, {{ collection.get_posts_count() }} {{ "post" | pluralize(num=collection.get_posts_count()) }}</div>
{% if collection.is_default %}
<i>Default collection</i>
{% else %}
<button type="button" class="delete-button critical">Delete</button>
{% endif %}
</div>
<bitty-7-0 data-connect="{{ '/static/js/bitties/pyrom-bitty.js' | cachebust }} CollectionsEditor">
<button type="button" data-send="addCollection">Add new collection</button>
{% set sorted_collections = collections | sort(attribute='sort_order') %}
{% macro collection_inner(collection) %}
<input type="text" class="collection-name" value="{{collection.name}}" placeholder="Collection name" required autocomplete="off" maxlength="60">
<div>{{ collection.get_threads_count() }} {{ "thread" | pluralize(num=collection.get_threads_count()) }}, {{ collection.get_posts_count() }} {{ "post" | pluralize(num=collection.get_posts_count()) }}</div>
{% if collection.is_default %}
<i>Default collection</i>
{% else %}
<button type="button" class="delete-button critical" data-send="deleteCollection">Delete</button>
{% endif %}
{% endmacro %}
{% call() sortable_list(attr={'data-receive': 'addCollection' }) %}
{% call() sortable_list_item(key='collections', immovable=true, attr={'data-collection-id': sorted_collections[0].id, 'data-receive': 'deleteCollection getCollectionData testValidity'}) %}
{{ collection_inner(sorted_collections[0]) }}
{% endcall %}
{% for collection in sorted_collections[1:] %}
{% call() sortable_list_item(key='collections', attr={'data-collection-id': collection.id, 'data-receive': 'deleteCollection getCollectionData testValidity'}) %}
{{ collection_inner(collection) }}
{% endcall %}
{% endfor %}
</div>
<button type="button" id="save-button" data-submit-href="{{ url_for('api.manage_bookmark_collections', user_id=active_user.id) }}">Save</button>
{% endcall %}
<button data-use="saveCollections" type="button" id="save-button" data-submit-href="{{ url_for('api.manage_bookmark_collections', user_id=active_user.id) }}">Save</button>
</div>
</div>
<script src="{{ "/static/js/manage-bookmark-collections.js" | cachebust }}"></script>
<template id="new-collection-template">
{% call() sortable_list_item(key='collections', attr={'data-receive': 'deleteCollection getCollectionData testValidity'}) %}
<input type="text" class="collection-name" value="" placeholder="Collection name" required autocomplete="off" maxlength="60">
<div>0 threads, 0 posts</div>
<button type="button" class="delete-button critical" data-send="deleteCollection">Delete</button>
{% endcall %}
</template>
</bitty-7-0>
{#<script src="{{ "/static/js/manage-bookmark-collections.js" | cachebust }}"></script>#}
{% endblock %}

View File

@@ -1,4 +1,4 @@
{% from 'common/macros.html' import babycode_editor_component %}
{% from 'common/macros.html' import babycode_editor_component, badge_editor_single, sortable_list %}
{% extends 'base.html' %}
{% block title %}settings{% endblock %}
{% block content %}
@@ -18,6 +18,16 @@
<span>1MB maximum size. Avatar will be cropped to square.</span>
</form>
</fieldset>
<fieldset class="hfc">
<legend>Change password</legend>
<form method='post' action='{{ url_for('users.change_password', username=active_user.username) }}'>
<label for="new_password">New password</label><br>
<input type="password" id="new_password" name="new_password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required autocomplete="new-password"><br>
<label for="new_password2">Confirm new password</label><br>
<input type="password" id="new_password2" name="new_password2" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required autocomplete="new-password"><br>
<input class="warn" type="submit" value="Change password">
</form>
</fieldset>
<fieldset class="hfc">
<legend>Personalization</legend>
<form method='post'>
@@ -44,20 +54,10 @@
</form>
</fieldset>
<fieldset class="hfc">
<legend>Change password</legend>
<form method='post' action='{{ url_for('users.change_password', username=active_user.username) }}'>
<label for="new_password">New password</label><br>
<input type="password" id="new_password" name="new_password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required autocomplete="new-password"><br>
<label for="new_password2">Confirm new password</label><br>
<input type="password" id="new_password2" name="new_password2" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required autocomplete="new-password"><br>
<input class="warn" type="submit" value="Change password">
</form>
</fieldset>
<fieldset>
<legend>Badges</legend>
<a href="{{ url_for('guides.guide_page', category='user-guides', slug='settings', _anchor='badges')}}">Badges help</a>
<bitty-7-0 data-connect="{{ '/static/js/bitties/pyrom-bitty.js' | cachebust }} BadgeEditorForm" data-listeners="click input submit change">
<form data-use="badgeEditorPrepareSubmit" data-init='loadBadgeEditor' data-receive='addBadge' method='post' enctype='multipart/form-data' action='{{ url_for('users.save_badges', username=active_user.username) }}'>
<form data-use="badgeEditorPrepareSubmit" data-init='loadBadgeEditor' method='post' enctype='multipart/form-data' action='{{ url_for('users.save_badges', username=active_user.username) }}'>
<div>Loading badges&hellip;</div>
<div>If badges fail to load, JS may be disabled.</div>
</form>
@@ -68,4 +68,12 @@
<a class="linkbutton critical" href="{{ url_for('users.delete_page', username=active_user.username) }}">Delete account</a>
</div>
</div>
<template id='badge-editor-template'>
{{ badge_editor_single(options=uploads) }}
</template>
<template id="badges-list-template">
{{ sortable_list(attr={'data-receive': 'addBadge'}) }}
</template>
{% endblock %}

View File

@@ -1,11 +1,31 @@
### REQUIRED CONFIGURATION
## the following settings are required.
## the app will not work if they are missing.
# the domain name you will be serving Pyrom from, without the scheme, including the subdomain(s).
# this is overridden by the app in development.
# used for generating URLs.
# the app will not start if this field is missing.
SERVER_NAME = "forum.your.domain"
### OPTIONAL CONFIGURATION
## the following settings are set to their default values.
## you can override any of them.
# your forum's name, shown on the header.
SITE_NAME = "Pyrom"
DISABLE_SIGNUP = false # if true, no one can sign up.
# if true, users can not sign up manually. see the following two settings.
DISABLE_SIGNUP = false
# 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.
# if true, allows moderators to create invite links. useless unless DISABLE_SIGNUP is true.
MODS_CAN_INVITE = true
# if true, allows users to create invite links. useless unless DISABLE_SIGNUP is true.
USERS_CAN_INVITE = false
# contact information, will be shown in /guides/contact
# some babycodes allowed

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 B

View File

@@ -44,13 +44,13 @@
.reaction-button.active, .tab-button, .currentpage, .pagebutton, input[type=file]::file-selector-button, button.warn, input[type=submit].warn, .linkbutton.warn, button.critical, input[type=submit].critical, .linkbutton.critical, button, input[type=submit], .linkbutton {
cursor: default;
font-size: 1rem;
font-size: 1em;
font-family: "Cadman", sans-serif;
text-decoration: none;
border: 1px solid black;
border-radius: 4px;
padding: 5px 20px;
margin: 10px 0;
margin: 5px 0;
}
body {
@@ -69,7 +69,7 @@ body {
}
.big {
font-size: 1.8rem;
font-size: 1.8em;
}
#topnav {
@@ -114,7 +114,7 @@ body {
.site-title {
font-family: "site-title";
font-size: 3rem;
font-size: 3em;
margin: 0 20px;
text-decoration: none;
color: black;
@@ -122,14 +122,15 @@ body {
.thread-title {
margin: 0;
font-size: 1.5rem;
font-size: 1.5em;
font-weight: bold;
}
.thread-actions {
display: flex;
align-items: center;
gap: 5px;
gap: 0 5px;
flex-wrap: wrap;
}
.post {
@@ -222,7 +223,7 @@ code {
pre code {
display: block;
background-color: rgb(38.5714173228, 40.9237007874, 35.6762992126);
font-size: 1rem;
font-size: 1em;
color: white;
border-bottom-right-radius: 8px;
border-bottom-left-radius: 8px;
@@ -606,7 +607,7 @@ pre code { /* Literal.Number.Integer.Long */ }
display: inline-block;
margin: 4px;
border-radius: 4px;
font-size: 1rem;
font-size: 1em;
white-space: pre;
}
@@ -799,7 +800,7 @@ input[type=file]::file-selector-button {
}
p {
margin: 15px 0;
margin: 10px 0;
}
.pagebutton {
@@ -859,13 +860,17 @@ input[type=text], input[type=password], textarea, select {
resize: vertical;
color: black;
background-color: rgb(217.8, 225.6, 208.2);
font-size: 100%;
font-size: 1em;
font-family: inherit;
}
input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus {
background-color: rgb(230.2, 235.4, 223.8);
}
input:not(form input):invalid {
border: 2px dashed red;
}
textarea {
font-family: "Atkinson Hyperlegible Mono", monospace;
}
@@ -1068,35 +1073,6 @@ textarea {
background-color: none;
}
.draggable-topic {
cursor: pointer;
user-select: none;
background-color: #c1ceb1;
padding: 20px;
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);
}
.draggable-collection {
cursor: pointer;
user-select: none;
background-color: #c1ceb1;
padding: 20px;
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-collection.dragged {
background-color: rgb(177, 206, 204.5);
}
.draggable-collection.default {
background-color: #beb1ce;
}
.editing {
background-color: rgb(217.26, 220.38, 213.42);
}
@@ -1123,7 +1099,7 @@ textarea {
}
.babycode-preview-errors-container {
font-size: 0.8rem;
font-size: 0.8em;
}
.tab-button {
@@ -1269,9 +1245,6 @@ ul.horizontal li, ol.horizontal li {
padding: 5px 10px;
min-width: 36px;
}
.babycode-button > * {
font-size: 1rem;
}
.quote-popover {
position: absolute;
@@ -1455,7 +1428,7 @@ a.mention:hover, a.mention:visited:hover {
display: grid;
gap: 10px;
--grid-item-max-width: calc((100% - 10px) / 2);
grid-template-columns: repeat(auto-fill, minmax(max(400px, var(--grid-item-max-width)), 1fr));
grid-template-columns: repeat(auto-fill, minmax(max(600px, var(--grid-item-max-width)), 1fr));
}
.settings-grid fieldset {
border: 1px solid white;
@@ -1523,3 +1496,83 @@ img.badge-button {
justify-content: center;
gap: 5px;
}
.rss-button {
background-color: #fba668;
color: black;
}
.rss-button:hover {
background-color: rgb(251.8, 183.8, 134.2);
color: black;
}
.rss-button:active {
background-color: rgb(186.8501612903, 155.5098387097, 132.6498387097);
color: black;
}
@media (orientation: portrait) {
body {
margin: 20px 0;
}
.guide-container {
grid-template-areas: "guide-toc" "guide-topics";
grid-template-columns: unset;
}
.guide-toc {
position: unset;
}
.guide-section {
padding-right: 20px;
}
}
ol.sortable-list {
list-style: none;
flex-grow: 1;
margin: 0;
}
ol.sortable-list li {
display: flex;
gap: 10px;
background-color: #c1ceb1;
padding: 20px;
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);
}
ol.sortable-list li.dragged {
background-color: rgb(177, 206, 204.5);
}
ol.sortable-list li.immovable {
background-color: #beb1ce;
}
ol.sortable-list li.immovable .dragger {
cursor: not-allowed;
}
.dragger {
display: flex;
align-items: center;
background-color: rgb(135.1928346457, 145.0974015748, 123.0025984252);
padding: 5px 10px;
cursor: move;
}
.sortable-item-inner {
display: flex;
gap: 10px;
flex-grow: 1;
flex-direction: column;
}
.sortable-item-inner > * {
flex-grow: 1;
}
.sortable-item-inner.row {
flex-direction: row;
}
.sortable-item-inner:not(.row) > * {
margin-right: auto;
}
.fg {
flex-grow: 1;
}

View File

@@ -44,13 +44,13 @@
.reaction-button.active, .tab-button, .currentpage, .pagebutton, input[type=file]::file-selector-button, button.warn, input[type=submit].warn, .linkbutton.warn, button.critical, input[type=submit].critical, .linkbutton.critical, button, input[type=submit], .linkbutton {
cursor: default;
font-size: 1rem;
font-size: 1em;
font-family: "Cadman", sans-serif;
text-decoration: none;
border: 1px solid black;
border-radius: 8px;
padding: 5px 20px;
margin: 10px 0;
margin: 5px 0;
}
body {
@@ -69,7 +69,7 @@ body {
}
.big {
font-size: 1.8rem;
font-size: 1.8em;
}
#topnav {
@@ -114,7 +114,7 @@ body {
.site-title {
font-family: "site-title";
font-size: 3rem;
font-size: 3em;
margin: 0 20px;
text-decoration: none;
color: white;
@@ -122,14 +122,15 @@ body {
.thread-title {
margin: 0;
font-size: 1.5rem;
font-size: 1.5em;
font-weight: bold;
}
.thread-actions {
display: flex;
align-items: center;
gap: 5px;
gap: 0 5px;
flex-wrap: wrap;
}
.post {
@@ -222,7 +223,7 @@ code {
pre code {
display: block;
background-color: #302731;
font-size: 1rem;
font-size: 1em;
color: white;
border-bottom-right-radius: 8px;
border-bottom-left-radius: 8px;
@@ -606,7 +607,7 @@ pre code { /* Literal.Number.Integer.Long */ }
display: inline-block;
margin: 4px;
border-radius: 8px;
font-size: 1rem;
font-size: 1em;
white-space: pre;
}
@@ -799,7 +800,7 @@ input[type=file]::file-selector-button {
}
p {
margin: 15px 0;
margin: 10px 0;
}
.pagebutton {
@@ -859,13 +860,17 @@ input[type=text], input[type=password], textarea, select {
resize: vertical;
color: #e6e6e6;
background-color: #371e37;
font-size: 100%;
font-size: 1em;
font-family: inherit;
}
input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus {
background-color: #514151;
}
input:not(form input):invalid {
border: 2px dashed #d53232;
}
textarea {
font-family: "Atkinson Hyperlegible Mono", monospace;
}
@@ -1068,35 +1073,6 @@ textarea {
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;
}
.draggable-collection {
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-collection.dragged {
background-color: #3c283c;
}
.draggable-collection.default {
background-color: #8a5584;
}
.editing {
background-color: #503250;
}
@@ -1123,7 +1099,7 @@ textarea {
}
.babycode-preview-errors-container {
font-size: 0.8rem;
font-size: 0.8em;
}
.tab-button {
@@ -1269,9 +1245,6 @@ ul.horizontal li, ol.horizontal li {
padding: 5px 10px;
min-width: 36px;
}
.babycode-button > * {
font-size: 1rem;
}
.quote-popover {
position: absolute;
@@ -1455,12 +1428,12 @@ a.mention:hover, a.mention:visited:hover {
display: grid;
gap: 10px;
--grid-item-max-width: calc((100% - 10px) / 2);
grid-template-columns: repeat(auto-fill, minmax(max(400px, var(--grid-item-max-width)), 1fr));
grid-template-columns: repeat(auto-fill, minmax(max(600px, var(--grid-item-max-width)), 1fr));
}
.settings-grid fieldset {
border: 1px solid black;
border-radius: 8px;
background-color: rgb(141.6, 79.65, 141.6);
background-color: #503250;
}
.hfc {
@@ -1481,10 +1454,10 @@ h1 {
margin: 10px 0;
}
.settings-badge-container:has(input:invalid) {
border: 2px dashed red;
border: 2px dashed #d53232;
}
.settings-badge-container input:invalid {
border: 2px dashed red;
border: 2px dashed #d53232;
}
.settings-badge-file-picker {
@@ -1524,6 +1497,86 @@ img.badge-button {
gap: 5px;
}
.rss-button {
background-color: #fba668;
color: black;
}
.rss-button:hover {
background-color: rgb(251.8, 183.8, 134.2);
color: black;
}
.rss-button:active {
background-color: rgb(186.8501612903, 155.5098387097, 132.6498387097);
color: black;
}
@media (orientation: portrait) {
body {
margin: 20px 0;
}
.guide-container {
grid-template-areas: "guide-toc" "guide-topics";
grid-template-columns: unset;
}
.guide-toc {
position: unset;
}
.guide-section {
padding-right: 20px;
}
}
ol.sortable-list {
list-style: none;
flex-grow: 1;
margin: 0;
}
ol.sortable-list li {
display: flex;
gap: 10px;
background-color: #9b649b;
padding: 20px;
margin: 15px 0;
border-top: 5px outset #503250;
border-bottom: 5px outset rgb(96.95, 81.55, 96.95);
}
ol.sortable-list li.dragged {
background-color: #3c283c;
}
ol.sortable-list li.immovable {
background-color: #8a5584;
}
ol.sortable-list li.immovable .dragger {
cursor: not-allowed;
}
.dragger {
display: flex;
align-items: center;
background-color: rgb(96.95, 81.55, 96.95);
padding: 5px 10px;
cursor: move;
}
.sortable-item-inner {
display: flex;
gap: 10px;
flex-grow: 1;
flex-direction: column;
}
.sortable-item-inner > * {
flex-grow: 1;
}
.sortable-item-inner.row {
flex-direction: row;
}
.sortable-item-inner:not(.row) > * {
margin-right: auto;
}
.fg {
flex-grow: 1;
}
#topnav {
margin-bottom: 10px;
border: 10px solid rgb(40, 40, 40);

View File

@@ -44,13 +44,13 @@
.reaction-button.active, .tab-button, .currentpage, .pagebutton, input[type=file]::file-selector-button, button.warn, input[type=submit].warn, .linkbutton.warn, button.critical, input[type=submit].critical, .linkbutton.critical, button, input[type=submit], .linkbutton {
cursor: default;
font-size: 1rem;
font-size: 1em;
font-family: "Cadman", sans-serif;
text-decoration: none;
border: 1px solid black;
border-radius: 16px;
padding: 8px 12px;
margin: 6px 0;
margin: 3px 0;
}
body {
@@ -69,7 +69,7 @@ body {
}
.big {
font-size: 1.8rem;
font-size: 1.8em;
}
#topnav {
@@ -114,7 +114,7 @@ body {
.site-title {
font-family: "site-title";
font-size: 3rem;
font-size: 3em;
margin: 0 12px;
text-decoration: none;
color: black;
@@ -122,14 +122,15 @@ body {
.thread-title {
margin: 0;
font-size: 1.5rem;
font-size: 1.5em;
font-weight: bold;
}
.thread-actions {
display: flex;
align-items: center;
gap: 3px;
gap: 0 3px;
flex-wrap: wrap;
}
.post {
@@ -222,7 +223,7 @@ code {
pre code {
display: block;
background-color: rgb(41.7051685393, 28.2759550562, 24.6948314607);
font-size: 1rem;
font-size: 1em;
color: white;
border-bottom-right-radius: 16px;
border-bottom-left-radius: 16px;
@@ -606,7 +607,7 @@ pre code { /* Literal.Number.Integer.Long */ }
display: inline-block;
margin: 4px;
border-radius: 16px;
font-size: 1rem;
font-size: 1em;
white-space: pre;
}
@@ -799,7 +800,7 @@ input[type=file]::file-selector-button {
}
p {
margin: 8px 0;
margin: 6px 0;
}
.pagebutton {
@@ -859,13 +860,17 @@ input[type=text], input[type=password], textarea, select {
resize: vertical;
color: black;
background-color: rgb(247.2, 175.2, 156);
font-size: 100%;
font-size: 1em;
font-family: inherit;
}
input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus {
background-color: rgb(249.8, 201.8, 189);
}
input:not(form input):invalid {
border: 2px dashed #f73030;
}
textarea {
font-family: "Atkinson Hyperlegible Mono", monospace;
}
@@ -1068,35 +1073,6 @@ textarea {
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;
}
.draggable-collection {
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-collection.dragged {
background-color: #f27a5a;
}
.draggable-collection.default {
background-color: #b54444;
}
.editing {
background-color: rgb(219.84, 191.04, 183.36);
}
@@ -1123,7 +1099,7 @@ textarea {
}
.babycode-preview-errors-container {
font-size: 0.8rem;
font-size: 0.8em;
}
.tab-button {
@@ -1269,9 +1245,6 @@ ul.horizontal li, ol.horizontal li {
padding: 3px 6px;
min-width: 36px;
}
.babycode-button > * {
font-size: 1rem;
}
.quote-popover {
position: absolute;
@@ -1455,7 +1428,7 @@ a.mention:hover, a.mention:visited:hover {
display: grid;
gap: 6px;
--grid-item-max-width: calc((100% - 6px) / 2);
grid-template-columns: repeat(auto-fill, minmax(max(400px, var(--grid-item-max-width)), 1fr));
grid-template-columns: repeat(auto-fill, minmax(max(600px, var(--grid-item-max-width)), 1fr));
}
.settings-grid fieldset {
border: 1px solid white;
@@ -1481,10 +1454,10 @@ h1 {
margin: 6px 0;
}
.settings-badge-container:has(input:invalid) {
border: 2px dashed red;
border: 2px dashed #f73030;
}
.settings-badge-container input:invalid {
border: 2px dashed red;
border: 2px dashed #f73030;
}
.settings-badge-file-picker {
@@ -1524,6 +1497,86 @@ img.badge-button {
gap: 3px;
}
.rss-button {
background-color: #fba668;
color: black;
}
.rss-button:hover {
background-color: rgb(251.8, 183.8, 134.2);
color: black;
}
.rss-button:active {
background-color: rgb(186.8501612903, 155.5098387097, 132.6498387097);
color: black;
}
@media (orientation: portrait) {
body {
margin: 12px 0;
}
.guide-container {
grid-template-areas: "guide-toc" "guide-topics";
grid-template-columns: unset;
}
.guide-toc {
position: unset;
}
.guide-section {
padding-right: 12px;
}
}
ol.sortable-list {
list-style: none;
flex-grow: 1;
margin: 0;
}
ol.sortable-list li {
display: flex;
gap: 6px;
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);
}
ol.sortable-list li.dragged {
background-color: #f27a5a;
}
ol.sortable-list li.immovable {
background-color: #b54444;
}
ol.sortable-list li.immovable .dragger {
cursor: not-allowed;
}
.dragger {
display: flex;
align-items: center;
background-color: rgb(155.8907865169, 93.2211235955, 76.5092134831);
padding: 3px 6px;
cursor: move;
}
.sortable-item-inner {
display: flex;
gap: 6px;
flex-grow: 1;
flex-direction: column;
}
.sortable-item-inner > * {
flex-grow: 1;
}
.sortable-item-inner.row {
flex-direction: row;
}
.sortable-item-inner:not(.row) > * {
margin-right: auto;
}
.fg {
flex-grow: 1;
}
#topnav {
border-top-left-radius: 16px;
border-top-right-radius: 16px;

View File

@@ -44,13 +44,13 @@
.reaction-button.active, .tab-button, .currentpage, .pagebutton, input[type=file]::file-selector-button, button.warn, input[type=submit].warn, .linkbutton.warn, button.critical, input[type=submit].critical, .linkbutton.critical, button, input[type=submit], .linkbutton {
cursor: default;
font-size: 1rem;
font-size: 1em;
font-family: "Cadman", sans-serif;
text-decoration: none;
border: 1px solid black;
border-radius: 4px;
padding: 5px 20px;
margin: 10px 0;
margin: 5px 0;
}
body {
@@ -69,7 +69,7 @@ body {
}
.big {
font-size: 1.8rem;
font-size: 1.8em;
}
#topnav {
@@ -114,7 +114,7 @@ body {
.site-title {
font-family: "site-title";
font-size: 3rem;
font-size: 3em;
margin: 0 20px;
text-decoration: none;
color: black;
@@ -122,14 +122,15 @@ body {
.thread-title {
margin: 0;
font-size: 1.5rem;
font-size: 1.5em;
font-weight: bold;
}
.thread-actions {
display: flex;
align-items: center;
gap: 5px;
gap: 0 5px;
flex-wrap: wrap;
}
.post {
@@ -222,7 +223,7 @@ code {
pre code {
display: block;
background-color: rgb(37.9418181818, 42.3818181818, 50.8581818182);
font-size: 1rem;
font-size: 1em;
color: white;
border-bottom-right-radius: 8px;
border-bottom-left-radius: 8px;
@@ -606,7 +607,7 @@ pre code { /* Literal.Number.Integer.Long */ }
display: inline-block;
margin: 4px;
border-radius: 4px;
font-size: 1rem;
font-size: 1em;
white-space: pre;
}
@@ -799,7 +800,7 @@ input[type=file]::file-selector-button {
}
p {
margin: 15px 0;
margin: 10px 0;
}
.pagebutton {
@@ -859,13 +860,17 @@ input[type=text], input[type=password], textarea, select {
resize: vertical;
color: black;
background-color: rgb(225.6, 232.2, 244.8);
font-size: 100%;
font-size: 1em;
font-family: inherit;
}
input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus {
background-color: rgb(235.4, 239.8, 248.2);
}
input:not(form input):invalid {
border: 2px dashed red;
}
textarea {
font-family: "Atkinson Hyperlegible Mono", monospace;
}
@@ -1068,35 +1073,6 @@ textarea {
background-color: none;
}
.draggable-topic {
cursor: pointer;
user-select: none;
background-color: #ced9ee;
padding: 20px;
margin: 15px 0;
border-top: 5px outset rgb(231.36, 234, 239.04);
border-bottom: 5px outset rgb(136.0836363636, 149.3636363636, 174.7163636364);
}
.draggable-topic.dragged {
background-color: #eecee9;
}
.draggable-collection {
cursor: pointer;
user-select: none;
background-color: #ced9ee;
padding: 20px;
margin: 15px 0;
border-top: 5px outset rgb(231.36, 234, 239.04);
border-bottom: 5px outset rgb(136.0836363636, 149.3636363636, 174.7163636364);
}
.draggable-collection.dragged {
background-color: #eecee9;
}
.draggable-collection.default {
background-color: #eee3ce;
}
.editing {
background-color: rgb(231.36, 234, 239.04);
}
@@ -1123,7 +1099,7 @@ textarea {
}
.babycode-preview-errors-container {
font-size: 0.8rem;
font-size: 0.8em;
}
.tab-button {
@@ -1269,9 +1245,6 @@ ul.horizontal li, ol.horizontal li {
padding: 5px 10px;
min-width: 36px;
}
.babycode-button > * {
font-size: 1rem;
}
.quote-popover {
position: absolute;
@@ -1455,7 +1428,7 @@ a.mention:hover, a.mention:visited:hover {
display: grid;
gap: 10px;
--grid-item-max-width: calc((100% - 10px) / 2);
grid-template-columns: repeat(auto-fill, minmax(max(400px, var(--grid-item-max-width)), 1fr));
grid-template-columns: repeat(auto-fill, minmax(max(600px, var(--grid-item-max-width)), 1fr));
}
.settings-grid fieldset {
border: 1px solid white;
@@ -1523,3 +1496,83 @@ img.badge-button {
justify-content: center;
gap: 5px;
}
.rss-button {
background-color: #fba668;
color: black;
}
.rss-button:hover {
background-color: rgb(251.8, 183.8, 134.2);
color: black;
}
.rss-button:active {
background-color: rgb(186.8501612903, 155.5098387097, 132.6498387097);
color: black;
}
@media (orientation: portrait) {
body {
margin: 20px 0;
}
.guide-container {
grid-template-areas: "guide-toc" "guide-topics";
grid-template-columns: unset;
}
.guide-toc {
position: unset;
}
.guide-section {
padding-right: 20px;
}
}
ol.sortable-list {
list-style: none;
flex-grow: 1;
margin: 0;
}
ol.sortable-list li {
display: flex;
gap: 10px;
background-color: #ced9ee;
padding: 20px;
margin: 15px 0;
border-top: 5px outset rgb(231.36, 234, 239.04);
border-bottom: 5px outset rgb(136.0836363636, 149.3636363636, 174.7163636364);
}
ol.sortable-list li.dragged {
background-color: #eecee9;
}
ol.sortable-list li.immovable {
background-color: #eee3ce;
}
ol.sortable-list li.immovable .dragger {
cursor: not-allowed;
}
.dragger {
display: flex;
align-items: center;
background-color: rgb(136.0836363636, 149.3636363636, 174.7163636364);
padding: 5px 10px;
cursor: move;
}
.sortable-item-inner {
display: flex;
gap: 10px;
flex-grow: 1;
flex-direction: column;
}
.sortable-item-inner > * {
flex-grow: 1;
}
.sortable-item-inner.row {
flex-direction: row;
}
.sortable-item-inner:not(.row) > * {
margin-right: auto;
}
.fg {
flex-grow: 1;
}

View File

@@ -23,7 +23,6 @@
if (inThread()) {
const form = ta.closest('.post-edit-form');
console.log(ta.closest('.post-edit-form'));
if (form){
form.addEventListener("submit", () => {
localStorage.removeItem(window.location.pathname);

View File

@@ -7,12 +7,12 @@ const delay = ms => {return new Promise(resolve => setTimeout(resolve, ms))}
export default class {
async showBookmarkMenu(ev, el) {
if ((el.sender.dataset.bookmarkId === el.ds('bookmarkId')) && el.childElementCount === 0) {
if ((ev.sender.dataset.bookmarkId === el.prop('bookmarkId')) && el.childElementCount === 0) {
const searchParams = new URLSearchParams({
'id': el.sender.dataset.conceptId,
'id': ev.sender.dataset.conceptId,
'require_reload': el.dataset.requireReload,
});
const bookmarkMenuHref = `${bookmarkMenuHrefTemplate}/${el.sender.dataset.bookmarkType}?${searchParams}`;
const bookmarkMenuHref = `${bookmarkMenuHrefTemplate}/${ev.sender.dataset.bookmarkType}?${searchParams}`;
const res = await this.api.getHTML(bookmarkMenuHref);
if (res.error) {
return;
@@ -50,9 +50,9 @@ export default class {
}
selectBookmarkCollection(ev, el) {
const clicked = el.sender;
const clicked = ev.sender;
if (el.sender === el) {
if (ev.sender === el) {
if (clicked.classList.contains('selected')) {
clicked.classList.remove('selected');
} else {
@@ -64,7 +64,7 @@ export default class {
}
async saveBookmarks(ev, el) {
const bookmarkHref = el.ds('bookmarkEndpoint');
const bookmarkHref = el.prop('bookmarkEndpoint');
const collection = el.querySelector('.bookmark-dropdown-item.selected');
let data = {};
if (collection) {
@@ -73,7 +73,7 @@ export default class {
data['memo'] = el.querySelector('.bookmark-memo-input').value;
} else {
data['operation'] = 'remove';
data['collection_id'] = el.ds('originallyContainedIn');
data['collection_id'] = el.prop('originallyContainedIn');
}
const options = {
@@ -83,7 +83,7 @@ export default class {
'Content-Type': 'application/json',
},
}
const requireReload = el.dsInt('requireReload') !== 0;
const requireReload = el.propToInt('requireReload') !== 0;
el.remove();
await fetch(bookmarkHref, options);
if (requireReload) {
@@ -104,10 +104,10 @@ export default class {
toggleAccordion(ev, el) {
const accordion = el;
const header = accordion.querySelector('.accordion-header');
if (!header.contains(el.sender)){
if (!header.contains(ev.sender)){
return;
}
const btn = el.sender;
const btn = ev.sender;
const content = el.querySelector('.accordion-content');
// these are all meant to be in sync
accordion.classList.toggle('hidden');
@@ -117,15 +117,15 @@ export default class {
toggleTab(ev, el) {
const tabButtonsContainer = el.querySelector('.tab-buttons');
if (!el.contains(el.sender)) {
if (!el.contains(ev.sender)) {
return;
}
if (el.sender.classList.contains('active')) {
if (ev.sender.classList.contains('active')) {
return;
}
const targetId = el.senderDs('targetId');
const targetId = ev.sender.prop('targetId');
const contents = el.querySelectorAll('.tab-content');
for (let content of contents) {
if (content.id === targetId) {
@@ -145,7 +145,7 @@ export default class {
#previousMarkup = null;
async babycodePreview(ev, el) {
if (el.sender.classList.contains('active')) {
if (ev.sender.classList.contains('active')) {
return;
}
@@ -200,9 +200,9 @@ export default class {
}
insertBabycodeTag(ev, el) {
const tagStart = el.senderDs('tag');
const breakLine = 'breakLine' in el.sender.dataset;
const prefill = 'prefill' in el.sender.dataset ? el.sender.dataset.prefill : '';
const tagStart = ev.sender.prop('tag');
const breakLine = 'breakLine' in ev.sender.dataset;
const prefill = 'prefill' in ev.sender.dataset ? ev.sender.dataset.prefill : '';
const hasAttr = tagStart[tagStart.length - 1] === '=';
let tagEnd = tagStart;
@@ -250,13 +250,13 @@ export default class {
}
addQuote(ev, el) {
el.value += el.sender.value;
el.value += ev.sender.value;
el.scrollIntoView();
el.focus();
}
convertTimestamps(ev, el) {
const timestamp = el.dsInt('utc');
const timestamp = el.propToInt('utc');
if (!isNaN(timestamp)) {
const date = new Date(timestamp * 1000);
el.textContent = date.toLocaleString();
@@ -273,7 +273,7 @@ export default class {
this.#currentUsername = userInfo.value.user.username;
}
if (el.ds('username') === this.#currentUsername) {
if (el.prop('username') === this.#currentUsername) {
el.classList.add('me');
}
}
@@ -287,11 +287,7 @@ export class BadgeEditorForm {
return;
}
if (this.#badgeTemplate === undefined){
const badge = await this.api.getHTML(`${badgeEditorEndpoint}/template`)
if (!badge.value){
return;
}
this.#badgeTemplate= badge.value;
this.#badgeTemplate = document.getElementById('badge-editor-template').content.firstElementChild.outerHTML;
}
el.replaceChildren();
const addButton = `<button data-disable-if-max="1" data-receive="updateBadgeCount" DISABLE_IF_MAX type="button" data-send="addBadge">Add badge</button>`;
@@ -303,31 +299,35 @@ export class BadgeEditorForm {
['DISABLE_IF_MAX', badgeCount === 10 ? 'disabled' : ''],
];
el.appendChild(this.api.makeHTML(controls, subs));
el.appendChild(badges.value);
const listTemplate = document.getElementById('badges-list-template').content.firstElementChild.outerHTML;
const list = this.api.makeHTML(listTemplate).firstElementChild;
list.appendChild(badges.value);
el.appendChild(list);
}
addBadge(ev, el) {
if (this.#badgeTemplate === undefined) {
return;
}
const badge = this.#badgeTemplate.cloneNode(true);
const badge = this.api.makeHTML(this.#badgeTemplate).firstElementChild;
el.appendChild(badge);
this.api.localTrigger('updateBadgeCount');
}
deleteBadge(ev, el) {
if (!el.contains(el.sender)) {
if (!el.contains(ev.sender)) {
return;
}
el.remove();
this.api.localTrigger('updateBadgeCount');
}
updateBadgeCount(_ev, el) {
updateBadgeCount(ev, el) {
const badgeCount = el.parentNode.parentNode.querySelectorAll('.settings-badge-container').length;
if (el.dsInt('disableIfMax') === 1) {
if (el.propToInt('disableIfMax') === 1) {
el.disabled = badgeCount === 10;
} else if (el.dsInt('count') === 1) {
} else if (el.propToInt('count') === 1) {
el.textContent = `${badgeCount}/10`;
}
}
@@ -344,9 +344,7 @@ export class BadgeEditorForm {
noUploads.forEach(e => {
e.value = null;
})
// console.log(noUploads);
el.submit();
// console.log('would submit now');
}
}
@@ -364,13 +362,13 @@ export class BadgeEditorBadge {
if (ev.type !== 'change') {
return;
}
// TODO: el.sender doesn't have a bittyParentBittyId
const selectBittyParent = el.sender.closest('bitty-7-0');
if (el.bittyParentBittyId !== selectBittyParent.dataset.bittyid) {
// TODO: ev.sender doesn't have a bittyParent
const selectBittyParent = ev.sender.closest('bitty-7-0');
if (el.bittyParent !== selectBittyParent) {
return;
}
if (ev.val === 'custom') {
if (ev.value === 'custom') {
if (this.#badgeCustomImageData) {
el.src = this.#badgeCustomImageData;
} else {
@@ -378,7 +376,7 @@ export class BadgeEditorBadge {
}
return;
}
const option = el.sender.selectedOptions[0];
const option = ev.sender.selectedOptions[0];
el.src = option.dataset.filePath;
}
@@ -386,13 +384,13 @@ export class BadgeEditorBadge {
if (ev.type !== 'change') {
return;
}
if (el.bittyParentBittyId !== el.sender.bittyParentBittyId) {
if (el.bittyParent !== ev.sender.bittyParent) {
return;
}
const file = ev.target.files[0];
if (file.size >= 1000 * 500) {
this.api.trigger('badgeErrorSize');
this.api.localTrigger('badgeErrorSize');
this.#badgeCustomImageData = null;
el.removeAttribute('src');
return;
@@ -403,14 +401,14 @@ export class BadgeEditorBadge {
reader.onload = async e => {
const dimsValid = await validateBase64Img(e.target.result);
if (!dimsValid) {
this.api.trigger('badgeErrorDim');
this.api.localTrigger('badgeErrorDim');
this.#badgeCustomImageData = null;
el.removeAttribute('src');
return;
}
this.#badgeCustomImageData = e.target.result;
el.src = this.#badgeCustomImageData;
this.api.trigger('badgeHideErrors');
this.api.localTrigger('badgeHideErrors');
}
reader.readAsDataURL(file);
@@ -420,13 +418,13 @@ export class BadgeEditorBadge {
if (ev.type !== 'change') {
return;
}
// TODO: el.sender doesn't have a bittyParentBittyId
const selectBittyParent = el.sender.closest('bitty-7-0');
if (el.bittyParentBittyId !== selectBittyParent.dataset.bittyid) {
// TODO: ev.sender doesn't have a bittyParent
const selectBittyParent = ev.sender.closest('bitty-7-0');
if (el.bittyParent !== selectBittyParent) {
return;
}
const filePicker = el.querySelector('input[type=file]');
if (ev.val === 'custom') {
if (ev.value === 'custom') {
el.classList.remove('hidden');
if (filePicker.dataset.validity) {
filePicker.setCustomValidity(filePicker.dataset.validity);
@@ -440,38 +438,106 @@ export class BadgeEditorBadge {
}
openBadgeFilePicker(ev, el) {
// TODO: el.sender doesn't have a bittyParentBittyId
if (el.sender.parentNode !== el.parentNode) {
// TODO: ev.sender doesn't have a bittyParent
if (ev.sender.parentNode !== el.parentNode) {
return;
}
el.click();
}
badgeErrorSize(_ev, el) {
if (el.sender !== el.bittyParent) {
return;
}
badgeErrorSize(ev, el) {
const validity = "Image can't be over 500KB."
el.dataset.validity = validity;
el.setCustomValidity(validity);
el.reportValidity();
}
badgeErrorDim(_ev, el) {
if (el.sender !== el.bittyParent) {
return;
}
badgeErrorDim(ev, el) {
const validity = "Image must be exactly 88x31 pixels."
el.dataset.validity = validity;
el.setCustomValidity(validity);
el.reportValidity();
}
badgeHideErrors(_ev, el) {
if (el.sender !== el.bittyParent) {
return;
}
badgeHideErrors(ev, el) {
delete el.dataset.validity;
el.setCustomValidity('');
}
}
const getCollectionDataForEl = el => {
const nameInput = el.querySelector(".collection-name");
const collectionId = el.dataset.collectionId;
return {
id: collectionId,
name: nameInput.value,
is_new: !('collectionId' in el.dataset),
};
}
export class CollectionsEditor {
#collectionTemplate = undefined;
#collectionsData = [];
#removedCollections = [];
#valid = true;
addCollection(ev, el) {
if (this.#collectionTemplate === undefined) {
this.#collectionTemplate = document.getElementById('new-collection-template').content;
}
// interesting
const newCollection = this.api.makeHTML(this.#collectionTemplate.firstElementChild.outerHTML);
el.appendChild(newCollection);
}
deleteCollection(ev, el) {
if (!el.contains(ev.sender)) {
return;
}
if ('collectionId' in el.dataset) {
this.#removedCollections.push(el.dataset.collectionId);
}
el.remove();
}
async saveCollections(ev, el) {
this.#valid = true;
this.api.localTrigger('testValidity');
if (!this.#valid) {
return;
}
this.#collectionsData = [];
this.api.localTrigger('getCollectionData');
const data = {
collections: this.#collectionsData,
removed_collections: this.#removedCollections,
};
const res = await this.api.getJSON(el.prop('submitHref'), [], {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (res.error) {
return;
}
window.location.reload();
}
getCollectionData(ev, el) {
this.#collectionsData.push(getCollectionDataForEl(el));
}
testValidity(ev, el) {
const input = el.querySelector('input');
if (!input.validity.valid) {
input.reportValidity();
this.#valid = false;
}
}
}

View File

@@ -1,128 +0,0 @@
let removedCollections = [];
document.getElementById("add-collection-button").addEventListener("click", () => {
const container = document.getElementById("collections-container");
const currentCount = container.querySelectorAll(".draggable-collection").length;
const newId = `new-${Date.now()}`
const collectionHtml = `
<div class="draggable-collection"
data-collection-id="${newId}"
draggable="true"
ondragover="dragOver(event)"
ondragstart="dragStart(event)"
ondragend="dragEnd()">
<input type="text" class="collection-name" value="" required placeholder="Enter collection name" autocomplete="off" maxlength="60"><br>
<div>0 threads, 0 posts</div>
<button type="button" class="delete-button critical">Delete</button>
</div>
`;
container.insertAdjacentHTML('beforeend', collectionHtml);
})
document.addEventListener("click", e => {
if (!e.target.classList.contains("delete-button")) {
return;
}
const collectionDiv = e.target.closest(".draggable-collection");
const collectionId = collectionDiv.dataset.collectionId;
if (!collectionId.startsWith("new-")) {
removedCollections.push(collectionId);
}
collectionDiv.remove();
})
document.getElementById("save-button").addEventListener("click", async () => {
const collections = [];
const collectionDivs = document.querySelectorAll(".draggable-collection");
let isValid = true;
collectionDivs.forEach((collection, index) => {
const collectionId = collection.dataset.collectionId;
const nameInput = collection.querySelector(".collection-name");
if (!nameInput.reportValidity()) {
isValid = false;
return;
}
collections.push({
id: collectionId,
name: nameInput.value,
is_new: collectionId.startsWith("new-"),
});
})
if (!isValid) {
return;
}
const data = {
collections: collections,
removed_collections: removedCollections,
};
try {
const saveHref = document.getElementById('save-button').dataset.submitHref;
const response = await fetch(saveHref, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (response.ok) {
window.location.reload();
} else {
console.error("Error saving collections");
}
} catch (error) {
console.error("Error saving collections: ", error);
}
})
// drag logic
// https://codepen.io/crouchingtigerhiddenadam/pen/qKXgap
let selected = null;
const container = document.getElementById("collections-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-collection")
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;
}
function dragStart(e) {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', "")
selected = e.target
selected.classList.add("dragged")
}

View File

@@ -1,45 +0,0 @@
// 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")
}

View File

@@ -97,7 +97,7 @@
if (ta.value.trim() !== "") {
ta.value += "\n"
}
ta.value += `@${authorUsername} [url=${postPermalink}]said:[/url]\n[quote]< :scissors: > ${document.getSelection().toString()} < :scissors: >[/quote]\n`;
ta.value += `@${authorUsername} [url=${postPermalink}]said:[/url]\n[quote]<:scissors:> ${document.getSelection().toString()} <:scissors:>[/quote]\n`;
ta.scrollIntoView()
ta.focus();

View File

@@ -90,21 +90,16 @@ document.addEventListener("DOMContentLoaded", () => {
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";
}
}
@@ -133,3 +128,108 @@ document.addEventListener("DOMContentLoaded", () => {
}
})
});
{
function isBefore(el1, el2) {
if (el2.parentNode === el1.parentNode) {
for (let cur = el1.previousSibling; cur; cur = cur.previousSibling) {
if (cur === el2) return true;
}
}
return false;
}
let draggedItem = null;
function sortableItemDragStart(e, item) {
const box = item.getBoundingClientRect();
const oX = e.clientX - box.left;
const oY = e.clientY - box.top;
draggedItem = item;
item.classList.add('dragged');
e.dataTransfer.setDragImage(item, oX, oY);
e.dataTransfer.effectAllowed = 'move';
}
function sortableItemDragEnd(e, item) {
draggedItem = null;
item.classList.remove('dragged');
}
function sortableItemDragOver(e, item) {
const target = e.target.closest('.sortable-item');
if (!target || target === draggedItem) {
return;
}
const inSameList = draggedItem.dataset.sortableListKey === target.dataset.sortableListKey;
if (!inSameList) {
return;
}
const targetList = draggedItem.closest('.sortable-list');
if (isBefore(draggedItem, target)) {
targetList.insertBefore(draggedItem, target);
} else {
targetList.insertBefore(draggedItem, target.nextSibling);
}
}
const listItemsHandled = new Map();
const getListItemsHandled = (list) => {
return listItemsHandled.get(list) || new Set();
}
function registerSortableList(list) {
list.querySelectorAll('li:not(.immovable)').forEach(item => {
const listItems = getListItemsHandled(list);
listItems.add(item);
listItemsHandled.set(list, listItems);
const dragger = item.querySelector('.dragger');
dragger.addEventListener('dragstart', e => {sortableItemDragStart(e, item)});
dragger.addEventListener('dragend', e => {sortableItemDragEnd(e, item)});
item.addEventListener('dragover', e => {sortableItemDragOver(e, item)});
});
const obs = new MutationObserver(records => {
for (const mutation of records) {
mutation.addedNodes.forEach(node => {
if (!(node instanceof HTMLElement)) {
return;
}
if (!node.classList.contains('sortable-item')) {
return;
}
const listItems = getListItemsHandled(list)
if (listItems.has(node)) {
return;
}
const dragger = node.querySelector('.dragger');
dragger.addEventListener('dragstart', e => {sortableItemDragStart(e, node)});
dragger.addEventListener('dragend', e => {sortableItemDragEnd(e, node)});
node.addEventListener('dragover', e => {sortableItemDragOver(e, node)});
listItems.add(node);
listItemsHandled.set(list, listItems);
});
}
});
obs.observe(list, {childList: true});
}
document.querySelectorAll('.sortable-list').forEach(registerSortableList);
listsObs = new MutationObserver(records => {
for (const mutation of records) {
mutation.addedNodes.forEach(node => {
if (!(node instanceof HTMLElement)) {
return;
}
if (!node.classList.contains('sortable-list')) {
return;
}
registerSortableList(node);
})
}
})
listsObs.observe(document.body, {childList: true, subtree: true});
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -56,6 +56,7 @@ $PAGE_SIDE_MARGIN: 100px !default;
// BORDERS
// **************
$DEFAULT_BORDER: 1px solid black !default;
$DEFAULT_BORDER_INVALID: 2px dashed $BUTTON_COLOR_CRITICAL !default;
$DEFAULT_BORDER_RADIUS: 4px !default;
// other variables can be found before the rule that uses them. they are usually constructed from these basic variables.
@@ -115,10 +116,10 @@ $DEFAULT_BORDER_RADIUS: 4px !default;
$button_border: $DEFAULT_BORDER !default;
$button_padding: $SMALL_PADDING $BIG_PADDING !default;
$button_border_radius: $DEFAULT_BORDER_RADIUS !default;
$button_margin: $MEDIUM_PADDING $ZERO_PADDING !default;
$button_margin: $SMALL_PADDING $ZERO_PADDING !default;
%button-base {
cursor: default;
font-size: 1rem;
font-size: 1em;
font-family: "Cadman", sans-serif;
text-decoration: none;
border: $button_border;
@@ -188,7 +189,7 @@ $link_color_visited: #730c0c !default;
}
.big {
font-size: 1.8rem;
font-size: 1.8em;
}
$topnav_color: $ACCENT_COLOR !default;
@@ -224,7 +225,7 @@ $user_actions_gap: $MEDIUM_BIG_PADDING !default;
}
$site_title_margin: $ZERO_PADDING $BIG_PADDING !default;
$site_title_size: 3rem !default;
$site_title_size: 3em !default;
$site_title_color: $DEFAULT_FONT_COLOR !default;
.site-title {
font-family: "site-title";
@@ -235,18 +236,19 @@ $site_title_color: $DEFAULT_FONT_COLOR !default;
}
$thread_title_margin: $ZERO_PADDING !default;
$thread_title_size: 1.5rem !default;
$thread_title_size: 1.5em !default;
.thread-title {
margin: $thread_title_margin;
font-size: $thread_title_size;
font-weight: bold;
}
$thread_actions_gap: $SMALL_PADDING !default;
$thread_actions_gap: $ZERO_PADDING $SMALL_PADDING !default;
.thread-actions {
display: flex;
align-items: center;
gap: $thread_actions_gap;
flex-wrap: wrap;
}
$post_usercard_width: 230px !default;
@@ -372,7 +374,7 @@ $code_border_left: $MEDIUM_PADDING solid $LIGHT_2 !default;
pre code {
display: block;
background-color: $code_background_color;
font-size: 1rem;
font-size: 1em;
color: $code_font_color;
border-bottom-right-radius: $code_border_radius;
border-bottom-left-radius: $code_border_radius;
@@ -496,7 +498,7 @@ $inline_code_padding: $SMALL_PADDING $MEDIUM_PADDING !default;
display: inline-block;
margin: $inline_code_margin;
border-radius: $inline_code_border_radius;
font-size: 1rem;
font-size: 1em;
white-space: pre;
}
@@ -647,7 +649,7 @@ input[type="file"]::file-selector-button {
margin: $MEDIUM_PADDING;
}
$para_margin: $MEDIUM_BIG_PADDING $ZERO_PADDING !default;
$para_margin: $MEDIUM_PADDING $ZERO_PADDING !default;
p {
margin: $para_margin;
}
@@ -700,7 +702,7 @@ input[type="text"], input[type="password"], textarea, select {
resize: vertical;
color: $text_input_font_color;
background-color: $text_input_background;
font-size: 100%;
font-size: 1em;
font-family: inherit;
&:focus {
@@ -708,6 +710,11 @@ input[type="text"], input[type="password"], textarea, select {
}
}
// lone required inputs managed by js
input:not(form input):invalid {
border: $DEFAULT_BORDER_INVALID;
}
textarea {
font-family: "Atkinson Hyperlegible Mono", monospace;
}
@@ -965,54 +972,6 @@ $topic_locked_background: none !default;
background-color: $topic_locked_background;
}
$draggable_topic_background: $ACCENT_COLOR !default;
$draggable_topic_dragged_color: $BUTTON_COLOR !default;
$draggable_topic_padding: $BIG_PADDING !default;
$draggable_topic_margin: $MEDIUM_BIG_PADDING 0 !default;
$draggable_topic_border: 5px outset !default;
$draggable_topic_border_top: $draggable_topic_border $LIGHT !default;
$draggable_topic_border_bottom: $draggable_topic_border $DARK_2 !default;
.draggable-topic {
cursor: pointer;
user-select: none;
background-color: $draggable_topic_background;
padding: $draggable_topic_padding;
margin: $draggable_topic_margin;
border-top: $draggable_topic_border_top;
border-bottom: $draggable_topic_border_bottom;
&.dragged {
background-color: $draggable_topic_dragged_color;
}
}
$draggable_collection_background: $ACCENT_COLOR !default;
$draggable_collection_dragged_color: $BUTTON_COLOR !default;
$draggable_collection_default_color: $BUTTON_COLOR_2 !default;
$draggable_collection_padding: $BIG_PADDING !default;
$draggable_collection_margin: $MEDIUM_BIG_PADDING 0 !default;
$draggable_collection_border: 5px outset !default;
$draggable_collection_border_top: $draggable_collection_border $LIGHT !default;
$draggable_collection_border_bottom: $draggable_collection_border $DARK_2 !default;
.draggable-collection {
cursor: pointer;
user-select: none;
background-color: $draggable_collection_background;
padding: $draggable_collection_padding;
margin: $draggable_collection_margin;
border-top: $draggable_collection_border_top;
border-bottom: $draggable_collection_border_bottom;
&.dragged {
background-color: $draggable_collection_dragged_color;
}
&.default {
background-color: $draggable_collection_default_color;
}
}
$post_editing_header_color: $LIGHT !default;
.editing {
background-color: $post_editing_header_color;
@@ -1041,7 +1000,7 @@ $post_editing_context_margin: $BIG_PADDING $ZERO_PADDING !default;
}
.babycode-preview-errors-container {
font-size: 0.8rem;
font-size: 0.8em;
}
$tab_button_color: $BUTTON_COLOR !default;
@@ -1199,10 +1158,6 @@ $babycode_button_min_width: $accordion_button_size !default;
.babycode-button {
padding: $babycode_button_padding;
min-width: $babycode_button_min_width;
&> * {
font-size: 1rem;
}
}
$quote_fragment_background_color: #00000080 !default;
@@ -1415,9 +1370,10 @@ a.mention, a.mention:visited {
}
$settings_grid_gap: $MEDIUM_PADDING !default;
$settings_grid_item_min_width: 400px !default;
$settings_grid_item_min_width: 600px !default;
$settings_grid_fieldset_border: 1px solid $DEFAULT_FONT_COLOR_INVERSE !default;
$settings_grid_fieldset_border_radius: $DEFAULT_BORDER_RADIUS !default;
$settings_grid_fieldset_background_color: $DARK_1_LIGHTER !default;
.settings-grid {
display: grid;
gap: $settings_grid_gap;
@@ -1428,7 +1384,7 @@ $settings_grid_fieldset_border_radius: $DEFAULT_BORDER_RADIUS !default;
& fieldset {
border: $settings_grid_fieldset_border;
border-radius: $settings_grid_fieldset_border_radius;
background-color: $DARK_1_LIGHTER;
background-color: $settings_grid_fieldset_background_color;
}
}
@@ -1443,7 +1399,7 @@ h1 {
$settings_badge_container_gap: $SMALL_PADDING !default;
$settings_badge_container_border: $DEFAULT_BORDER !default;
$settings_badge_container_border_invalid: 2px dashed red !default;
$settings_badge_container_border_invalid: $DEFAULT_BORDER_INVALID !default;
$settings_badge_container_border_radius: $DEFAULT_BORDER_RADIUS !default;
$settings_badge_container_padding: $SMALL_PADDING $MEDIUM_PADDING !default;
$settings_badge_container_margin: $MEDIUM_PADDING $ZERO_PADDING !default;
@@ -1511,3 +1467,117 @@ $badges_container_gap: $SMALL_PADDING !default;
justify-content: center;
gap: $badges_container_gap;
}
$rss_button_color: #fba668 !default;
$rss_button_color_hover: color.scale($rss_button_color, $lightness: 20%) !default;
$rss_button_color_active: color.scale($rss_button_color, $lightness: -10%, $saturation: -70%) !default;
$rss_button_font_color: black !default;
$rss_button_font_color_hover: black !default;
$rss_button_font_color_active: black !default;
.rss-button {
background-color: $rss_button_color;
color: $rss_button_font_color;
&:hover {
background-color: $rss_button_color_hover;
color: $rss_button_font_color_hover;
}
&:active {
background-color: $rss_button_color_active;
color: $rss_button_font_color_active;
}
}
@media (orientation: portrait) {
$body_portrait_margin: $BIG_PADDING $ZERO_PADDING !default;
body {
margin: $body_portrait_margin;
}
.guide-container {
grid-template-areas:
"guide-toc"
"guide-topics";
grid-template-columns: unset;
}
.guide-toc {
position: unset;
}
$guide_section_padding_right_portrait: $BIG_PADDING !default;
.guide-section {
padding-right: $guide_section_padding_right_portrait;
}
}
$sortable_item_background: $ACCENT_COLOR !default;
$sortable_item_dragged_color: $BUTTON_COLOR !default;
$sortable_item_immovable_color: $BUTTON_COLOR_2 !default;
$sortable_item_padding: $BIG_PADDING !default;
$sortable_item_margin: $MEDIUM_BIG_PADDING 0 !default;
$sortable_item_border: 5px outset !default;
$sortable_item_border_top: $sortable_item_border $LIGHT !default;
$sortable_item_border_bottom: $sortable_item_border $DARK_2 !default;
$sortable_item_gap: $MEDIUM_PADDING !default;
ol.sortable-list {
list-style: none;
flex-grow: 1;
margin: 0;
li {
display: flex;
gap: $sortable_item_gap;
background-color: $sortable_item_background;
padding: $sortable_item_padding;
margin: $sortable_item_margin;
border-top: $sortable_item_border_top;
border-bottom: $sortable_item_border_bottom;
}
li.dragged {
background-color: $sortable_item_dragged_color;
}
li.immovable {
background-color: $sortable_item_immovable_color;
}
li.immovable .dragger {
cursor: not-allowed;
}
}
$sortable_item_grabber_padding: $SMALL_PADDING $MEDIUM_PADDING !default;
.dragger {
display: flex;
align-items: center;
background-color: $DARK_2;
padding: $sortable_item_grabber_padding;
cursor: move;
}
$sortable_item_inner_gap: $MEDIUM_PADDING !default;
.sortable-item-inner {
display: flex;
gap: $sortable_item_inner_gap;
flex-grow: 1;
flex-direction: column;
& > * {
flex-grow: 1;
}
&.row {
flex-direction: row;
}
&:not(.row) > * {
margin-right: auto;
}
}
.fg {
flex-grow: 1;
}

View File

@@ -83,6 +83,8 @@ $br: 8px;
$mention_font_color: $fc,
$settings_grid_fieldset_background_color: $lightish_accent,
// $settings_badge_container_border_invalid: 2px dashed $crit,
);

View File

@@ -12,3 +12,7 @@ pythonpath = /opt/venv/lib/python3.13/site-packages
uid = www-data
gid = www-data
env = LANG=C.UTF-8
env = LANGUAGE=C.UTF-8
env = LC_ALL=C.UTF-8