Compare commits
43 Commits
508b313871
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
40219f2b54
|
|||
|
4a45b62521
|
|||
|
fc55aaf87a
|
|||
|
db68ef2c3d
|
|||
|
a808137e5b
|
|||
|
a93a89f0df
|
|||
|
7aa3a9382e
|
|||
|
46704df7d9
|
|||
|
98bf430604
|
|||
|
21ace9299f
|
|||
|
122b706350
|
|||
|
c655caab9e
|
|||
|
b2d16e305d
|
|||
|
a8398cad51
|
|||
|
f27d8eaf7e
|
|||
|
36e17c6677
|
|||
|
d7a90745f6
|
|||
|
d90b4643cb
|
|||
|
d82f25471d
|
|||
|
791911b416
|
|||
|
ba2c9132f6
|
|||
|
d4e3d7cded
|
|||
|
0898c56a51
|
|||
|
96c37f9081
|
|||
|
94a4be8b97
|
|||
|
fa1140895a
|
|||
|
fc6c5d46e1
|
|||
|
dc0aa0dba7
|
|||
|
dbf0150a5e
|
|||
|
1539486456
|
|||
|
c18dad4a77
|
|||
|
2b45cab4e8
|
|||
|
37c1ffc2a1
|
|||
|
09a19b5352
|
|||
|
6c96563a0e
|
|||
|
77677eef6d
|
|||
|
f99ae75503
|
|||
|
552fb67c6c
|
|||
|
e9c03b9046
|
|||
|
f0b0fb8909
|
|||
|
9ae4e376b8
|
|||
|
d1bc1c644b
|
|||
|
7840399d01
|
@@ -4,5 +4,7 @@
|
||||
data/db/*
|
||||
data/static/avatars/*
|
||||
!data/static/avatars/default.webp
|
||||
data/static/badges/user
|
||||
data/_cached
|
||||
|
||||
.local/
|
||||
|
||||
3
.gitignore
vendored
@@ -4,7 +4,10 @@
|
||||
data/db/*
|
||||
data/static/avatars/*
|
||||
!data/static/avatars/default.webp
|
||||
data/static/badges/user
|
||||
data/_cached
|
||||
|
||||
config/secrets.prod.env
|
||||
config/pyrom_config.toml
|
||||
|
||||
.local/
|
||||
|
||||
73
README.md
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
151
app/__init__.py
@@ -1,21 +1,23 @@
|
||||
from flask import Flask, session, request, render_template
|
||||
from dotenv import load_dotenv
|
||||
from .models import Avatars, Users, PostHistory, Posts, MOTD
|
||||
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
|
||||
import tomllib
|
||||
import json
|
||||
|
||||
def create_default_avatar():
|
||||
if Avatars.count() == 0:
|
||||
@@ -54,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)
|
||||
@@ -64,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.')
|
||||
@@ -99,24 +114,87 @@ def reparse_babycode():
|
||||
|
||||
print('Re-parsing done.')
|
||||
|
||||
def bind_default_badges(path):
|
||||
from .db import db
|
||||
with db.transaction():
|
||||
potential_stales = BadgeUploads.get_default()
|
||||
d = os.listdir(path)
|
||||
for bu in potential_stales:
|
||||
if os.path.basename(bu.file_path) not in d:
|
||||
print(f'Deleted stale default badge{os.path.basename(bu.file_path)}')
|
||||
bu.delete()
|
||||
|
||||
for f in d:
|
||||
real_path = os.path.join(path, f)
|
||||
if not os.path.isfile(real_path):
|
||||
continue
|
||||
if not f.endswith('.webp'):
|
||||
continue
|
||||
proxied_path = f'/static/badges/{f}'
|
||||
bu = BadgeUploads.find({'file_path': proxied_path})
|
||||
if not bu:
|
||||
BadgeUploads.create({
|
||||
'file_path': proxied_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__)
|
||||
app.config.from_file('../config/pyrom_config.toml', load=tomllib.load, text=False)
|
||||
app.config['SITE_NAME'] = 'Pyrom'
|
||||
app.config['DISABLE_SIGNUP'] = False
|
||||
app.config['MODS_CAN_INVITE'] = True
|
||||
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:
|
||||
print('No configuration file found, leaving defaults.')
|
||||
|
||||
if os.getenv("PYROM_PROD") is None:
|
||||
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")
|
||||
|
||||
app.config['AVATAR_UPLOAD_PATH'] = 'data/static/avatars/'
|
||||
app.config['MAX_CONTENT_LENGTH'] = 1000 * 1000
|
||||
app.config['BADGES_PATH'] = 'data/static/badges/'
|
||||
app.config['BADGES_UPLOAD_PATH'] = 'data/static/badges/user/'
|
||||
app.config['MAX_CONTENT_LENGTH'] = 3 * 1000 * 1000 # 3M total, subject to further limits per route
|
||||
|
||||
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 = []
|
||||
@@ -129,18 +207,6 @@ def create_app():
|
||||
allowed_themes.sort(key=(lambda x: (x != 'style', x)))
|
||||
app.config['allowed_themes'] = allowed_themes
|
||||
|
||||
with app.app_context():
|
||||
from .schema import create as create_tables
|
||||
from .migrations import run_migrations
|
||||
create_tables()
|
||||
run_migrations()
|
||||
|
||||
create_default_avatar()
|
||||
create_admin()
|
||||
create_deleted_user()
|
||||
|
||||
reparse_babycode()
|
||||
|
||||
from app.routes.app import bp as app_bp
|
||||
from app.routes.topics import bp as topics_bp
|
||||
from app.routes.threads import bp as threads_bp
|
||||
@@ -160,6 +226,22 @@ def create_app():
|
||||
app.register_blueprint(hyperapi_bp)
|
||||
app.register_blueprint(guides_bp)
|
||||
|
||||
with app.app_context():
|
||||
from .schema import create as create_tables
|
||||
from .migrations import run_migrations
|
||||
create_tables()
|
||||
run_migrations()
|
||||
|
||||
create_default_avatar()
|
||||
create_admin()
|
||||
create_deleted_user()
|
||||
|
||||
clear_stale_sessions()
|
||||
|
||||
reparse_babycode()
|
||||
|
||||
bind_default_badges(app.config['BADGES_PATH'])
|
||||
|
||||
app.config['SESSION_COOKIE_SECURE'] = True
|
||||
|
||||
@app.before_request
|
||||
@@ -189,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")
|
||||
@@ -211,12 +295,12 @@ def create_app():
|
||||
return permission_level_string(term)
|
||||
|
||||
@app.template_filter('babycode')
|
||||
def babycode_filter(markup):
|
||||
return babycode_to_html(markup).result
|
||||
def babycode_filter(markup, nofrag=False):
|
||||
return babycode_to_html(markup, fragment=not nofrag).result
|
||||
|
||||
@app.template_filter('babycode_strict')
|
||||
def babycode_strict_filter(markup):
|
||||
return babycode_to_html(markup, STRICT_BANNED_TAGS).result
|
||||
def babycode_strict_filter(markup, nofrag=False):
|
||||
return babycode_to_html(markup, banned_tags=STRICT_BANNED_TAGS, fragment=not nofrag).result
|
||||
|
||||
@app.template_filter('extract_h2')
|
||||
def extract_h2(content):
|
||||
@@ -228,6 +312,10 @@ def create_app():
|
||||
for id_, text in matches
|
||||
]
|
||||
|
||||
@app.template_filter('basename_noext')
|
||||
def basename_noext(subj):
|
||||
return os.path.splitext(os.path.basename(subj))[0]
|
||||
|
||||
@app.errorhandler(404)
|
||||
def _handle_404(e):
|
||||
if request.path.startswith('/hyperapi/'):
|
||||
@@ -237,6 +325,15 @@ def create_app():
|
||||
else:
|
||||
return render_template('common/404.html'), e.code
|
||||
|
||||
@app.errorhandler(413)
|
||||
def _handle_413(e):
|
||||
if request.path.startswith('/hyperapi/'):
|
||||
return '<h1>request body too large</h1>', e.code
|
||||
elif request.path.startswith('/api/'):
|
||||
return {'error': 'body too large'}, e.code
|
||||
else:
|
||||
return render_template('common/413.html'), e.code
|
||||
|
||||
# this only happens at build time but
|
||||
# build time is when updates are done anyway
|
||||
# sooo... /shrug
|
||||
@@ -249,6 +346,14 @@ def create_app():
|
||||
if subject == 'style':
|
||||
return 'Default'
|
||||
|
||||
return f'{subject.removeprefix('theme-').capitalize()} (beta)'
|
||||
return f'{subject.removeprefix('theme-').replace('-', ' ').capitalize()} (beta)'
|
||||
|
||||
@app.template_filter('fromjson')
|
||||
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
|
||||
|
||||
@@ -31,6 +31,7 @@ class DB:
|
||||
except Exception as e:
|
||||
if in_transaction and self._connection:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
if in_transaction:
|
||||
self._transaction_depth -= 1
|
||||
@@ -126,7 +127,7 @@ class DB:
|
||||
def where(self, condition, operator = "="):
|
||||
if isinstance(condition, dict):
|
||||
for key, value in condition.items():
|
||||
self._where.append((key, "=", value))
|
||||
self._where.append((key, operator, value))
|
||||
elif isinstance(condition, list):
|
||||
for c in condition:
|
||||
self._where.append(c)
|
||||
|
||||
@@ -6,17 +6,203 @@ from pygments.lexers import get_lexer_by_name
|
||||
from pygments.util import ClassNotFound as PygmentsClassNotFound
|
||||
import re
|
||||
|
||||
class BabycodeParseResult:
|
||||
BABYCODE_VERSION = 8
|
||||
|
||||
|
||||
class BabycodeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class BabycodeRenderError(BabycodeError):
|
||||
pass
|
||||
|
||||
|
||||
class UnknownASTElementError(BabycodeRenderError):
|
||||
def __init__(self, element_type, element=None):
|
||||
self.element_type = element_type
|
||||
self.element = element
|
||||
|
||||
message = f'Unknown AST element: {element_type}'
|
||||
if element:
|
||||
message += f' (element: {element})'
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class BabycodeRenderResult:
|
||||
def __init__(self, result, mentions=[]):
|
||||
self.result = result
|
||||
self.mentions = mentions
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return self.result
|
||||
|
||||
|
||||
BABYCODE_VERSION = 5
|
||||
class BabycodeRenderer:
|
||||
def __init__(self, tag_map, void_tag_map, emote_map, fragment=False):
|
||||
self.tag_map = tag_map
|
||||
self.void_tag_map = void_tag_map
|
||||
self.emote_map = emote_map
|
||||
self.fragment = fragment
|
||||
|
||||
def make_mention(self, element):
|
||||
raise NotImplementedError
|
||||
|
||||
def transform_para_whitespace(self, text):
|
||||
# markdown rules:
|
||||
# two spaces at end of line -> <br>
|
||||
text = re.sub(r' +\n', '<br>', text)
|
||||
# single newlines -> space (collapsed)
|
||||
text = re.sub(r'\n', ' ', text)
|
||||
return text
|
||||
|
||||
def wrap_in_paragraphs(self, nodes, context_is_block=True, is_root=False):
|
||||
result = []
|
||||
current_paragraph = []
|
||||
is_first_para = is_root and self.fragment
|
||||
|
||||
def flush_paragraph():
|
||||
# TIL nonlocal exists
|
||||
nonlocal result, current_paragraph, is_first_para
|
||||
if not current_paragraph:
|
||||
return
|
||||
|
||||
para_content = ''.join(current_paragraph)
|
||||
if para_content.strip(): # skip empty paragraphs
|
||||
if is_first_para:
|
||||
result.append(para_content)
|
||||
is_first_para = False
|
||||
else:
|
||||
result.append(f"<p>{para_content}</p>")
|
||||
current_paragraph.clear()
|
||||
|
||||
for node in nodes:
|
||||
if isinstance(node, str):
|
||||
paras = re.split(r'\n\n+', node)
|
||||
for i, para in enumerate(paras):
|
||||
if i > 0 and context_is_block:
|
||||
flush_paragraph()
|
||||
|
||||
if para:
|
||||
processed = self.transform_para_whitespace(para)
|
||||
current_paragraph.append(processed)
|
||||
else:
|
||||
inline = is_inline(node)
|
||||
|
||||
if inline and context_is_block:
|
||||
# inline child within a paragraph context
|
||||
current_paragraph.append(self.fold(node))
|
||||
elif not inline and context_is_block:
|
||||
# block child within a block context
|
||||
flush_paragraph()
|
||||
if is_root:
|
||||
# this is relevant for fragment.
|
||||
# fragment only applies to the first inline node(s).
|
||||
# if the first element is a block, reset "fragment mode".
|
||||
is_first_para = False
|
||||
result.append(self.fold(node))
|
||||
else:
|
||||
# either inline in inline context, or block in inline context
|
||||
current_paragraph.append(self.fold(node))
|
||||
|
||||
if context_is_block:
|
||||
# flush final para if we're in a block context
|
||||
flush_paragraph()
|
||||
elif current_paragraph:
|
||||
# inline context - just append whatever we collected
|
||||
result.append(''.join(current_paragraph))
|
||||
|
||||
return ''.join(result)
|
||||
|
||||
def fold(self, element):
|
||||
if isinstance(element, str):
|
||||
return element
|
||||
|
||||
match element['type']:
|
||||
case 'bbcode':
|
||||
tag_name = element['name']
|
||||
|
||||
if is_inline(element):
|
||||
# inline tag
|
||||
# since its inline, all children should be processed inline
|
||||
content = "".join(self.fold(child) for child in element['children'])
|
||||
return self.tag_map[tag_name](content, element['attr'])
|
||||
else:
|
||||
# block tag
|
||||
if tag_name in {'ul', 'ol', 'code', 'img'}:
|
||||
# these handle their own internal structure
|
||||
content = ''.join(
|
||||
child if isinstance(child, str) else self.fold(child)
|
||||
for child in element['children']
|
||||
)
|
||||
return self.tag_map[tag_name](content, element['attr'])
|
||||
else:
|
||||
# block elements that can contain paragraphs
|
||||
content = self.wrap_in_paragraphs(element['children'], context_is_block=True, is_root=False)
|
||||
return self.tag_map[tag_name](content, element['attr'])
|
||||
case 'bbcode_void':
|
||||
return self.void_tag_map[element['name']](element['attr'])
|
||||
case 'link':
|
||||
return f"<a href=\"{element['url']}\">{element['url']}</a>"
|
||||
case 'emote':
|
||||
return self.emote_map[element['name']]
|
||||
case 'rule':
|
||||
return '<hr>'
|
||||
case 'mention':
|
||||
return self.make_mention(element)
|
||||
case _:
|
||||
raise UnknownASTElementError(
|
||||
element_type=element['type'],
|
||||
element=element
|
||||
)
|
||||
|
||||
def render(self, ast):
|
||||
out = self.wrap_in_paragraphs(ast, context_is_block=True, is_root=True)
|
||||
return out
|
||||
|
||||
|
||||
class HTMLRenderer(BabycodeRenderer):
|
||||
def __init__(self, fragment=False):
|
||||
super().__init__(TAGS, VOID_TAGS, EMOJI, fragment)
|
||||
|
||||
self.mentions = []
|
||||
|
||||
def make_mention(self, e):
|
||||
from ..models import Users
|
||||
from flask import url_for, current_app
|
||||
with current_app.test_request_context('/'):
|
||||
target_user = Users.find({'username': e['name'].lower()})
|
||||
if not target_user:
|
||||
return f"@{e['name']}"
|
||||
|
||||
mention_data = {
|
||||
'mention_text': f"@{e['name']}",
|
||||
'mentioned_user_id': int(target_user.id),
|
||||
"start": e['start'],
|
||||
"end": e['end'],
|
||||
}
|
||||
if mention_data not in self.mentions:
|
||||
self.mentions.append(mention_data)
|
||||
|
||||
return f"<a class='mention{' display' if target_user.has_display_name() else ''}' href='{url_for('users.page', username=target_user.username)}' title='@{target_user.username}' data-init='highlightMentions' data-username='{target_user.username}'>{'@' if not target_user.has_display_name() else ''}{target_user.get_readable_name()}</a>"
|
||||
|
||||
def render(self, ast):
|
||||
out = super().render(ast)
|
||||
return BabycodeRenderResult(out, self.mentions)
|
||||
|
||||
|
||||
class RSSXMLRenderer(BabycodeRenderer):
|
||||
def __init__(self, fragment=False):
|
||||
super().__init__(RSS_TAGS, VOID_TAGS, RSS_EMOJI, fragment)
|
||||
|
||||
def make_mention(self, e):
|
||||
from ..models import Users
|
||||
from flask import url_for
|
||||
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}">{'@' if not target_user.has_display_name() else ''}{target_user.get_readable_name()}</a>'
|
||||
|
||||
|
||||
NAMED_COLORS = [
|
||||
'black', 'silver', 'gray', 'white', 'maroon', 'red',
|
||||
@@ -49,113 +235,10 @@ NAMED_COLORS = [
|
||||
'violet', 'wheat', 'white', 'whitesmoke', 'yellow', 'yellowgreen',
|
||||
]
|
||||
|
||||
def is_tag(e, tag=None):
|
||||
if e is None:
|
||||
return False
|
||||
if isinstance(e, str):
|
||||
return False
|
||||
if e['type'] != 'bbcode':
|
||||
return False
|
||||
|
||||
if tag is None:
|
||||
return True
|
||||
|
||||
return e['name'] == tag
|
||||
|
||||
def is_text(e):
|
||||
return isinstance(e, str)
|
||||
|
||||
def tag_code(children, attr, surrounding):
|
||||
is_inline = children.find('\n') == -1
|
||||
if is_inline:
|
||||
return f"<code class=\"inline-code\">{children}</code>"
|
||||
else:
|
||||
input_code = children.strip()
|
||||
button = f"<button type=button class=\"copy-code\" value=\"{input_code}\" data-send=\"copyCode\" data-receive=\"copyCode\">Copy</button>"
|
||||
unhighlighted = f"<pre><span class=\"copy-code-container\"><span class=\"code-language-identifier\">code block</span>{button}</span><code>{input_code}</code></pre>"
|
||||
if not attr:
|
||||
return unhighlighted
|
||||
try:
|
||||
lexer = get_lexer_by_name(attr.strip())
|
||||
formatter = HtmlFormatter(nowrap=True)
|
||||
return f"<pre><span class=\"copy-code-container\"><span class=\"code-language-identifier\">{lexer.name}</span>{button}</span><code>{highlight(input_code.unescape(), lexer, formatter)}</code></pre>"
|
||||
except PygmentsClassNotFound:
|
||||
return unhighlighted
|
||||
|
||||
def tag_list(children):
|
||||
list_body = re.sub(r" +\n", "<br>", children.strip())
|
||||
list_body = re.sub(r"\n\n+", "\1", list_body)
|
||||
return " ".join([f"<li>{x}</li>" for x in list_body.split("\1") if x])
|
||||
|
||||
def tag_color(children, attr, surrounding):
|
||||
if not attr:
|
||||
return f"[color]{children}[/color]"
|
||||
|
||||
hex_re = r"^#?([0-9a-f]{6}|[0-9a-f]{3})$"
|
||||
potential_color = attr.lower().strip()
|
||||
|
||||
if potential_color in NAMED_COLORS:
|
||||
return f"<span style='color: {potential_color};'>{children}</span>"
|
||||
|
||||
m = re.match(hex_re, potential_color)
|
||||
if m:
|
||||
return f"<span style='color: #{m.group(1)};'>{children}</span>"
|
||||
|
||||
# return just the way it was if we can't parse it
|
||||
return f"[color={attr}]{children}[/color]"
|
||||
|
||||
def tag_spoiler(children, attr, surrounding):
|
||||
spoiler_name = attr if attr else "Spoiler"
|
||||
content = f"<div class='accordion-content post-accordion-content hidden'>{children}</div>"
|
||||
container = f"""<div class='accordion hidden' data-receive='toggleAccordion'><div class='accordion-header'><button type='button' class='accordion-toggle' data-send='toggleAccordion'>+</button><span>{spoiler_name}</span></div>{content}</div>"""
|
||||
return container
|
||||
|
||||
def tag_image(children, attr, surrounding):
|
||||
img = f"<img class=\"post-image\" src=\"{attr}\" alt=\"{children}\">"
|
||||
if not is_tag(surrounding[0], 'img'):
|
||||
img = f"<div class=post-img-container>{img}"
|
||||
if not is_tag(surrounding[1], 'img'):
|
||||
img = f"{img}</div>"
|
||||
return img
|
||||
|
||||
TAGS = {
|
||||
"b": lambda children, attr, _: f"<strong>{children}</strong>",
|
||||
"i": lambda children, attr, _: f"<em>{children}</em>",
|
||||
"s": lambda children, attr, _: f"<del>{children}</del>",
|
||||
"u": lambda children, attr, _: f"<u>{children}</u>",
|
||||
|
||||
"img": tag_image,
|
||||
"url": lambda children, attr, _: f"<a href={attr}>{children}</a>",
|
||||
"quote": lambda children, attr, _: f"<blockquote>{children}</blockquote>",
|
||||
"code": tag_code,
|
||||
"ul": lambda children, attr, _: f"<ul>{tag_list(children)}</ul>",
|
||||
"ol": lambda children, attr, _: f"<ol>{tag_list(children)}</ol>",
|
||||
|
||||
"big": lambda children, attr, _: f"<span style='font-size: 2rem;'>{children}</span>",
|
||||
"small": lambda children, attr, _: f"<span style='font-size: 0.75rem;'>{children}</span>",
|
||||
"color": tag_color,
|
||||
|
||||
"center": lambda children, attr, _: f"<div style='text-align: center;'>{children}</div>",
|
||||
"right": lambda children, attr, _: f"<div style='text-align: right;'>{children}</div>",
|
||||
|
||||
"spoiler": tag_spoiler,
|
||||
}
|
||||
|
||||
VOID_TAGS = {
|
||||
'lb': lambda attr: '[',
|
||||
'rb': lambda attr: ']',
|
||||
'@': lambda attr: '@',
|
||||
}
|
||||
|
||||
# [img] is considered block for the purposes of collapsing whitespace,
|
||||
# despite being potentially inline (since the resulting <img> tag is inline, but creates a block container around itself and sibling images).
|
||||
# [code] has a special case in is_inline().
|
||||
INLINE_TAGS = {
|
||||
'b', 'i', 's', 'u', 'color', 'big', 'small', 'url'
|
||||
}
|
||||
|
||||
def make_emoji(name, code):
|
||||
return f' <img class=emoji src="/static/emoji/{name}.png" alt="{name}" title=":{code}:">'
|
||||
return f'<img class=emoji src="/static/emoji/{name}.png" alt="{name}" title=":{code}:">'
|
||||
|
||||
|
||||
EMOJI = {
|
||||
'angry': make_emoji('angry', 'angry'),
|
||||
@@ -203,12 +286,208 @@ EMOJI = {
|
||||
'wink': make_emoji('wink', 'wink'),
|
||||
}
|
||||
|
||||
|
||||
RSS_EMOJI = {
|
||||
**EMOJI,
|
||||
|
||||
'angry': '😡',
|
||||
|
||||
'(': '🙁',
|
||||
|
||||
'D': '😃',
|
||||
|
||||
'imp': '😈',
|
||||
|
||||
'angryimp': '👿',
|
||||
'impangry': '👿',
|
||||
|
||||
'lobster': '🦞',
|
||||
|
||||
'|': '😐',
|
||||
|
||||
'pensive': '😔',
|
||||
|
||||
'scissors': '✂️',
|
||||
|
||||
')': '🙂',
|
||||
|
||||
'smiletear': '🥲',
|
||||
'crytear': '🥲',
|
||||
|
||||
',': '😭',
|
||||
'T': '😭',
|
||||
'cry': '😭',
|
||||
'sob': '😭',
|
||||
|
||||
'o': '😮',
|
||||
'O': '😮',
|
||||
|
||||
'hmm': '🤔',
|
||||
'think': '🤔',
|
||||
'thinking': '🤔',
|
||||
|
||||
'P': '😛',
|
||||
'p': '😛',
|
||||
|
||||
'weary': '😩',
|
||||
|
||||
';': '😉',
|
||||
'wink': '😉',
|
||||
}
|
||||
|
||||
|
||||
TEXT_ONLY = ["code"]
|
||||
|
||||
def break_lines(text):
|
||||
text = re.sub(r" +\n", "<br>", text)
|
||||
text = re.sub(r"\n\n+", "<br><br>", text)
|
||||
return text
|
||||
|
||||
def tag_code(children, attr):
|
||||
is_inline = children.find('\n') == -1
|
||||
if is_inline:
|
||||
return f"<code class=\"inline-code\">{children}</code>"
|
||||
else:
|
||||
input_code = children.strip()
|
||||
button = f"<button type=button class=\"copy-code\" value=\"{input_code}\" data-send=\"copyCode\" data-receive=\"copyCode\">Copy</button>"
|
||||
unhighlighted = f"<pre><span class=\"copy-code-container\"><span class=\"code-language-identifier\">code block</span>{button}</span><code>{input_code}</code></pre>"
|
||||
if not attr:
|
||||
return unhighlighted
|
||||
try:
|
||||
lexer = get_lexer_by_name(attr.strip())
|
||||
formatter = HtmlFormatter(nowrap=True)
|
||||
return f"<pre><span class=\"copy-code-container\"><span class=\"code-language-identifier\">{lexer.name}</span>{button}</span><code>{highlight(Markup(input_code).unescape(), lexer, formatter)}</code></pre>"
|
||||
except PygmentsClassNotFound:
|
||||
return unhighlighted
|
||||
|
||||
|
||||
def tag_list(children):
|
||||
list_body = re.sub(r" +\n", "<br>", children.strip())
|
||||
list_body = re.sub(r"\n\n+", "\1", list_body)
|
||||
return " ".join([f"<li>{x}</li>" for x in list_body.split("\1") if x])
|
||||
|
||||
|
||||
def tag_color(children, attr):
|
||||
if not attr:
|
||||
return f"[color]{children}[/color]"
|
||||
|
||||
hex_re = r"^#?([0-9a-f]{6}|[0-9a-f]{3})$"
|
||||
potential_color = attr.lower().strip()
|
||||
|
||||
if potential_color in NAMED_COLORS:
|
||||
return f"<span style='color: {potential_color};'>{children}</span>"
|
||||
|
||||
m = re.match(hex_re, potential_color)
|
||||
if m:
|
||||
return f"<span style='color: #{m.group(1)};'>{children}</span>"
|
||||
|
||||
# return just the way it was if we can't parse it
|
||||
return f"[color={attr}]{children}[/color]"
|
||||
|
||||
|
||||
def tag_spoiler(children, attr):
|
||||
spoiler_name = attr if attr else "Spoiler"
|
||||
content = f"<div class='accordion-content post-accordion-content hidden'>{children}</div>"
|
||||
container = f"""<div class='accordion hidden' data-receive='toggleAccordion'><div class='accordion-header'><button type='button' class='accordion-toggle' data-send='toggleAccordion'>+</button><span>{spoiler_name}</span></div>{content}</div>"""
|
||||
return container
|
||||
|
||||
|
||||
def tag_image(children, attr):
|
||||
img = f"<img class=\"post-image\" src=\"{attr}\" alt=\"{children}\">"
|
||||
return f"<div class=post-img-container>{img}</div>"
|
||||
|
||||
|
||||
TAGS = {
|
||||
"b": lambda children, attr: f"<strong>{children}</strong>",
|
||||
"i": lambda children, attr: f"<em>{children}</em>",
|
||||
"s": lambda children, attr: f"<del>{children}</del>",
|
||||
"u": lambda children, attr: f"<u>{children}</u>",
|
||||
|
||||
"img": tag_image,
|
||||
"url": lambda children, attr: f"<a href={attr}>{children}</a>",
|
||||
"quote": lambda children, attr: f"<blockquote>{children}</blockquote>",
|
||||
"code": tag_code,
|
||||
"ul": lambda children, attr: f"<ul>{tag_list(children)}</ul>",
|
||||
"ol": lambda children, attr: f"<ol>{tag_list(children)}</ol>",
|
||||
|
||||
"big": lambda children, attr: f"<span style='font-size: 2rem;'>{children}</span>",
|
||||
"small": lambda children, attr: f"<span style='font-size: 0.75rem;'>{children}</span>",
|
||||
"color": tag_color,
|
||||
|
||||
"center": lambda children, attr: f"<div style='text-align: center;'>{children}</div>",
|
||||
"right": lambda children, attr: f"<div style='text-align: right;'>{children}</div>",
|
||||
|
||||
"spoiler": tag_spoiler,
|
||||
}
|
||||
|
||||
|
||||
def tag_code_rss(children, attr):
|
||||
is_inline = children.find('\n') == -1
|
||||
if is_inline:
|
||||
return f'<code>{children}</code>'
|
||||
else:
|
||||
return f'<pre><code>{children}</code></pre>'
|
||||
|
||||
|
||||
def tag_url_rss(children, attr):
|
||||
if attr.startswith('/'):
|
||||
from flask import current_app
|
||||
uri = f"{current_app.config['PREFERRED_URL_SCHEME']}://{current_app.config['SERVER_NAME']}{attr}"
|
||||
return f"<a href={uri}>{children}</a>"
|
||||
|
||||
return f"<a href={attr}>{children}</a>"
|
||||
|
||||
|
||||
def tag_image_rss(children, attr):
|
||||
if attr.startswith('/'):
|
||||
from flask import current_app
|
||||
uri = f"{current_app.config['PREFERRED_URL_SCHEME']}://{current_app.config['SERVER_NAME']}{attr}"
|
||||
return f'<img src="{uri}" alt={children} />'
|
||||
|
||||
return f'<img src="{attr}" alt={children} />'
|
||||
|
||||
|
||||
RSS_TAGS = {
|
||||
**TAGS,
|
||||
'img': tag_image_rss,
|
||||
'url': tag_url_rss,
|
||||
'spoiler': lambda children, attr: f'<details><summary>{attr or "Spoiler"} (click to reveal)</summary>{children}</details>',
|
||||
'code': tag_code_rss,
|
||||
|
||||
'big': lambda children, attr: f'<span style="font-size: 1.2em">{children}</span>',
|
||||
'small': lambda children, attr: f'<small>{children}</small>'
|
||||
}
|
||||
|
||||
|
||||
VOID_TAGS = {
|
||||
'lb': lambda attr: '[',
|
||||
'rb': lambda attr: ']',
|
||||
'at': lambda attr: '@',
|
||||
'd': lambda attr: '-',
|
||||
}
|
||||
|
||||
|
||||
# [img] is considered block for the purposes of collapsing whitespace,
|
||||
# despite being potentially inline (since the resulting <img> tag is inline, but creates a block container around itself and sibling images).
|
||||
# [code] has a special case in is_inline().
|
||||
INLINE_TAGS = {
|
||||
'b', 'i', 's', 'u', 'color', 'big', 'small', 'url', 'lb', 'rb', 'at', 'd'
|
||||
}
|
||||
|
||||
|
||||
def is_tag(e, tag=None):
|
||||
if e is None:
|
||||
return False
|
||||
if isinstance(e, str):
|
||||
return False
|
||||
if e['type'] != 'bbcode' and e['type'] != 'bbcode_void':
|
||||
return False
|
||||
|
||||
if tag is None:
|
||||
return True
|
||||
|
||||
return e['name'] == tag
|
||||
|
||||
|
||||
def is_text(e):
|
||||
return isinstance(e, str)
|
||||
|
||||
|
||||
def is_inline(e):
|
||||
if e is None:
|
||||
@@ -219,29 +498,12 @@ def is_inline(e):
|
||||
|
||||
if is_tag(e):
|
||||
if is_tag(e, 'code'): # special case, since [code] can be inline OR block
|
||||
return '\n' not in e['children']
|
||||
return '\n' not in e['children'][0]
|
||||
|
||||
return e['name'] in INLINE_TAGS
|
||||
|
||||
return e['type'] != 'rule'
|
||||
|
||||
def make_mention(e, mentions):
|
||||
from ..models import Users
|
||||
from flask import url_for
|
||||
target_user = Users.find({'username': e['name'].lower()})
|
||||
if not target_user:
|
||||
return f"@{e['name']}"
|
||||
|
||||
mention_data = {
|
||||
'mention_text': f"@{e['name']}",
|
||||
'mentioned_user_id': int(target_user.id),
|
||||
"start": e['start'],
|
||||
"end": e['end'],
|
||||
}
|
||||
if mention_data not in mentions:
|
||||
mentions.append(mention_data)
|
||||
|
||||
return f"<a class='mention{' display' if target_user.has_display_name() else ''}' href='{url_for('users.page', username=target_user.username)}' title='@{target_user.username}' data-init='highlightMentions' data-username='{target_user.username}'>{'@' if not target_user.has_display_name() else ''}{target_user.get_readable_name()}</a>"
|
||||
|
||||
def should_collapse(text, surrounding):
|
||||
if not isinstance(text, str):
|
||||
@@ -255,10 +517,30 @@ def should_collapse(text, surrounding):
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def sanitize(s):
|
||||
return escape(s.strip().replace('\r\n', '\n').replace('\r', '\n'))
|
||||
|
||||
def babycode_to_html(s, banned_tags=[]):
|
||||
|
||||
def babycode_ast(s: str, banned_tags=[]):
|
||||
"""
|
||||
transforms a string of babycode into an AST.
|
||||
the AST is a list of strings or dicts.
|
||||
|
||||
a string element is plain unformatted text.
|
||||
|
||||
a dict element is a node that contains at least the key `type`.
|
||||
|
||||
possible types are:
|
||||
- bbcode
|
||||
- bbcode_void
|
||||
- link
|
||||
- emote
|
||||
- rule
|
||||
- mention
|
||||
|
||||
bbcode type elements have a children key that is a list of children of that node. the children are themselves elements (string or dict).
|
||||
"""
|
||||
allowed_tags = set(TAGS.keys())
|
||||
if banned_tags is not None:
|
||||
for tag in banned_tags:
|
||||
@@ -281,44 +563,38 @@ def babycode_to_html(s, banned_tags=[]):
|
||||
)
|
||||
if not should_collapse(e, surrounding):
|
||||
elements.append(e)
|
||||
return elements
|
||||
|
||||
out = ""
|
||||
mentions = []
|
||||
def fold(element, nobr, surrounding):
|
||||
if isinstance(element, str):
|
||||
if nobr:
|
||||
return element
|
||||
return break_lines(element)
|
||||
|
||||
match element['type']:
|
||||
case "bbcode":
|
||||
c = ""
|
||||
for i in range(len(element['children'])):
|
||||
child = element['children'][i]
|
||||
_surrounding = (
|
||||
element['children'][i - 1] if i-1 >= 0 else None,
|
||||
element['children'][i + 1] if i+1 < len(element['children']) else None
|
||||
)
|
||||
_nobr = element['name'] == "code" or element['name'] == "ul" or element['name'] == "ol"
|
||||
c = c + Markup(fold(child, _nobr, _surrounding))
|
||||
res = TAGS[element['name']](c, element['attr'], surrounding)
|
||||
return res
|
||||
case "bbcode_void":
|
||||
return VOID_TAGS[element['name']](element['attr'])
|
||||
case "link":
|
||||
return f"<a href=\"{element['url']}\">{element['url']}</a>"
|
||||
case 'emote':
|
||||
return EMOJI[element['name']]
|
||||
case "rule":
|
||||
return "<hr>"
|
||||
case "mention":
|
||||
return make_mention(element, mentions)
|
||||
def babycode_to_html(s: str, banned_tags=[], fragment=False) -> BabycodeRenderResult:
|
||||
"""
|
||||
transforms a string of babycode into html.
|
||||
|
||||
for i in range(len(elements)):
|
||||
e = elements[i]
|
||||
surrounding = (
|
||||
elements[i - 1] if i-1 >= 0 else None,
|
||||
elements[i + 1] if i+1 < len(elements) else None
|
||||
)
|
||||
out = out + fold(e, False, surrounding)
|
||||
return BabycodeParseResult(out, mentions)
|
||||
parameters:
|
||||
|
||||
s (str) - babycode string
|
||||
|
||||
banned_tags (list) - babycode tags to exclude from being parsed. they will remain as plain text in the transformation.
|
||||
|
||||
fragment (bool) - skip adding an html p tag to the first element if it is inline.
|
||||
"""
|
||||
ast = babycode_ast(s, banned_tags)
|
||||
r = HTMLRenderer(fragment=fragment)
|
||||
return r.render(ast)
|
||||
|
||||
|
||||
def babycode_to_rssxml(s: str, banned_tags=[], fragment=False) -> str:
|
||||
"""
|
||||
transforms a string of babycode into rss-compatible x/html.
|
||||
|
||||
parameters:
|
||||
|
||||
s (str) - babycode string
|
||||
|
||||
banned_tags (list) - babycode tags to exclude from being parsed. they will remain as plain text in the transformation.
|
||||
|
||||
fragment (bool) - skip adding an html p tag to the first element if it is inline.
|
||||
"""
|
||||
ast = babycode_ast(s, banned_tags)
|
||||
r = RSSXMLRenderer(fragment=fragment)
|
||||
return r.render(ast)
|
||||
|
||||
9
app/lib/exceptions.py
Normal 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
@@ -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
|
||||
@@ -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():
|
||||
|
||||
@@ -116,6 +116,9 @@ class Users(Model):
|
||||
def has_display_name(self):
|
||||
return self.display_name != ''
|
||||
|
||||
def get_badges(self):
|
||||
return Badges.findall({'user_id': int(self.id)})
|
||||
|
||||
|
||||
class Topics(Model):
|
||||
table = "topics"
|
||||
@@ -227,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"
|
||||
@@ -235,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)
|
||||
|
||||
@@ -243,14 +282,32 @@ class Threads(Model):
|
||||
|
||||
class Posts(Model):
|
||||
FULL_POSTS_QUERY = """
|
||||
WITH 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
|
||||
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,
|
||||
users.signature_rendered, threads.slug AS thread_slug,
|
||||
threads.is_locked AS thread_is_locked, threads.title AS thread_title
|
||||
threads.is_locked AS thread_is_locked, threads.title AS thread_title,
|
||||
COALESCE(user_badges.badges_json, '[]') AS badges_json
|
||||
FROM
|
||||
posts
|
||||
JOIN
|
||||
@@ -260,7 +317,9 @@ class Posts(Model):
|
||||
JOIN
|
||||
threads ON posts.thread_id = threads.id
|
||||
LEFT JOIN
|
||||
avatars ON users.avatar_id = avatars.id"""
|
||||
avatars ON users.avatar_id = avatars.id
|
||||
LEFT JOIN
|
||||
user_badges ON users.id = user_badges.user_id"""
|
||||
|
||||
table = "posts"
|
||||
|
||||
@@ -434,3 +493,31 @@ class MOTD(Model):
|
||||
|
||||
class Mentions(Model):
|
||||
table = 'mentions'
|
||||
|
||||
|
||||
class BadgeUploads(Model):
|
||||
table = 'badge_uploads'
|
||||
|
||||
@classmethod
|
||||
def get_default(cls):
|
||||
return BadgeUploads.findall({'user_id': None}, 'IS')
|
||||
|
||||
@classmethod
|
||||
def get_for_user(cls, user_id):
|
||||
q = "SELECT * FROM badge_uploads WHERE user_id = ? OR user_id IS NULL ORDER BY uploaded_at"
|
||||
res = db.query(q, int(user_id))
|
||||
return [cls.from_data(row) for row in res]
|
||||
|
||||
@classmethod
|
||||
def get_unused_for_user(cls, user_id):
|
||||
q = 'SELECT bu.* FROM badge_uploads bu LEFT JOIN badges b ON bu.id = b.upload WHERE bu.user_id = ? AND b.upload IS NULL'
|
||||
res = db.query(q, int(user_id))
|
||||
return [cls.from_data(row) for row in res]
|
||||
|
||||
|
||||
class Badges(Model):
|
||||
table = 'badges'
|
||||
|
||||
def get_image_url(self):
|
||||
bu = BadgeUploads.find({'id': int(self.upload)})
|
||||
return bu.file_path
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from flask import Blueprint, request, url_for
|
||||
from flask import Blueprint, request, url_for, make_response
|
||||
from ..lib.babycode import babycode_to_html
|
||||
from ..constants import REACTION_EMOJI
|
||||
from .users import is_logged_in, get_active_user
|
||||
from ..models import APIRateLimits, Threads, Reactions, Users, BookmarkCollections, BookmarkedThreads, BookmarkedPosts
|
||||
from ..models import APIRateLimits, Threads, Reactions, Users, BookmarkCollections, BookmarkedThreads, BookmarkedPosts, BadgeUploads
|
||||
from ..db import db
|
||||
|
||||
bp = Blueprint("api", __name__, url_prefix="/api/")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from flask import Blueprint, redirect, url_for, render_template
|
||||
from datetime import datetime
|
||||
|
||||
bp = Blueprint("app", __name__, url_prefix = "/")
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from flask import Blueprint, render_template, abort, request
|
||||
from .users import get_active_user, is_logged_in
|
||||
from ..models import BookmarkCollections, BookmarkedPosts, BookmarkedThreads
|
||||
from ..models import BookmarkCollections, BookmarkedPosts, BookmarkedThreads, BadgeUploads, Badges
|
||||
from functools import wraps
|
||||
|
||||
bp = Blueprint('hyperapi', __name__, url_prefix='/hyperapi/')
|
||||
@@ -26,7 +26,7 @@ def handle_403(e):
|
||||
return "<h1>forbidden</h1>", 403
|
||||
|
||||
|
||||
@bp.get('bookmarks-dropdown/<bookmark_type>')
|
||||
@bp.get('/bookmarks-dropdown/<bookmark_type>')
|
||||
@login_required
|
||||
@account_required
|
||||
def bookmarks_dropdown(bookmark_type):
|
||||
@@ -51,3 +51,12 @@ def bookmarks_dropdown(bookmark_type):
|
||||
|
||||
|
||||
return render_template('components/bookmarks_dropdown.html', collections=collections, id=concept_id, selected=selected, type=bookmark_type, memo=memo, require_reload=require_reload)
|
||||
|
||||
|
||||
@bp.get('/badge-editor')
|
||||
@login_required
|
||||
@account_required
|
||||
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)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -8,7 +8,7 @@ from ..models import (
|
||||
Users, Sessions, Subscriptions,
|
||||
Avatars, PasswordResetLinks, InviteKeys,
|
||||
BookmarkCollections, BookmarkedThreads,
|
||||
Mentions, PostHistory,
|
||||
Mentions, PostHistory, Badges, BadgeUploads,
|
||||
)
|
||||
from ..constants import InfoboxKind, PermissionLevel, SIG_BANNED_TAGS
|
||||
from ..auth import digest, verify
|
||||
@@ -20,12 +20,17 @@ import time
|
||||
import re
|
||||
import os
|
||||
|
||||
AVATAR_MAX_SIZE = 1000 * 1000
|
||||
BADGE_MAX_SIZE = 1000 * 500
|
||||
|
||||
bp = Blueprint("users", __name__, url_prefix = "/users/")
|
||||
|
||||
|
||||
def validate_and_create_avatar(input_image, filename):
|
||||
try:
|
||||
with Image(blob=input_image) as img:
|
||||
if hasattr(img, 'sequence') and len(img.sequence) > 1:
|
||||
img = Image(image=img.sequence[0])
|
||||
img.strip()
|
||||
img.gravity = 'center'
|
||||
|
||||
@@ -52,9 +57,34 @@ def validate_and_create_avatar(input_image, filename):
|
||||
except WandException:
|
||||
return False
|
||||
|
||||
def validate_and_create_badge(input_image, filename):
|
||||
try:
|
||||
with Image(blob=input_image) as img:
|
||||
if img.width != 88 or img.height != 31:
|
||||
return False
|
||||
if hasattr(img, 'sequence') and len(img.sequence) > 1:
|
||||
img = Image(image=img.sequence[0])
|
||||
img.strip()
|
||||
|
||||
img.format = 'webp'
|
||||
img.compression_quality = 90
|
||||
img.save(filename=filename)
|
||||
return True
|
||||
except WandException:
|
||||
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():
|
||||
@@ -63,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})
|
||||
|
||||
|
||||
@@ -374,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')
|
||||
@@ -400,7 +433,7 @@ def settings_form(username):
|
||||
else:
|
||||
rendered_sig = ''
|
||||
session['subscribe_by_default'] = request.form.get('subscribe_by_default', default='off') == 'on'
|
||||
display_name = request.form.get('display_name', default='')
|
||||
display_name = request.form.get('display_name', default='').replace('@', '_')
|
||||
if not validate_display_name(display_name):
|
||||
flash('Invalid display name.', InfoboxKind.ERROR)
|
||||
return redirect('.settings', username=user.username)
|
||||
@@ -451,6 +484,14 @@ def set_avatar(username):
|
||||
flash('Avatar missing.', InfoboxKind.ERROR)
|
||||
return redirect(url_for('.settings', username=user.username))
|
||||
|
||||
file.seek(0, os.SEEK_END)
|
||||
file_size = file.tell()
|
||||
file.seek(0, os.SEEK_SET)
|
||||
|
||||
if file_size > AVATAR_MAX_SIZE:
|
||||
flash('Avatar image is over 1MB.', InfoboxKind.ERROR)
|
||||
return redirect(url_for('.settings', username=user.username))
|
||||
|
||||
file_bytes = file.read()
|
||||
|
||||
now = int(time.time())
|
||||
@@ -616,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
|
||||
@@ -636,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
|
||||
@@ -669,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'],
|
||||
})
|
||||
@@ -835,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:
|
||||
@@ -843,3 +908,96 @@ def delete_page_confirm(username):
|
||||
session.clear()
|
||||
target_user.delete()
|
||||
return redirect(url_for('topics.all_topics'))
|
||||
|
||||
|
||||
@bp.post('/<username>/save-badges')
|
||||
@login_required
|
||||
@redirect_to_own
|
||||
def save_badges(username):
|
||||
user = get_active_user()
|
||||
badge_choices = request.form.getlist('badge_choice[]')
|
||||
badge_files = request.files.getlist('badge_file[]')
|
||||
badge_labels = request.form.getlist('badge_label[]')
|
||||
badge_links = request.form.getlist('badge_link[]')
|
||||
|
||||
if not (len(badge_choices) == len(badge_files) == len(badge_labels) == len(badge_links)):
|
||||
return 'nope'
|
||||
pending_badges = []
|
||||
rejected_filenames = []
|
||||
|
||||
# lack of file can be checked with a simple `if not file`
|
||||
|
||||
for i in range(len(badge_choices)):
|
||||
is_custom = badge_choices[i] == 'custom'
|
||||
file = badge_files[i]
|
||||
pending_badge = {
|
||||
'upload': badge_choices[i],
|
||||
'is_custom': is_custom,
|
||||
'label': badge_labels[i],
|
||||
'link': badge_links[i],
|
||||
'sort_order': i,
|
||||
}
|
||||
if is_custom:
|
||||
file.seek(0, os.SEEK_END)
|
||||
file_size = file.tell()
|
||||
file.seek(0, os.SEEK_SET)
|
||||
|
||||
if file_size >= BADGE_MAX_SIZE:
|
||||
rejected_filenames.append(file.filename)
|
||||
continue
|
||||
|
||||
file_bytes = file.read()
|
||||
|
||||
pending_badge['original_filename'] = file.filename
|
||||
|
||||
now = int(time.time())
|
||||
filename = f'u{user.id}d{now}s{i}.webp'
|
||||
output_path = os.path.join(current_app.config['BADGES_UPLOAD_PATH'], filename)
|
||||
proxied_filename = f'/static/badges/user/{filename}'
|
||||
res = validate_and_create_badge(file_bytes, output_path)
|
||||
if not res:
|
||||
rejected_filenames.append(file.filename)
|
||||
continue
|
||||
|
||||
pending_badge['proxied_filename'] = proxied_filename
|
||||
pending_badge['uploaded_at'] = now
|
||||
|
||||
pending_badges.append(pending_badge)
|
||||
|
||||
if rejected_filenames:
|
||||
flash(f'Invalid badges.;Some of your uploaded badges are incorrect: {", ".join(rejected_filenames)}. Your badges have not been modified.', InfoboxKind.ERROR)
|
||||
return redirect(url_for('.settings', username=user.username))
|
||||
|
||||
with db.transaction():
|
||||
existing_badges = Badges.findall({'user_id': int(user.id)})
|
||||
for badge in existing_badges:
|
||||
badge.delete()
|
||||
|
||||
with db.transaction():
|
||||
for pending_badge in pending_badges:
|
||||
if pending_badge['is_custom']:
|
||||
bu = BadgeUploads.create({
|
||||
'file_path': pending_badge['proxied_filename'],
|
||||
'uploaded_at': pending_badge['uploaded_at'],
|
||||
'original_filename': pending_badge['original_filename'],
|
||||
'user_id': int(user.id),
|
||||
})
|
||||
else:
|
||||
bu = BadgeUploads.find({
|
||||
'id': int(pending_badge['upload'])
|
||||
})
|
||||
badge = Badges.create({
|
||||
'user_id': int(user.id),
|
||||
'upload': int(bu.id),
|
||||
'label': pending_badge['label'],
|
||||
'link': pending_badge['link'],
|
||||
'sort_order': pending_badge['sort_order']
|
||||
})
|
||||
|
||||
for stale_upload in BadgeUploads.get_unused_for_user(user.id):
|
||||
filename = os.path.join(current_app.config['BADGES_UPLOAD_PATH'], os.path.basename(stale_upload.file_path))
|
||||
os.remove(filename)
|
||||
stale_upload.delete()
|
||||
|
||||
flash('Badges saved.', InfoboxKind.INFO)
|
||||
return redirect(url_for('.settings', username=user.username))
|
||||
|
||||
@@ -141,6 +141,23 @@ SCHEMA = [
|
||||
"original_mention_text" TEXT NOT NULL
|
||||
)""",
|
||||
|
||||
"""CREATE TABLE IF NOT EXISTS "badge_uploads" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY,
|
||||
"file_path" TEXT NOT NULL UNIQUE,
|
||||
"uploaded_at" INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP)),
|
||||
"original_filename" TEXT,
|
||||
"user_id" REFERENCES users(id) ON DELETE CASCADE
|
||||
)""",
|
||||
|
||||
"""CREATE TABLE IF NOT EXISTS "badges" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY,
|
||||
"user_id" NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
"upload" NOT NULL REFERENCES badge_uploads(id) ON DELETE CASCADE,
|
||||
"label" TEXT NOT NULL,
|
||||
"link" TEXT DEFAULT '',
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0
|
||||
)""",
|
||||
|
||||
# INDEXES
|
||||
"CREATE INDEX IF NOT EXISTS idx_post_history_post_id ON post_history(post_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_posts_thread ON posts(thread_id, created_at, id)",
|
||||
@@ -167,6 +184,9 @@ SCHEMA = [
|
||||
|
||||
"CREATE INDEX IF NOT EXISTS idx_mentioned_user ON mentions(mentioned_user_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_mention_revision_id ON mentions(revision_id)",
|
||||
|
||||
"CREATE INDEX IF NOT EXISTS idx_badge_upload_user ON badge_uploads(user_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_badge_user ON badges(user_id)",
|
||||
]
|
||||
|
||||
def create():
|
||||
|
||||
20
app/templates/base.atom
Normal 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>
|
||||
@@ -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 }}">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}not found{% endblock %}
|
||||
{% block content %}
|
||||
<div class="darkbg settings-container">
|
||||
<h1 class="thread-title">404 Not Found</h1>
|
||||
<div class="darkbg">
|
||||
<h1>404 Not Found</h1>
|
||||
<p>The requested URL does not exist.</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
8
app/templates/common/413.html
Normal file
@@ -0,0 +1,8 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}request entity too large{% endblock %}
|
||||
{% block content %}
|
||||
<div class="darkbg">
|
||||
<h1>413 Request Entity Too Large</h1>
|
||||
<p>The file(s) you tried to upload are too large.</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -138,6 +142,17 @@
|
||||
</form>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro badge_button(badge) %}
|
||||
{% set img_url = badge.file_path if badge.file_path else badge.get_image_url() %}
|
||||
{% if badge.link %}
|
||||
<a href="{{badge.link}}" rel="noopener noreferrer me" target="_blank">
|
||||
{% endif %}
|
||||
<img class="badge-button" src="{{img_url}}" alt="{{badge.label}}" title="{{badge.label}}"></img>
|
||||
{% if badge.link %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro full_post(
|
||||
post, render_sig = True, is_latest = False,
|
||||
editing = False, active_user = None, no_reply = false,
|
||||
@@ -161,6 +176,11 @@
|
||||
{% if post['status'] %}
|
||||
<em class="user-status">{{ post['status'] }}</em>
|
||||
{% endif %}
|
||||
<div class="badges-container">
|
||||
{% for badge_data in (post.badges_json | fromjson) %}
|
||||
{{ badge_button(badge_data) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -306,3 +326,68 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro badge_editor_single(options={}, selected=none, fp_hidden=true, badge=none) %}
|
||||
{% set defaults = options | selectattr('user_id', 'none') | list | sort(attribute='file_path') %}
|
||||
{% set uploads = options | selectattr('user_id') | list %}
|
||||
{% if selected is not none %}
|
||||
{% set selected_href = (options | selectattr('id', 'equalto', selected) | list)[0].file_path %}
|
||||
{% else %}
|
||||
{% set selected_href = defaults[0].file_path %}
|
||||
{% endif %}
|
||||
<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">
|
||||
{% for option in defaults %}
|
||||
<option data-file-path="{{ option.file_path }}" value="{{ option.id }}" {{ "selected" if selected==option.id else "" }}>{{option.file_path | basename_noext}}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
<optgroup label="Your uploads">
|
||||
{% for option in uploads %}
|
||||
<option data-file-path="{{ option.file_path }}" value="{{ option.id }}" {{ "selected" if selected==option.id else "" }}>{{option.original_filename | basename_noext}}</option>
|
||||
{% endfor %}
|
||||
<option value="custom">Upload new...</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<img class="badge-button" data-receive="badgeUpdatePreview badgeUpdatePreviewCustom" src="{{ selected_href }}"></img>
|
||||
</div>
|
||||
<div class="settings-badge-file-picker{{ " hidden" if fp_hidden else ""}}" data-receive="badgeToggleFilePicker">
|
||||
<button data-send="openBadgeFilePicker" type=button>Browse…</button>
|
||||
<input data-receive="openBadgeFilePicker badgeErrorSize badgeErrorDim badgeHideErrors" data-send="badgeUpdatePreviewCustom" type="file" accept="image/*" class="hidden" name="badge_file[]">
|
||||
</div>
|
||||
<input type="text" required placeholder="Label" name="badge_label[]" value="{{badge.label}}">
|
||||
<input type="text" placeholder="(Optional) Link" name="badge_link[]" value="{{badge.link}}">
|
||||
<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's actually atom, don't tell anyone ;)">{{ 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 %}
|
||||
|
||||
4
app/templates/components/badge_editor_badges.html
Normal file
@@ -0,0 +1,4 @@
|
||||
{% from 'common/macros.html' import badge_editor_single %}
|
||||
{% for badge in badges %}
|
||||
{{ badge_editor_single(options=uploads, selected=badge.upload, badge=badge) }}
|
||||
{% endfor %}
|
||||
2
app/templates/components/badge_editor_template.html
Normal file
@@ -0,0 +1,2 @@
|
||||
{% from 'common/macros.html' import badge_editor_single %}
|
||||
{{ badge_editor_single(options=uploads) }}
|
||||
@@ -3,7 +3,7 @@
|
||||
{% block title %}guide - {{ guide.title }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="darkbg" id="top">
|
||||
<h1 class="thread-title">Guide: {{ guide.title }}</h1>
|
||||
<h1>Guide: {{ guide.title }}</h1>
|
||||
<ul class="horizontal">
|
||||
<li><a href="{{ url_for('guides.category_index', category=category) }}">↑ Back to category</a></li>
|
||||
{% if prev_guide %}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}guides - {{ category | title }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="darkbg settings-container">
|
||||
<h1 class="thread-title">All guides in category "{{ category | replace('-', ' ') | title }}"</h1>
|
||||
<div class="darkbg">
|
||||
<h1>All guides in category "{{ category | replace('-', ' ') | title }}"</h1>
|
||||
<ul>
|
||||
{% for page in pages %}
|
||||
<li><a href="{{ page.url }}">{{ page.title }}</a></li>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}contact us{% endblock %}
|
||||
{% block content %}
|
||||
<div class="darkbg settings-container">
|
||||
<div class="darkbg">
|
||||
<h1>Contact</h1>
|
||||
{% if config.ADMIN_CONTACT_INFO %}
|
||||
<p>The administrators of {{ config.SITE_NAME }} provide the following contact information:</p>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}guides{% endblock %}
|
||||
{% block content %}
|
||||
<div class="darkbg settings-container">
|
||||
<h1 class="thread-title">Guides index</h1>
|
||||
<div class="darkbg">
|
||||
<h1>Guides index</h1>
|
||||
<ul>
|
||||
{% for category in categories %}
|
||||
<li>
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
<p>A thread may be locked by the original poster (OP) or a moderator. When a thread is locked, nobody except moderators can reply to it.</p>
|
||||
<p>A thread may also be stickied by a moderator. Stickied threads show above all other threads in the topic it's under. If a thread is stickied, it is likely important.</p>
|
||||
<p>You can subscribe to any thread. When subscribed, you will receive an overview of posts you missed for that thread in your inbox.</p>
|
||||
<p>You can</p>
|
||||
</section>
|
||||
<section class="guide-section">
|
||||
<h2 id="posts">Posts</h2>
|
||||
@@ -54,7 +53,7 @@
|
||||
<p>A post is split up into five sections:</p>
|
||||
<ol>
|
||||
<li>Usercard
|
||||
<ul><li>The post author's information shows up to the left of the post. This includes their avatar, display name, mention, and status.</li></ul>
|
||||
<ul><li>The post author's information shows up to the left of the post. This includes their avatar, display name, mention, status, and badges.</li></ul>
|
||||
</li>
|
||||
<li>Post actions
|
||||
<ul><li>This shows the time and date when the post has been made (or edited), and buttons for actions you can perform on the post, such as quoting or editing.</li></ul>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<li>Their username and display name;</li>
|
||||
<li>Their status;</li>
|
||||
<li>Their signature;</li>
|
||||
<li>Their badges;</li>
|
||||
<li>Their stats:
|
||||
<ul>
|
||||
<li>Their permission level (regular user, moderator, etc);</li>
|
||||
|
||||
@@ -51,7 +51,35 @@
|
||||
</section>
|
||||
<section class="guide-section">
|
||||
<h2 id="password">Changing your password</h2>
|
||||
<p>You can change your password by typing it in the "New password" field and again in the "Confirm new password" field, then pressing the <button class="warn">Change password</button> button. The passwords in the two fields must match.</p>
|
||||
<p>You can change your password by typing your desired new password in the "New password" field and again in the "Confirm new password" field, then pressing the <button class="warn">Change password</button> button. The passwords in the two fields must match.</p>
|
||||
</section>
|
||||
<section class="guide-section">
|
||||
<h2 id="badges">Badges</h2>
|
||||
<p>Badges, also known as buttons, are 88x31 images that you can use to add more flair to your profile or link to other websites.</p>
|
||||
<p>Badges you set will be shown on the usercard in threads and on your profile page. You can have up to 10 badges.</p>
|
||||
<p>To add a badge, press the "Add badge" button. A badge editor will be added. A badge consists of three parts:</p>
|
||||
<ol>
|
||||
<li>Image
|
||||
<ul>
|
||||
<li>{{ config.SITE_NAME }} provides a selection of default badges that you can use. You may select one by using the dropdown, under the "Default" category.</li>
|
||||
<li>Alternatively, you may upload your own image. To do so, select the "Upload…" option in the dropdown, under the "Your uploads" category. A <button>Browse…</button> button will appear, letting you select a file. The image must be exactly 88x31 pixels and may not be over 500KB in size.<br>
|
||||
Custom badge images that you have uploaded before can be reused and will appear under "Your uploads". If a badge image you've uploaded before is not used by any of your badges, it will be deleted and you will have to upload it again if you wish to reuse it.<br>
|
||||
Other users can not use images you've uploaded as part of their badges unless they download it manually.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Label
|
||||
<ul>
|
||||
<li>The label will be shown when the badge is hovered over. It will also be the badge image's alt text.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Link
|
||||
<ul>
|
||||
<li>Optionally, you may turn the badge into a clickable button by providing a link. The link will open in a new tab when pressed, and will use <code class="inline-code">rel="me"</code> so you can use your profile as verification on platforms like Mastodon.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
<p>If a badge is not valid, its editor and any invalid fields will have a dashed border.</p>
|
||||
<p>You can delete a badge by pressing the <button class="critical">X</button> button. Your changes are not saved until you press the <button>Save badges</button> button.</p>
|
||||
</section>
|
||||
<section class="guide-section">
|
||||
<h2 id="deleting">Deleting your account</h2>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}editing MOTD{% endblock %}
|
||||
{% block content %}
|
||||
<div class="darkbg settings-container">
|
||||
<div class="darkbg">
|
||||
<h1>Edit Message of the Day</h1>
|
||||
<p>The Message of the Day will show up on the main page and in every topic.</p>
|
||||
<form method="POST">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}moderation{% endblock %}
|
||||
{% block content %}
|
||||
<div class="darkbg settings-container">
|
||||
<div class="darkbg">
|
||||
<h1>Moderation actions</h1>
|
||||
<ul>
|
||||
<li><a href="{{ url_for('mod.user_list') }}">User list</a></li>
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
{% from 'common/macros.html' import sortable_list, sortable_list_item %}
|
||||
{% block content %}
|
||||
<div class="darkbg settings-container">
|
||||
<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 %}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}drafting a thread{% endblock %}
|
||||
{% block content %}
|
||||
<div class="darkbg settings-container">
|
||||
<div class="darkbg">
|
||||
<h1>New thread</h1>
|
||||
<form method="post">
|
||||
<label for="topic_id">Topic</label>
|
||||
|
||||
19
app/templates/threads/thread.atom
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}creating a topic{% endblock %}
|
||||
{% block content %}
|
||||
<div class="darkbg settings-container">
|
||||
<div class="darkbg">
|
||||
<h1>Create topic</h1>
|
||||
<form method="post">
|
||||
<label for=name>Name</label>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}creating a topic{% endblock %}
|
||||
{% block content %}
|
||||
<div class="darkbg settings-container">
|
||||
<div class="darkbg">
|
||||
<h1>Editing topic {{ topic['name'] }}</h1>
|
||||
<form method="post">
|
||||
<label for=name>Name</label>
|
||||
|
||||
20
app/templates/topics/topic.atom
Normal 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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 settings-container">
|
||||
<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 %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}delete confirmation{% endblock %}
|
||||
{% block content %}
|
||||
<div class="darkbg login-container">
|
||||
<div class="darkbg">
|
||||
<h1>Confirm account deletion</h1>
|
||||
<p>Are you sure you want to delete your account on {{ config.SITE_NAME }}? <strong>This action is irreversible.</strong> Your posts and threads will remain accessible to preserve history but will be de-personalized, showing up as authored by a system user. Posts that @mention you will also mention the system user instead.</p>
|
||||
<p>If you wish for any and all content relating to you to be removed, you will have to <a href="{{url_for("guides.contact")}}" target="_blank">contact {{ config.SITE_NAME }}'s administrators separately.</a></p>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Log in{% endblock %}
|
||||
{% block content %}
|
||||
<div class="darkbg login-container">
|
||||
<div class="darkbg">
|
||||
<h1>Log in</h1>
|
||||
<form method="post">
|
||||
<label for="username">Username</label><br>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Reset password{% endblock %}
|
||||
{% block content %}
|
||||
<div class="darkbg login-container">
|
||||
<div class="darkbg">
|
||||
<h1>Reset password for {{username}}</h1>
|
||||
<p>Send this link to {{username}} to allow them to reset their password.</p>
|
||||
<form method="post">
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{% 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 %}
|
||||
{% set disable_avatar = not is_logged_in() %}
|
||||
<div class='darkbg settings-container'>
|
||||
<div class='darkbg'>
|
||||
<h1>User settings</h1>
|
||||
<div class="settings-grid">
|
||||
<fieldset class="hfc">
|
||||
@@ -15,7 +15,17 @@
|
||||
<input type='submit' value='Save avatar' {{ 'disabled' if disable_avatar else '' }}>
|
||||
<input type='submit' value='Clear avatar' formaction='{{ url_for('users.clear_avatar', username=active_user.username) }}' formnovalidate {{ 'disabled' if active_user.is_default_avatar() else '' }}>
|
||||
</div>
|
||||
<span>1MB maximum size. Avatar will be scaled down to fit a square.</span>
|
||||
<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">
|
||||
@@ -44,18 +54,26 @@
|
||||
</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>
|
||||
<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' method='post' enctype='multipart/form-data' action='{{ url_for('users.save_badges', username=active_user.username) }}'>
|
||||
<div>Loading badges…</div>
|
||||
<div>If badges fail to load, JS may be disabled.</div>
|
||||
</form>
|
||||
</bitty-7-0>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div>
|
||||
<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 %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Sign up{% endblock %}
|
||||
{% block content %}
|
||||
<div class="darkbg login-container">
|
||||
<div class="darkbg">
|
||||
<h1>Sign up</h1>
|
||||
{% if inviter %}
|
||||
<p>You have been invited by <a href="{{ url_for('users.page', username=inviter.username) }}">{{ inviter.get_readable_name() }}</a> to join {{ config.SITE_NAME }}. Create an identity below.</p>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{% from 'common/macros.html' import timestamp %}
|
||||
{% from 'common/macros.html' import timestamp, badge_button %}
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{{ target_user.get_readable_name() }}'s profile{% endblock %}
|
||||
{% block content %}
|
||||
@@ -54,6 +54,11 @@
|
||||
Signature:
|
||||
<div>{{ target_user.signature_rendered | safe }}</div>
|
||||
{% endif %}
|
||||
<div class="badges-container">
|
||||
{% for badge in target_user.get_badges() %}
|
||||
{{ badge_button(badge) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-page-stats">
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
SITE_NAME = "Porom"
|
||||
DISABLE_SIGNUP = false # if true, no one can sign up.
|
||||
|
||||
# if neither of the following two options is true,
|
||||
# no one can sign up. this may be useful later when/if LDAP is implemented.
|
||||
|
||||
MODS_CAN_INVITE = true # if true, allows moderators to create invite links. useless unless DISABLE_SIGNUP to be true.
|
||||
USERS_CAN_INVITE = false # if true, allows users to create invite links. useless unless DISABLE_SIGNUP to be true.
|
||||
|
||||
# contact information, will be shown in /guides/contact
|
||||
# some babycodes allowed
|
||||
# forbidden tags: [spoiler], [img], @mention, [big], [small], [center], [right], [color]
|
||||
ADMIN_CONTACT_INFO = ""
|
||||
|
||||
# forum information. shown in the introduction guide at /guides/user/introduction
|
||||
# some babycodes allowed
|
||||
# forbidden tags: [spoiler], [img], @mention, [big], [small], [center], [right], [color]
|
||||
GUIDE_DESCRIPTION = ""
|
||||
38
config/pyrom_config.toml.example
Normal file
@@ -0,0 +1,38 @@
|
||||
### 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"
|
||||
|
||||
# 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.
|
||||
|
||||
# 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
|
||||
# forbidden tags: [spoiler], [img], @mention, [big], [small], [center], [right], [color]
|
||||
ADMIN_CONTACT_INFO = ""
|
||||
|
||||
# forum information. shown in the introduction guide at /guides/user/introduction
|
||||
# some babycodes allowed
|
||||
# forbidden tags: [spoiler], [img], @mention, [big], [small], [center], [right], [color]
|
||||
GUIDE_DESCRIPTION = ""
|
||||
BIN
data/static/badges/link-bsky.webp
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
data/static/badges/link-itch-io.webp
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
data/static/badges/link-mastodon.webp
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
data/static/badges/link-www.webp
Normal file
|
After Width: | Height: | Size: 1000 B |
BIN
data/static/badges/pride-asexual.webp
Normal file
|
After Width: | Height: | Size: 256 B |
BIN
data/static/badges/pride-bisexual.webp
Normal file
|
After Width: | Height: | Size: 366 B |
BIN
data/static/badges/pride-intersex.webp
Normal file
|
After Width: | Height: | Size: 682 B |
BIN
data/static/badges/pride-lesbian.webp
Normal file
|
After Width: | Height: | Size: 394 B |
BIN
data/static/badges/pride-nonbinary.webp
Normal file
|
After Width: | Height: | Size: 274 B |
BIN
data/static/badges/pride-progress.webp
Normal file
|
After Width: | Height: | Size: 756 B |
BIN
data/static/badges/pride-six.webp
Normal file
|
After Width: | Height: | Size: 478 B |
BIN
data/static/badges/pride-trans.webp
Normal file
|
After Width: | Height: | Size: 402 B |
BIN
data/static/badges/pronoun-any-all.webp
Normal file
|
After Width: | Height: | Size: 676 B |
BIN
data/static/badges/pronoun-fae-faer.webp
Normal file
|
After Width: | Height: | Size: 772 B |
BIN
data/static/badges/pronoun-he-him.webp
Normal file
|
After Width: | Height: | Size: 616 B |
BIN
data/static/badges/pronoun-it-its.webp
Normal file
|
After Width: | Height: | Size: 582 B |
BIN
data/static/badges/pronoun-no-pronouns.webp
Normal file
|
After Width: | Height: | Size: 850 B |
BIN
data/static/badges/pronoun-she-her.webp
Normal file
|
After Width: | Height: | Size: 690 B |
BIN
data/static/badges/pronoun-they-them.webp
Normal file
|
After Width: | Height: | Size: 842 B |
BIN
data/static/badges/pronoun-xe-xem.webp
Normal file
|
After Width: | Height: | Size: 658 B |
BIN
data/static/badges/pronoun-xe-xir.webp
Normal file
|
After Width: | Height: | Size: 620 B |
@@ -44,13 +44,13 @@
|
||||
|
||||
.reaction-button.active, .tab-button, .currentpage, .pagebutton, input[type=file]::file-selector-button, button.warn, input[type=submit].warn, .linkbutton.warn, button.critical, input[type=submit].critical, .linkbutton.critical, button, input[type=submit], .linkbutton {
|
||||
cursor: default;
|
||||
font-size: 1rem;
|
||||
font-size: 1em;
|
||||
font-family: "Cadman", sans-serif;
|
||||
text-decoration: none;
|
||||
border: 1px solid black;
|
||||
border-radius: 4px;
|
||||
padding: 5px 20px;
|
||||
margin: 10px 0;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -60,15 +60,16 @@ body {
|
||||
color: black;
|
||||
}
|
||||
|
||||
a:link {
|
||||
:where(a:link) {
|
||||
color: #c11c1c;
|
||||
}
|
||||
a:visited {
|
||||
|
||||
:where(a:visited) {
|
||||
color: #730c0c;
|
||||
}
|
||||
|
||||
.big {
|
||||
font-size: 1.8rem;
|
||||
font-size: 1.8em;
|
||||
}
|
||||
|
||||
#topnav {
|
||||
@@ -113,27 +114,28 @@ a:visited {
|
||||
|
||||
.site-title {
|
||||
font-family: "site-title";
|
||||
font-size: 3rem;
|
||||
font-size: 3em;
|
||||
margin: 0 20px;
|
||||
text-decoration: none;
|
||||
color: black !important;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
grid-template-columns: 230px 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
gap: 0;
|
||||
grid-auto-flow: row;
|
||||
@@ -221,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;
|
||||
@@ -605,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;
|
||||
}
|
||||
|
||||
@@ -710,7 +712,7 @@ blockquote {
|
||||
button, input[type=submit], .linkbutton {
|
||||
display: inline-block;
|
||||
background-color: rgb(177, 206, 204.5);
|
||||
color: black !important;
|
||||
color: black;
|
||||
}
|
||||
button:hover, input[type=submit]:hover, .linkbutton:hover {
|
||||
background-color: rgb(192.6, 215.8, 214.6);
|
||||
@@ -731,7 +733,7 @@ button.icon, input[type=submit].icon, .linkbutton.icon {
|
||||
}
|
||||
button.critical, input[type=submit].critical, .linkbutton.critical {
|
||||
background-color: red;
|
||||
color: white !important;
|
||||
color: white;
|
||||
}
|
||||
button.critical:hover, input[type=submit].critical:hover, .linkbutton.critical:hover {
|
||||
background-color: #ff3333;
|
||||
@@ -752,7 +754,7 @@ button.critical.icon, input[type=submit].critical.icon, .linkbutton.critical.ico
|
||||
}
|
||||
button.warn, input[type=submit].warn, .linkbutton.warn {
|
||||
background-color: #fbfb8d;
|
||||
color: black !important;
|
||||
color: black;
|
||||
}
|
||||
button.warn:hover, input[type=submit].warn:hover, .linkbutton.warn:hover {
|
||||
background-color: rgb(251.8, 251.8, 163.8);
|
||||
@@ -774,7 +776,7 @@ button.warn.icon, input[type=submit].warn.icon, .linkbutton.warn.icon {
|
||||
|
||||
input[type=file]::file-selector-button {
|
||||
background-color: rgb(177, 206, 204.5);
|
||||
color: black !important;
|
||||
color: black;
|
||||
}
|
||||
input[type=file]::file-selector-button:hover {
|
||||
background-color: rgb(192.6, 215.8, 214.6);
|
||||
@@ -798,12 +800,12 @@ input[type=file]::file-selector-button {
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 15px 0;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.pagebutton {
|
||||
background-color: rgb(177, 206, 204.5);
|
||||
color: black !important;
|
||||
color: black;
|
||||
}
|
||||
.pagebutton:hover {
|
||||
background-color: rgb(192.6, 215.8, 214.6);
|
||||
@@ -843,16 +845,6 @@ p {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.login-container > * {
|
||||
width: 85%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.settings-container > * {
|
||||
width: 85%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.avatar-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -868,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;
|
||||
}
|
||||
@@ -973,10 +969,6 @@ textarea {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.thread-info-bookmark-button {
|
||||
margin-left: auto !important;
|
||||
}
|
||||
|
||||
.thread-info-post-preview {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -1081,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);
|
||||
}
|
||||
@@ -1136,12 +1099,12 @@ textarea {
|
||||
}
|
||||
|
||||
.babycode-preview-errors-container {
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
background-color: rgb(177, 206, 204.5);
|
||||
color: black !important;
|
||||
color: black;
|
||||
}
|
||||
.tab-button:hover {
|
||||
background-color: rgb(192.6, 215.8, 214.6);
|
||||
@@ -1282,9 +1245,6 @@ ul.horizontal li, ol.horizontal li {
|
||||
padding: 5px 10px;
|
||||
min-width: 36px;
|
||||
}
|
||||
.babycode-button > * {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.quote-popover {
|
||||
position: absolute;
|
||||
@@ -1302,7 +1262,7 @@ footer {
|
||||
|
||||
.reaction-button.active {
|
||||
background-color: #beb1ce;
|
||||
color: black !important;
|
||||
color: black;
|
||||
}
|
||||
.reaction-button.active:hover {
|
||||
background-color: rgb(203, 192.6, 215.8);
|
||||
@@ -1468,13 +1428,151 @@ 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;
|
||||
border-radius: 4px;
|
||||
background-color: rgb(171.527945374, 172.0409719488, 170.8965280512);
|
||||
}
|
||||
|
||||
.hfc {
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-badge-container {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
border: 1px solid black;
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.settings-badge-container:has(input:invalid) {
|
||||
border: 2px dashed red;
|
||||
}
|
||||
.settings-badge-container input:invalid {
|
||||
border: 2px dashed red;
|
||||
}
|
||||
|
||||
.settings-badge-file-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.settings-badge-file-picker input.hidden[type=file] {
|
||||
width: 100%;
|
||||
}
|
||||
.settings-badge-file-picker input.hidden[type=file]::file-selector-button {
|
||||
display: none;
|
||||
}
|
||||
.settings-badge-file-picker.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-badge-select {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
img.badge-button {
|
||||
min-width: 88px;
|
||||
min-height: 31px;
|
||||
max-width: 88px;
|
||||
max-height: 31px;
|
||||
}
|
||||
|
||||
.badges-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -44,13 +44,13 @@
|
||||
|
||||
.reaction-button.active, .tab-button, .currentpage, .pagebutton, input[type=file]::file-selector-button, button.warn, input[type=submit].warn, .linkbutton.warn, button.critical, input[type=submit].critical, .linkbutton.critical, button, input[type=submit], .linkbutton {
|
||||
cursor: default;
|
||||
font-size: 1rem;
|
||||
font-size: 1em;
|
||||
font-family: "Cadman", sans-serif;
|
||||
text-decoration: none;
|
||||
border: 1px solid black;
|
||||
border-radius: 8px;
|
||||
padding: 5px 20px;
|
||||
margin: 10px 0;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -60,15 +60,16 @@ body {
|
||||
color: #e6e6e6;
|
||||
}
|
||||
|
||||
a:link {
|
||||
:where(a:link) {
|
||||
color: #e87fe1;
|
||||
}
|
||||
a:visited {
|
||||
|
||||
:where(a:visited) {
|
||||
color: #ed4fb1;
|
||||
}
|
||||
|
||||
.big {
|
||||
font-size: 1.8rem;
|
||||
font-size: 1.8em;
|
||||
}
|
||||
|
||||
#topnav {
|
||||
@@ -113,27 +114,28 @@ a:visited {
|
||||
|
||||
.site-title {
|
||||
font-family: "site-title";
|
||||
font-size: 3rem;
|
||||
font-size: 3em;
|
||||
margin: 0 20px;
|
||||
text-decoration: none;
|
||||
color: white !important;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
grid-template-columns: 230px 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
gap: 0;
|
||||
grid-auto-flow: row;
|
||||
@@ -221,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;
|
||||
@@ -605,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;
|
||||
}
|
||||
|
||||
@@ -710,7 +712,7 @@ blockquote {
|
||||
button, input[type=submit], .linkbutton {
|
||||
display: inline-block;
|
||||
background-color: #3c283c;
|
||||
color: #e6e6e6 !important;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
button:hover, input[type=submit]:hover, .linkbutton:hover {
|
||||
background-color: rgb(109.2, 72.8, 109.2);
|
||||
@@ -731,7 +733,7 @@ button.icon, input[type=submit].icon, .linkbutton.icon {
|
||||
}
|
||||
button.critical, input[type=submit].critical, .linkbutton.critical {
|
||||
background-color: #d53232;
|
||||
color: #e6e6e6 !important;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
button.critical:hover, input[type=submit].critical:hover, .linkbutton.critical:hover {
|
||||
background-color: rgb(221.4, 91, 91);
|
||||
@@ -752,7 +754,7 @@ button.critical.icon, input[type=submit].critical.icon, .linkbutton.critical.ico
|
||||
}
|
||||
button.warn, input[type=submit].warn, .linkbutton.warn {
|
||||
background-color: #eaea6a;
|
||||
color: black !important;
|
||||
color: black;
|
||||
}
|
||||
button.warn:hover, input[type=submit].warn:hover, .linkbutton.warn:hover {
|
||||
background-color: rgb(238.2, 238.2, 135.8);
|
||||
@@ -774,7 +776,7 @@ button.warn.icon, input[type=submit].warn.icon, .linkbutton.warn.icon {
|
||||
|
||||
input[type=file]::file-selector-button {
|
||||
background-color: #3c283c;
|
||||
color: #e6e6e6 !important;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
input[type=file]::file-selector-button:hover {
|
||||
background-color: rgb(109.2, 72.8, 109.2);
|
||||
@@ -798,12 +800,12 @@ input[type=file]::file-selector-button {
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 15px 0;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.pagebutton {
|
||||
background-color: #3c283c;
|
||||
color: #e6e6e6 !important;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
.pagebutton:hover {
|
||||
background-color: rgb(109.2, 72.8, 109.2);
|
||||
@@ -843,16 +845,6 @@ p {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.login-container > * {
|
||||
width: 85%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.settings-container > * {
|
||||
width: 85%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.avatar-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -868,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;
|
||||
}
|
||||
@@ -973,10 +969,6 @@ textarea {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.thread-info-bookmark-button {
|
||||
margin-left: auto !important;
|
||||
}
|
||||
|
||||
.thread-info-post-preview {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -1081,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;
|
||||
}
|
||||
@@ -1136,12 +1099,12 @@ textarea {
|
||||
}
|
||||
|
||||
.babycode-preview-errors-container {
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
background-color: #3c283c;
|
||||
color: #e6e6e6 !important;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
.tab-button:hover {
|
||||
background-color: rgb(109.2, 72.8, 109.2);
|
||||
@@ -1282,9 +1245,6 @@ ul.horizontal li, ol.horizontal li {
|
||||
padding: 5px 10px;
|
||||
min-width: 36px;
|
||||
}
|
||||
.babycode-button > * {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.quote-popover {
|
||||
position: absolute;
|
||||
@@ -1302,7 +1262,7 @@ footer {
|
||||
|
||||
.reaction-button.active {
|
||||
background-color: #8a5584;
|
||||
color: #e6e6e6 !important;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
.reaction-button.active:hover {
|
||||
background-color: rgb(167.4843049327, 112.9156950673, 161.3067264574);
|
||||
@@ -1468,17 +1428,155 @@ 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: #503250;
|
||||
}
|
||||
|
||||
.hfc {
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-badge-container {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
border: 1px solid black;
|
||||
border-radius: 8px;
|
||||
padding: 5px 10px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.settings-badge-container:has(input:invalid) {
|
||||
border: 2px dashed #d53232;
|
||||
}
|
||||
.settings-badge-container input:invalid {
|
||||
border: 2px dashed #d53232;
|
||||
}
|
||||
|
||||
.settings-badge-file-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.settings-badge-file-picker input.hidden[type=file] {
|
||||
width: 100%;
|
||||
}
|
||||
.settings-badge-file-picker input.hidden[type=file]::file-selector-button {
|
||||
display: none;
|
||||
}
|
||||
.settings-badge-file-picker.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-badge-select {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
img.badge-button {
|
||||
min-width: 88px;
|
||||
min-height: 31px;
|
||||
max-width: 88px;
|
||||
max-height: 31px;
|
||||
}
|
||||
|
||||
.badges-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
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: #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);
|
||||
|
||||
@@ -44,13 +44,13 @@
|
||||
|
||||
.reaction-button.active, .tab-button, .currentpage, .pagebutton, input[type=file]::file-selector-button, button.warn, input[type=submit].warn, .linkbutton.warn, button.critical, input[type=submit].critical, .linkbutton.critical, button, input[type=submit], .linkbutton {
|
||||
cursor: default;
|
||||
font-size: 1rem;
|
||||
font-size: 1em;
|
||||
font-family: "Cadman", sans-serif;
|
||||
text-decoration: none;
|
||||
border: 1px solid black;
|
||||
border-radius: 16px;
|
||||
padding: 8px 12px;
|
||||
margin: 6px 0;
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -60,15 +60,16 @@ body {
|
||||
color: black;
|
||||
}
|
||||
|
||||
a:link {
|
||||
:where(a:link) {
|
||||
color: black;
|
||||
}
|
||||
a:visited {
|
||||
|
||||
:where(a:visited) {
|
||||
color: black;
|
||||
}
|
||||
|
||||
.big {
|
||||
font-size: 1.8rem;
|
||||
font-size: 1.8em;
|
||||
}
|
||||
|
||||
#topnav {
|
||||
@@ -113,27 +114,28 @@ a:visited {
|
||||
|
||||
.site-title {
|
||||
font-family: "site-title";
|
||||
font-size: 3rem;
|
||||
font-size: 3em;
|
||||
margin: 0 12px;
|
||||
text-decoration: none;
|
||||
color: black !important;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
grid-template-columns: 230px 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
gap: 0;
|
||||
grid-auto-flow: row;
|
||||
@@ -221,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;
|
||||
@@ -605,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;
|
||||
}
|
||||
|
||||
@@ -710,7 +712,7 @@ blockquote {
|
||||
button, input[type=submit], .linkbutton {
|
||||
display: inline-block;
|
||||
background-color: #f27a5a;
|
||||
color: black !important;
|
||||
color: black;
|
||||
}
|
||||
button:hover, input[type=submit]:hover, .linkbutton:hover {
|
||||
background-color: rgb(244.6, 148.6, 123);
|
||||
@@ -731,7 +733,7 @@ button.icon, input[type=submit].icon, .linkbutton.icon {
|
||||
}
|
||||
button.critical, input[type=submit].critical, .linkbutton.critical {
|
||||
background-color: #f73030;
|
||||
color: white !important;
|
||||
color: white;
|
||||
}
|
||||
button.critical:hover, input[type=submit].critical:hover, .linkbutton.critical:hover {
|
||||
background-color: rgb(248.6, 89.4, 89.4);
|
||||
@@ -752,7 +754,7 @@ button.critical.icon, input[type=submit].critical.icon, .linkbutton.critical.ico
|
||||
}
|
||||
button.warn, input[type=submit].warn, .linkbutton.warn {
|
||||
background-color: #fbfb8d;
|
||||
color: black !important;
|
||||
color: black;
|
||||
}
|
||||
button.warn:hover, input[type=submit].warn:hover, .linkbutton.warn:hover {
|
||||
background-color: rgb(251.8, 251.8, 163.8);
|
||||
@@ -774,7 +776,7 @@ button.warn.icon, input[type=submit].warn.icon, .linkbutton.warn.icon {
|
||||
|
||||
input[type=file]::file-selector-button {
|
||||
background-color: #f27a5a;
|
||||
color: black !important;
|
||||
color: black;
|
||||
}
|
||||
input[type=file]::file-selector-button:hover {
|
||||
background-color: rgb(244.6, 148.6, 123);
|
||||
@@ -798,12 +800,12 @@ input[type=file]::file-selector-button {
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 8px 0;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.pagebutton {
|
||||
background-color: #f27a5a;
|
||||
color: black !important;
|
||||
color: black;
|
||||
}
|
||||
.pagebutton:hover {
|
||||
background-color: rgb(244.6, 148.6, 123);
|
||||
@@ -843,16 +845,6 @@ p {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.login-container > * {
|
||||
width: 85%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.settings-container > * {
|
||||
width: 85%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.avatar-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -868,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;
|
||||
}
|
||||
@@ -973,10 +969,6 @@ textarea {
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.thread-info-bookmark-button {
|
||||
margin-left: auto !important;
|
||||
}
|
||||
|
||||
.thread-info-post-preview {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -1081,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);
|
||||
}
|
||||
@@ -1136,12 +1099,12 @@ textarea {
|
||||
}
|
||||
|
||||
.babycode-preview-errors-container {
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
background-color: #f27a5a;
|
||||
color: black !important;
|
||||
color: black;
|
||||
}
|
||||
.tab-button:hover {
|
||||
background-color: rgb(244.6, 148.6, 123);
|
||||
@@ -1282,9 +1245,6 @@ ul.horizontal li, ol.horizontal li {
|
||||
padding: 3px 6px;
|
||||
min-width: 36px;
|
||||
}
|
||||
.babycode-button > * {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.quote-popover {
|
||||
position: absolute;
|
||||
@@ -1302,7 +1262,7 @@ footer {
|
||||
|
||||
.reaction-button.active {
|
||||
background-color: #b54444;
|
||||
color: white !important;
|
||||
color: white;
|
||||
}
|
||||
.reaction-button.active:hover {
|
||||
background-color: rgb(197.978313253, 103.221686747, 103.221686747);
|
||||
@@ -1468,25 +1428,161 @@ 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;
|
||||
border-radius: 16px;
|
||||
background-color: rgb(176.5961538462, 106.9038461538, 147.1947115385);
|
||||
}
|
||||
|
||||
.hfc {
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-badge-container {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 3px;
|
||||
border: 1px solid black;
|
||||
border-radius: 16px;
|
||||
padding: 3px 6px;
|
||||
margin: 6px 0;
|
||||
}
|
||||
.settings-badge-container:has(input:invalid) {
|
||||
border: 2px dashed #f73030;
|
||||
}
|
||||
.settings-badge-container input:invalid {
|
||||
border: 2px dashed #f73030;
|
||||
}
|
||||
|
||||
.settings-badge-file-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.settings-badge-file-picker input.hidden[type=file] {
|
||||
width: 100%;
|
||||
}
|
||||
.settings-badge-file-picker input.hidden[type=file]::file-selector-button {
|
||||
display: none;
|
||||
}
|
||||
.settings-badge-file-picker.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-badge-select {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
img.badge-button {
|
||||
min-width: 88px;
|
||||
min-height: 31px;
|
||||
max-width: 88px;
|
||||
max-height: 31px;
|
||||
}
|
||||
|
||||
.badges-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
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;
|
||||
}
|
||||
|
||||
#bottomnav {
|
||||
border-bottom-left-radius: 16px;
|
||||
border-bottom-right-radius: 16px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@@ -1494,9 +1590,10 @@ textarea {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 10px;
|
||||
#footer {
|
||||
border-radius: 16px;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
1578
data/static/css/theme-snow-white.css
Normal 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);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const bookmarkMenuHrefTemplate = '/hyperapi/bookmarks-dropdown';
|
||||
const badgeEditorEndpoint = '/hyperapi/badge-editor';
|
||||
const previewEndpoint = '/api/babycode-preview';
|
||||
const userEndpoint = '/api/current-user';
|
||||
|
||||
@@ -6,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;
|
||||
@@ -49,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 {
|
||||
@@ -63,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) {
|
||||
@@ -72,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 = {
|
||||
@@ -82,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) {
|
||||
@@ -103,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');
|
||||
@@ -116,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) {
|
||||
@@ -144,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;
|
||||
}
|
||||
|
||||
@@ -199,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;
|
||||
@@ -249,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();
|
||||
@@ -272,8 +273,271 @@ 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class BadgeEditorForm {
|
||||
#badgeTemplate = undefined;
|
||||
async loadBadgeEditor(ev, el) {
|
||||
const badges = await this.api.getHTML(badgeEditorEndpoint);
|
||||
if (!badges.value) {
|
||||
return;
|
||||
}
|
||||
if (this.#badgeTemplate === undefined){
|
||||
this.#badgeTemplate = document.getElementById('badge-editor-template').content.firstElementChild.outerHTML;
|
||||
}
|
||||
el.replaceChildren();
|
||||
const addButton = `<button data-disable-if-max="1" data-receive="updateBadgeCount" DISABLE_IF_MAX type="button" data-send="addBadge">Add badge</button>`;
|
||||
const submitButton = `<input data-receive="updateBadgeCount" type="submit" value="Save badges">`;
|
||||
const controls = `<span>${addButton} ${submitButton} <span data-count="1" data-receive="updateBadgeCount">BADGECOUNT/10</span></span>`
|
||||
const badgeCount = badges.value.querySelectorAll('.settings-badge-container').length;
|
||||
const subs = [
|
||||
['BADGECOUNT', badgeCount],
|
||||
['DISABLE_IF_MAX', badgeCount === 10 ? 'disabled' : ''],
|
||||
];
|
||||
el.appendChild(this.api.makeHTML(controls, subs));
|
||||
|
||||
const listTemplate = document.getElementById('badges-list-template').content.firstElementChild.outerHTML;
|
||||
const list = this.api.makeHTML(listTemplate).firstElementChild;
|
||||
list.appendChild(badges.value);
|
||||
el.appendChild(list);
|
||||
}
|
||||
|
||||
addBadge(ev, el) {
|
||||
if (this.#badgeTemplate === undefined) {
|
||||
return;
|
||||
}
|
||||
const badge = this.api.makeHTML(this.#badgeTemplate).firstElementChild;
|
||||
el.appendChild(badge);
|
||||
this.api.localTrigger('updateBadgeCount');
|
||||
}
|
||||
|
||||
deleteBadge(ev, el) {
|
||||
if (!el.contains(ev.sender)) {
|
||||
return;
|
||||
}
|
||||
el.remove();
|
||||
this.api.localTrigger('updateBadgeCount');
|
||||
}
|
||||
|
||||
updateBadgeCount(ev, el) {
|
||||
const badgeCount = el.parentNode.parentNode.querySelectorAll('.settings-badge-container').length;
|
||||
if (el.propToInt('disableIfMax') === 1) {
|
||||
el.disabled = badgeCount === 10;
|
||||
} else if (el.propToInt('count') === 1) {
|
||||
el.textContent = `${badgeCount}/10`;
|
||||
}
|
||||
}
|
||||
|
||||
badgeEditorPrepareSubmit(ev, el) {
|
||||
if (ev.type !== 'submit') {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
|
||||
const badges = el.querySelectorAll('.settings-badge-container').length;
|
||||
|
||||
const noUploads = el.querySelectorAll('.settings-badge-file-picker.hidden input[type=file]');
|
||||
noUploads.forEach(e => {
|
||||
e.value = null;
|
||||
})
|
||||
el.submit();
|
||||
}
|
||||
}
|
||||
|
||||
const validateBase64Img = dataURL => new Promise(resolve => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
resolve(img.width === 88 && img.height === 31);
|
||||
};
|
||||
img.src = dataURL;
|
||||
});
|
||||
|
||||
export class BadgeEditorBadge {
|
||||
#badgeCustomImageData = null;
|
||||
badgeUpdatePreview(ev, el) {
|
||||
if (ev.type !== 'change') {
|
||||
return;
|
||||
}
|
||||
// TODO: ev.sender doesn't have a bittyParent
|
||||
const selectBittyParent = ev.sender.closest('bitty-7-0');
|
||||
if (el.bittyParent !== selectBittyParent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.value === 'custom') {
|
||||
if (this.#badgeCustomImageData) {
|
||||
el.src = this.#badgeCustomImageData;
|
||||
} else {
|
||||
el.removeAttribute('src');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const option = ev.sender.selectedOptions[0];
|
||||
el.src = option.dataset.filePath;
|
||||
}
|
||||
|
||||
async badgeUpdatePreviewCustom(ev, el) {
|
||||
if (ev.type !== 'change') {
|
||||
return;
|
||||
}
|
||||
if (el.bittyParent !== ev.sender.bittyParent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const file = ev.target.files[0];
|
||||
if (file.size >= 1000 * 500) {
|
||||
this.api.localTrigger('badgeErrorSize');
|
||||
this.#badgeCustomImageData = null;
|
||||
el.removeAttribute('src');
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = async e => {
|
||||
const dimsValid = await validateBase64Img(e.target.result);
|
||||
if (!dimsValid) {
|
||||
this.api.localTrigger('badgeErrorDim');
|
||||
this.#badgeCustomImageData = null;
|
||||
el.removeAttribute('src');
|
||||
return;
|
||||
}
|
||||
this.#badgeCustomImageData = e.target.result;
|
||||
el.src = this.#badgeCustomImageData;
|
||||
this.api.localTrigger('badgeHideErrors');
|
||||
}
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
badgeToggleFilePicker(ev, el) {
|
||||
if (ev.type !== 'change') {
|
||||
return;
|
||||
}
|
||||
// TODO: ev.sender doesn't have a bittyParent
|
||||
const selectBittyParent = ev.sender.closest('bitty-7-0');
|
||||
if (el.bittyParent !== selectBittyParent) {
|
||||
return;
|
||||
}
|
||||
const filePicker = el.querySelector('input[type=file]');
|
||||
if (ev.value === 'custom') {
|
||||
el.classList.remove('hidden');
|
||||
if (filePicker.dataset.validity) {
|
||||
filePicker.setCustomValidity(filePicker.dataset.validity);
|
||||
}
|
||||
filePicker.required = true;
|
||||
} else {
|
||||
el.classList.add('hidden');
|
||||
filePicker.setCustomValidity('');
|
||||
filePicker.required = false;
|
||||
}
|
||||
}
|
||||
|
||||
openBadgeFilePicker(ev, el) {
|
||||
// TODO: ev.sender doesn't have a bittyParent
|
||||
if (ev.sender.parentNode !== el.parentNode) {
|
||||
return;
|
||||
}
|
||||
el.click();
|
||||
}
|
||||
|
||||
badgeErrorSize(ev, el) {
|
||||
const validity = "Image can't be over 500KB."
|
||||
el.dataset.validity = validity;
|
||||
el.setCustomValidity(validity);
|
||||
el.reportValidity();
|
||||
}
|
||||
|
||||
badgeErrorDim(ev, el) {
|
||||
const validity = "Image must be exactly 88x31 pixels."
|
||||
el.dataset.validity = validity;
|
||||
el.setCustomValidity(validity);
|
||||
el.reportValidity();
|
||||
}
|
||||
|
||||
badgeHideErrors(ev, el) {
|
||||
delete el.dataset.validity;
|
||||
el.setCustomValidity('');
|
||||
}
|
||||
}
|
||||
|
||||
const getCollectionDataForEl = el => {
|
||||
const nameInput = el.querySelector(".collection-name");
|
||||
const collectionId = el.dataset.collectionId;
|
||||
|
||||
return {
|
||||
id: collectionId,
|
||||
name: nameInput.value,
|
||||
is_new: !('collectionId' in el.dataset),
|
||||
};
|
||||
}
|
||||
|
||||
export class CollectionsEditor {
|
||||
#collectionTemplate = undefined;
|
||||
#collectionsData = [];
|
||||
#removedCollections = [];
|
||||
#valid = true;
|
||||
|
||||
addCollection(ev, el) {
|
||||
if (this.#collectionTemplate === undefined) {
|
||||
this.#collectionTemplate = document.getElementById('new-collection-template').content;
|
||||
}
|
||||
// interesting
|
||||
const newCollection = this.api.makeHTML(this.#collectionTemplate.firstElementChild.outerHTML);
|
||||
el.appendChild(newCollection);
|
||||
}
|
||||
|
||||
deleteCollection(ev, el) {
|
||||
if (!el.contains(ev.sender)) {
|
||||
return;
|
||||
}
|
||||
if ('collectionId' in el.dataset) {
|
||||
this.#removedCollections.push(el.dataset.collectionId);
|
||||
}
|
||||
el.remove();
|
||||
}
|
||||
|
||||
async saveCollections(ev, el) {
|
||||
this.#valid = true;
|
||||
this.api.localTrigger('testValidity');
|
||||
if (!this.#valid) {
|
||||
return;
|
||||
}
|
||||
this.#collectionsData = [];
|
||||
this.api.localTrigger('getCollectionData');
|
||||
|
||||
const data = {
|
||||
collections: this.#collectionsData,
|
||||
removed_collections: this.#removedCollections,
|
||||
};
|
||||
|
||||
const res = await this.api.getJSON(el.prop('submitHref'), [], {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (res.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
getCollectionData(ev, el) {
|
||||
this.#collectionsData.push(getCollectionDataForEl(el));
|
||||
}
|
||||
|
||||
testValidity(ev, el) {
|
||||
const input = el.querySelector('input');
|
||||
if (!input.validity.valid) {
|
||||
input.reportValidity();
|
||||
this.#valid = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
1
data/static/js/vnd/bitty-7.0.0-rc1.min.js
vendored
1
data/static/js/vnd/bitty-7.0.0.js
Normal 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
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
$ACCENT_COLOR: #c1ceb1 !default;
|
||||
|
||||
$DARK_1: color.scale($ACCENT_COLOR, $lightness: -25%, $saturation: -97%) !default;
|
||||
$DARK_1_LIGHTER: color.scale($DARK_1, $lightness: 25%);
|
||||
$DARK_2: color.scale($ACCENT_COLOR, $lightness: -30%, $saturation: -60%) !default;
|
||||
$DARK_3: color.scale($ACCENT_COLOR, $lightness: -80%, $saturation: -70%) !default;
|
||||
|
||||
@@ -51,12 +52,11 @@ $BIGGER_PADDING: 30px !default;
|
||||
|
||||
$PAGE_SIDE_MARGIN: 100px !default;
|
||||
|
||||
$SETTINGS_WIDTH: 85% !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.
|
||||
@@ -116,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;
|
||||
@@ -134,7 +134,7 @@ $icon_button_padding_left: $BIG_PADDING - 4px !default;
|
||||
@mixin button($color, $font_color) {
|
||||
@extend %button-base;
|
||||
background-color: $color;
|
||||
color: $font_color !important; //!important because linkbutton is an <a>
|
||||
color: $font_color;
|
||||
|
||||
&:hover {
|
||||
background-color: color.scale($color, $lightness: 20%);
|
||||
@@ -181,17 +181,15 @@ body {
|
||||
|
||||
$link_color: #c11c1c !default;
|
||||
$link_color_visited: #730c0c !default;
|
||||
a{
|
||||
&:link {
|
||||
color: $link_color;
|
||||
}
|
||||
&:visited {
|
||||
color: $link_color_visited;
|
||||
}
|
||||
:where(a:link){
|
||||
color: $link_color;
|
||||
}
|
||||
:where(a:visited) {
|
||||
color: $link_color_visited;
|
||||
}
|
||||
|
||||
.big {
|
||||
font-size: 1.8rem;
|
||||
font-size: 1.8em;
|
||||
}
|
||||
|
||||
$topnav_color: $ACCENT_COLOR !default;
|
||||
@@ -227,32 +225,33 @@ $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";
|
||||
font-size: $site_title_size;
|
||||
margin: $site_title_margin;
|
||||
text-decoration: none;
|
||||
color: $site_title_color !important;
|
||||
color: $site_title_color;
|
||||
}
|
||||
|
||||
$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: 200px !default;
|
||||
$post_usercard_width: 230px !default;
|
||||
$post_border: 2px outset $DARK_2 !default;
|
||||
.post {
|
||||
display: grid;
|
||||
@@ -375,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;
|
||||
@@ -499,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;
|
||||
}
|
||||
|
||||
@@ -650,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;
|
||||
}
|
||||
@@ -681,18 +680,6 @@ $pagebutton_min_width: $BIG_PADDING !default;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
$login_container_width: $SETTINGS_WIDTH !default;
|
||||
.login-container > * {
|
||||
width: $login_container_width;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
$settings_container_width: $SETTINGS_WIDTH !default;
|
||||
.settings-container > * {
|
||||
width: $settings_container_width;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
$avatar_form_padding: $BIG_PADDING $ZERO_PADDING !default;
|
||||
.avatar-form {
|
||||
display: flex;
|
||||
@@ -715,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 {
|
||||
@@ -723,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;
|
||||
}
|
||||
@@ -849,10 +841,6 @@ $thread_info_header_gap: $SMALL_PADDING !default;
|
||||
gap: $thread_info_header_gap;
|
||||
}
|
||||
|
||||
.thread-info-bookmark-button {
|
||||
margin-left: auto !important; // :(
|
||||
}
|
||||
|
||||
$thread_info_post_preview_margin_right: $post_inner_padding_right !default;
|
||||
.thread-info-post-preview {
|
||||
overflow: hidden;
|
||||
@@ -984,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;
|
||||
@@ -1060,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;
|
||||
@@ -1218,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;
|
||||
@@ -1434,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;
|
||||
@@ -1447,9 +1384,200 @@ $settings_grid_fieldset_border_radius: $DEFAULT_BORDER_RADIUS !default;
|
||||
& fieldset {
|
||||
border: $settings_grid_fieldset_border;
|
||||
border-radius: $settings_grid_fieldset_border_radius;
|
||||
background-color: $settings_grid_fieldset_background_color;
|
||||
}
|
||||
}
|
||||
|
||||
.hfc {
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
$compact_h1_margin: $ZERO_PADDING !default;
|
||||
h1 {
|
||||
margin: $compact_h1_margin;
|
||||
}
|
||||
|
||||
$settings_badge_container_gap: $SMALL_PADDING !default;
|
||||
$settings_badge_container_border: $DEFAULT_BORDER !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;
|
||||
.settings-badge-container {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: $settings_badge_container_gap;
|
||||
border: $settings_badge_container_border;
|
||||
border-radius: $settings_badge_container_border_radius;
|
||||
padding: $settings_badge_container_padding;
|
||||
margin: $settings_badge_container_margin;
|
||||
|
||||
// the file picker's validity is managed by js
|
||||
// so we got lucky here. when the file picker
|
||||
// is hidden, its set to be valid. it's only invalid
|
||||
// when, well, invalid.
|
||||
&:has(input:invalid) {
|
||||
border: $settings_badge_container_border_invalid;
|
||||
}
|
||||
|
||||
input:invalid {
|
||||
border: $settings_badge_container_border_invalid;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-badge-file-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
& input.hidden[type=file] {
|
||||
width: 100%;
|
||||
|
||||
&::file-selector-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
$settings_badge_select_gap: $SMALL_PADDING !default;
|
||||
$settings_badge_select_min_width: 200px !default;
|
||||
.settings-badge-select {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $settings_badge_select_gap;
|
||||
align-items: center;
|
||||
min-width: $settings_badge_select_min_width;
|
||||
}
|
||||
|
||||
img.badge-button {
|
||||
min-width: 88px;
|
||||
min-height: 31px;
|
||||
max-width: 88px;
|
||||
max-height: 31px;
|
||||
}
|
||||
|
||||
$badges_container_gap: $SMALL_PADDING !default;
|
||||
.badges-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -82,6 +82,10 @@ $br: 8px;
|
||||
$bookmarks_dropdown_background_color: $lightish_accent,
|
||||
|
||||
$mention_font_color: $fc,
|
||||
|
||||
$settings_grid_fieldset_background_color: $lightish_accent,
|
||||
|
||||
// $settings_badge_container_border_invalid: 2px dashed $crit,
|
||||
);
|
||||
|
||||
#topnav {
|
||||
|
||||
@@ -73,9 +73,6 @@ $br: 16px;
|
||||
}
|
||||
|
||||
#bottomnav {
|
||||
border-bottom-left-radius: $br;
|
||||
border-bottom-right-radius: $br;
|
||||
|
||||
color: white;
|
||||
}
|
||||
|
||||
@@ -83,9 +80,10 @@ textarea {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 10px;
|
||||
#footer {
|
||||
border-radius: $br;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
7
sass/snow-white.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
// a simple light theme
|
||||
|
||||
@use 'default' with (
|
||||
$ACCENT_COLOR: #ced9ee,
|
||||
$link_color: #711579,
|
||||
$link_color_visited: #4a144f,
|
||||
)
|
||||