Compare commits
181 Commits
3699daa44a
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
cd507ac25f
|
|||
|
82659cedef
|
|||
|
7eafcde1d7
|
|||
|
a2ceaa0966
|
|||
|
f1931c76e6
|
|||
|
65ad672748
|
|||
|
b9c4ec3911
|
|||
|
0c2e920206
|
|||
|
9682295dae
|
|||
|
f798bb5d7d
|
|||
|
68958e304b
|
|||
|
d2cdeaed1d
|
|||
|
9d8404b774
|
|||
|
84e69187ff
|
|||
|
0e71f597c9
|
|||
|
76d600f01d
|
|||
|
54ed6fef3a
|
|||
|
7c0cb623e3
|
|||
|
9c4f271259
|
|||
|
d6b44da6c2
|
|||
|
d0daaf4494
|
|||
|
7db111d18b
|
|||
|
dd54f5fe33
|
|||
|
4aa4e58c58
|
|||
|
ce9bca0a75
|
|||
|
099b5c135e
|
|||
|
5d53a0d179
|
|||
|
f31752797e
|
|||
|
0b845b75c4
|
|||
|
af57e2f10c
|
|||
|
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
|
|||
|
508b313871
|
|||
|
db677abaa5
|
|||
|
65abea2093
|
|||
|
1533f82a6b
|
|||
|
35483c27aa
|
|||
|
005d2f3b6c
|
|||
|
265e249eaf
|
|||
|
b812e01473
|
|||
|
88f80c38cc
|
|||
|
c70f13d069
|
|||
|
73af2dc3b9
|
|||
|
062cab44bc
|
|||
|
3baccb87b1
|
|||
|
3742749cf6
|
|||
|
eb76338c4a
|
|||
|
9951ed3fae
|
|||
|
a7876ca410
|
|||
|
7c037d1593
|
|||
|
24fe0aba30
|
|||
|
a185208fc1
|
|||
|
1d5d5a8c64
|
|||
|
414298b4b4
|
|||
|
c3a3ead852
|
|||
|
54907db896
|
|||
|
db2d09cb03
|
|||
|
5c03ba3d3a
|
|||
|
3a9f8a111b
|
|||
|
bbe57d6e94
|
|||
|
0bed6b58ae
|
|||
|
8164e63b09
|
|||
|
8b5b38e38b
|
|||
|
d0dfd3a4c3
|
|||
|
fca214dfcf
|
|||
|
04fd3f5d20
|
|||
|
1a3c015612
|
|||
|
fc9ae63471
|
|||
|
4d88b5c24c
|
|||
|
fefdbdb493
|
|||
|
d0c82cf9a9
|
|||
|
90fe38497d
|
|||
|
97e2c041c9
|
|||
|
bbbe152ff8
|
|||
|
a3ad36e9a9
|
|||
|
48fcadf61e
|
|||
|
62e1724f6c
|
|||
|
19383a538d
|
|||
|
2d3eef6531
|
|||
|
e874d41fbc
|
|||
|
844499383c
|
|||
|
4e8b7d2172
|
|||
|
075a9bd498
|
|||
|
962b833a80
|
|||
|
71b04ca4bd
|
|||
|
831eb32b8a
|
|||
|
10934c557d
|
|||
|
4b70ae1b43
|
|||
|
729b7300e6
|
|||
|
f8101e57c1
|
|||
|
95decd9a56
|
|||
|
b86e049263
|
|||
|
5233f2ef4c
|
|||
|
81183f2c02
|
|||
|
86cd55c25b
|
|||
|
a8013f7718
|
|||
|
d2bf93abe6
|
|||
|
ef95da4d47
|
|||
|
661d1ee1b1
|
|||
|
98188c1c69
|
|||
|
64cfbbc057
|
|||
|
6cfc862d63
|
|||
|
70646ba381
|
|||
|
f04f0fb51b
|
|||
|
317182ae12
|
|||
|
751be27b52
|
|||
|
6dd9f5bf65
|
|||
|
1f80ed7ca5
|
|||
|
89817340c9
|
|||
|
fc80823713
|
|||
|
184472726e
|
|||
|
68cf5f7d57
|
|||
|
4ef7b0ba1e
|
|||
|
aaeb3a524b
|
|||
|
f1f62fa2c8
|
|||
|
8c917f6ae2
|
|||
|
4f88d14b45
|
|||
|
9238385244
|
|||
|
cf89070639
|
|||
|
4a8f87d64a
|
|||
|
2b1f52a99d
|
|||
|
d0b702e1e8
|
|||
|
14b96bf37e
|
|||
|
cf4bf3caa3
|
|||
|
382080ceaa
|
|||
|
304a862931
|
|||
|
348b782350
|
|||
|
aec4724e2f
|
|||
|
53d39d5a36
|
|||
|
05bd034b23
|
|||
|
033df03c49
|
|||
|
a0c86f33b4
|
|||
|
712782bc1c
|
|||
|
1c80777fe4
|
|||
|
4c2877403d
|
|||
|
cf2d605077
|
|||
|
c68ead85c0
|
|||
|
b0fd2a4f0c
|
|||
|
a529c1db65
|
|||
|
acac6ed778
|
@@ -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/
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
FROM python:3.11-slim
|
||||
FROM python:3.13-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
nginx \
|
||||
uwsgi \
|
||||
uwsgi-plugin-python3 \
|
||||
sqlite3 \
|
||||
libargon2-0 \
|
||||
libargon2-1 \
|
||||
imagemagick \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
72
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,5 +111,3 @@ $ source .venv/bin/activate
|
||||
$ python -m app.run
|
||||
```
|
||||
|
||||
# icons
|
||||
the icons in the `data/static/misc/` folder are by [Gabriele Malaspina](https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license)
|
||||
|
||||
@@ -23,14 +23,12 @@ Copyright: `© 2017-2020 by Paul James Miller. All rights reserved.`
|
||||
License: SIL Open Font License 1.1
|
||||
Designers: Paul James Miller
|
||||
|
||||
## ICONCINO
|
||||
|
||||
Affected files: [`data/static/misc/error.svg`](./data/static/misc/error.svg) [`data/static/misc/image.svg`](./data/static/misc/image.svg) [`data/static/misc/info.svg`](./data/static/misc/info.svg) [`data/static/misc/lock.svg`](./data/static/misc/lock.svg) [`data/static/misc/sticky.svg`](./data/static/misc/sticky.svg) [`data/static/misc/warn.svg`](./data/static/misc/warn.svg)
|
||||
URL: https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license
|
||||
Copyright: Gabriele Malaspina
|
||||
Designers: Gabriele Malaspina
|
||||
License: CC0 1.0/CC BY 4.0
|
||||
CC BY 4.0 compliance: Modified to indicate the URL. Modified size.
|
||||
## Atkinson Hyperlegible Mono
|
||||
Affected files: [`data/static/fonts/AtkinsonHyperlegibleMono-VariableFont_wght.ttf`](./data/static/fonts/AtkinsonHyperlegibleMono-VariableFont_wght.ttf) [`data/static/fonts/AtkinsonHyperlegibleMono-Italic-VariableFont_wght.ttf`](./data/static/fonts/AtkinsonHyperlegibleMono-Italic-VariableFont_wght.ttf)
|
||||
URL: https://www.brailleinstitute.org/freefont/
|
||||
Copyright: Copyright 2020-2024 The Atkinson Hyperlegible Mono Project Authors (https://github.com/googlefonts/atkinson-hyperlegible-next-mono)
|
||||
License: SIL Open Font License 1.1
|
||||
Designers: Elliott Scott, Megan Eiswerth, Braille Institute, Applied Design Works, Letters From Sweden
|
||||
|
||||
## Forumoji
|
||||
|
||||
@@ -38,3 +36,70 @@ Affected files: everything in [`data/static/emoji`](./data/static/emoji) except
|
||||
URL: https://gh.vercte.net/forumoji/
|
||||
License: CC0 1.0
|
||||
Designers: lolecksdeehaha; Scratch137; 64lu; stickfiregames; mybearworld (the project has many more contributors, but these are the people whose designs were reproduced here)
|
||||
|
||||
## argon2-cffi
|
||||
|
||||
URL: https://github.com/hynek/argon2-cffi
|
||||
Copyright: `Copyright (c) 2015 Hynek Schlawack and the argon2-cffi contributors`
|
||||
License: MIT
|
||||
Repo: https://github.com/hynek/argon2-cffi
|
||||
|
||||
## python-dotenv
|
||||
|
||||
URL: https://github.com/theskumar/python-dotenv
|
||||
Copyright: `Copyright (c) 2014, Saurabh Kumar (python-dotenv), 2013, Ted Tieken (django-dotenv-rw), 2013, Jacob Kaplan-Moss (django-dotenv)`
|
||||
License: BSD-3-Clause
|
||||
Repo: https://github.com/theskumar/python-dotenv
|
||||
|
||||
## python-slugify
|
||||
|
||||
URL: https://github.com/un33k/python-slugify
|
||||
Copyright: `Copyright (c) Val Neekman @ Neekware Inc. http://neekware.com`
|
||||
License: MIT
|
||||
Repo: https://github.com/un33k/python-slugify
|
||||
|
||||
## Wand
|
||||
|
||||
URL: http://wand-py.org
|
||||
Copyright:
|
||||
|
||||
```
|
||||
Original work Copyright (C) 2011-2018 by Hong Minhee <https://hongminhee.org>
|
||||
Modified work Copyright (C) 2019-2025 by E. McConville <https://emcconville.com>
|
||||
```
|
||||
|
||||
License: MIT
|
||||
Repo: https://github.com/emcconville/wand
|
||||
|
||||
## Bitty
|
||||
|
||||
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
|
||||
|
||||
# Legacy
|
||||
|
||||
this section lists credits for files/libraries that are no longer used by the project.
|
||||
|
||||
## ICONCINO
|
||||
|
||||
URL: https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license
|
||||
Designers: Gabriele Malaspina
|
||||
License: CC0 1.0
|
||||
|
||||
343
app/__init__.py
@@ -1,67 +1,217 @@
|
||||
from flask import Flask, session
|
||||
from flask import Flask, session, request, render_template, redirect, url_for
|
||||
from dotenv import load_dotenv
|
||||
from .models import Avatars, Users
|
||||
from .auth import digest
|
||||
from .routes.users import is_logged_in, get_active_user
|
||||
from .models import Avatars, Users, PostHistory, Posts, MOTD, BadgeUploads, Sessions
|
||||
from .auth import digest, is_logged_in, get_active_user
|
||||
from .constants import (
|
||||
PermissionLevel, permission_level_string,
|
||||
InfoboxKind, InfoboxIcons, InfoboxHTMLClass
|
||||
InfoboxKind, InfoboxHTMLClass,
|
||||
REACTION_EMOJI, MOTD_BANNED_TAGS,
|
||||
SIG_BANNED_TAGS, STRICT_BANNED_TAGS,
|
||||
)
|
||||
from .lib.babycode import babycode_to_html, EMOJI
|
||||
from datetime import datetime
|
||||
from .lib.babycode import babycode_to_html, babycode_to_rssxml, EMOJI, BABYCODE_VERSION
|
||||
from .lib.exceptions import SiteNameMissingException
|
||||
from .util import get_post_url, dict_to_query_string, csrf_input, get_csrf_token
|
||||
from datetime import datetime, timezone
|
||||
from flask_caching import Cache
|
||||
import os
|
||||
import time
|
||||
import secrets
|
||||
import hmac
|
||||
import tomllib
|
||||
import json
|
||||
|
||||
def create_default_avatar():
|
||||
if Avatars.count() == 0:
|
||||
print("Creating default avatar reference")
|
||||
print('Creating default avatar reference')
|
||||
Avatars.create({
|
||||
"file_path": "/static/avatars/default.webp",
|
||||
"uploaded_at": int(time.time())
|
||||
'file_path': '/static/avatars/default.webp',
|
||||
'uploaded_at': int(time.time())
|
||||
})
|
||||
|
||||
def create_admin():
|
||||
username = "admin"
|
||||
if Users.count({"username": username}) == 0:
|
||||
print("!!!!!Creating admin account!!!!!")
|
||||
username = 'admin'
|
||||
if Users.count({'username': username}) == 0:
|
||||
print('!!!!!Creating admin account!!!!!')
|
||||
password_length = 16
|
||||
password = secrets.token_urlsafe(password_length)
|
||||
hashed = digest(password)
|
||||
Users.create({
|
||||
"username": username,
|
||||
"password_hash": hashed,
|
||||
"permission": PermissionLevel.ADMIN.value,
|
||||
'username': username,
|
||||
'password_hash': hashed,
|
||||
'permission': PermissionLevel.ADMIN.value,
|
||||
})
|
||||
print(f"!!!!!Administrator account created, use '{username}' as the login and '{password}' as the password. This will only be shown once!!!!!")
|
||||
|
||||
def create_deleted_user():
|
||||
username = "DeletedUser"
|
||||
if Users.count({"username": username}) == 0:
|
||||
print("Creating DeletedUser")
|
||||
username = 'DeletedUser'
|
||||
if Users.count({'username': username.lower()}) == 0:
|
||||
print('Creating DeletedUser')
|
||||
Users.create({
|
||||
"username": username,
|
||||
"password_hash": "",
|
||||
"permission": PermissionLevel.SYSTEM.value,
|
||||
'username': username.lower(),
|
||||
'display_name': username,
|
||||
'password_hash': '',
|
||||
'permission': PermissionLevel.SYSTEM.value,
|
||||
})
|
||||
|
||||
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)
|
||||
])
|
||||
if len(post_histories) > 0:
|
||||
print('Re-parsing user posts...')
|
||||
with db.transaction():
|
||||
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.')
|
||||
|
||||
users_with_sigs = Users.findall([
|
||||
('signature_markup_language', '=', 'babycode'),
|
||||
('signature_format_version', 'IS NOT', BABYCODE_VERSION),
|
||||
('signature_original_markup', 'IS NOT', '')
|
||||
])
|
||||
if len(users_with_sigs) > 0:
|
||||
print('Re-parsing user sigs...')
|
||||
with db.transaction():
|
||||
for user in users_with_sigs:
|
||||
user.update({
|
||||
'signature_rendered': babycode_to_html(user['signature_original_markup']).result,
|
||||
'signature_format_version': BABYCODE_VERSION,
|
||||
})
|
||||
print(f'Re-parsed {len(users_with_sigs)} user sigs.')
|
||||
|
||||
stale_motds = MOTD.findall([
|
||||
['markup_language', '=', 'babycode'],
|
||||
['format_version', 'IS NOT', BABYCODE_VERSION]
|
||||
])
|
||||
if stale_motds:
|
||||
print('Re-parsing MOTDs...')
|
||||
with db.transaction():
|
||||
for motd in stale_motds:
|
||||
motd.update({
|
||||
'body_rendered': babycode_to_html(motd['body_original_markup'], banned_tags=MOTD_BANNED_TAGS).result,
|
||||
'format_version': BABYCODE_VERSION,
|
||||
})
|
||||
print('Re-parsing MOTDs done.')
|
||||
|
||||
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['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'] = ''
|
||||
|
||||
if os.getenv("PYROM_PROD") is None:
|
||||
app.static_folder = os.path.join(os.path.dirname(__file__), "../data/static")
|
||||
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['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"
|
||||
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['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)
|
||||
|
||||
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
|
||||
from app.routes.users import bp as users_bp
|
||||
from app.routes.guides import bp as guides_bp
|
||||
from app.routes.mod import bp as mod_bp
|
||||
from app.routes.posts import bp as posts_bp
|
||||
app.register_blueprint(app_bp)
|
||||
app.register_blueprint(topics_bp)
|
||||
app.register_blueprint(threads_bp)
|
||||
app.register_blueprint(users_bp)
|
||||
app.register_blueprint(guides_bp)
|
||||
app.register_blueprint(mod_bp)
|
||||
app.register_blueprint(posts_bp)
|
||||
|
||||
os.makedirs(os.path.dirname(app.config["DB_PATH"]), exist_ok = True)
|
||||
with app.app_context():
|
||||
from .schema import create as create_tables
|
||||
from .migrations import run_migrations
|
||||
@@ -72,80 +222,133 @@ def create_app():
|
||||
create_admin()
|
||||
create_deleted_user()
|
||||
|
||||
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
|
||||
from app.routes.users import bp as users_bp
|
||||
from app.routes.mod import bp as mod_bp
|
||||
from app.routes.api import bp as api_bp
|
||||
from app.routes.posts import bp as posts_bp
|
||||
app.register_blueprint(app_bp)
|
||||
app.register_blueprint(topics_bp)
|
||||
app.register_blueprint(threads_bp)
|
||||
app.register_blueprint(users_bp)
|
||||
app.register_blueprint(mod_bp)
|
||||
app.register_blueprint(api_bp)
|
||||
app.register_blueprint(posts_bp)
|
||||
clear_stale_sessions()
|
||||
|
||||
reparse_babycode()
|
||||
|
||||
bind_default_badges(app.config['BADGES_PATH'])
|
||||
|
||||
app.config['SESSION_COOKIE_SECURE'] = True
|
||||
|
||||
@app.before_request
|
||||
def make_session_permanent():
|
||||
session.permanent = True
|
||||
def revoke_session():
|
||||
if is_logged_in():
|
||||
sess = Sessions.find({'key': session['pyrom_session_key']})
|
||||
if int(time.time()) > int(sess.expires_at):
|
||||
sess.delete()
|
||||
session.clear()
|
||||
return redirect(url_for('topics.all_topics'))
|
||||
|
||||
commit = ""
|
||||
@app.before_request
|
||||
def generate_csrf_token():
|
||||
if is_logged_in() and not session.get('csrf'):
|
||||
rng = secrets.token_bytes(32)
|
||||
session_key = session['pyrom_session_key']
|
||||
message = f'd${len(session_key)}${session_key}@{len(rng)}@{rng.hex()}'
|
||||
hashed = hmac.digest(app.config['SECRET_KEY'].encode('utf-8'), message.encode('utf-8'), 'SHA256')
|
||||
csrf_token = f'{hashed.hex()}.{rng.hex()}'
|
||||
|
||||
session['csrf'] = csrf_token
|
||||
|
||||
commit = ''
|
||||
with open('.git/refs/heads/main') as f:
|
||||
commit = f.read().strip()
|
||||
|
||||
@app.context_processor
|
||||
def inject_constants():
|
||||
return {
|
||||
"InfoboxIcons": InfoboxIcons,
|
||||
"InfoboxHTMLClass": InfoboxHTMLClass,
|
||||
"InfoboxKind": InfoboxKind,
|
||||
"PermissionLevel": PermissionLevel,
|
||||
"__commit": commit,
|
||||
"__emoji": EMOJI,
|
||||
'InfoboxHTMLClass': InfoboxHTMLClass,
|
||||
'InfoboxKind': InfoboxKind,
|
||||
'PermissionLevel': PermissionLevel,
|
||||
'__commit': commit,
|
||||
'__emoji': EMOJI,
|
||||
'REACTION_EMOJI': REACTION_EMOJI,
|
||||
'MOTD_BANNED_TAGS': MOTD_BANNED_TAGS,
|
||||
'SIG_BANNED_TAGS': SIG_BANNED_TAGS,
|
||||
}
|
||||
|
||||
@app.context_processor
|
||||
def inject_auth():
|
||||
return {"is_logged_in": is_logged_in, "get_active_user": get_active_user, "active_user": get_active_user()}
|
||||
def inject_funcs():
|
||||
return {
|
||||
'get_motds': MOTD.get_all,
|
||||
'get_time_now': lambda: int(time.time()),
|
||||
'is_logged_in': is_logged_in,
|
||||
'is_mod': lambda: is_logged_in() and get_active_user().is_mod(),
|
||||
'get_active_user': get_active_user,
|
||||
'get_post_url': get_post_url,
|
||||
'csrf_input': csrf_input,
|
||||
'get_csrf_token': get_csrf_token,
|
||||
}
|
||||
|
||||
@app.template_filter("ts_datetime")
|
||||
@app.template_filter('ts_datetime')
|
||||
def ts_datetime(ts, format):
|
||||
return datetime.utcfromtimestamp(ts or int(time.time())).strftime(format)
|
||||
|
||||
@app.template_filter("pluralize")
|
||||
def pluralize(subject, num=1, singular = "", plural = "s"):
|
||||
@app.template_filter('dict_to_query_string')
|
||||
def d2q(d):
|
||||
return dict_to_query_string(d)
|
||||
|
||||
@app.template_filter('pluralize')
|
||||
def pluralize(subject, num=1, singular = '', plural = 's'):
|
||||
if int(num) == 1:
|
||||
return subject + singular
|
||||
|
||||
return subject + plural
|
||||
|
||||
@app.template_filter("permission_string")
|
||||
@app.template_filter('permission_string')
|
||||
def permission_string(term):
|
||||
return permission_level_string(term)
|
||||
|
||||
@app.template_filter('babycode')
|
||||
def babycode_filter(markup):
|
||||
return babycode_to_html(markup)
|
||||
def babycode_filter(markup, nofrag=False):
|
||||
return babycode_to_html(markup, fragment=not nofrag).result
|
||||
|
||||
@app.template_filter('extract_h2')
|
||||
def extract_h2(content):
|
||||
import re
|
||||
pattern = r'<h2\s+id="([^"]+)"[^>]*>(.*?)<\/h2>'
|
||||
matches = re.findall(pattern, content, re.IGNORECASE | re.DOTALL)
|
||||
return [
|
||||
{'id': id_.strip(), 'text': text.strip()}
|
||||
for id_, text in matches
|
||||
]
|
||||
@app.template_filter('babycode_strict')
|
||||
def babycode_strict_filter(markup, nofrag=False):
|
||||
return babycode_to_html(markup, banned_tags=STRICT_BANNED_TAGS, fragment=not nofrag).result
|
||||
|
||||
@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/'):
|
||||
return '<h1>not found</h1>', e.code
|
||||
elif request.path.startswith('/api/'):
|
||||
return {'error': 'not found'}, e.code
|
||||
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
|
||||
@app.template_filter('cachebust')
|
||||
def cachebust(subject):
|
||||
return f"{subject}?v={str(int(time.time()))}"
|
||||
return f'{subject}?v={str(int(time.time()))}'
|
||||
|
||||
@app.template_filter('theme_name')
|
||||
def get_theme_name(subject: str):
|
||||
if subject == 'style':
|
||||
return 'Default'
|
||||
|
||||
return f'{subject.removeprefix('theme-').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
|
||||
|
||||
112
app/auth.py
@@ -1,7 +1,25 @@
|
||||
from flask import session, flash, redirect, url_for, abort, request, current_app
|
||||
from .models import Sessions, Users
|
||||
from argon2 import PasswordHasher
|
||||
from functools import wraps
|
||||
import secrets
|
||||
import hmac
|
||||
import time
|
||||
import re
|
||||
|
||||
ph = PasswordHasher()
|
||||
|
||||
FORBIDDEN_USERNAMES = (
|
||||
'administrator', 'administration', 'administrators',
|
||||
'system',
|
||||
'mod', 'moderator', 'moderators', 'moderation',
|
||||
'deleted-user', 'deleted_user',
|
||||
'support',
|
||||
#routes
|
||||
'log-in', 'log_in', 'login',
|
||||
'sign-up', 'sign_up', 'signup',
|
||||
)
|
||||
|
||||
def digest(password):
|
||||
return ph.hash(password)
|
||||
|
||||
@@ -10,3 +28,97 @@ def verify(expected, given):
|
||||
return ph.verify(expected, given)
|
||||
except:
|
||||
return False
|
||||
|
||||
def is_logged_in() -> bool:
|
||||
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() -> Users | None:
|
||||
if not is_logged_in():
|
||||
return None
|
||||
|
||||
sess = Sessions.find({'key': session['pyrom_session_key']})
|
||||
return Users.find({'id': sess.user_id})
|
||||
|
||||
def create_session(user_id, temporary=False):
|
||||
expires_days = 2 if temporary else 31
|
||||
return Sessions.create({
|
||||
'key': secrets.token_hex(16),
|
||||
'user_id': user_id,
|
||||
'expires_at': int(time.time()) + (expires_days * 24 * 60 * 60),
|
||||
})
|
||||
|
||||
def parse_username(username: str) -> Tuple[str, str]:
|
||||
"""first is the unmodified name/display name, second is username"""
|
||||
if len(username) < 3:
|
||||
raise ValueError
|
||||
|
||||
if username.lower() in FORBIDDEN_USERNAMES:
|
||||
raise ValueError
|
||||
|
||||
invalid_regex = r'[^a-zA-Z0-9_-]'
|
||||
return re.sub(invalid_regex, '_', username.lower())[:24], username
|
||||
|
||||
def is_password_valid(password: str) -> bool:
|
||||
return re.match(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,255}$', password) is not None
|
||||
|
||||
# annotations
|
||||
def login_required(view_func):
|
||||
@wraps(view_func)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not is_logged_in():
|
||||
return redirect(url_for('users.log_in'))
|
||||
return view_func(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
def mod_only(view_func):
|
||||
@wraps(view_func)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not is_logged_in():
|
||||
abort(403)
|
||||
if not get_active_user().is_mod():
|
||||
abort(403)
|
||||
return view_func(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
def csrf_verified(view_func):
|
||||
"""
|
||||
protects a request with a form against csrf and invalidates the csrf token stored in the session.
|
||||
|
||||
requires @login_requred.
|
||||
"""
|
||||
|
||||
@wraps(view_func)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not session.get('csrf'):
|
||||
abort(403)
|
||||
if not request.form.get('csrf'):
|
||||
abort(403)
|
||||
|
||||
parts = request.form['csrf'].split('.')
|
||||
if len(parts) != 2:
|
||||
abort(403)
|
||||
|
||||
given_message = parts[0]
|
||||
rng = bytes.fromhex(parts[1])
|
||||
session_key = session['pyrom_session_key']
|
||||
message = f'd${len(session_key)}${session_key}@{len(rng)}@{rng.hex()}'
|
||||
expected = hmac.digest(current_app.config['SECRET_KEY'].encode('utf-8'), message.encode('utf-8'), 'SHA256').hex()
|
||||
|
||||
if not hmac.compare_digest(given_message, expected):
|
||||
abort(403)
|
||||
|
||||
session.pop('csrf')
|
||||
|
||||
return view_func(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
@@ -15,6 +15,51 @@ PermissionLevelString = {
|
||||
PermissionLevel.ADMIN: 'Administrator',
|
||||
}
|
||||
|
||||
REACTION_EMOJI = [
|
||||
'smile',
|
||||
'grin',
|
||||
|
||||
'neutral',
|
||||
|
||||
'wink',
|
||||
|
||||
'frown',
|
||||
'angry',
|
||||
|
||||
'think',
|
||||
|
||||
'sob',
|
||||
|
||||
'surprised',
|
||||
|
||||
'smiletear',
|
||||
|
||||
'tongue',
|
||||
|
||||
'pensive',
|
||||
'weary',
|
||||
|
||||
'imp',
|
||||
'impangry',
|
||||
|
||||
'lobster',
|
||||
|
||||
'scissors',
|
||||
]
|
||||
|
||||
MOTD_BANNED_TAGS = [
|
||||
'img', 'spoiler', '@mention',
|
||||
]
|
||||
|
||||
SIG_BANNED_TAGS = [
|
||||
'@mention',
|
||||
]
|
||||
|
||||
STRICT_BANNED_TAGS = [
|
||||
'img', 'spoiler', '@mention',
|
||||
'big', 'small', 'center', 'right', 'color',
|
||||
]
|
||||
|
||||
def permission_level_string(perm):
|
||||
return PermissionLevelString[PermissionLevel(int(perm))]
|
||||
|
||||
@@ -24,16 +69,9 @@ class InfoboxKind(IntEnum):
|
||||
WARN = 2
|
||||
ERROR = 3
|
||||
|
||||
InfoboxIcons = {
|
||||
InfoboxKind.INFO: "/static/misc/info.svg",
|
||||
InfoboxKind.LOCK: "/static/misc/lock.svg",
|
||||
InfoboxKind.WARN: "/static/misc/warn.svg",
|
||||
InfoboxKind.ERROR: "/static/misc/error.svg",
|
||||
}
|
||||
|
||||
InfoboxHTMLClass = {
|
||||
InfoboxKind.INFO: "",
|
||||
InfoboxKind.LOCK: "warn",
|
||||
InfoboxKind.WARN: "warn",
|
||||
InfoboxKind.ERROR: "critical",
|
||||
InfoboxKind.INFO: '',
|
||||
InfoboxKind.LOCK: 'warn',
|
||||
InfoboxKind.WARN: 'warn',
|
||||
InfoboxKind.ERROR: 'critical',
|
||||
}
|
||||
|
||||
59
app/db.py
@@ -28,9 +28,10 @@ class DB:
|
||||
|
||||
if in_transaction:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
if in_transaction and self._connection:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
if in_transaction:
|
||||
self._transaction_depth -= 1
|
||||
@@ -55,7 +56,7 @@ class DB:
|
||||
|
||||
def insert(self, table, columns, *values):
|
||||
if isinstance(columns, (list, tuple)):
|
||||
columns = ", ".join(columns)
|
||||
columns = ", ".join([f'"{column}"' for column in columns])
|
||||
|
||||
placeholders = ", ".join(["?"] * len(values))
|
||||
sql = f"""
|
||||
@@ -89,6 +90,9 @@ class DB:
|
||||
self.table = table
|
||||
self._where = [] # list of tuples
|
||||
self._select = "*"
|
||||
self._group_by = ""
|
||||
self._order_by = ""
|
||||
self._order_asc = True
|
||||
|
||||
|
||||
def _build_where(self):
|
||||
@@ -104,6 +108,17 @@ class DB:
|
||||
return " WHERE " + " AND ".join(conditions), params
|
||||
|
||||
|
||||
def group_by(self, stmt):
|
||||
self._group_by = stmt
|
||||
return self
|
||||
|
||||
|
||||
def order_by(self, stmt, asc = True):
|
||||
self._order_by = stmt
|
||||
self._order_asc = asc
|
||||
return self
|
||||
|
||||
|
||||
def select(self, columns = "*"):
|
||||
self._select = columns
|
||||
return self
|
||||
@@ -112,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)
|
||||
@@ -122,7 +137,16 @@ class DB:
|
||||
def build_select(self):
|
||||
sql = f"SELECT {self._select} FROM {self.table}"
|
||||
where_clause, params = self._build_where()
|
||||
return sql + where_clause, params
|
||||
|
||||
stmt = sql + where_clause
|
||||
|
||||
if self._group_by:
|
||||
stmt += " GROUP BY " + self._group_by
|
||||
|
||||
if self._order_by:
|
||||
stmt += " ORDER BY " + self._order_by + (" ASC" if self._order_asc else " DESC")
|
||||
|
||||
return stmt, params
|
||||
|
||||
|
||||
def build_update(self, data):
|
||||
@@ -166,6 +190,13 @@ class Model:
|
||||
raise AttributeError(f"No column '{key}'")
|
||||
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data):
|
||||
instance = cls(cls.table)
|
||||
instance._data = dict(data)
|
||||
return instance
|
||||
|
||||
|
||||
@classmethod
|
||||
def find(cls, condition):
|
||||
row = db.QueryBuilder(cls.table)\
|
||||
@@ -173,9 +204,16 @@ class Model:
|
||||
.first()
|
||||
if not row:
|
||||
return None
|
||||
instance = cls(cls.table)
|
||||
instance._data = dict(row)
|
||||
return instance
|
||||
return cls.from_data(row)
|
||||
|
||||
|
||||
@classmethod
|
||||
def findall(cls, condition, operator='='):
|
||||
rows = db.QueryBuilder(cls.table)\
|
||||
.where(condition, operator)\
|
||||
.all()
|
||||
res = []
|
||||
return [cls.from_data(row) for row in rows]
|
||||
|
||||
|
||||
@classmethod
|
||||
@@ -187,9 +225,7 @@ class Model:
|
||||
row = db.insert(cls.table, columns, *values.values())
|
||||
|
||||
if row:
|
||||
instance = cls(cls.table)
|
||||
instance._data = row
|
||||
return instance
|
||||
return cls.from_data(row)
|
||||
return None
|
||||
|
||||
|
||||
@@ -207,7 +243,8 @@ class Model:
|
||||
def select(cls, sel = "*"):
|
||||
qb = db.QueryBuilder(cls.table).select(sel)
|
||||
result = qb.all()
|
||||
return result if result else []
|
||||
# return result if result else []
|
||||
return [cls.from_data(data) for data in (result if result else [])]
|
||||
|
||||
|
||||
def update(self, data):
|
||||
|
||||
@@ -1,36 +1,245 @@
|
||||
from .babycode_parser import Parser
|
||||
from markupsafe import escape
|
||||
from markupsafe import Markup, escape
|
||||
from pygments import highlight
|
||||
from pygments.formatters import HtmlFormatter
|
||||
from pygments.lexers import get_lexer_by_name
|
||||
from pygments.util import ClassNotFound as PygmentsClassNotFound
|
||||
import re
|
||||
|
||||
def tag_code(children, attr):
|
||||
is_inline = children.find('\n') == -1
|
||||
if is_inline:
|
||||
return f"<code class=\"inline-code\">{children}</code>"
|
||||
BABYCODE_VERSION = 10
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
t = children.strip()
|
||||
button = f"<button type=button class=\"copy-code\" value=\"{t}\">Copy</button>"
|
||||
return f"<pre><span class=\"copy-code-container\">{button}</span><code>{t}</code></pre>"
|
||||
result.append(f"<p>{para_content}</p>")
|
||||
current_paragraph.clear()
|
||||
|
||||
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])
|
||||
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()
|
||||
|
||||
TAGS = {
|
||||
"b": lambda children, attr: f"<strong>{children}</strong>",
|
||||
"i": lambda children, attr: f"<em>{children}</em>",
|
||||
"s": lambda children, attr: f"<del>{children}</del>",
|
||||
"img": lambda children, attr: f"<div class=\"post-img-container\"><img class=\"block-img\" src=\"{attr}\" alt=\"{children}\"></div>",
|
||||
"url": lambda children, attr: f"<a href={attr}>{children}</a>",
|
||||
"quote": lambda children, attr: f"<blockquote>{children}</blockquote>",
|
||||
"code": tag_code,
|
||||
"ul": lambda children, attr: f"<ul>{tag_list(children)}</ul>",
|
||||
"ol": lambda children, attr: f"<ol>{tag_list(children)}</ol>",
|
||||
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.user_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.user_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',
|
||||
'purple', 'fuchsia', 'green', 'lime', 'olive', 'yellow',
|
||||
'navy', 'blue', 'teal', 'aqua', 'aliceblue', 'antiquewhite',
|
||||
'aqua', 'aquamarine', 'azure', 'beige', 'bisque', 'black',
|
||||
'blanchedalmond', 'blue', 'blueviolet', 'brown', 'burlywood', 'cadetblue',
|
||||
'chartreuse', 'chocolate', 'coral', 'cornflowerblue', 'cornsilk', 'crimson',
|
||||
'cyan', 'aqua', 'darkblue', 'darkcyan', 'darkgoldenrod', 'darkgray',
|
||||
'darkgreen', 'darkgrey', 'darkkhaki', 'darkmagenta', 'darkolivegreen', 'darkorange',
|
||||
'darkorchid', 'darkred', 'darksalmon', 'darkseagreen', 'darkslateblue', 'darkslategray',
|
||||
'darkslategrey', 'darkturquoise', 'darkviolet', 'deeppink', 'deepskyblue', 'dimgray',
|
||||
'dimgrey', 'dodgerblue', 'firebrick', 'floralwhite', 'forestgreen', 'fuchsia',
|
||||
'gainsboro', 'ghostwhite', 'gold', 'goldenrod', 'gray', 'green',
|
||||
'greenyellow', 'grey', 'gray', 'honeydew', 'hotpink', 'indianred',
|
||||
'indigo', 'ivory', 'khaki', 'lavender', 'lavenderblush', 'lawngreen',
|
||||
'lemonchiffon', 'lightblue', 'lightcoral', 'lightcyan', 'lightgoldenrodyellow', 'lightgray',
|
||||
'lightgreen', 'lightgrey', 'lightpink', 'lightsalmon', 'lightseagreen', 'lightskyblue',
|
||||
'lightslategray', 'lightslategrey', 'lightsteelblue', 'lightyellow', 'lime', 'limegreen',
|
||||
'linen', 'magenta', 'fuchsia', 'maroon', 'mediumaquamarine', 'mediumblue',
|
||||
'mediumorchid', 'mediumpurple', 'mediumseagreen', 'mediumslateblue', 'mediumspringgreen', 'mediumturquoise',
|
||||
'mediumvioletred', 'midnightblue', 'mintcream', 'mistyrose', 'moccasin', 'navajowhite',
|
||||
'navy', 'oldlace', 'olive', 'olivedrab', 'orange', 'orangered',
|
||||
'orchid', 'palegoldenrod', 'palegreen', 'paleturquoise', 'palevioletred', 'papayawhip',
|
||||
'peachpuff', 'peru', 'pink', 'plum', 'powderblue', 'purple',
|
||||
'rebeccapurple', 'red', 'rosybrown', 'royalblue', 'saddlebrown', 'salmon',
|
||||
'sandybrown', 'seagreen', 'seashell', 'sienna', 'silver', 'skyblue',
|
||||
'slateblue', 'slategray', 'slategrey', 'snow', 'springgreen', 'steelblue',
|
||||
'tan', 'teal', 'thistle', 'tomato', 'transparent', 'turquoise',
|
||||
'violet', 'wheat', 'white', 'whitesmoke', 'yellow', 'yellowgreen',
|
||||
]
|
||||
|
||||
|
||||
def make_emoji(name, code):
|
||||
return f'<img class=emoji src="/static/emoji/{name}.png" alt="{name}" title=":{code}:">'
|
||||
|
||||
|
||||
EMOJI = {
|
||||
'angry': make_emoji('angry', 'angry'),
|
||||
|
||||
@@ -77,43 +286,325 @@ 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 babycode_to_html(s):
|
||||
subj = escape(s.strip().replace('\r\n', '\n').replace('\r', '\n'))
|
||||
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()
|
||||
language = 'code block'
|
||||
if attr:
|
||||
try:
|
||||
lexer = get_lexer_by_name(attr.strip())
|
||||
formatter = HtmlFormatter(nowrap=True)
|
||||
language = lexer.name
|
||||
code = highlight(Markup(input_code).unescape(), lexer, formatter)
|
||||
except PygmentsClassNotFound:
|
||||
code = input_code
|
||||
else:
|
||||
code = input_code
|
||||
|
||||
button = f'<button type=button class="copy-code" data-s="copyCode">Copy</button>'
|
||||
block = f'<fieldset data-r="copyCode" value="{input_code}" class="code-block-container plank minimal no-shadow secondary-bg"><legend>{language}</legend>{button}<pre><code>{code}</code></pre></fieldset>'
|
||||
return block
|
||||
|
||||
|
||||
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='plank minimal even no-shadow hidden'>{children}</div>"
|
||||
container = f"""<details><summary class='plank secondary-bg no-shadow even'>{spoiler_name}</summary>{content}</details>"""
|
||||
return container
|
||||
|
||||
|
||||
def tag_image(children, attr):
|
||||
img = f"<img class=\"post-image\" src=\"{attr}\" alt=\"{children}\">"
|
||||
return img
|
||||
|
||||
|
||||
def tag_quote(children, attr):
|
||||
if attr:
|
||||
quotee = f'Quoting: {attr.strip()}'
|
||||
else:
|
||||
quotee = 'Quote'
|
||||
|
||||
return f'<fieldset class="plank minimal no-shadow secondary-bg"><legend>{quotee}</legend><blockquote>{children}</blockquote></fieldset>'
|
||||
|
||||
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": tag_quote,
|
||||
"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: '-',
|
||||
}
|
||||
|
||||
|
||||
INLINE_TAGS = {
|
||||
'b', 'i', 's', 'u', 'color', 'big', 'small', 'url', 'lb', 'rb', 'at', 'd', 'img'
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
return False # i think
|
||||
|
||||
if is_text(e):
|
||||
return True
|
||||
|
||||
if is_tag(e):
|
||||
if is_tag(e, 'code'): # special case, since [code] can be inline OR block
|
||||
return '\n' not in e['children'][0]
|
||||
|
||||
return e['name'] in INLINE_TAGS
|
||||
|
||||
return e['type'] != 'rule'
|
||||
|
||||
|
||||
def should_collapse(text, surrounding):
|
||||
if not isinstance(text, str):
|
||||
return False
|
||||
|
||||
if not text:
|
||||
return True
|
||||
|
||||
if not text.strip() and '\n' not in text:
|
||||
return not is_inline(surrounding[0]) and not is_inline(surrounding[1])
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def sanitize(s):
|
||||
return escape(s.strip().replace('\r\n', '\n').replace('\r', '\n'))
|
||||
|
||||
|
||||
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:
|
||||
allowed_tags.discard(tag)
|
||||
subj = sanitize(s)
|
||||
parser = Parser(subj)
|
||||
parser.valid_bbcode_tags = TAGS.keys()
|
||||
parser.valid_bbcode_tags = allowed_tags
|
||||
parser.void_bbcode_tags = set(VOID_TAGS)
|
||||
parser.bbcode_tags_only_text_children = TEXT_ONLY
|
||||
parser.mentions_allowed = '@mention' not in banned_tags
|
||||
parser.valid_emotes = EMOJI.keys()
|
||||
|
||||
elements = parser.parse()
|
||||
print(elements)
|
||||
out = ""
|
||||
def fold(element, nobr):
|
||||
if isinstance(element, str):
|
||||
if nobr:
|
||||
return element
|
||||
return break_lines(element)
|
||||
uncollapsed = parser.parse()
|
||||
elements = []
|
||||
for i in range(len(uncollapsed)):
|
||||
e = uncollapsed[i]
|
||||
surrounding = (
|
||||
uncollapsed[i - 1] if i-1 >= 0 else None,
|
||||
uncollapsed[i + 1] if i+1 < len(uncollapsed) else None
|
||||
)
|
||||
if not should_collapse(e, surrounding):
|
||||
elements.append(e)
|
||||
return elements
|
||||
|
||||
match element['type']:
|
||||
case "bbcode":
|
||||
c = ""
|
||||
for child in element['children']:
|
||||
_nobr = element['name'] == "code" or element['name'] == "ul" or element['name'] == "ol"
|
||||
c = c + fold(child, _nobr)
|
||||
res = TAGS[element['name']](c, element['attr'])
|
||||
return res
|
||||
case "link":
|
||||
return f"<a href=\"{element['url']}\">{element['url']}</a>"
|
||||
case 'emote':
|
||||
return EMOJI[element['name']]
|
||||
case "rule":
|
||||
return "<hr>"
|
||||
for e in elements:
|
||||
out = out + fold(e, False)
|
||||
return out
|
||||
|
||||
def babycode_to_html(s: str, banned_tags=[], fragment=False) -> BabycodeRenderResult:
|
||||
"""
|
||||
transforms a string of babycode into 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 = 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)
|
||||
|
||||
@@ -4,14 +4,17 @@ import re
|
||||
|
||||
PAT_EMOTE = r"[^\s:]"
|
||||
PAT_BBCODE_TAG = r"\w"
|
||||
PAT_BBCODE_ATTR = r"[^\s\]]"
|
||||
PAT_BBCODE_ATTR = r"[^\]]"
|
||||
PAT_LINK = r"https?:\/\/[\w\-_.?:\/=&~@#%]+[\w\-\/]"
|
||||
PAT_MENTION = r'[a-zA-Z0-9_-]'
|
||||
|
||||
class Parser:
|
||||
def __init__(self, src_str):
|
||||
self.valid_bbcode_tags = []
|
||||
self.valid_bbcode_tags = {}
|
||||
self.void_bbcode_tags = {}
|
||||
self.valid_emotes = []
|
||||
self.bbcode_tags_only_text_children = [],
|
||||
self.bbcode_tags_only_text_children = []
|
||||
self.mentions_allowed = True
|
||||
self.source = src_str
|
||||
self.position = 0
|
||||
self.position_stack = []
|
||||
@@ -193,9 +196,10 @@ class Parser:
|
||||
self.save_position()
|
||||
|
||||
# extract printable chars (extreme hack edition)
|
||||
word = self.match_pattern(r'[ -~]')
|
||||
word = self.match_pattern(r'[!-~]')
|
||||
|
||||
if not re.match(PAT_LINK, word):
|
||||
match = re.match(PAT_LINK, word)
|
||||
if not match:
|
||||
self.restore_position()
|
||||
return None
|
||||
|
||||
@@ -205,15 +209,70 @@ class Parser:
|
||||
"url": word
|
||||
}
|
||||
|
||||
def parse_mention(self):
|
||||
if not self.mentions_allowed:
|
||||
return None
|
||||
|
||||
self.save_position()
|
||||
|
||||
if not self.check_char('@'):
|
||||
self.restore_position()
|
||||
return None
|
||||
|
||||
mention = self.match_pattern(PAT_MENTION)
|
||||
self.forget_position()
|
||||
return {
|
||||
"type": "mention",
|
||||
"name": mention,
|
||||
"start": self.position - len(mention) - 1,
|
||||
"end": self.position,
|
||||
}
|
||||
|
||||
|
||||
def parse_bbcode_void(self):
|
||||
self.save_position()
|
||||
|
||||
if not self.check_char("["):
|
||||
self.restore_position()
|
||||
return None
|
||||
|
||||
name = self.match_pattern(PAT_BBCODE_TAG)
|
||||
|
||||
if name == "":
|
||||
self.restore_position()
|
||||
return None
|
||||
|
||||
attr = None
|
||||
|
||||
if self.check_char("="):
|
||||
attr = self.match_pattern(PAT_BBCODE_ATTR)
|
||||
|
||||
if not self.check_char("]"):
|
||||
self.restore_position()
|
||||
return None
|
||||
|
||||
if not name in self.void_bbcode_tags:
|
||||
self.restore_position()
|
||||
return None
|
||||
|
||||
self.forget_position()
|
||||
return {
|
||||
'type': 'bbcode_void',
|
||||
'name': name,
|
||||
'attr': attr,
|
||||
}
|
||||
|
||||
|
||||
def parse_element(self, siblings):
|
||||
if self.is_end_of_source():
|
||||
return None
|
||||
|
||||
element = self.parse_emote() \
|
||||
or self.parse_bbcode_void() \
|
||||
or self.parse_bbcode() \
|
||||
or self.parse_rule() \
|
||||
or self.parse_link()
|
||||
or self.parse_link() \
|
||||
or self.parse_mention()
|
||||
|
||||
if element is None:
|
||||
if len(siblings) > 0:
|
||||
|
||||
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
|
||||
@@ -5,10 +5,45 @@ def migrate_old_avatars():
|
||||
new_path = f"/static{avatar['file_path']}"
|
||||
db.execute('UPDATE avatars SET file_path = ? WHERE id = ?', new_path, avatar['id'])
|
||||
|
||||
def add_signature_format():
|
||||
db.execute('ALTER TABLE "users" ADD COLUMN "signature_markup_language" TEXT NOT NULL DEFAULT "babycode"')
|
||||
db.execute('ALTER TABLE "users" ADD COLUMN "signature_format_version" INTEGER DEFAULT NULL')
|
||||
|
||||
def create_default_bookmark_collections():
|
||||
from .constants import PermissionLevel
|
||||
q = """SELECT users.id FROM users
|
||||
LEFT JOIN bookmark_collections bc ON (users.id = bc.user_id AND bc.is_default = TRUE)
|
||||
WHERE bc.id IS NULL and users.permission IS NOT ?"""
|
||||
user_ids_without_default_collection = db.query(q, PermissionLevel.SYSTEM.value)
|
||||
if len(user_ids_without_default_collection) == 0:
|
||||
return
|
||||
|
||||
from .models import BookmarkCollections
|
||||
for user in user_ids_without_default_collection:
|
||||
BookmarkCollections.create_default(user['id'])
|
||||
|
||||
def add_display_name():
|
||||
dq = 'ALTER TABLE "users" ADD COLUMN "display_name" TEXT NOT NULL DEFAULT ""'
|
||||
db.execute(dq)
|
||||
from .models import Users
|
||||
for user in Users.select():
|
||||
data = {
|
||||
'username': user.username.lower(),
|
||||
}
|
||||
if user.username.lower() != user.username:
|
||||
data['display_name'] = user.username
|
||||
user.update(data)
|
||||
|
||||
# format: [str|tuple(str, any...)|callable]
|
||||
MIGRATIONS = [
|
||||
migrate_old_avatars,
|
||||
'DELETE FROM sessions', # delete old lua porom sessions
|
||||
'ALTER TABLE "users" ADD COLUMN "invited_by" INTEGER REFERENCES users(id)', # invitation system
|
||||
'ALTER TABLE "post_history" ADD COLUMN "format_version" INTEGER DEFAULT NULL',
|
||||
add_signature_format,
|
||||
create_default_bookmark_collections,
|
||||
add_display_name,
|
||||
'ALTER TABLE "post_history" ADD COLUMN "content_rss" STRING DEFAULT NULL'
|
||||
]
|
||||
|
||||
def run_migrations():
|
||||
@@ -18,11 +53,11 @@ def run_migrations():
|
||||
)
|
||||
""")
|
||||
if len(MIGRATIONS) == 0:
|
||||
print("No migrations defined.")
|
||||
print('No migrations defined.')
|
||||
return
|
||||
print("Running migrations...")
|
||||
print('Running migrations...')
|
||||
ran = 0
|
||||
completed = {int(row["id"]) for row in db.query("SELECT id FROM _migrations")}
|
||||
completed = {int(row['id']) for row in db.query('SELECT id FROM _migrations')}
|
||||
to_run = {idx: migration_obj for idx, migration_obj in enumerate(MIGRATIONS) if idx not in completed}
|
||||
if not to_run:
|
||||
print('No migrations need to run.')
|
||||
@@ -39,4 +74,4 @@ def run_migrations():
|
||||
|
||||
db.execute('INSERT INTO _migrations (id) VALUES (?)', migration_id)
|
||||
ran += 1
|
||||
print(f"Ran {ran} migrations.")
|
||||
print(f'Ran {ran} migrations.')
|
||||
|
||||
461
app/models.py
@@ -1,12 +1,13 @@
|
||||
from .db import Model, db
|
||||
from .constants import PermissionLevel
|
||||
from flask import current_app
|
||||
import time
|
||||
|
||||
class Users(Model):
|
||||
table = "users"
|
||||
table = 'users'
|
||||
|
||||
def get_avatar_url(self):
|
||||
return Avatars.find({"id": self.avatar_id}).file_path
|
||||
return Avatars.find({'id': self.avatar_id}).file_path
|
||||
|
||||
def is_default_avatar(self):
|
||||
return int(Avatars.find({'id': self.avatar_id}).id) == 1
|
||||
@@ -17,6 +18,9 @@ class Users(Model):
|
||||
def is_mod(self):
|
||||
return self.permission >= PermissionLevel.MODERATOR.value
|
||||
|
||||
def is_mod_only(self):
|
||||
return self.permission == PermissionLevel.MODERATOR.value
|
||||
|
||||
def is_admin(self):
|
||||
return self.permission == PermissionLevel.ADMIN.value
|
||||
|
||||
@@ -26,29 +30,14 @@ class Users(Model):
|
||||
def is_default_avatar(self):
|
||||
return self.avatar_id == 1
|
||||
|
||||
def get_latest_posts(self):
|
||||
q = """SELECT
|
||||
posts.id, posts.created_at, post_history.content, post_history.edited_at, threads.title AS thread_title, topics.name as topic_name, threads.slug as thread_slug
|
||||
FROM
|
||||
posts
|
||||
JOIN
|
||||
post_history ON posts.current_revision_id = post_history.id
|
||||
JOIN
|
||||
threads ON posts.thread_id = threads.id
|
||||
JOIN
|
||||
topics ON threads.topic_id = topics.id
|
||||
WHERE
|
||||
posts.user_id = ?
|
||||
ORDER BY posts.created_at DESC
|
||||
LIMIT 10"""
|
||||
return db.query(q, self.id)
|
||||
|
||||
def get_post_stats(self):
|
||||
q = """SELECT
|
||||
COUNT(DISTINCT posts.id) AS post_count,
|
||||
COUNT(DISTINCT threads.id) AS thread_count,
|
||||
MAX(threads.title) FILTER (WHERE threads.created_at = latest.created_at) AS latest_thread_title,
|
||||
MAX(threads.slug) FILTER (WHERE threads.created_at = latest.created_at) AS latest_thread_slug
|
||||
MAX(threads.slug) FILTER (WHERE threads.created_at = latest.created_at) AS latest_thread_slug,
|
||||
inviter.username AS inviter_username,
|
||||
inviter.display_name AS inviter_display_name
|
||||
FROM users
|
||||
LEFT JOIN posts ON posts.user_id = users.id
|
||||
LEFT JOIN threads ON threads.user_id = users.id
|
||||
@@ -57,6 +46,7 @@ class Users(Model):
|
||||
FROM threads
|
||||
GROUP BY user_id
|
||||
) latest ON latest.user_id = users.id
|
||||
LEFT JOIN users AS inviter ON inviter.id = users.invited_by
|
||||
WHERE users.id = ?"""
|
||||
return db.fetch_one(q, self.id)
|
||||
|
||||
@@ -83,89 +73,128 @@ class Users(Model):
|
||||
|
||||
return True
|
||||
|
||||
def can_invite(self):
|
||||
if not current_app.config['DISABLE_SIGNUP']:
|
||||
return False
|
||||
|
||||
if current_app.config['MODS_CAN_INVITE'] and self.is_mod():
|
||||
return True
|
||||
|
||||
if current_app.config['USERS_CAN_INVITE'] and not self.is_guest():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_bookmark_collections(self):
|
||||
q = 'SELECT id FROM bookmark_collections WHERE user_id = ? ORDER BY sort_order ASC'
|
||||
res = db.query(q, self.id)
|
||||
return [BookmarkCollections.find({'id': bc['id']}) for bc in res]
|
||||
|
||||
def get_readable_name(self):
|
||||
if self.display_name:
|
||||
return self.display_name
|
||||
|
||||
return self.username
|
||||
|
||||
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"
|
||||
table = 'topics'
|
||||
|
||||
@classmethod
|
||||
def get_list(_cls):
|
||||
q = """
|
||||
SELECT
|
||||
topics.id, topics.name, topics.slug, topics.description, topics.is_locked,
|
||||
users.username AS latest_thread_username,
|
||||
threads.title AS latest_thread_title,
|
||||
threads.slug AS latest_thread_slug,
|
||||
threads.created_at AS latest_thread_created_at
|
||||
COUNT(DISTINCT threads.id) as threads_count,
|
||||
COUNT(posts.id) AS posts_count,
|
||||
MAX(posts.created_at) as latest_post_timestamp
|
||||
FROM
|
||||
topics
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
*,
|
||||
row_number() OVER (PARTITION BY threads.topic_id ORDER BY threads.created_at DESC) as rn
|
||||
FROM
|
||||
threads
|
||||
) threads ON threads.topic_id = topics.id AND threads.rn = 1
|
||||
LEFT JOIN
|
||||
users on users.id = threads.user_id
|
||||
ORDER BY
|
||||
topics.sort_order ASC"""
|
||||
threads ON threads.topic_id = topics.id
|
||||
LEFT JOIN
|
||||
posts ON posts.thread_id = threads.id
|
||||
GROUP BY topics.id ORDER BY topics.sort_order ASC"""
|
||||
return db.query(q)
|
||||
|
||||
@classmethod
|
||||
def get_active_threads(cls):
|
||||
def new(_cls, name: str, description: str) -> Topics:
|
||||
from slugify import slugify
|
||||
name = name.strip()
|
||||
description = description.strip()
|
||||
now = int(time.time())
|
||||
slug = f'{slugify(name)}-{now}'
|
||||
|
||||
topic_count = Topics.count()
|
||||
return Topics.create({
|
||||
'name': name,
|
||||
'description': description,
|
||||
'slug': slug,
|
||||
'sort_order': topic_count + 1,
|
||||
})
|
||||
|
||||
def get_threads(self, per_page, page, sort_by = 'activity'):
|
||||
order_clause = ''
|
||||
if sort_by == 'thread':
|
||||
order_clause = 'ORDER BY threads.is_stickied DESC, threads.created_at DESC'
|
||||
else:
|
||||
order_clause = 'ORDER BY threads.is_stickied DESC, latest_post_created_at DESC'
|
||||
|
||||
q = """
|
||||
WITH ranked_threads AS (
|
||||
WITH latest_posts AS (
|
||||
SELECT
|
||||
threads.topic_id, threads.id AS thread_id, threads.title AS thread_title, threads.slug AS thread_slug,
|
||||
posts.id AS post_id, posts.created_at AS post_created_at,
|
||||
users.username,
|
||||
ROW_NUMBER() OVER (PARTITION BY threads.topic_id ORDER BY posts.created_at DESC) AS rn
|
||||
FROM
|
||||
threads
|
||||
JOIN
|
||||
posts ON threads.id = posts.thread_id
|
||||
LEFT JOIN
|
||||
users ON posts.user_id = users.id
|
||||
thread_id,
|
||||
id AS latest_post_id,
|
||||
user_id AS latest_post_user_id,
|
||||
created_at AS latest_post_created_at,
|
||||
ROW_NUMBER() OVER (PARTITION BY thread_id ORDER BY created_at DESC) AS rn
|
||||
FROM posts
|
||||
),
|
||||
post_counts AS (
|
||||
SELECT
|
||||
thread_id,
|
||||
COUNT(*) AS posts_count
|
||||
FROM posts
|
||||
GROUP BY thread_id
|
||||
)
|
||||
SELECT
|
||||
topic_id,
|
||||
thread_id, thread_title, thread_slug,
|
||||
post_id, post_created_at,
|
||||
username
|
||||
FROM
|
||||
ranked_threads
|
||||
WHERE
|
||||
rn = 1
|
||||
ORDER BY
|
||||
topic_id"""
|
||||
threads.id,
|
||||
threads.title,
|
||||
threads.slug,
|
||||
threads.created_at,
|
||||
threads.is_locked,
|
||||
threads.is_stickied,
|
||||
starter.username AS started_by,
|
||||
starter.display_name AS started_by_display_name,
|
||||
latest_poster.username AS latest_post_username,
|
||||
latest_poster.display_name AS latest_post_display_name,
|
||||
latest_posts.latest_post_created_at,
|
||||
latest_posts.latest_post_id,
|
||||
COALESCE(post_counts.posts_count, 0) AS posts_count
|
||||
FROM threads
|
||||
JOIN users AS starter ON starter.id = threads.user_id
|
||||
LEFT JOIN latest_posts ON latest_posts.thread_id = threads.id AND latest_posts.rn = 1
|
||||
LEFT JOIN users AS latest_poster ON latest_poster.id = latest_posts.latest_post_user_id
|
||||
LEFT JOIN post_counts ON post_counts.thread_id = threads.id
|
||||
WHERE threads.topic_id = ?
|
||||
""" + order_clause + ' LIMIT ? OFFSET ?'
|
||||
|
||||
active_threads_raw = db.query(q)
|
||||
active_threads = {}
|
||||
for thread in active_threads_raw:
|
||||
active_threads[int(thread['topic_id'])] = {
|
||||
'thread_title': thread['thread_title'],
|
||||
'thread_slug': thread['thread_slug'],
|
||||
'post_id': thread['post_id'],
|
||||
'username': thread['username'],
|
||||
'post_created_at': thread['post_created_at']
|
||||
}
|
||||
return active_threads
|
||||
|
||||
def get_threads(self, per_page, page, sort_by = "activity"):
|
||||
order_clause = ""
|
||||
if sort_by == "thread":
|
||||
order_clause = "ORDER BY threads.is_stickied DESC, threads.created_at DESC"
|
||||
else:
|
||||
order_clause = "ORDER BY threads.is_stickied DESC, latest_post_created_at DESC"
|
||||
return db.query(q, self.id, per_page, (page - 1) * per_page)
|
||||
|
||||
def get_threads_with_op_rss(self):
|
||||
q = """
|
||||
SELECT
|
||||
threads.title, threads.slug, threads.created_at, threads.is_locked, threads.is_stickied,
|
||||
threads.id, threads.title, threads.slug, threads.created_at, threads.is_locked, threads.is_stickied,
|
||||
users.username AS started_by,
|
||||
u.username AS latest_post_username,
|
||||
ph.content AS latest_post_content,
|
||||
posts.created_at AS latest_post_created_at,
|
||||
posts.id AS latest_post_id
|
||||
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
|
||||
@@ -176,7 +205,7 @@ class Topics(Model):
|
||||
posts.user_id,
|
||||
posts.created_at,
|
||||
posts.current_revision_id,
|
||||
ROW_NUMBER() OVER (PARTITION BY posts.thread_id ORDER BY posts.created_at DESC) AS rn
|
||||
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
|
||||
@@ -186,17 +215,24 @@ class Topics(Model):
|
||||
users u ON u.id = posts.user_id
|
||||
WHERE
|
||||
threads.topic_id = ?
|
||||
""" + order_clause + " LIMIT ? OFFSET ?"
|
||||
ORDER BY threads.created_at DESC"""
|
||||
|
||||
return db.query(q, self.id, per_page, (page - 1) * per_page)
|
||||
return db.query(q, self.id)
|
||||
|
||||
def locked(self):
|
||||
return bool(self.is_locked)
|
||||
|
||||
|
||||
class Threads(Model):
|
||||
table = "threads"
|
||||
table = 'threads'
|
||||
|
||||
def get_posts(self, limit, offset):
|
||||
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(self, per_page, page):
|
||||
q = Posts.FULL_POSTS_QUERY + ' WHERE posts.thread_id = ? ORDER BY posts.created_at ASC LIMIT ? OFFSET ?'
|
||||
return db.query(q, self.id, per_page, (page - 1) * per_page)
|
||||
|
||||
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)
|
||||
@@ -204,10 +240,49 @@ class Threads(Model):
|
||||
def stickied(self):
|
||||
return bool(self.is_stickied)
|
||||
|
||||
@classmethod
|
||||
def new(cls, user_id: int, topic_id: int, title: str, content: str, language: str = 'babycode') -> Threads:
|
||||
from slugify import slugify
|
||||
now = int(time.time())
|
||||
slug = f'{slugify(title)}-{now}'
|
||||
thread = Threads.create({
|
||||
'topic_id': topic_id,
|
||||
'user_id': user_id,
|
||||
'title': title.strip(),
|
||||
'slug': slug,
|
||||
'created_at': int(time.time()),
|
||||
})
|
||||
post = Posts.new(user_id, thread.id, content, language)
|
||||
return thread
|
||||
|
||||
class Posts(Model):
|
||||
FULL_POSTS_QUERY = """
|
||||
WITH user_badges AS (
|
||||
SELECT
|
||||
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, threads.slug AS thread_slug, threads.is_locked AS thread_is_locked
|
||||
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_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,
|
||||
COALESCE(user_badges.badges_json, '[]') AS badges_json
|
||||
FROM
|
||||
posts
|
||||
JOIN
|
||||
@@ -217,21 +292,70 @@ 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"
|
||||
table = 'posts'
|
||||
|
||||
def get_full_post_view(self):
|
||||
q = f'{self.FULL_POSTS_QUERY} WHERE posts.id = ?'
|
||||
return db.fetch_one(q, self.id)
|
||||
|
||||
@classmethod
|
||||
def new(cls, user_id: int, thread_id: int, content: str, language: str = 'babycode') -> Posts:
|
||||
from .lib.babycode import babycode_to_html, babycode_to_rssxml, BABYCODE_VERSION
|
||||
html_content = babycode_to_html(content)
|
||||
rssxml_content = babycode_to_rssxml(content)
|
||||
with db.transaction():
|
||||
post = Posts.create({
|
||||
'thread_id': thread_id,
|
||||
'user_id': user_id,
|
||||
'current_revision_id': None,
|
||||
})
|
||||
|
||||
revision = PostHistory.create({
|
||||
'post_id': post.id,
|
||||
'content': html_content.result,
|
||||
'content_rss': rssxml_content,
|
||||
'is_initial_revision': True,
|
||||
'original_markup': content,
|
||||
'markup_language': language,
|
||||
'format_version': BABYCODE_VERSION,
|
||||
})
|
||||
|
||||
for mention in html_content.mentions:
|
||||
Mentions.create({
|
||||
'revision_id': revision.id,
|
||||
'mentioned_iser_id': mention['mentioned_iser_id'],
|
||||
'start_index': mention['start'],
|
||||
'end_index': mention['end'],
|
||||
})
|
||||
|
||||
post.update({'current_revision_id': revision.id})
|
||||
return post
|
||||
|
||||
class PostHistory(Model):
|
||||
table = "post_history"
|
||||
table = 'post_history'
|
||||
|
||||
class Sessions(Model):
|
||||
table = "sessions"
|
||||
table = 'sessions'
|
||||
|
||||
class Avatars(Model):
|
||||
table = "avatars"
|
||||
table = 'avatars'
|
||||
|
||||
class Subscriptions(Model):
|
||||
table = "subscriptions"
|
||||
table = 'subscriptions'
|
||||
|
||||
def get_unread_count(self):
|
||||
q = """SELECT COUNT(*) AS unread_count
|
||||
FROM posts
|
||||
LEFT JOIN subscriptions ON subscriptions.thread_id = posts.thread_id
|
||||
WHERE subscriptions.user_id = ? AND posts.created_at > subscriptions.last_seen AND posts.thread_id = ?"""
|
||||
res = db.fetch_one(q, self.user_id, self.thread_id)
|
||||
if res:
|
||||
return res['unread_count']
|
||||
return None
|
||||
|
||||
class APIRateLimits(Model):
|
||||
table = 'api_rate_limits'
|
||||
@@ -256,3 +380,152 @@ class APIRateLimits(Model):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
class Reactions(Model):
|
||||
table = 'reactions'
|
||||
|
||||
@classmethod
|
||||
def for_post(cls, post_id):
|
||||
qb = db.QueryBuilder(cls.table)\
|
||||
.select('reaction_text, COUNT(*) as c')\
|
||||
.where({'post_id': post_id})\
|
||||
.group_by('reaction_text')\
|
||||
.order_by('c', False)
|
||||
result = qb.all()
|
||||
return result if result else []
|
||||
|
||||
@classmethod
|
||||
def get_users(cls, post_id, reaction_text):
|
||||
q = """
|
||||
SELECT user_id, username FROM reactions
|
||||
JOIN
|
||||
users ON users.id = user_id
|
||||
WHERE
|
||||
post_id = ? AND reaction_text = ?
|
||||
"""
|
||||
|
||||
return db.query(q, post_id, reaction_text)
|
||||
|
||||
|
||||
class PasswordResetLinks(Model):
|
||||
table = 'password_reset_links'
|
||||
|
||||
|
||||
class InviteKeys(Model):
|
||||
table = 'invite_keys'
|
||||
|
||||
|
||||
class BookmarkCollections(Model):
|
||||
table = 'bookmark_collections'
|
||||
|
||||
@classmethod
|
||||
def create_default(cls, user_id):
|
||||
q = """INSERT INTO bookmark_collections (user_id, name, is_default, sort_order)
|
||||
VALUES (?, "Bookmarks", 1, 0) RETURNING id
|
||||
"""
|
||||
res = db.fetch_one(q, user_id)
|
||||
|
||||
def has_posts(self):
|
||||
q = 'SELECT EXISTS(SELECT 1 FROM bookmarked_posts WHERE collection_id = ?) as e'
|
||||
res = db.fetch_one(q, self.id)['e']
|
||||
return int(res) == 1
|
||||
|
||||
def has_threads(self):
|
||||
q = 'SELECT EXISTS(SELECT 1 FROM bookmarked_threads WHERE collection_id = ?) as e'
|
||||
res = db.fetch_one(q, self.id)['e']
|
||||
return int(res) == 1
|
||||
|
||||
def is_empty(self):
|
||||
return not (self.has_posts() or self.has_threads())
|
||||
|
||||
def get_threads(self):
|
||||
q = 'SELECT id FROM bookmarked_threads WHERE collection_id = ?'
|
||||
res = db.query(q, self.id)
|
||||
return [BookmarkedThreads.find({'id': bt['id']}) for bt in res]
|
||||
|
||||
def get_posts(self):
|
||||
q = 'SELECT id FROM bookmarked_posts WHERE collection_id = ?'
|
||||
res = db.query(q, self.id)
|
||||
return [BookmarkedPosts.find({'id': bt['id']}) for bt in res]
|
||||
|
||||
def get_threads_count(self):
|
||||
q = 'SELECT COUNT(*) as tc FROM bookmarked_threads WHERE collection_id = ?'
|
||||
res = db.fetch_one(q, self.id)
|
||||
return int(res['tc'])
|
||||
|
||||
def get_posts_count(self):
|
||||
q = 'SELECT COUNT(*) as pc FROM bookmarked_posts WHERE collection_id = ?'
|
||||
res = db.fetch_one(q, self.id)
|
||||
return int(res['pc'])
|
||||
|
||||
def has_thread(self, thread_id):
|
||||
q = 'SELECT EXISTS(SELECT 1 FROM bookmarked_threads WHERE collection_id = ? AND thread_id = ?) as e'
|
||||
res = db.fetch_one(q, self.id, int(thread_id))['e']
|
||||
return int(res) == 1
|
||||
|
||||
def has_post(self, post_id):
|
||||
q = 'SELECT EXISTS(SELECT 1 FROM bookmarked_posts WHERE collection_id = ? AND post_id = ?) as e'
|
||||
res = db.fetch_one(q, self.id, int(post_id))['e']
|
||||
return int(res) == 1
|
||||
|
||||
|
||||
class BookmarkedPosts(Model):
|
||||
table = 'bookmarked_posts'
|
||||
|
||||
def get_post(self):
|
||||
return Posts.find({'id': self.post_id})
|
||||
|
||||
|
||||
class BookmarkedThreads(Model):
|
||||
table = 'bookmarked_threads'
|
||||
|
||||
def get_thread(self):
|
||||
return Threads.find({'id': self.thread_id})
|
||||
|
||||
|
||||
class MOTD(Model):
|
||||
table = 'motd'
|
||||
|
||||
@classmethod
|
||||
def has_motd(cls):
|
||||
q = 'SELECT EXISTS(SELECT 1 FROM motd) as e'
|
||||
res = db.fetch_one(q)['e']
|
||||
return int(res) == 1
|
||||
|
||||
@classmethod
|
||||
def get_all(cls):
|
||||
q = 'SELECT id FROM motd'
|
||||
res = db.query(q)
|
||||
return [MOTD.find({'id': i['id']}) for i in res]
|
||||
|
||||
|
||||
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,43 +0,0 @@
|
||||
from flask import Blueprint, request, url_for
|
||||
from ..lib.babycode import babycode_to_html
|
||||
from .users import is_logged_in, get_active_user
|
||||
from ..models import APIRateLimits, Threads
|
||||
from ..db import db
|
||||
|
||||
bp = Blueprint("api", __name__, url_prefix="/api/")
|
||||
|
||||
|
||||
@bp.post('/thread-updates/<thread_id>')
|
||||
def thread_updates(thread_id):
|
||||
thread = Threads.find({'id': thread_id})
|
||||
if not thread:
|
||||
return {'error': 'no such thread'}, 404
|
||||
target_time = request.json.get('since')
|
||||
if not target_time:
|
||||
return {'error': 'missing parameter "since"'}, 400
|
||||
try:
|
||||
target_time = int(target_time)
|
||||
except:
|
||||
return {'error': 'parameter "since" is not/cannot be converted to a number'}, 400
|
||||
|
||||
q = 'SELECT id FROM posts WHERE thread_id = ? AND posts.created_at > ? ORDER BY posts.created_at ASC LIMIT 1'
|
||||
new_post = db.fetch_one(q, thread_id, target_time)
|
||||
if not new_post:
|
||||
return {'status': 'none'}
|
||||
|
||||
url = url_for('threads.thread', slug=thread.slug, after=new_post['id'], _anchor=f"post-{new_post['id']}")
|
||||
return {'status': 'new_post', 'url': url}
|
||||
|
||||
|
||||
@bp.post('/babycode-preview')
|
||||
def babycode_preview():
|
||||
if not is_logged_in():
|
||||
return {'error': 'not authorized'}, 401
|
||||
user = get_active_user()
|
||||
if not APIRateLimits.is_allowed(user.id, 'babycode_preview', 5):
|
||||
return {'error': 'too many requests'}, 429
|
||||
markup = request.json.get('markup')
|
||||
if not markup or not isinstance(markup, str):
|
||||
return {'error': 'markup field missing or invalid type'}, 400
|
||||
rendered = babycode_to_html(markup)
|
||||
return {'html': rendered}
|
||||
@@ -1,12 +1,6 @@
|
||||
from flask import Blueprint, redirect, url_for, render_template
|
||||
bp = Blueprint('app', __name__, url_prefix = '/')
|
||||
|
||||
bp = Blueprint("app", __name__, url_prefix = "/")
|
||||
|
||||
@bp.route("/")
|
||||
@bp.get('/')
|
||||
def index():
|
||||
return redirect(url_for("topics.all_topics"))
|
||||
|
||||
|
||||
@bp.route("/babycode")
|
||||
def babycode_guide():
|
||||
return render_template('babycode.html')
|
||||
return redirect(url_for('topics.all_topics'))
|
||||
|
||||
11
app/routes/guides.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from flask import Blueprint
|
||||
|
||||
bp = Blueprint('guides', __name__, url_prefix = '/guides/')
|
||||
|
||||
@bp.get('/')
|
||||
def index():
|
||||
return 'stub'
|
||||
|
||||
@bp.get('/contact')
|
||||
def contact():
|
||||
return 'stub'
|
||||
@@ -1,33 +1,96 @@
|
||||
from flask import (
|
||||
Blueprint, render_template, request, redirect, url_for
|
||||
)
|
||||
from .users import login_required, mod_only, get_active_user
|
||||
from ..models import Users
|
||||
from ..db import db, DB
|
||||
bp = Blueprint("mod", __name__, url_prefix = "/mod/")
|
||||
from flask import Blueprint, abort, redirect, url_for, request, render_template
|
||||
from ..auth import is_logged_in, get_active_user, csrf_verified
|
||||
from ..models import Topics, Threads
|
||||
bp = Blueprint('mod', __name__, url_prefix='/mod/')
|
||||
|
||||
@bp.get("/sort-topics")
|
||||
@login_required
|
||||
@mod_only("topics.all_topics")
|
||||
@bp.before_request
|
||||
def mod_only():
|
||||
if not is_logged_in():
|
||||
abort(403)
|
||||
if not get_active_user().is_mod():
|
||||
abort(403)
|
||||
|
||||
@bp.get('/')
|
||||
def index():
|
||||
return 'stub'
|
||||
|
||||
@bp.get('/topics/new/')
|
||||
def new_topic():
|
||||
return render_template('mod/new_topic.html')
|
||||
|
||||
@bp.post('/topics/new/')
|
||||
def new_topic_post():
|
||||
topic = Topics.new(request.form.get('name'), request.form.get('description'))
|
||||
return redirect(url_for('topics.topic_by_id', topic_id=topic.id))
|
||||
|
||||
@bp.get('/topics/sort/')
|
||||
def sort_topics():
|
||||
topics = db.query("SELECT * FROM topics ORDER BY sort_order ASC")
|
||||
return render_template("mod/sort-topics.html", topics = topics)
|
||||
return 'stub'
|
||||
|
||||
@bp.get('/topics/<int:topic_id>/edit/')
|
||||
def edit_topic(topic_id):
|
||||
topic = Topics.find({'id': topic_id})
|
||||
if not topic:
|
||||
abort(404)
|
||||
return render_template('mod/edit_topic.html', topic=topic)
|
||||
|
||||
@bp.post("/sort-topics")
|
||||
@login_required
|
||||
@mod_only("topics.all_topics")
|
||||
def sort_topics_post():
|
||||
with db.transaction():
|
||||
for topic_id, new_order in request.form.items():
|
||||
db.execute("UPDATE topics SET sort_order = ? WHERE id = ?", new_order, topic_id)
|
||||
@bp.post('/topics/<int:topic_id>/edit/')
|
||||
def edit_topic_post(topic_id):
|
||||
topic = Topics.find({'id': topic_id})
|
||||
if not topic:
|
||||
abort(404)
|
||||
topic.update({
|
||||
'name': request.form.get('name').strip(),
|
||||
'description': request.form.get('description').strip(),
|
||||
})
|
||||
return redirect(url_for('topics.topic_by_id', topic_id=topic.id))
|
||||
|
||||
return redirect(url_for(".sort_topics"))
|
||||
@bp.post('/topics/<int:topic_id>/lock/')
|
||||
def lock_topic(topic_id):
|
||||
topic = Topics.find({'id': topic_id})
|
||||
if not topic:
|
||||
abort(404)
|
||||
topic.update({'is_locked': request.form.get('lock', default=0)})
|
||||
return redirect(url_for('topics.topic_by_id', topic_id=topic.id))
|
||||
|
||||
@bp.post('/threads/<int:thread_id>/move/')
|
||||
def move_thread(thread_id):
|
||||
thread = Threads.find({'id': thread_id})
|
||||
if not thread:
|
||||
abort(404)
|
||||
target_topic = Topics.find({'id': request.form.get('new_topic_id', default=None)})
|
||||
if not target_topic:
|
||||
abort(404)
|
||||
thread.update({'topic_id': target_topic.id})
|
||||
return redirect(url_for('threads.thread_by_id', thread_id=thread.id))
|
||||
|
||||
@bp.get("/user-list")
|
||||
@login_required
|
||||
@mod_only("users.page", username = lambda: get_active_user().username)
|
||||
def user_list():
|
||||
users = Users.select()
|
||||
return render_template("mod/user-list.html", users = users)
|
||||
@bp.post('/threads/<int:thread_id>/lock/')
|
||||
def lock_thread(thread_id):
|
||||
thread = Threads.find({'id': thread_id})
|
||||
if not thread:
|
||||
abort(404)
|
||||
thread.update({'is_locked': request.form.get('lock')})
|
||||
return redirect(url_for('threads.thread_by_id', thread_id=thread.id))
|
||||
|
||||
@bp.post('/threads/<int:thread_id>/sticky/')
|
||||
def sticky_thread(thread_id):
|
||||
thread = Threads.find({'id': thread_id})
|
||||
if not thread:
|
||||
abort(404)
|
||||
thread.update({'is_stickied': request.form.get('sticky')})
|
||||
return redirect(url_for('threads.thread_by_id', thread_id=thread.id))
|
||||
|
||||
@bp.post('/users/<int:user_id>/make-guest/')
|
||||
@csrf_verified
|
||||
def make_user_guest(user_id):
|
||||
return 'stub'
|
||||
|
||||
@bp.post('/users/<int:user_id>/make-user/')
|
||||
@csrf_verified
|
||||
def make_user_regular(user_id):
|
||||
return 'stub'
|
||||
|
||||
@bp.post('/users/<int:user_id>/make-mod/')
|
||||
@csrf_verified
|
||||
def make_user_mod(user_id):
|
||||
return 'stub'
|
||||
|
||||
@@ -1,99 +1,44 @@
|
||||
from flask import (
|
||||
Blueprint, redirect, url_for, flash, render_template, request
|
||||
)
|
||||
from .users import login_required, get_active_user
|
||||
from ..lib.babycode import babycode_to_html
|
||||
from ..constants import InfoboxKind
|
||||
from ..db import db
|
||||
from ..models import Posts, PostHistory, Threads
|
||||
from flask import Blueprint, abort
|
||||
from functools import wraps
|
||||
from ..auth import login_required, get_active_user
|
||||
from ..models import Posts
|
||||
|
||||
bp = Blueprint("posts", __name__, url_prefix = "/post")
|
||||
bp = Blueprint('posts', __name__, url_prefix='/posts/')
|
||||
|
||||
def ownership_required(view_func):
|
||||
@wraps(view_func)
|
||||
def wrapper(*args, **kwargs):
|
||||
post = Posts.find({'id': kwargs.get('post_id', None)})
|
||||
if not post:
|
||||
abort(404)
|
||||
|
||||
def create_post(thread_id, user_id, content, markup_language="babycode"):
|
||||
with db.transaction():
|
||||
post = Posts.create({
|
||||
"thread_id": thread_id,
|
||||
"user_id": user_id,
|
||||
"current_revision_id": None,
|
||||
})
|
||||
if post.user_id != get_active_user().id:
|
||||
abort(403)
|
||||
|
||||
revision = PostHistory.create({
|
||||
"post_id": post.id,
|
||||
"content": babycode_to_html(content),
|
||||
"is_initial_revision": True,
|
||||
"original_markup": content,
|
||||
"markup_language": markup_language,
|
||||
})
|
||||
return view_func(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
post.update({"current_revision_id": revision.id})
|
||||
return post
|
||||
def ownership_or_mod_required(view_func):
|
||||
@wraps(view_func)
|
||||
def wrapper(*args, **kwargs):
|
||||
post = Posts.find({'id': kwargs.get('post_id', None)})
|
||||
if not post:
|
||||
abort(404)
|
||||
|
||||
if post.user_id != get_active_user().id and not get_active_user().is_mod():
|
||||
abort(403)
|
||||
|
||||
def update_post(post_id, new_content, markup_language='babycode'):
|
||||
with db.transaction():
|
||||
post = Posts.find({'id': post_id})
|
||||
new_revision = PostHistory.create({
|
||||
'post_id': post.id,
|
||||
'content': babycode_to_html(new_content),
|
||||
'is_initial_revision': False,
|
||||
'original_markup': new_content,
|
||||
'markup_language': markup_language,
|
||||
})
|
||||
return view_func(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
post.update({'current_revision_id': new_revision.id})
|
||||
|
||||
|
||||
@bp.post("/<post_id>/delete")
|
||||
@login_required
|
||||
def delete(post_id):
|
||||
post = Posts.find({'id': post_id})
|
||||
thread = Threads.find({'id': post.thread_id})
|
||||
user = get_active_user()
|
||||
|
||||
if user.is_mod() or post.user_id == user.id:
|
||||
post.delete()
|
||||
flash("Post deleted.", InfoboxKind.INFO)
|
||||
|
||||
return redirect(url_for('threads.thread', slug=thread.slug))
|
||||
|
||||
|
||||
@bp.get("/<post_id>/edit")
|
||||
@bp.get('/<int:post_id>/edit/')
|
||||
@login_required
|
||||
@ownership_required
|
||||
def edit(post_id):
|
||||
user = get_active_user()
|
||||
q = f"{Posts.FULL_POSTS_QUERY} WHERE posts.id = ?"
|
||||
editing_post = db.fetch_one(q, post_id)
|
||||
if not editing_post:
|
||||
return redirect(url_for('topics.all_topics'))
|
||||
if editing_post['user_id'] != user.id:
|
||||
return redirect(url_for('topics.all_topics'))
|
||||
return 'stub'
|
||||
|
||||
thread = Threads.find({'id': editing_post['thread_id']})
|
||||
|
||||
thread_predicate = f'{Posts.FULL_POSTS_QUERY} WHERE posts.thread_id = ?'
|
||||
|
||||
context_prev_q = f'{thread_predicate} AND posts.created_at < ? ORDER BY posts.created_at DESC LIMIT 2'
|
||||
context_next_q = f'{thread_predicate} AND posts.created_at > ? ORDER BY posts.created_at ASC LIMIT 2'
|
||||
prev_context = db.query(context_prev_q, thread.id, editing_post['created_at'])
|
||||
next_context = db.query(context_next_q, thread.id, editing_post['created_at'])
|
||||
|
||||
return render_template('posts/edit.html',
|
||||
editing_post = editing_post,
|
||||
thread = thread,
|
||||
prev_context = prev_context,
|
||||
next_context = next_context,
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/<post_id>/edit")
|
||||
@bp.get('/<int:post_id>/delete/')
|
||||
@login_required
|
||||
def edit_form(post_id):
|
||||
user = get_active_user()
|
||||
post = Posts.find({'id': post_id})
|
||||
if post.user_id != user.id:
|
||||
return redirect(url_for('topics.all_topics'))
|
||||
|
||||
update_post(post.id, request.form.get('new_content', default=''))
|
||||
thread = Threads.find({'id': post.thread_id})
|
||||
return redirect(url_for('threads.thread', slug=thread.slug, after=post.id, _anchor=f'post-{post.id}'))
|
||||
@ownership_or_mod_required
|
||||
def delete(post_id):
|
||||
return 'stub'
|
||||
|
||||
@@ -1,225 +1,102 @@
|
||||
from flask import (
|
||||
Blueprint, render_template, request, redirect, url_for, flash
|
||||
)
|
||||
from .users import login_required, mod_only, get_active_user, is_logged_in
|
||||
from ..db import db
|
||||
from ..models import Threads, Topics, Posts, Subscriptions
|
||||
from ..constants import InfoboxKind
|
||||
from .posts import create_post
|
||||
from slugify import slugify
|
||||
from flask import Blueprint, redirect, url_for, render_template, request, abort
|
||||
from ..auth import login_required, get_active_user
|
||||
from ..models import Threads, Posts, Topics, Users, Reactions
|
||||
import math
|
||||
import time
|
||||
|
||||
bp = Blueprint("threads", __name__, url_prefix = "/threads/")
|
||||
bp = Blueprint('threads', __name__, url_prefix='/threads/')
|
||||
|
||||
|
||||
@bp.get("/<slug>")
|
||||
def thread(slug):
|
||||
POSTS_PER_PAGE = 10
|
||||
thread = Threads.find({"slug": slug})
|
||||
@bp.get('/<int:thread_id>/')
|
||||
def thread_by_id(thread_id):
|
||||
thread = Threads.find({'id': thread_id})
|
||||
if not thread:
|
||||
return redirect(url_for('topics.all_topics'))
|
||||
abort(404)
|
||||
return redirect(url_for('.thread', thread_id=thread_id, slug=thread.slug, **request.args))
|
||||
|
||||
post_count = Posts.count({"thread_id": thread.id})
|
||||
page_count = max(math.ceil(post_count / POSTS_PER_PAGE), 1)
|
||||
@bp.get('/<int:thread_id>/<slug>/')
|
||||
def thread(thread_id, slug):
|
||||
thread = Threads.find({'id': thread_id})
|
||||
if not thread:
|
||||
abort(404)
|
||||
if thread.slug != slug:
|
||||
return redirect(url_for('.thread', thread_id=thread_id, slug=thread.slug, **request.kwargs))
|
||||
|
||||
topic = Topics.find({'id': thread.topic_id})
|
||||
started_by = Users.find({'id': thread.user_id})
|
||||
PER_PAGE = 10
|
||||
posts_count = Posts.count({'thread_id': thread.id})
|
||||
page_count = max(1, math.ceil(posts_count / PER_PAGE))
|
||||
page = 1
|
||||
after = request.args.get("after", default=None)
|
||||
after = request.args.get('after')
|
||||
if after is not None:
|
||||
try:
|
||||
after_id = int(after)
|
||||
post_position = Posts.count([
|
||||
("thread_id", "=", thread.id),
|
||||
("id", "<=", after_id),
|
||||
('thread_id', '=', thread.id),
|
||||
('id', '<=', after_id),
|
||||
])
|
||||
page = math.ceil((post_position) / POSTS_PER_PAGE)
|
||||
page = math.ceil((post_position) / PER_PAGE)
|
||||
except ValueError:
|
||||
abort(404)
|
||||
else:
|
||||
page = max(1, min(page_count, int(request.args.get("page", default = 1))))
|
||||
try:
|
||||
page = max(1, min(int(request.args.get('page', default=1)), page_count))
|
||||
except ValueError:
|
||||
abort(404)
|
||||
return render_template('threads/thread.html', thread=thread, posts=thread.get_posts(PER_PAGE, page), page=page, page_count=page_count, topic=topic, started_by=started_by, topics=Topics.get_list(), Reactions=Reactions)
|
||||
|
||||
posts = thread.get_posts(POSTS_PER_PAGE, (page - 1) * POSTS_PER_PAGE)
|
||||
topic = Topics.find({"id": thread.topic_id})
|
||||
other_topics = Topics.select()
|
||||
|
||||
is_subscribed = False
|
||||
if is_logged_in():
|
||||
subscription = Subscriptions.find({
|
||||
'thread_id': thread.id,
|
||||
'user_id': get_active_user().id,
|
||||
})
|
||||
if subscription:
|
||||
if int(posts[-1]['created_at']) > int(subscription.last_seen):
|
||||
subscription.update({
|
||||
'last_seen': int(posts[-1]['created_at'])
|
||||
})
|
||||
is_subscribed = True
|
||||
|
||||
return render_template(
|
||||
"threads/thread.html",
|
||||
thread = thread,
|
||||
current_page = page,
|
||||
page_count = page_count,
|
||||
posts = posts,
|
||||
topic = topic,
|
||||
topics = other_topics,
|
||||
is_subscribed = is_subscribed,
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/<slug>")
|
||||
@bp.post('/<int:thread_id>/reply/')
|
||||
@login_required
|
||||
def reply(slug):
|
||||
thread = Threads.find({"slug": slug})
|
||||
if not thread:
|
||||
return redirect(url_for('topics.all_topics'))
|
||||
def reply(thread_id):
|
||||
user = get_active_user()
|
||||
if user.is_guest():
|
||||
return redirect(url_for('.thread', slug=slug))
|
||||
thread = Threads.find({'id': thread_id})
|
||||
if not thread:
|
||||
abort(404)
|
||||
if thread.locked() and not user.is_mod():
|
||||
return redirect(url_for('.thread', slug=slug))
|
||||
# TODO: flash
|
||||
return redirect(url_for('.thread_by_id', thread_id=thread_id))
|
||||
post = Posts.new(user.id, thread.id, request.form.get('babycode_content'))
|
||||
return redirect(url_for('.thread_by_id', thread_id=thread_id, after=post.id, _anchor=f'post-{post.id}'))
|
||||
|
||||
post_content = request.form['post_content']
|
||||
post = create_post(thread.id, user.id, post_content)
|
||||
@bp.get('/<int:thread_id>/feed.atom/')
|
||||
def feed(thread_id):
|
||||
return 'stub'
|
||||
|
||||
subscription = Subscriptions.find({'user_id': user.id, 'thread_id': thread.id})
|
||||
|
||||
if subscription:
|
||||
subscription.update({'last_seen': int(time.time())})
|
||||
elif request.form.get('subscribe', default=None) == 'on':
|
||||
Subscriptions.create({'user_id': user.id, 'thread_id': thread.id, 'last_seen': int(time.time())})
|
||||
|
||||
return redirect(url_for(".thread", slug=slug, after=post.id, _anchor="latest-post"))
|
||||
|
||||
|
||||
@bp.get("/create")
|
||||
@bp.get('/new/')
|
||||
@login_required
|
||||
def create():
|
||||
all_topics = Topics.select()
|
||||
return render_template("threads/create.html", all_topics = all_topics)
|
||||
def new():
|
||||
topics = Topics.select()
|
||||
try:
|
||||
selected_topic = int(request.args.get('topic_id'))
|
||||
except ValueError, TypeError:
|
||||
selected_topic = None
|
||||
return render_template('threads/new_thread.html', topics=topics, selected_topic=selected_topic)
|
||||
|
||||
|
||||
@bp.post("/create")
|
||||
@bp.post('/new/')
|
||||
@login_required
|
||||
def create_form():
|
||||
topic = Topics.find({"id": request.form['topic_id']})
|
||||
user = get_active_user()
|
||||
def new_post():
|
||||
try:
|
||||
topic_id = int(request.form.get('topic_id'))
|
||||
except ValueError, TypeError:
|
||||
abort(404)
|
||||
topic_id = int(topic_id)
|
||||
topic = Topics.find({'id': topic_id})
|
||||
if not topic:
|
||||
flash('Invalid topic', InfoboxKind.ERROR)
|
||||
return redirect(url_for('.create'))
|
||||
abort(404)
|
||||
|
||||
if topic.is_locked and not get_active_user().is_mod():
|
||||
flash(f'Topic "{topic.name}" is locked', InfoboxKind.ERROR)
|
||||
return redirect(url_for('.create'))
|
||||
|
||||
title = request.form['title'].strip()
|
||||
now = int(time.time())
|
||||
slug = f"{slugify(title)}-{now}"
|
||||
|
||||
post_content = request.form['initial_post']
|
||||
thread = Threads.create({
|
||||
"topic_id": topic.id,
|
||||
"user_id": user.id,
|
||||
"title": title,
|
||||
"slug": slug,
|
||||
"created_at": now,
|
||||
})
|
||||
post = create_post(thread.id, user.id, post_content)
|
||||
return redirect(url_for(".thread", slug = thread.slug))
|
||||
|
||||
|
||||
@bp.post("/<slug>/lock")
|
||||
@login_required
|
||||
def lock(slug):
|
||||
user = get_active_user()
|
||||
thread = Threads.find({'slug': slug})
|
||||
if not thread:
|
||||
return redirect(url_for('topics.all_topics'))
|
||||
if not ((thread.user_id == user.id) or user.is_mod()):
|
||||
return redirect(url_for('.thread', slug=slug))
|
||||
target_op = request.form.get('target_op')
|
||||
thread.update({
|
||||
'is_locked': target_op
|
||||
})
|
||||
return redirect(url_for('.thread', slug=slug))
|
||||
if not user.can_post_to_topic(topic):
|
||||
abort(404)
|
||||
|
||||
title = request.form.get('title')
|
||||
if not title:
|
||||
abort(404)
|
||||
|
||||
@bp.post("/<slug>/sticky")
|
||||
@login_required
|
||||
@mod_only(".thread", slug = lambda slug: slug)
|
||||
def sticky(slug):
|
||||
user = get_active_user()
|
||||
thread = Threads.find({'slug': slug})
|
||||
if not thread:
|
||||
return redirect(url_for('topics.all_topics'))
|
||||
if not ((thread.user_id == user.id) or user.is_mod()):
|
||||
return redirect(url_for('.thread', slug=slug))
|
||||
target_op = request.form.get('target_op')
|
||||
thread.update({
|
||||
'is_stickied': target_op
|
||||
})
|
||||
return redirect(url_for('.thread', slug=slug))
|
||||
if not title.strip():
|
||||
abort(404)
|
||||
|
||||
title = title.strip()
|
||||
|
||||
@bp.post("/<slug>/move")
|
||||
@login_required
|
||||
@mod_only(".thread", slug = lambda slug: slug)
|
||||
def move(slug):
|
||||
user = get_active_user()
|
||||
content = request.form.get('babycode_content')
|
||||
|
||||
new_topic_id = request.form.get('new_topic_id', default=None)
|
||||
if new_topic_id is None:
|
||||
flash('Thread is already in this topic.', InfoboxKind.ERROR)
|
||||
return redirect(url_for('.thread', slug=slug))
|
||||
|
||||
new_topic = Topics.find({
|
||||
'id': new_topic_id
|
||||
})
|
||||
if not new_topic:
|
||||
return redirect(url_for('topics.all_topics'))
|
||||
thread = Threads.find({
|
||||
'slug': slug
|
||||
})
|
||||
if not thread:
|
||||
return redirect(url_for('topics.all_topics'))
|
||||
if new_topic.id == thread.topic_id:
|
||||
flash('Thread is already in this topic.', InfoboxKind.ERROR)
|
||||
return redirect(url_for('.thread', slug=slug))
|
||||
|
||||
old_topic = Topics.find({'id': thread.topic_id})
|
||||
thread.update({'topic_id': new_topic_id})
|
||||
flash(f'Topic moved from "{old_topic.name}" to "{new_topic.name}".', InfoboxKind.INFO)
|
||||
return redirect(url_for('.thread', slug=slug))
|
||||
|
||||
|
||||
@bp.post("/<slug>/subscribe")
|
||||
@login_required
|
||||
def subscribe(slug):
|
||||
user = get_active_user()
|
||||
thread = Threads.find({'slug': slug})
|
||||
if not thread:
|
||||
return redirect(url_for('topics.all_topics'))
|
||||
subscription = Subscriptions.find({
|
||||
'user_id': user.id,
|
||||
'thread_id': thread.id,
|
||||
})
|
||||
if request.form['subscribe'] == 'subscribe':
|
||||
if subscription:
|
||||
subscription.delete()
|
||||
Subscriptions.create({
|
||||
'user_id': user.id,
|
||||
'thread_id': thread.id,
|
||||
'last_seen': int(time.time()),
|
||||
})
|
||||
elif request.form['subscribe'] == 'unsubscribe':
|
||||
if not subscription:
|
||||
return redirect(url_for('.thread', slug=slug))
|
||||
subscription.delete()
|
||||
elif request.form['subscribe'] == 'read':
|
||||
if not subscription:
|
||||
return redirect(url_for('.thread', slug=slug))
|
||||
subscription.update({
|
||||
'last_seen': int(time.time())
|
||||
})
|
||||
last_visible_post = request.form.get('last_visible_post', default=None)
|
||||
if last_visible_post is not None:
|
||||
return redirect(url_for('.thread', slug=thread.slug, after=last_visible_post))
|
||||
else:
|
||||
return redirect(url_for('users.inbox', username=user.username))
|
||||
thread = Threads.new(user.id, topic.id, title, content)
|
||||
return redirect(url_for('.thread', slug=thread.slug))
|
||||
|
||||
@@ -1,112 +1,40 @@
|
||||
from flask import (
|
||||
Blueprint, render_template, request, redirect, url_for, flash, session
|
||||
)
|
||||
from .users import login_required, mod_only
|
||||
from ..models import Users, Topics, Threads
|
||||
from ..constants import InfoboxKind
|
||||
from slugify import slugify
|
||||
import time
|
||||
from flask import Blueprint, redirect, url_for, render_template, request, session, abort
|
||||
|
||||
from ..models import Topics, Threads
|
||||
import math
|
||||
|
||||
bp = Blueprint("topics", __name__, url_prefix = "/topics/")
|
||||
bp = Blueprint('topics', __name__, url_prefix = '/topics/')
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@bp.get('/')
|
||||
def all_topics():
|
||||
admin = Users.find({"id": 1})
|
||||
return render_template("topics/topics.html", topic_list = Topics.get_list(), active_threads = Topics.get_active_threads())
|
||||
topic_list = Topics.get_list()
|
||||
return render_template('topics/topics.html', topics=topic_list)
|
||||
|
||||
@bp.get('/<int:topic_id>/')
|
||||
def topic_by_id(topic_id):
|
||||
topic = Topics.find({'id': topic_id})
|
||||
if not topic:
|
||||
abort(404)
|
||||
return redirect(url_for('.topic', topic_id=topic_id, slug=topic.slug, **request.args))
|
||||
|
||||
@bp.get("/create")
|
||||
@login_required
|
||||
@mod_only(".all_topics")
|
||||
def create():
|
||||
return render_template("topics/create.html")
|
||||
@bp.get('/<int:topic_id>/<slug>/')
|
||||
def topic(topic_id, slug):
|
||||
topic = Topics.find({'id': topic_id})
|
||||
if not topic:
|
||||
abort(404)
|
||||
if topic.slug != slug:
|
||||
return redirect(url_for('.topic', topic_id=topic_id, slug=topic.slug, **request.args))
|
||||
|
||||
|
||||
@bp.post("/create")
|
||||
@login_required
|
||||
@mod_only(".all_topics")
|
||||
def create_post():
|
||||
topic_name = request.form['name'].strip()
|
||||
now = int(time.time())
|
||||
slug = f"{slugify(topic_name)}-{now}"
|
||||
|
||||
topic_count = Topics.count()
|
||||
topic = Topics.create({
|
||||
"name": topic_name,
|
||||
"description": request.form['description'],
|
||||
"slug": slug,
|
||||
"sort_order": topic_count + 1,
|
||||
})
|
||||
|
||||
flash("Topic created.", InfoboxKind.INFO)
|
||||
return redirect(url_for("topics.topic", slug = slug))
|
||||
|
||||
|
||||
@bp.get("/<slug>")
|
||||
def topic(slug):
|
||||
THREADS_PER_PAGE = 10
|
||||
target_topic = Topics.find({
|
||||
"slug": slug
|
||||
})
|
||||
if not target_topic:
|
||||
return redirect(url_for('.all_topics'))
|
||||
|
||||
threads_count = Threads.count({
|
||||
"topic_id": target_topic.id
|
||||
})
|
||||
|
||||
sort_by = session.get('sort_by', default="activity")
|
||||
|
||||
page_count = max(math.ceil(threads_count / THREADS_PER_PAGE), 1)
|
||||
sort_by = request.args.get('sort_by', default=session.get('sort_by', default='activity'))
|
||||
PER_PAGE = 10
|
||||
threads_count = Threads.count({'topic_id': topic.id})
|
||||
page_count = max(1, math.ceil(threads_count / PER_PAGE))
|
||||
try:
|
||||
page = max(1, min(int(request.args.get('page', default=1)), page_count))
|
||||
except ValueError:
|
||||
abort(404)
|
||||
return render_template('topics/topic.html', topic=topic, threads=topic.get_threads(PER_PAGE, page, sort_by), sort_by=sort_by, page=page, page_count=page_count)
|
||||
|
||||
return render_template(
|
||||
"topics/topic.html",
|
||||
threads_list = target_topic.get_threads(THREADS_PER_PAGE, page, sort_by),
|
||||
topic = target_topic,
|
||||
current_page = page,
|
||||
page_count = page_count
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/<slug>/edit")
|
||||
@login_required
|
||||
@mod_only(".topic", slug = lambda slug: slug)
|
||||
def edit(slug):
|
||||
topic = Topics.find({"slug": slug})
|
||||
if not topic:
|
||||
return redirect(url_for('.all_topics'))
|
||||
return render_template("topics/edit.html", topic=topic)
|
||||
|
||||
|
||||
@bp.post("/<slug>/edit")
|
||||
@login_required
|
||||
@mod_only(".topic", slug = lambda slug: slug)
|
||||
def edit_post(slug):
|
||||
topic = Topics.find({"slug": slug})
|
||||
if not topic:
|
||||
return redirect(url_for('.all_topics'))
|
||||
|
||||
topic.update({
|
||||
"name": request.form.get('name', default = topic.name).strip(),
|
||||
"description": request.form.get('description', default = topic.description),
|
||||
"is_locked": int(request.form.get("is_locked", default = topic.is_locked)),
|
||||
})
|
||||
|
||||
return redirect(url_for("topics.topic", slug=slug))
|
||||
|
||||
|
||||
@bp.post("/<slug>/delete")
|
||||
@login_required
|
||||
@mod_only(".topic", slug = lambda slug: slug)
|
||||
def delete(slug):
|
||||
topic = Topics.find({"slug": slug})
|
||||
if not topic:
|
||||
return redirect(url_for('.all_topics'))
|
||||
|
||||
topic.delete()
|
||||
|
||||
flash("Topic deleted.", InfoboxKind.INFO)
|
||||
return redirect(url_for("topics.all_topics"))
|
||||
@bp.get('/<int:topic_id>/feed.atom/')
|
||||
def feed(topic_id):
|
||||
return 'stub'
|
||||
|
||||
@@ -1,516 +1,135 @@
|
||||
from flask import (
|
||||
Blueprint, render_template, request, redirect, url_for, flash, session, current_app
|
||||
)
|
||||
from flask import Blueprint, redirect, url_for, render_template, request, session
|
||||
from functools import wraps
|
||||
from ..db import db
|
||||
from ..lib.babycode import babycode_to_html
|
||||
from ..models import Users, Sessions, Subscriptions, Avatars
|
||||
from ..constants import InfoboxKind, PermissionLevel
|
||||
from ..auth import digest, verify
|
||||
from wand.image import Image
|
||||
from wand.exceptions import WandException
|
||||
from datetime import datetime, timedelta
|
||||
import secrets
|
||||
import time
|
||||
import re
|
||||
import os
|
||||
|
||||
bp = Blueprint("users", __name__, url_prefix = "/users/")
|
||||
from ..auth import (
|
||||
digest, verify, create_session,
|
||||
is_logged_in, parse_username, is_password_valid,
|
||||
login_required
|
||||
)
|
||||
from ..models import Users
|
||||
from ..constants import PermissionLevel
|
||||
from secrets import compare_digest as compare_timesafe
|
||||
|
||||
bp = Blueprint('users', __name__, url_prefix='/users/')
|
||||
|
||||
def validate_and_create_avatar(input_image, filename):
|
||||
try:
|
||||
with Image(blob=input_image) as img:
|
||||
img.strip()
|
||||
img.gravity = 'center'
|
||||
|
||||
width, height = img.width, img.height
|
||||
min_dim = min(width, height)
|
||||
if min_dim > 256:
|
||||
ratio = 256.0 / min_dim
|
||||
new_width = int(width * ratio)
|
||||
new_height = int(height * ratio)
|
||||
img.resize(new_width, new_height)
|
||||
|
||||
width, height = img.width, img.height
|
||||
crop_size = min(width, height)
|
||||
x_offset = (width - crop_size) // 2
|
||||
y_offset = (height - crop_size) // 2
|
||||
img.crop(left=x_offset, top=y_offset,
|
||||
width=crop_size, height=crop_size)
|
||||
|
||||
img.resize(256, 256)
|
||||
img.format = 'webp'
|
||||
img.compression_quality = 85
|
||||
img.save(filename=filename)
|
||||
return True
|
||||
except WandException:
|
||||
return False
|
||||
|
||||
|
||||
def is_logged_in():
|
||||
return "pyrom_session_key" in session
|
||||
|
||||
|
||||
def get_active_user():
|
||||
if not is_logged_in():
|
||||
return None
|
||||
sess = Sessions.find({"key": session["pyrom_session_key"]})
|
||||
if not sess:
|
||||
return None
|
||||
return Users.find({"id": sess.user_id})
|
||||
|
||||
|
||||
def create_session(user_id):
|
||||
return Sessions.create({
|
||||
"key": secrets.token_hex(16),
|
||||
"user_id": user_id,
|
||||
"expires_at": int(time.time()) + 31 * 24 * 60 * 60,
|
||||
})
|
||||
|
||||
def extend_session(user_id):
|
||||
session_obj = Sessions.find({'key': session['pyrom_session_key']})
|
||||
if not session_obj:
|
||||
return
|
||||
new_duration = timedelta(31)
|
||||
current_app.permanent_session_lifetime = new_duration
|
||||
session.modified = True
|
||||
session_obj.update({
|
||||
'expires_at': int(time.time()) + 31 * 24 * 60 * 60
|
||||
})
|
||||
|
||||
|
||||
def validate_password(password):
|
||||
pattern = r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,255}$'
|
||||
return bool(re.fullmatch(pattern, password))
|
||||
|
||||
|
||||
def validate_username(username):
|
||||
pattern = r'^[a-zA-Z0-9_-]{3,20}$'
|
||||
return bool(re.fullmatch(pattern, username))
|
||||
|
||||
|
||||
def redirect_if_logged_in(*args, **kwargs):
|
||||
def redirect_if_logged_in(destination='topics.all_topics'):
|
||||
def decorator(view_func):
|
||||
@wraps(view_func)
|
||||
def wrapper(*view_args, **view_kwargs):
|
||||
if is_logged_in():
|
||||
# resolve callables
|
||||
processed_kwargs = {
|
||||
k: v(**view_kwargs) if callable(v) else v
|
||||
for k, v in kwargs.items()
|
||||
}
|
||||
endpoint = args[0] if args else processed_kwargs.get("endpoint")
|
||||
if endpoint.startswith("."):
|
||||
blueprint = current_app.blueprints.get(view_func.__name__.split(".")[0])
|
||||
if blueprint:
|
||||
endpoint = endpoint.lstrip(".")
|
||||
return redirect(url_for(f"{blueprint.name}.{endpoint}", **processed_kwargs))
|
||||
return redirect(url_for(*args, **processed_kwargs))
|
||||
return view_func(*view_args, **view_kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def login_required(view_func):
|
||||
@wraps(view_func)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not is_logged_in():
|
||||
return redirect(url_for("users.log_in"))
|
||||
if is_logged_in():
|
||||
return redirect(url_for(destination))
|
||||
return view_func(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
def mod_only(*args, **kwargs):
|
||||
def decorator(view_func):
|
||||
@wraps(view_func)
|
||||
def wrapper(*view_args, **view_kwargs):
|
||||
if not get_active_user().is_mod():
|
||||
# resolve callables
|
||||
processed_kwargs = {
|
||||
k: v(**view_kwargs) if callable(v) else v
|
||||
for k, v in kwargs.items()
|
||||
}
|
||||
endpoint = args[0] if args else processed_kwargs.get("endpoint")
|
||||
if endpoint.startswith("."):
|
||||
blueprint = current_app.blueprints.get(view_func.__name__.split(".")[0])
|
||||
if blueprint:
|
||||
endpoint = endpoint.lstrip(".")
|
||||
return redirect(url_for(f"{blueprint.name}.{endpoint}", **processed_kwargs))
|
||||
return redirect(url_for(*args, **processed_kwargs))
|
||||
return view_func(*view_args, **view_kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def admin_only(*args, **kwargs):
|
||||
def decorator(view_func):
|
||||
@wraps(view_func)
|
||||
def wrapper(*view_args, **view_kwargs):
|
||||
if not get_active_user().is_admin():
|
||||
# resolve callables
|
||||
processed_kwargs = {
|
||||
k: v(**view_kwargs) if callable(v) else v
|
||||
for k, v in kwargs.items()
|
||||
}
|
||||
endpoint = args[0] if args else processed_kwargs.get("endpoint")
|
||||
if endpoint.startswith("."):
|
||||
blueprint = current_app.blueprints.get(view_func.__name__.split(".")[0])
|
||||
if blueprint:
|
||||
endpoint = endpoint.lstrip(".")
|
||||
return redirect(url_for(f"{blueprint.name}.{endpoint}", **processed_kwargs))
|
||||
return redirect(url_for(*args, **processed_kwargs))
|
||||
return view_func(*view_args, **view_kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
@bp.get("/log_in")
|
||||
@redirect_if_logged_in(".page", username = lambda: get_active_user().username)
|
||||
@bp.get('/log-in/')
|
||||
@redirect_if_logged_in()
|
||||
def log_in():
|
||||
return render_template("users/log_in.html")
|
||||
return render_template('users/log_in.html')
|
||||
|
||||
|
||||
@bp.post("/log_in")
|
||||
@redirect_if_logged_in(".page", username = lambda: get_active_user().username)
|
||||
def log_in_post():
|
||||
target_user = Users.find({
|
||||
"username": request.form['username']
|
||||
})
|
||||
if not target_user:
|
||||
flash("Incorrect username or password.", InfoboxKind.ERROR)
|
||||
return redirect(url_for("users.log_in"))
|
||||
|
||||
if not verify(target_user.password_hash, request.form['password']):
|
||||
flash("Incorrect username or password.", InfoboxKind.ERROR)
|
||||
return redirect(url_for("users.log_in"))
|
||||
|
||||
session_obj = create_session(target_user.id)
|
||||
|
||||
session['pyrom_session_key'] = session_obj.key
|
||||
flash("Logged in!", InfoboxKind.INFO)
|
||||
return redirect(url_for("users.log_in"))
|
||||
|
||||
|
||||
@bp.get("/sign_up")
|
||||
@redirect_if_logged_in(".page", username = lambda: get_active_user().username)
|
||||
def sign_up():
|
||||
return render_template("users/sign_up.html")
|
||||
|
||||
|
||||
@bp.post("/sign_up")
|
||||
@redirect_if_logged_in(".page", username = lambda: get_active_user().username)
|
||||
def sign_up_post():
|
||||
username = request.form['username']
|
||||
password = request.form['password']
|
||||
password_confirm = request.form['password-confirm']
|
||||
|
||||
if not validate_username(username):
|
||||
flash("Invalid username.", InfoboxKind.ERROR)
|
||||
return redirect(url_for("users.sign_up"))
|
||||
|
||||
user_exists = Users.count({"username": username}) > 0
|
||||
if user_exists:
|
||||
flash(f"Username '{username}' is already taken.", InfoboxKind.ERROR)
|
||||
return redirect(url_for("users.sign_up"))
|
||||
|
||||
if not validate_password(password):
|
||||
flash("Invalid password.", InfoboxKind.ERROR)
|
||||
return redirect(url_for("users.sign_up"))
|
||||
|
||||
if password != password_confirm:
|
||||
flash("Passwords do not match.", InfoboxKind.ERROR)
|
||||
return redirect(url_for("users.sign_up"))
|
||||
|
||||
hashed = digest(password)
|
||||
|
||||
new_user = Users.create({
|
||||
"username": username,
|
||||
"password_hash": hashed,
|
||||
"permission": PermissionLevel.GUEST.value,
|
||||
})
|
||||
|
||||
session_obj = create_session(new_user.id)
|
||||
|
||||
session['pyrom_session_key'] = session_obj.key
|
||||
flash("Signed up successfully!", InfoboxKind.INFO)
|
||||
return redirect(url_for("users.sign_up"))
|
||||
|
||||
|
||||
@bp.get("/<username>")
|
||||
def page(username):
|
||||
target_user = Users.find({"username": username})
|
||||
return render_template("users/user.html", target_user = target_user)
|
||||
|
||||
|
||||
@bp.get("/<username>/settings")
|
||||
@login_required
|
||||
def settings(username):
|
||||
target_user = Users.find({'username': username})
|
||||
if target_user.id != get_active_user().id:
|
||||
return redirect('.settings', username = get_active_user().username)
|
||||
|
||||
return render_template('users/settings.html')
|
||||
|
||||
|
||||
@bp.post('/<username>/settings')
|
||||
@login_required
|
||||
def settings_form(username):
|
||||
# we silently ignore the passed username
|
||||
# and grab the correct user from the session
|
||||
user = get_active_user()
|
||||
topic_sort_by = request.form.get('topic_sort_by', default='activity')
|
||||
if topic_sort_by == 'activity' or topic_sort_by == 'thread':
|
||||
sort_by = session['sort_by'] = topic_sort_by
|
||||
status = request.form.get('status', default="")[:100]
|
||||
original_sig = request.form.get('signature', default='')
|
||||
rendered_sig = babycode_to_html(original_sig)
|
||||
session['subscribe_by_default'] = request.form.get('subscribe_by_default', default='off') == 'on'
|
||||
|
||||
user.update({
|
||||
'status': status,
|
||||
'signature_original_markup': original_sig,
|
||||
'signature_rendered': rendered_sig,
|
||||
})
|
||||
flash('Settings updated.', InfoboxKind.INFO)
|
||||
return redirect(url_for('.settings', username=user.username))
|
||||
|
||||
|
||||
@bp.post('/<username>/set_avatar')
|
||||
@login_required
|
||||
def set_avatar(username):
|
||||
user = get_active_user()
|
||||
if user.is_guest():
|
||||
flash('You must be logged in to perform this action.', InfoboxKind.ERROR)
|
||||
return redirect(url_for('.settings', user.username))
|
||||
if 'avatar' not in request.files:
|
||||
flash('Avatar missing.', InfoboxKind.ERROR)
|
||||
return redirect(url_for('.settings', user.username))
|
||||
|
||||
file = request.files['avatar']
|
||||
|
||||
if file.filename == '':
|
||||
flash('Avatar missing.', InfoboxKind.ERROR)
|
||||
return redirect(url_for('.settings', user.username))
|
||||
|
||||
file_bytes = file.read()
|
||||
|
||||
now = int(time.time())
|
||||
filename = f"u{user.id}d{now}.webp"
|
||||
output_path = os.path.join(current_app.config['AVATAR_UPLOAD_PATH'], filename)
|
||||
proxied_filename = f"/static/avatars/{filename}"
|
||||
res = validate_and_create_avatar(file_bytes, output_path)
|
||||
if res:
|
||||
flash('Avatar updated.', InfoboxKind.INFO)
|
||||
avatar = Avatars.create({
|
||||
'file_path': proxied_filename,
|
||||
'uploaded_at': now,
|
||||
})
|
||||
old_avatar = Avatars.find({'id': user.avatar_id})
|
||||
user.update({'avatar_id': avatar.id})
|
||||
if int(old_avatar.id) != 1:
|
||||
# delete old avi, but not default
|
||||
filename = os.path.join(current_app.config['AVATAR_UPLOAD_PATH'], os.path.basename(old_avatar.file_path))
|
||||
os.remove(filename)
|
||||
old_avatar.delete()
|
||||
return redirect(url_for('.settings', username=user.username))
|
||||
else:
|
||||
flash('Something went wrong. Please try again later.', InfoboxKind.WARN)
|
||||
return redirect(url_for('.settings', user.username))
|
||||
|
||||
|
||||
@bp.post('/<username>/change_password')
|
||||
@login_required
|
||||
def change_password(username):
|
||||
user = get_active_user()
|
||||
password = request.form.get('new_password')
|
||||
password2 = request.form.get('new_password2')
|
||||
|
||||
if not validate_password(password):
|
||||
flash("Invalid password.", InfoboxKind.ERROR)
|
||||
return redirect(url_for('.settings', username=user.username))
|
||||
|
||||
if password != password2:
|
||||
flash("Passwords do not match.", InfoboxKind.ERROR)
|
||||
return redirect(url_for('.settings', username=user.username))
|
||||
|
||||
hashed = digest(password)
|
||||
user.update({'password_hash': hashed})
|
||||
extend_session(user.id)
|
||||
flash('Password updated.', InfoboxKind.INFO)
|
||||
return redirect(url_for('.settings', username=user.username))
|
||||
|
||||
|
||||
@bp.post('/<username>/clear_avatar')
|
||||
@login_required
|
||||
def clear_avatar(username):
|
||||
user = get_active_user()
|
||||
if user.is_default_avatar():
|
||||
return redirect(url_for('.settings', user.username))
|
||||
|
||||
old_avatar = Avatars.find({'id': user.avatar_id})
|
||||
user.update({'avatar_id': 1})
|
||||
# delete old avi
|
||||
filename = os.path.join(current_app.config['AVATAR_UPLOAD_PATH'], os.path.basename(old_avatar.file_path))
|
||||
os.remove(filename)
|
||||
old_avatar.delete()
|
||||
return redirect(url_for('.settings', username=user.username))
|
||||
|
||||
|
||||
@bp.post("/log_out")
|
||||
@bp.post('/log-out/')
|
||||
@login_required
|
||||
def log_out():
|
||||
user = get_active_user()
|
||||
session_obj = Sessions.find({"key": session['pyrom_session_key']})
|
||||
session_obj.delete()
|
||||
return 'stub'
|
||||
|
||||
session.clear()
|
||||
return redirect(url_for(".log_in"))
|
||||
@bp.post('/log-in/')
|
||||
@redirect_if_logged_in()
|
||||
def log_in_post():
|
||||
username = request.form.get('username', default='').lower()
|
||||
user = Users.find({'username': username})
|
||||
if not user:
|
||||
return redirect(url_for('.log_in', error='The username or password you entered is incorrect.'))
|
||||
password = request.form.get('password', default='')
|
||||
if not verify(user.password_hash, password):
|
||||
return redirect(url_for('.log_in', error='The username or password you entered is incorrect.'))
|
||||
|
||||
session['remember'] = request.form.get('remember') == 'on'
|
||||
sess = create_session(user.id, not session['remember'])
|
||||
session['pyrom_session_key'] = sess.key
|
||||
if session['remember']:
|
||||
session.permanent = True
|
||||
return redirect(request.form.get('return_to', default=url_for('topics.all_topics')))
|
||||
|
||||
@bp.post("/confirm_user/<user_id>")
|
||||
@login_required
|
||||
@mod_only("topics.all_topics")
|
||||
def confirm_user(user_id):
|
||||
target_user = Users.find({"id": user_id})
|
||||
if not target_user:
|
||||
return redirect(url_for('.all_topics'))
|
||||
if int(target_user.permission) > PermissionLevel.GUEST.value:
|
||||
return redirect(url_for('.page', username=target_user.username))
|
||||
@bp.get('/sign-up/')
|
||||
@redirect_if_logged_in()
|
||||
def sign_up():
|
||||
return render_template('users/sign_up.html')
|
||||
|
||||
target_user.update({
|
||||
"permission": PermissionLevel.USER.value,
|
||||
"confirmed_on": int(time.time()),
|
||||
@bp.post('/sign-up/')
|
||||
@redirect_if_logged_in()
|
||||
def sign_up_post():
|
||||
generic_error_page = redirect(url_for('.sign_up', error='The username or password you entered is invalid.'))
|
||||
invalid_username_error_page = redirect(url_for('.sign_up', error='This username cannot be used. Please pick another.'))
|
||||
passwords_error_page = redirect(url_for('.sign_up', error='The passwords do not match.'))
|
||||
username = request.form.get('username', default='')
|
||||
if not username:
|
||||
return generic_error_page
|
||||
if request.form.get('password') is None:
|
||||
return generic_error_page
|
||||
if len(request.form.getlist('password')) != 2:
|
||||
return passwords_error_page
|
||||
try:
|
||||
username_pair = parse_username(username)
|
||||
except ValueError:
|
||||
return invalid_username_error_page
|
||||
potential_user = Users.find({'username': username})
|
||||
if potential_user:
|
||||
return invalid_username_error_page
|
||||
|
||||
if not compare_timesafe(request.form.getlist('password')[0], request.form.getlist('password')[1]):
|
||||
return passwords_error_page
|
||||
|
||||
password_hash = digest(request.form.get('password'))
|
||||
|
||||
user = Users.create({
|
||||
'username': username_pair[0],
|
||||
'password_hash': password_hash,
|
||||
'permission': PermissionLevel.GUEST.value,
|
||||
'created_at': int(time.time()),
|
||||
})
|
||||
return redirect(url_for(".page", username=target_user.username))
|
||||
|
||||
|
||||
@bp.post("/mod_user/<user_id>")
|
||||
@login_required
|
||||
@admin_only("topics.all_topics")
|
||||
def mod_user(user_id):
|
||||
target_user = Users.find({"id": user_id})
|
||||
if not target_user:
|
||||
return redirect(url_for('.all_topics'))
|
||||
if target_user.is_mod():
|
||||
return redirect(url_for('.page', username=target_user.username))
|
||||
|
||||
target_user.update({
|
||||
"permission": PermissionLevel.MODERATOR.value,
|
||||
if username_pair[0] != username_pair[1]:
|
||||
user.update({
|
||||
'display_name': username_pair[1]
|
||||
})
|
||||
return redirect(url_for(".page", username=target_user.username))
|
||||
|
||||
session['remember'] = request.form.get('remember') == 'on'
|
||||
sess = create_session(user.id, not session['remember'])
|
||||
session['pyrom_session_key'] = sess.key
|
||||
if session['remember']:
|
||||
session.permanent = True
|
||||
|
||||
@bp.post("/demod_user/<user_id>")
|
||||
@login_required
|
||||
@admin_only("topics.all_topics")
|
||||
def demod_user(user_id):
|
||||
target_user = Users.find({"id": user_id})
|
||||
return redirect(url_for('topics.all_topics'))
|
||||
|
||||
@bp.get('/<username>/')
|
||||
def user_page(username):
|
||||
target_user = Users.find({'username': username})
|
||||
if not target_user:
|
||||
return redirect(url_for('.all_topics'))
|
||||
if not target_user.is_mod():
|
||||
return redirect(url_for('.page', username=target_user.username))
|
||||
abort(404)
|
||||
return render_template('users/user_page.html', target_user=target_user)
|
||||
|
||||
target_user.update({
|
||||
"permission": PermissionLevel.USER.value,
|
||||
})
|
||||
return redirect(url_for(".page", username=target_user.username))
|
||||
@bp.get('/<username>/posts/')
|
||||
def posts(username):
|
||||
return 'stub'
|
||||
|
||||
@bp.get('/<username>/threads/')
|
||||
def threads(username):
|
||||
return 'stub'
|
||||
|
||||
@bp.post("/guest_user/<user_id>")
|
||||
@login_required
|
||||
@admin_only("topics.all_topics")
|
||||
def guest_user(user_id):
|
||||
target_user = Users.find({"id": user_id})
|
||||
if not target_user:
|
||||
return redirect(url_for('.all_topics'))
|
||||
if target_user.is_mod():
|
||||
return redirect(url_for('.page', username=target_user.username))
|
||||
@bp.get('/<username>/comments/')
|
||||
def comments(username):
|
||||
return 'stub'
|
||||
|
||||
target_user.update({
|
||||
"permission": PermissionLevel.GUEST.value,
|
||||
})
|
||||
return redirect(url_for(".page", username=target_user.username))
|
||||
@bp.get('/<username>/settings/')
|
||||
def settings(username):
|
||||
return 'stub'
|
||||
|
||||
|
||||
@bp.get("/<username>/inbox")
|
||||
@login_required
|
||||
@bp.get('/<username>/inbox/')
|
||||
def inbox(username):
|
||||
user = get_active_user()
|
||||
if username != user.username:
|
||||
return redirect(url_for(".inbox", username = user.username))
|
||||
return 'stub'
|
||||
|
||||
new_posts = []
|
||||
subscription = Subscriptions.find({"user_id": user.id})
|
||||
all_subscriptions = None
|
||||
total_unreads_count = None
|
||||
if subscription:
|
||||
all_subscriptions = user.get_all_subscriptions()
|
||||
q = """
|
||||
WITH thread_metadata AS (
|
||||
SELECT
|
||||
posts.thread_id, threads.slug AS thread_slug, threads.title AS thread_title, COUNT(*) AS unread_count, MAX(posts.created_at) AS newest_post_time
|
||||
FROM
|
||||
posts
|
||||
LEFT JOIN
|
||||
threads ON threads.id = posts.thread_id
|
||||
LEFT JOIN
|
||||
subscriptions ON subscriptions.thread_id = posts.thread_id
|
||||
WHERE subscriptions.user_id = ? AND posts.created_at > subscriptions.last_seen
|
||||
GROUP BY posts.thread_id
|
||||
)
|
||||
@bp.get('/<username>/bookmarks/')
|
||||
def bookmarks(username):
|
||||
return 'stub'
|
||||
|
||||
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
|
||||
FROM
|
||||
thread_metadata tm
|
||||
JOIN
|
||||
posts ON posts.thread_id = tm.thread_id
|
||||
JOIN
|
||||
post_history ON posts.current_revision_id = post_history.id
|
||||
JOIN
|
||||
users ON posts.user_id = users.id
|
||||
LEFT JOIN
|
||||
threads ON threads.id = posts.thread_id
|
||||
LEFT JOIN
|
||||
avatars ON users.avatar_id = avatars.id
|
||||
LEFT JOIN
|
||||
subscriptions ON subscriptions.thread_id = posts.thread_id
|
||||
WHERE
|
||||
subscriptions.user_id = ? AND posts.created_at > subscriptions.last_seen
|
||||
ORDER BY
|
||||
tm.newest_post_time DESC, posts.created_at ASC"""
|
||||
new_posts_raw = db.query(q, user.id, user.id)
|
||||
current_thread_id = None
|
||||
current_thread_group = None
|
||||
total_unreads_count = 0
|
||||
for row in new_posts_raw:
|
||||
if row['thread_id'] != current_thread_id:
|
||||
current_thread_group = {
|
||||
'thread_id': row['thread_id'],
|
||||
'thread_title': row['thread_title'],
|
||||
'unread_count': row['unread_count'],
|
||||
'thread_slug': row['thread_slug'],
|
||||
'newest_post_time': row['newest_post_time'],
|
||||
'posts': [],
|
||||
}
|
||||
total_unreads_count += int(row['unread_count'])
|
||||
new_posts.append(current_thread_group)
|
||||
current_thread_id = row['thread_id']
|
||||
current_thread_group['posts'].append({
|
||||
'id': row['id'],
|
||||
'created_at': row['created_at'],
|
||||
'content': row['content'],
|
||||
'edited_at': row['edited_at'],
|
||||
'username': row['username'],
|
||||
'status': row['status'],
|
||||
'avatar_path': row['avatar_path'],
|
||||
'thread_id': row['thread_id'],
|
||||
'user_id': row['user_id'],
|
||||
'original_markup': row['original_markup'],
|
||||
'signature_rendered': row['signature_rendered']
|
||||
})
|
||||
|
||||
return render_template("users/inbox.html", new_posts = new_posts, total_unreads_count = total_unreads_count, all_subscriptions = all_subscriptions)
|
||||
|
||||
124
app/schema.py
@@ -76,22 +76,122 @@ SCHEMA = [
|
||||
"signature_rendered" TEXT NOT NULL DEFAULT ''
|
||||
)""",
|
||||
|
||||
"""CREATE TABLE IF NOT EXISTS "reactions" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY,
|
||||
"user_id" REFERENCES users(id) ON DELETE CASCADE,
|
||||
"post_id" REFERENCES posts(id) ON DELETE CASCADE,
|
||||
"reaction_text" TEXT NOT NULL DEFAULT ''
|
||||
)""",
|
||||
|
||||
"""CREATE TABLE IF NOT EXISTS "password_reset_links" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY,
|
||||
"user_id" REFERENCES users(id) ON DELETE CASCADE,
|
||||
"expires_at" INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP)),
|
||||
"key" TEXT NOT NULL UNIQUE
|
||||
)""",
|
||||
|
||||
"""CREATE TABLE IF NOT EXISTS "invite_keys" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY,
|
||||
"created_by" REFERENCES users(id) ON DELETE CASCADE,
|
||||
"key" TEXT NOT NULL UNIQUE
|
||||
)""",
|
||||
|
||||
"""CREATE TABLE IF NOT EXISTS "bookmark_collections" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY,
|
||||
"user_id" REFERENCES users(id) ON DELETE CASCADE,
|
||||
"name" TEXT NOT NULL,
|
||||
"is_default" BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0
|
||||
)""",
|
||||
|
||||
"""CREATE TABLE IF NOT EXISTS "bookmarked_posts" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY,
|
||||
"collection_id" REFERENCES bookmark_collections(id) ON DELETE CASCADE,
|
||||
"post_id" REFERENCES posts(id) ON DELETE CASCADE,
|
||||
"note" TEXT,
|
||||
UNIQUE(collection_id, post_id)
|
||||
)""",
|
||||
|
||||
"""CREATE TABLE IF NOT EXISTS "bookmarked_threads" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY,
|
||||
"collection_id" REFERENCES bookmark_collections(id) ON DELETE CASCADE,
|
||||
"thread_id" REFERENCES threads(id) ON DELETE CASCADE,
|
||||
"note" TEXT,
|
||||
UNIQUE(collection_id, thread_id)
|
||||
)""",
|
||||
|
||||
"""CREATE TABLE IF NOT EXISTS "motd" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY,
|
||||
"title" TEXT NOT NULL,
|
||||
"body_original_markup" TEXT NOT NULL,
|
||||
"body_rendered" TEXT NOT NULL,
|
||||
"markup_language" TEXT NOT NULL DEFAULT 'babycode',
|
||||
"format_version" INTEGER DEFAULT NULL,
|
||||
"created_at" INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP)),
|
||||
"edited_at" INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP)),
|
||||
"user_id" REFERENCES users(id) ON DELETE CASCADE
|
||||
)""",
|
||||
|
||||
"""CREATE TABLE IF NOT EXISTS "mentions" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY,
|
||||
"revision_id" REFERENCES post_history(id) ON DELETE CASCADE,
|
||||
"mentioned_user_id" REFERENCES users(id) ON DELETE CASCADE,
|
||||
"start_index" INTEGER NOT NULL,
|
||||
"end_index" INTEGER NOT NULL,
|
||||
"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)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_posts_thread_id ON posts(thread_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_rate_limit_user_method ON api_rate_limits (user_id, method)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_subscription_user_thread ON subscriptions (user_id, thread_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_threads_slug ON threads(slug)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_threads_topic_id ON threads(topic_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_topics_slug ON topics(slug)",
|
||||
"CREATE INDEX IF NOT EXISTS session_keys ON sessions(key)",
|
||||
"CREATE INDEX IF NOT EXISTS sessions_user_id ON sessions(user_id)",
|
||||
'CREATE INDEX IF NOT EXISTS 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)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_posts_thread_id ON posts(thread_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_rate_limit_user_method ON api_rate_limits (user_id, method)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_subscription_user_thread ON subscriptions (user_id, thread_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_threads_slug ON threads(slug)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_threads_topic_id ON threads(topic_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_topics_slug ON topics(slug)',
|
||||
'CREATE INDEX IF NOT EXISTS session_keys ON sessions(key)',
|
||||
'CREATE INDEX IF NOT EXISTS sessions_user_id ON sessions(user_id)',
|
||||
|
||||
'CREATE INDEX IF NOT EXISTS reaction_post_text ON reactions(post_id, reaction_text)',
|
||||
'CREATE INDEX IF NOT EXISTS reaction_user_post_text ON reactions(user_id, post_id, reaction_text)',
|
||||
|
||||
'CREATE INDEX IF NOT EXISTS idx_bookmark_collections_user_id ON bookmark_collections(user_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_bookmark_collections_user_default ON bookmark_collections(user_id, is_default) WHERE is_default = 1',
|
||||
|
||||
'CREATE INDEX IF NOT EXISTS idx_bookmarked_posts_collection ON bookmarked_posts(collection_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_bookmarked_posts_post ON bookmarked_posts(post_id)',
|
||||
|
||||
'CREATE INDEX IF NOT EXISTS idx_bookmarked_threads_collection ON bookmarked_threads(collection_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_bookmarked_threads_thread ON bookmarked_threads(thread_id)',
|
||||
|
||||
'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():
|
||||
print("Creating schema...")
|
||||
print('Creating schema...')
|
||||
with db.transaction():
|
||||
for stmt in SCHEMA:
|
||||
db.execute(stmt)
|
||||
print("Schema completed.")
|
||||
print('Schema completed.')
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
<!-- kate: remove-trailing-space off; -->
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}babycode guide{% endblock %}
|
||||
{% block content %}
|
||||
<div class=darkbg>
|
||||
<h1 class="thread-title">Babycode guide</h1>
|
||||
</div>
|
||||
<div class="babycode-guide-container">
|
||||
<div class="guide-topics">
|
||||
{% set sections %}
|
||||
<section class="babycode-guide-section">
|
||||
<h2 id="what-is-babycode">What is babycode?</h2>
|
||||
<p>You may be familiar with BBCode, a loosely related family of markup languages popular on forums. Babycode is another, simplified, dialect of those languages. It is a way of formatting text by enclosing parts of it in special tags.</p>
|
||||
</section>
|
||||
<section class="babycode-guide-section">
|
||||
<h2 id="text-formatting-tags">Text formatting tags</h2>
|
||||
<ul>
|
||||
<li>To make some text <strong>bold</strong>, enclose it in <code class="inline-code">[b][/b]</code>:<br>
|
||||
[b]Hello World[/b]<br>
|
||||
Will become<br>
|
||||
<strong>Hello World</strong>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>To <em>italicize</em> text, enclose it in <code class="inline-code">[i][/i]</code>:<br>
|
||||
[i]Hello World[/i]<br>
|
||||
Will become<br>
|
||||
<em>Hello World</em>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>To make some text <del>strikethrough</del>, enclose it in <code class="inline-code">[s][/s]</code>:<br>
|
||||
[s]Hello World[/s]<br>
|
||||
Will become<br>
|
||||
<del>Hello World</del>
|
||||
</ul>
|
||||
</section>
|
||||
<section class="babycode-guide-section">
|
||||
<h2 id="emoji">Emoji</h2>
|
||||
<p>There are a few emoji in the style of old forum emotes:</p>
|
||||
<table class="emoji-table">
|
||||
<tr>
|
||||
<th>Short code</th>
|
||||
<th>Emoji result</th>
|
||||
</tr>
|
||||
{% for emoji in __emoji %}
|
||||
<tr>
|
||||
<td>{{ ("[code]:%s:[/code]" % emoji) | babycode | safe }}</td>
|
||||
<td>{{ __emoji[emoji] | safe }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<p>Special thanks to the <a href="https://gh.vercte.net/forumoji/">Forumoji project</a> and its contributors for these graphics.</p>
|
||||
</section>
|
||||
<section class="babycode-guide-section">
|
||||
<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 }}
|
||||
<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 }}
|
||||
</section>
|
||||
<section class="babycode-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>
|
||||
<a href="https://example.com">Link label</a></p>
|
||||
</section>
|
||||
<section class="babycode-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>
|
||||
{{ '[img=/static/avatars/default.webp]the Python logo with a cowboy hat[/img]' | babycode | safe }}
|
||||
</p>
|
||||
<p>Text inside the tag becomes the alt text. The attribute is the image URL.</p>
|
||||
<p>Images will always break up a paragraph and will get scaled down to a maximum of 400px. The text inside the tag will become the image's alt text.</p>
|
||||
</section>
|
||||
<section class="babycode-guide-section">
|
||||
<h2 id="adding-code-blocks">Adding code blocks</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 }}
|
||||
<br>
|
||||
<p>Inline code tags look like this: {{ '[code]Inline code[/code]' | babycode | safe }}</p>
|
||||
<p>Babycodes are not parsed inside code blocks.</p>
|
||||
</section>
|
||||
<section class="babycode-guide-section">
|
||||
<h2 id="quoting">Quoting</h2>
|
||||
<p>Text enclosed within <code class="inline-code">[quote][/quote]</code> will look like a quote:</p>
|
||||
<blockquote>A man provided with paper, pencil, and rubber, and subject to strict discipline, is in effect a universal machine.</blockquote>
|
||||
</section>
|
||||
<section class="babycode-guide-section">
|
||||
<h2 id="lists">Lists</h2>
|
||||
{% set list = '[ul]\nitem 1\n\nitem 2\n\nitem 3 \nstill item 3 (break line without inserting a new item by using two spaces at the end of a line)\n[/ul]' %}
|
||||
<p>There are two kinds of lists, ordered (1, 2, 3, ...) and unordered (bullet points). Ordered lists are made with <code class="inline-code">[ol][/ol]</code> tags, and unordered with <code class="inline-code">[ul][/ul]</code>. Every new paragraph according to the <a href="#paragraph-rules">usual paragraph rules</a> will create a new list item. For example:</p>
|
||||
{{ ('[code]%s[/code]' % list) | babycode | safe }}
|
||||
Will produce the following list:
|
||||
{{ list | babycode | safe }}
|
||||
</section>
|
||||
{% endset %}
|
||||
{{ sections | safe }}
|
||||
</div>
|
||||
<div class="guide-toc">
|
||||
<h2>Table of contents</h2>
|
||||
{% set toc = sections | extract_h2 %}
|
||||
<ul>
|
||||
{% for heading in toc %}
|
||||
<li><a href='#{{ heading.id }}'>{{ heading.text }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,29 +1,19 @@
|
||||
{% from 'common/macros.html' import infobox with context %}
|
||||
<!DOCTYPE HTML>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
{% if self.title() %}
|
||||
<title>Porom - {% block title %}{% endblock %}</title>
|
||||
{% else %}
|
||||
<title>Porom</title>
|
||||
{% endif %}
|
||||
<link rel="stylesheet" href="{{ "/static/style.css" | cachebust }}">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/png" href="/static/favicon.png">
|
||||
<link rel="stylesheet" href="{{ "/static/css/style.css" | cachebust }}">
|
||||
{% if self.title() -%}
|
||||
<title>{{ config.SITE_NAME }} - {% block title -%}{%- endblock -%}</title>
|
||||
{%- else -%}
|
||||
<title>{{ config.SITE_NAME }}</title>
|
||||
{%- endif -%}
|
||||
</head>
|
||||
<body>
|
||||
{% include 'common/topnav.html' %}
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
{{ infobox(message, category) }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
<footer class="darkbg">
|
||||
<span>Pyrom commit <a href="{{ "https://git.poto.cafe/yagich/pyrom/commit/" + __commit }}">{{ __commit[:8] }}</a></span>
|
||||
</footer>
|
||||
<script src="{{ "/static/js/ui.js" | cachebust }}"></script>
|
||||
<script src="{{ "/static/js/date-fmt.js" | cachebust }}"></script>
|
||||
{%- include 'common/topnav.html' -%}
|
||||
{%- block content -%}{%- endblock -%}
|
||||
{%- include 'common/footer.html' -%}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
8
app/templates/common/404.html
Normal file
@@ -0,0 +1,8 @@
|
||||
{%- from 'common/macros.html' import subheader -%}
|
||||
{%- extends 'base.html' -%}
|
||||
{%- block title -%}Not found{%- endblock -%}
|
||||
{%- block content -%}
|
||||
{%- call() subheader('404 Not Found') -%}
|
||||
<span>The requested URL was not found.</span>
|
||||
{%- endcall -%}
|
||||
{%- endblock -%}
|
||||
7
app/templates/common/footer.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<footer class="plank secondary-bg bottom">
|
||||
<span>Pyrom commit <a href="{{ "https://git.poto.cafe/yagich/pyrom/commit/" + __commit }}">{{ __commit[:8] }}</a></span>
|
||||
<ul class="horizontal">
|
||||
<li><a href="{{url_for('guides.contact')}}">Contact</a></li>
|
||||
<li><a href="{{url_for('guides.index')}}">Guides</a></li>
|
||||
</ul>
|
||||
</footer>
|
||||
@@ -1,188 +1,182 @@
|
||||
{% macro pager(current_page, page_count) %}
|
||||
{% set left_start = [1, current_page - 5] | max %}
|
||||
{% set right_end = [page_count, current_page + 5] | min %}
|
||||
<div class="pager">
|
||||
<span>Page:</span>
|
||||
{% if current_page > 5 %}
|
||||
<a href="?page=1" class="pagebutton">1</a>
|
||||
{% if left_start > 2 %}
|
||||
<span class="currentpage">…</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% for i in range(left_start, current_page) %}
|
||||
<a href="?page={{i}}" class="pagebutton">{{i}}</a>
|
||||
{% endfor %}
|
||||
{% if page_count > 0 %}
|
||||
<span class="currentpage">{{current_page}}</span>
|
||||
{% endif %}
|
||||
{% for i in range(current_page + 1, right_end + 1) %}
|
||||
<a href="?page={{i}}" class="pagebutton">{{i}}</a>
|
||||
{% endfor %}
|
||||
{% if right_end < page_count %}
|
||||
{% if right_end < page_count - 1 %}
|
||||
<span class="currentpage">…</span>
|
||||
{% endif %}
|
||||
<a href="?page={{page_count}}" class="pagebutton">{{page_count}}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro infobox(message, kind=InfoboxKind.INFO) %}
|
||||
<div class="{{ "infobox " + InfoboxHTMLClass[kind] }}">
|
||||
<span>
|
||||
<div class="infobox-icon-container">
|
||||
<img src="{{ InfoboxIcons[kind] }}">
|
||||
</div>
|
||||
{{ message }}
|
||||
</span>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro timestamp(unix_ts) %}
|
||||
{% macro timestamp(unix_ts) -%}
|
||||
<span class="timestamp" data-utc="{{ unix_ts }}">{{ unix_ts | ts_datetime('%Y-%m-%d %H:%M')}} <abbr title="Server Time">ST</abbr></span>
|
||||
{% endmacro %}
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro babycode_editor_component(ta_name, ta_placeholder="Post body", optional=False, prefill="") %}
|
||||
<div class="babycode-editor-container">
|
||||
<div class="tab-buttons">
|
||||
<button type=button class="tab-button active" data-target-id="tab-edit">Write</button>
|
||||
<button type=button class="tab-button" data-target-id="tab-preview">Preview</button>
|
||||
{% macro subheader(title, desc='') -%}
|
||||
<div id="subheader" class="plank secondary-bg">
|
||||
<h1 class="info">{{title}}</h1>
|
||||
{%- if desc -%}<span>{{desc}}</span>{%- endif -%}
|
||||
<div class="actions-group">{% if caller %}{{- caller() -}}{% endif %}</div>
|
||||
</div>
|
||||
<div class="tab-content active" id="tab-edit">
|
||||
<span class="babycode-button-container">
|
||||
<button class="babycode-button" type=button id="post-editor-bold" title="Insert Bold"><strong>B</strong></button>
|
||||
<button class="babycode-button" type=button id="post-editor-italics" title="Insert Italics"><em>I</em></button>
|
||||
<button class="babycode-button" type=button id="post-editor-strike" title="Insert Strikethrough"><del>S</del></button>
|
||||
<button class="babycode-button" type=button id="post-editor-url" title="Insert Link"><code>://</code></button>
|
||||
<button class="babycode-button" type=button id="post-editor-code" title="Insert Code block"><code></></code></button>
|
||||
<button class="babycode-button contain-svg full" type=button id="post-editor-img" title="Insert Image"><img src="/static/misc/image.svg"></button>
|
||||
<button class="babycode-button" type=button id="post-editor-ol" title="Insert Ordered list">1.</button>
|
||||
<button class="babycode-button" type=button id="post-editor-ul" title="Insert Unordered list">•</button>
|
||||
</span>
|
||||
<textarea class="babycode-editor" name="{{ ta_name }}" id="babycode-content" placeholder="{{ ta_placeholder }}" {{ "required" if not optional else "" }}>{{ prefill }}</textarea>
|
||||
<a href="{{ url_for("app.babycode_guide") }}" target="_blank">babycode guide</a>
|
||||
</div>
|
||||
<div class="tab-content" id="tab-preview">
|
||||
<div id="babycode-preview-errors-container">Type something!</div>
|
||||
<div id="babycode-preview-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="{{ "/static/js/babycode-editor.js" | cachebust }}"></script>
|
||||
{% endmacro %}
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro babycode_editor_form(ta_name, prefill = "", cancel_url="", endpoint="") %}
|
||||
{% set save_button_text = "Post reply" if not cancel_url else "Save" %}
|
||||
<form class="post-edit-form" method="post" action={{ endpoint }}>
|
||||
{{babycode_editor_component(ta_name, prefill = prefill)}}
|
||||
{% if not cancel_url %}
|
||||
<span>
|
||||
<input type="checkbox" id="subscribe" name="subscribe" {{ "checked" if session.get('subscribe_by_default', default=true) else "" }}>
|
||||
<label for="subscribe">Subscribe to thread</label>
|
||||
</span>
|
||||
{% endif %}
|
||||
<span>
|
||||
<input type="submit" value="{{ save_button_text }}">
|
||||
{% if cancel_url %}
|
||||
<a class="linkbutton warn" href="{{ cancel_url }}">Cancel</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
</form>
|
||||
{% endmacro %}
|
||||
{% macro pager(current_page, page_count, classes='', url='', args={}) -%}
|
||||
{%- set args = dict(args.items() | rejectattr(0, 'equalto', 'page')) -%}
|
||||
{%- if args -%}
|
||||
{#- remove the page query argument -#}
|
||||
{%- set url = url + (args | dict_to_query_string) + '&page=' -%}
|
||||
{%- else -%}
|
||||
{%- set url = url + '?page=' -%}
|
||||
{%- endif -%}
|
||||
<span class="button-row {{classes}}">
|
||||
{%- if current_page == 0 -%}
|
||||
{%- if page_count <= 3 -%}
|
||||
{%- for i in range(page_count) -%}
|
||||
<a href="{{url}}{{i+1}}" class="linkbutton minimal">{{i+1}}</a>
|
||||
{%- endfor -%}
|
||||
{%- else -%}
|
||||
<a href="{{url}}1" class="linkbutton minimal">1</a>
|
||||
<a href="{{url}}2" class="linkbutton minimal">2</a>
|
||||
<button class="minimal" disabled>…</button>
|
||||
<a href="{{url}}{{page_count - 1}}" class="linkbutton minimal">{{page_count - 1}}</a>
|
||||
<a href="{{url}}{{page_count}}" class="linkbutton minimal">{{page_count}}</a>
|
||||
{%- endif -%}
|
||||
{%- else -%}
|
||||
{%- set left_start = [2, current_page - 1] | max -%}
|
||||
{%- set right_end = [page_count - 1, current_page + 1] | min -%}
|
||||
|
||||
{% macro full_post(post, render_sig = True, is_latest = False, editing = False, active_user = None, no_reply = false) %}
|
||||
{% set postclass = "post" %}
|
||||
{% if editing %}
|
||||
{% set postclass = postclass + " editing" %}
|
||||
{% endif %}
|
||||
{% set post_permalink = url_for("threads.thread", slug = post['thread_slug'], after = post['id'], _anchor = ("post-" + (post['id'] | string))) %}
|
||||
<div class=" {{ postclass }}" id="post-{{ post['id'] }}">
|
||||
<div class="usercard">
|
||||
{%- if current_page != 1 -%}
|
||||
<a href="{{url}}1" class="linkbutton minimal">1</a>
|
||||
{%- endif -%}
|
||||
|
||||
{%- if left_start > 2 -%}
|
||||
<button class="minimal" disabled>…</button>
|
||||
{%- endif -%}
|
||||
|
||||
{%- for i in range(left_start, current_page) -%}
|
||||
<a href="{{url}}{{i}}" class="linkbutton minimal">{{i}}</a>
|
||||
{%- endfor -%}
|
||||
|
||||
{%- if page_count > 0 -%}
|
||||
<button class="minimal" disabled>{{current_page}}</button>
|
||||
{%- endif -%}
|
||||
|
||||
{%- for i in range(current_page + 1, right_end + 1) -%}
|
||||
<a href="{{url}}{{i}}" class="linkbutton minimal">{{i}}</a>
|
||||
{%- endfor -%}
|
||||
|
||||
{%- if right_end < page_count - 1 -%}
|
||||
<button class="minimal" disabled>…</button>
|
||||
{%- endif -%}
|
||||
|
||||
{%- if page_count > 1 and current_page != page_count -%}
|
||||
<a href="{{url}}{{page_count}}" class="linkbutton minimal">{{page_count}}</a>
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
</span>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro tabs(prefix='', labels = []) -%}
|
||||
<div class="tab-container">
|
||||
<div class="tab-bar" role="tablist">
|
||||
{%- for tab_label in labels -%}
|
||||
<button type="button" class="tab-button" role="tab" aria-selected="{{'true' if loop.index0==0 else 'false'}}" id="{{prefix+'-'+(tab_label | lower)+'-tab'}}" aria-controls="{{prefix+'-'+(tab_label | lower)+'-content'}}">{{tab_label}}</button>
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
{%- for tab_label in labels -%}
|
||||
<div class="plank secondary-bg even no-shadow tab-content {{'hidden' if loop.index0!=0 else ''}}" role="tabpanel" aria-labelledby="{{prefix+'-'+(tab_label | lower)+'-tab'}}" id="{{prefix+'-'+(tab_label | lower)+'-content'}}">
|
||||
{{- caller(loop.index0) -}}
|
||||
</div>
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro babycode_editor_component(
|
||||
placeholder='Post content',
|
||||
prefill='',
|
||||
required=true,
|
||||
id='babycode-content'
|
||||
) -%}
|
||||
{%- call(idx) tabs(prefix='babycode', labels=['Write', 'Preview']) -%}
|
||||
{%- if idx == 0 -%}
|
||||
<span class="babycode-editor-controls">
|
||||
<span class="button-row">
|
||||
<button type="button" class="minimal"><b>B</b></button>
|
||||
<button type="button" class="minimal"><i>i</i></button>
|
||||
<button type="button" class="minimal"><s>S</s></button>
|
||||
<button type="button" class="minimal"><u>U</u></button>
|
||||
<button type="button" class="minimal"><code>://</code></button>
|
||||
<button type="button" class="minimal"><code></></code></button>
|
||||
<button type="button" class="minimal">1.</button>
|
||||
<button type="button" class="minimal">•</button>
|
||||
<button type="button" class="minimal"><img src="/static/emoji/angry.png" class="emoji"></button>
|
||||
</span>
|
||||
<a href="##">babycode help</a>
|
||||
</span>
|
||||
<textarea name="babycode_content" id="{{id}}" class="babycode-editor" placeholder="{{placeholder}}" {{'required' if required else ''}}>{{ prefill }}</textarea>
|
||||
{%- endif -%}
|
||||
{%- endcall -%}
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro full_post(
|
||||
post, render_sig=true, is_latest=false,
|
||||
show_toolbar=true, is_editing=false, thread=none,
|
||||
show_reactions=true
|
||||
) -%}
|
||||
{%- if is_logged_in() -%}
|
||||
{%- set can_delete = post.user_id == get_active_user().id or is_mod() -%}
|
||||
{%- else -%}
|
||||
{%- set show_toolbar = false -%}
|
||||
{%- endif -%}
|
||||
{%- set owns = is_logged_in() and post.user_id == get_active_user().id -%}
|
||||
{%- set can_reply = (is_logged_in()) and (not thread.locked or is_mod()) -%}
|
||||
<div class="usercard plank even contrast-bg minimal no-shadow">
|
||||
<div class="usercard-inner">
|
||||
<a href="{{ url_for("users.page", username=post['username']) }}" style="display: contents;">
|
||||
<img src="{{ post['avatar_path'] }}" class="avatar">
|
||||
</a>
|
||||
<a href="{{ url_for("users.page", username=post['username']) }}" class="username-link">{{ post['username'] }}</a>
|
||||
{% if post['status'] %}
|
||||
<em class="user-status">{{ post['status'] }}</em>
|
||||
{% endif %}
|
||||
<img src="{{post.avatar_path}}" class="avatar">
|
||||
<div class="usercard-rest">
|
||||
<a href="{{url_for('users.user_page', username=post.username)}}">{{post.display_name if post.display_name else post.username}}</a>
|
||||
<abbr title="mention">@{{post.username}}</abbr>
|
||||
<i>{{post.status}}</i>
|
||||
{%- set badges=post.badges_json | fromjson -%}
|
||||
<div class="badges-container">
|
||||
{%- for badge in badges -%}
|
||||
{%- if badge.link -%}<a href="{{badge.link}}">{%- endif -%}
|
||||
<img src="{{badge.file_path}}" alt="{{badge.label}}" title="{{badge.label}}" class="badge-button">
|
||||
{%- if badge.link -%}</a>{%- endif -%}
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="post-content-container" {{ "id=latest-post" if is_latest else "" }}>
|
||||
<div class="post-info">
|
||||
<a href="{{ post_permalink }}" title="Permalink"><i>
|
||||
{% if (post['edited_at'] | int) > (post['created_at'] | int) %}
|
||||
Edited on {{ timestamp(post['edited_at']) }}
|
||||
{% else %}
|
||||
Posted on {{ timestamp(post['edited_at']) }}
|
||||
{% endif %}
|
||||
</i></a>
|
||||
<span>
|
||||
{% set show_edit = false %}
|
||||
{% if active_user %}
|
||||
{% set show_edit = (active_user.id | string) == (post['user_id'] | string) and (not post['thread_is_locked'] or active_user.is_mod()) and not no_reply %}
|
||||
{% endif %}
|
||||
{% if show_edit %}
|
||||
<a class="linkbutton" href="{{ url_for('posts.edit', post_id=post.id, _anchor='babycode-content') }}">Edit</a>
|
||||
{% endif %}
|
||||
|
||||
{% set show_reply = true %}
|
||||
|
||||
{% if active_user and post['thread_is_locked'] and not active_user.is_mod() %}
|
||||
{% set show_reply = false %}
|
||||
{% elif active_user and active_user.is_guest() %}
|
||||
{% set show_reply = false %}
|
||||
{% elif editing %}
|
||||
{% set show_reply = false %}
|
||||
{% elif no_reply %}
|
||||
{% set show_reply = false %}
|
||||
{% endif %}
|
||||
|
||||
{% if show_reply %}
|
||||
{% set qtext = "[url=%s]%s said:[/url]" | format(post_permalink, post['username']) %}
|
||||
{% set reply_text = "%s\n[quote]%s[/quote]\n" | format(qtext, post['original_markup']) %}
|
||||
<button value="{{ reply_text }}" class="reply-button">Quote</button>
|
||||
{% endif %}
|
||||
|
||||
{% set show_delete = false %}
|
||||
|
||||
{% if active_user %}
|
||||
{% set show_delete = (((post['user_id'] | string) == (active_user.id | string) and not post['thread_is_locked']) or active_user.is_mod()) and not no_reply %}
|
||||
{% endif %}
|
||||
|
||||
{% if show_delete %}
|
||||
<button class="critical post-delete-button" value="{{ post['id'] }}">Delete</button>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="post-content">
|
||||
{% if not editing %}
|
||||
<div class="post-inner" data-post-permalink="{{ post_permalink }}" data-author-username="{{ post.username }}">{{ post['content'] | safe }}</div>
|
||||
{% if render_sig and post['signature_rendered'] %}
|
||||
<div class="signature-container">
|
||||
<hr>
|
||||
{{ post['signature_rendered'] | safe }}
|
||||
<div class="plank even minimal secondary-bg no-shadow post-info">
|
||||
<a href="{{get_post_url(post.id, _anchor=true)}}"><i>Posted on {{timestamp(post.created_at)}}</i></a>
|
||||
{%- if show_toolbar -%}
|
||||
<span class="thread-actions">
|
||||
{%- if owns -%}
|
||||
<a class="linkbutton" href="{{url_for('posts.edit', post_id=post.id)}}">Edit</a>
|
||||
{%- endif -%}
|
||||
{%- if can_reply -%}
|
||||
<button disabled title="This feature requires JavaScript to be enabled.">Quote</button>
|
||||
{%- endif -%}
|
||||
{%- if can_delete -%}
|
||||
<a class="linkbutton critical" href="{{url_for('posts.delete', post_id=post.id)}}">Delete</a>
|
||||
{%- endif -%}
|
||||
<button disabled title="This feature requires JavaScript to be enabled.">Bookmark…</button>
|
||||
</span>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
<div class="plank even no-shadow post-content-inner minimal">{{post.content | safe}}
|
||||
{%- if render_sig and post.signature_rendered -%}
|
||||
<aside class="post-signature">{{post.signature_rendered | safe}}</aside>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
{%- if show_reactions -%}
|
||||
<div class="plank even secondary-bg minimal no-shadow">
|
||||
<span class="button-row">
|
||||
{%- for reaction in Reactions.for_post(post.id) -%}
|
||||
{% set reactors = Reactions.get_users(post.id, reaction.reaction_text) | map(attribute='username') | list %}
|
||||
{% set reactors_trimmed = reactors[:10] %}
|
||||
{% set reactors_str = reactors_trimmed | join (',\n') %}
|
||||
{% if reactors | count > 10 %}
|
||||
{% set reactors_str = reactors_str + '\n...and many others' %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{{ babycode_editor_form(cancel_url = post_permalink, prefill = post['original_markup'], ta_name = "new_content") }}
|
||||
{% endif %}
|
||||
{% set has_reacted = get_active_user() is not none and get_active_user().username in reactors %}
|
||||
<button disabled title="{{reactors_str}}" class="minimal {{'alt' if has_reacted else ''}}"><img src="/static/emoji/{{reaction.reaction_text}}.png">{{reaction.c}}</button>
|
||||
{%- endfor -%}
|
||||
</span>
|
||||
{%- if is_logged_in() -%}<button disabled title="This feature requires JavaScript to be enabled.">Add reaction</button>{%- endif -%}
|
||||
</div>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro accordion(hidden=false, style="", disabled=false) %}
|
||||
{% if disabled %}
|
||||
{% set hidden = true %}
|
||||
{% endif %}
|
||||
<div class="accordion {{ "hidden" if hidden else ""}}" style="{{style}}">
|
||||
<div class="accordion-header">
|
||||
<button type="button" class="accordion-toggle" {{"disabled" if disabled else ""}}>{{ "+" if hidden else "-" }}</button>
|
||||
{{ caller('header') }}
|
||||
</div>
|
||||
<div class="accordion-content {{ "hidden" if hidden else "" }}">
|
||||
{{ caller('content') }}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
{%- endmacro %}
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
<nav id="topnav">
|
||||
<span>
|
||||
<a class="site-title" href="{{url_for('topics.all_topics')}}">Porom</a>
|
||||
</span>
|
||||
<span>
|
||||
{% if not is_logged_in() %}
|
||||
Welcome, guest. Please <a href="{{url_for('users.sign_up')}}">sign up</a> or <a href="{{url_for('users.log_in')}}">log in</a>
|
||||
{% else %}
|
||||
{% with user = get_active_user() %}
|
||||
Welcome, <a href="{{ url_for("users.page", username = user.username) }}">{{user.username}}</a>
|
||||
•
|
||||
<a href="{{ url_for("users.settings", username = user.username) }}">Settings</a>
|
||||
•
|
||||
<a href="{{ url_for("users.inbox", username = user.username) }}">Inbox</a>
|
||||
{% if user.is_mod() %}
|
||||
•
|
||||
<a href="{{ url_for("mod.user_list") }}">User list</a>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
</span>
|
||||
<nav id="header" class="plank top">
|
||||
<a class="site-title" href="/">Porom</a>
|
||||
<span>anti-social media</span>
|
||||
{%- if is_logged_in() -%}
|
||||
{%- with user = get_active_user() -%}
|
||||
<ul class="horizontal wrap">
|
||||
<li class="mobile-fill-flex">Welcome, <a href="{{url_for('users.user_page', username=user.username)}}">{{ user.get_readable_name() }}</a></li>
|
||||
<li><a class="linkbutton" href="{{url_for('users.settings', username=user.username)}}">Settings</a></li>
|
||||
<li><a class="linkbutton" href="{{url_for('users.inbox', username=user.username)}}">Inbox</a></li>
|
||||
<li><a class="linkbutton" href="{{url_for('users.bookmarks', username=user.username)}}">Bookmarks</a></li>
|
||||
{% if user.is_mod() -%}
|
||||
<li><a class="linkbutton" href="{{url_for('mod.index')}}">Moderation</a></li>
|
||||
{%- endif %}
|
||||
</ul>
|
||||
{%- endwith -%}
|
||||
{%- elif request.path != url_for('users.sign_up') and request.path != url_for('users.log_in') -%}
|
||||
<form class="horizontal wrap" method="POST" action="{{url_for('users.log_in_post')}}">
|
||||
<input type="hidden" name="return_to" value="{{request.path}}">
|
||||
<input type="text" placeholder="Username" name="username" autocomplete="username" required>
|
||||
<input type="password" placeholder="Password" name="password" autocomplete="current-password" required>
|
||||
<span><input type="checkbox" name="remember" id="remember"> <label for="remember">Remember me</label></span>
|
||||
<input type="submit" value="Log in">
|
||||
<a href="{{url_for('users.sign_up')}}" class="linkbutton alt">Sign up</a>
|
||||
</form>
|
||||
{%- endif -%}
|
||||
</nav>
|
||||
|
||||
13
app/templates/mod/edit_topic.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{%- from 'common/macros.html' import subheader -%}
|
||||
{%- extends 'base.html' -%}
|
||||
{%- block title -%}editing topic {{topic.name}}{%- endblock -%}
|
||||
{%- block content -%}
|
||||
{{subheader('Editing topic %s' % topic.name, 'To preserve history, the URL of the topic can not be changed.')}}
|
||||
<form class="plank primary-bg full-width" method="POST">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" required value="{{topic.name}}">
|
||||
<label for="description">Description</label>
|
||||
<textarea name="description" id="description" rows="5" required>{{topic.description}}</textarea>
|
||||
<input type="submit" value="Save">
|
||||
</form>
|
||||
{%- endblock -%}
|
||||
13
app/templates/mod/new_topic.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{%- from 'common/macros.html' import subheader -%}
|
||||
{%- extends 'base.html' -%}
|
||||
{%- block title -%}creating a topic{%- endblock -%}
|
||||
{%- block content -%}
|
||||
{{subheader('Create topic', 'The new topic will appear at the bottom of the current topic list. You can sort it later.')}}
|
||||
<form class="plank primary-bg full-width" method="POST">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" required>
|
||||
<label for="description">Description</label>
|
||||
<textarea name="description" id="description" rows="5" required></textarea>
|
||||
<input type="submit" value="Create">
|
||||
</form>
|
||||
{%- endblock -%}
|
||||
@@ -1,18 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="darkbg settings-container">
|
||||
<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>
|
||||
{% 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>
|
||||
<div>{{ topic.description }}</div>
|
||||
<input type="hidden" name="{{ topic['id'] }}" value="{{ topic['sort-order'] }}" class="topic-input">
|
||||
</div>
|
||||
{% endfor %}
|
||||
<input type=submit value="Save order">
|
||||
</form>
|
||||
</div>
|
||||
<script src="{{ "/static/js/sort-topics.js" | cachebust }}"></script>
|
||||
{% endblock %}
|
||||
@@ -1,59 +0,0 @@
|
||||
{% from "common/macros.html" import timestamp, accordion %}
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="darkbg inbox-container">
|
||||
{% set guests = (users | selectattr('permission', 'eq', PermissionLevel.GUEST.value) | list) %}
|
||||
{% set not_guests = (users | selectattr('permission', 'gt', PermissionLevel.GUEST.value) | list) %}
|
||||
{% call(section) accordion(disabled=(guests | count==0)) %}
|
||||
{% if section == "header" %}
|
||||
<span>Unconfirmed guests</span>
|
||||
{% elif section == "content" %}
|
||||
<table class="colorful-table">
|
||||
<thead>
|
||||
<th>Username</th>
|
||||
<th class="small">Signed up on</th>
|
||||
</thead>
|
||||
{% for user in guests %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{url_for("users.page", username=user['username'])}}">{{user['username']}}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{{ timestamp(user.created_at) }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endif %}
|
||||
{% endcall %}
|
||||
|
||||
{% call(section) accordion() %}
|
||||
{% if section == "header" %}
|
||||
<span>Other users</span>
|
||||
{% elif section == "content" %}
|
||||
<table class="colorful-table">
|
||||
<thead>
|
||||
<th>Username</th>
|
||||
<th class="small">Permission</th>
|
||||
<th class="small">Signed up on</th>
|
||||
</thead>
|
||||
{% for user in not_guests %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{url_for("users.page", username=user['username'])}}">{{user['username']}}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{{ user.permission | permission_string }}
|
||||
</td>
|
||||
<td>
|
||||
{{ timestamp(user.created_at) }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endif %}
|
||||
{% endcall %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,18 +0,0 @@
|
||||
{% from 'common/macros.html' import full_post %}
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}editing a post{% endblock %}
|
||||
{% block content %}
|
||||
{% for post in prev_context | reverse %}
|
||||
{{ full_post(post=post, no_reply=true, active_user=active_user) }}
|
||||
{% endfor %}
|
||||
<span class="context-explain">
|
||||
<span>↑↑↑</span><i>Context</i><span>↑↑↑</span>
|
||||
</span>
|
||||
{{ full_post(post=editing_post, editing=true, no_reply=true, active_user=active_user) }}
|
||||
<span class="context-explain">
|
||||
<span>↓↓↓</span><i>Context</i><span>↓↓↓</span>
|
||||
</span>
|
||||
{% for post in next_context %}
|
||||
{{ full_post(post=post, no_reply=true, active_user=active_user) }}
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
@@ -1,22 +0,0 @@
|
||||
{% from "common/macros.html" import babycode_editor_component %}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}drafting a thread{% endblock %}
|
||||
{% block content %}
|
||||
<div class="darkbg settings-container">
|
||||
<h1>New thread</h1>
|
||||
<form method="post">
|
||||
<label for="topic_id">Topic</label>
|
||||
<select name="topic_id" id="topic_id" autocomplete="off">
|
||||
{% for topic in all_topics %}
|
||||
{% set disable_topic = active_user and not active_user.can_post_to_topic(topic) %}
|
||||
<option value="{{ topic['id'] }}" {{"selected" if (request.args.get('topic_id')) == (topic['id'] | string) else ""}} {{'disabled' if disable_topic else ''}} >{{ topic['name'] }}{{ ' (locked)' if topic.is_locked }}</option>
|
||||
{% endfor %}
|
||||
</select><br>
|
||||
<label for="title">Thread title</label>
|
||||
<input type="text" id="title" name="title" placeholder="Required" required>
|
||||
<label for="initial_post">Post body</label><br>
|
||||
{{ babycode_editor_component("initial_post") }}
|
||||
<input type="submit" value="Create thread">
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
19
app/templates/threads/new_thread.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{%- from 'common/macros.html' import subheader, babycode_editor_component -%}
|
||||
{%- extends 'base.html' -%}
|
||||
{%- block title -%}drafting a thread{%- endblock -%}
|
||||
{%- block content -%}
|
||||
{{subheader('New thread')}}
|
||||
<form class="plank primary-bg full-width" method="POST">
|
||||
<label for="topic">Topic</label>
|
||||
<select name="topic_id" id="topic" autocomplete="off">
|
||||
{%- for topic in topics -%}
|
||||
<option value="{{topic.id}}" {{'selected' if selected_topic == topic.id else ''}} {{'disabled' if not get_active_user().can_post_to_topic(topic) else ''}}>{{topic.name}}{{ ' (locked)' if topic.locked() else ''}}</option>
|
||||
{%- endfor -%}
|
||||
</select>
|
||||
<label for="title">Title</label>
|
||||
<input type="text" id="title" name="title" required>
|
||||
<label for="babycode-content">Starting post</label>
|
||||
{{ babycode_editor_component() }}
|
||||
<input type="submit" value="Create">
|
||||
</form>
|
||||
{%- endblock -%}
|
||||
@@ -1,87 +1,77 @@
|
||||
{% from 'common/macros.html' import pager, babycode_editor_form, full_post %}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ thread.title }}{% endblock %}
|
||||
{% block content %}
|
||||
{% set can_post = false %}
|
||||
{% set can_lock = false %}
|
||||
{% set can_subscribe = false %}
|
||||
{% if active_user %}
|
||||
{% set can_subscribe = true %}
|
||||
{% set can_post = (not thread.is_locked and not active_user.is_guest()) or active_user.is_mod() %}
|
||||
{% set can_lock = ((active_user.id | int) == (thread.user_id | int)) or active_user.is_mod() %}
|
||||
{% endif %}
|
||||
<main>
|
||||
<nav class="darkbg">
|
||||
<h1 class="thread-title">{{ thread.title }}</h1>
|
||||
<span>Posted in <a href="{{ url_for("topics.topic", slug=topic.slug) }}">{{ topic.name }}</a>
|
||||
{% if thread.is_stickied %}
|
||||
• <i>stickied, so it's probably important</i>
|
||||
{% endif %}
|
||||
</span>
|
||||
<div>
|
||||
{% if can_subscribe %}
|
||||
<form class="modform" action="{{ url_for('threads.subscribe', slug=thread.slug) }}" method="post">
|
||||
<input type='hidden' name='last_visible_post' value='{{posts[-1].id}}'>
|
||||
<input type='hidden' name='subscribe' value='{{ 'unsubscribe' if is_subscribed else 'subscribe' }}'>
|
||||
<input type='submit' value='{{ 'Unsubscribe' if is_subscribed else 'Subscribe' }}'>
|
||||
{%- from 'common/macros.html' import subheader, timestamp, pager, babycode_editor_component -%}
|
||||
{%- from 'common/macros.html' import full_post with context -%}
|
||||
{%- extends 'base.html' -%}
|
||||
{%- block title -%}{{thread.title}}{%- endblock -%}
|
||||
{%- block content -%}
|
||||
{%- set td -%}
|
||||
<ul class="horizontal">
|
||||
<li>Started by <a href="{{url_for('users.user_page', username=started_by.username)}}">{{started_by.get_readable_name()}}</a> in topic <a href="{{url_for('topics.topic_by_id', topic_id=topic.id)}}">{{topic.name}}</a></li>
|
||||
{%- if thread.locked() or thread.stickied() -%}
|
||||
{%- if thread.locked() -%}
|
||||
<li class="visible">Locked</li>
|
||||
{%- endif -%}
|
||||
{%- if thread.stickied() -%}
|
||||
<li class="visible">Stickied</li>
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
</ul>
|
||||
{%- endset -%}
|
||||
{%- call() subheader(thread.title, td) -%}
|
||||
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||
<legend>Actions</legend>
|
||||
{%- if is_logged_in() -%}
|
||||
<button>Subscribe</button>
|
||||
<button disabled title="This feature requires JavaScript to be enabled.">Bookmark…</button>
|
||||
{%- endif -%}
|
||||
<a href="{{url_for('threads.feed', thread_id=thread.id)}}" class="linkbutton rss">Subscribe via RSS</a>
|
||||
</fieldset>
|
||||
{%- if is_mod() -%}
|
||||
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||
<legend>Moderation actions</legend>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="lock" value="{{(not thread.locked()) | int}}">
|
||||
<input type="hidden" name="sticky" value="{{(not thread.stickied()) | int}}">
|
||||
<input type="submit" class="warn" value="{{'Unlock' if thread.locked() else 'Lock'}}" formaction="{{url_for('mod.lock_thread', thread_id=thread.id)}}">
|
||||
<input type="submit" class="warn" value="{{'Unsticky' if thread.stickied() else 'Sticky'}}" formaction="{{url_for('mod.sticky_thread', thread_id=thread.id)}}">
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if can_lock %}
|
||||
<form class="modform" action="{{ url_for("threads.lock", slug=thread.slug) }}" method="post">
|
||||
<input type=hidden name='target_op' value="{{ (not thread.is_locked) | int }}">
|
||||
<input class="warn" type="submit" value="{{"Unlock thread" if thread.is_locked else "Lock thread"}}">
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if active_user and active_user.is_mod() %}
|
||||
<form class="modform" action="{{ url_for("threads.sticky", slug=thread.slug) }}" method="post">
|
||||
<input type=hidden name='target_op' value="{{ (not thread.is_stickied) | int }}">
|
||||
<input class="warn" type="submit" value="{{"Unsticky thread" if thread.is_stickied else "Sticky thread"}}">
|
||||
</form>
|
||||
<form class="modform" action="{{ url_for("threads.move", slug=thread.slug) }}" method="post">
|
||||
<label for="new_topic_id">Move to topic:</label>
|
||||
<select style="width:200px;" id="new_topic_id" name="new_topic_id" autocomplete="off">
|
||||
{% for topic in topics %}
|
||||
<option value="{{ topic['id'] }}" {{ "selected disabled" if (thread.topic_id | string) == (topic['id'] | string) else "" }}>{{ topic['name'] }}</option>
|
||||
{% endfor %}
|
||||
<form class="horizontal wrap" method="POST" action="{{url_for('mod.move_thread', thread_id=thread.id)}}">
|
||||
<select name="new_topic_id" id="new-topic-id" autocomplete="off" required>
|
||||
<option selected disabled value="">Move to topic:</option>
|
||||
{%- for t in topics -%}
|
||||
<option value="{{t.id}}" {{'disabled' if t.id==topic.id else ''}}>{{t.name}}</option>
|
||||
{%- endfor -%}
|
||||
</select>
|
||||
<input class="warn" type="submit" value="Move thread">
|
||||
<input type="submit" value="Move" class="warn">
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
{% for post in posts %}
|
||||
{{ full_post(post = post, active_user = active_user, is_latest = loop.index == (posts | length)) }}
|
||||
{% endfor %}
|
||||
</fieldset>
|
||||
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||
<legend>Page</legend>
|
||||
{{- pager(page, page_count) -}}
|
||||
</fieldset>
|
||||
{%- endif -%}
|
||||
{%- endcall -%}
|
||||
<main>
|
||||
{%- for post in posts -%}
|
||||
<article id="post-{{post.id}}" class="post plank">
|
||||
{{full_post(post)}}
|
||||
</article>
|
||||
{%- endfor -%}
|
||||
</main>
|
||||
|
||||
<nav id="bottomnav">
|
||||
{{ pager(current_page = current_page, page_count = page_count) }}
|
||||
</nav>
|
||||
|
||||
{% if can_post %}
|
||||
<h1>Respond to "{{ thread.title }}"</h1>
|
||||
{{ babycode_editor_form(ta_name = "post_content")}}
|
||||
{% endif %}
|
||||
<dialog id="delete-dialog">
|
||||
<div class=delete-dialog-inner>
|
||||
Are you sure you want to delete the highlighted post?
|
||||
<div class="plank secondary-bg">
|
||||
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||
<legend>Page</legend>
|
||||
{{- pager(page, page_count) -}}
|
||||
</fieldset>
|
||||
</div>
|
||||
{%- if is_logged_in() -%}
|
||||
<form action="{{url_for('threads.reply', thread_id=thread.id)}}" method="POST" class="plank post-edit-form">
|
||||
<h2 class="info">Reply to "{{thread.title}}"</h2>
|
||||
{{- babycode_editor_component() -}}
|
||||
<span>
|
||||
<button id=post-delete-dialog-close>Cancel</button>
|
||||
<button class="critical" form=post-delete-form>Delete</button>
|
||||
<form id="post-delete-form" method="post"></form>
|
||||
<input type="checkbox" checked name="subscribe" id="subscribe">
|
||||
<label for="subscribe">Subscribe to thread</label>
|
||||
</span>
|
||||
</div>
|
||||
</dialog>
|
||||
<input type='hidden' id='thread-subscribe-endpoint' value='{{ url_for('api.thread_updates', thread_id=thread.id) }}'>
|
||||
<div id="new-post-notification" class="new-concept-notification hidden">
|
||||
<div class="new-notification-content">
|
||||
<p>New post in thread!</p>
|
||||
<span class="notification-buttons">
|
||||
<button id="dismiss-new-post-button">Dismiss</button>
|
||||
<a class="linkbutton" id="go-to-new-post-button">View post</a>
|
||||
<button id="unsub-new-post-button">Stop updates</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<script src="{{ "/static/js/thread.js" | cachebust }}"></script>
|
||||
{% endblock %}
|
||||
<span><input type="submit" value="Post reply"></span>
|
||||
</form>
|
||||
{%- endif -%}
|
||||
{%- endblock -%}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}creating a topic{% endblock %}
|
||||
{% block content %}
|
||||
<div class="darkbg settings-container">
|
||||
<h1>Create topic</h1>
|
||||
<form method="post">
|
||||
<label for=name>Name</label>
|
||||
<input type="text" name="name" id="name" required><br>
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" name="description" required rows=5></textarea><br>
|
||||
<input type="submit" value="Create topic">
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,16 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}creating a topic{% endblock %}
|
||||
{% block content %}
|
||||
<div class="darkbg settings-container">
|
||||
<h1>Editing topic {{ topic['name'] }}</h1>
|
||||
<form method="post">
|
||||
<label for=name>Name</label>
|
||||
<input type="text" name="name" id="name" required value="{{ topic['name'] }}"><br>
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" name="description" required rows=5>{{ topic['description'] }}</textarea><br>
|
||||
<input type="submit" value="Save changes">
|
||||
<a class="linkbutton warn" href={{ url_for("topics.topic", slug=topic['slug'] )}}>Cancel</a><br>
|
||||
<i> Note: to preserve history, you cannot change the topic URL.</i>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,79 +1,70 @@
|
||||
{% from 'common/macros.html' import pager, timestamp %}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}browsing topic {{ topic['name'] }}{% endblock %}
|
||||
{% block content %}
|
||||
<nav class="darkbg">
|
||||
<h1 class="thread-title">All threads in "{{topic['name']}}"</h1>
|
||||
<span>{{topic['description']}}</span>
|
||||
<div>
|
||||
{% if active_user %}
|
||||
{% 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>
|
||||
{% endif %}
|
||||
{% if active_user.is_mod() %}
|
||||
<a class="linkbutton" href="{{url_for("topics.edit", slug=topic['slug'])}}">Edit topic</a>
|
||||
<form class="modform" method="post" action="{{url_for("topics.edit", slug=topic['slug']) }}">
|
||||
<input type="hidden" name="is_locked" value="{{ (not topic.is_locked) | int }}">
|
||||
<input class="warn" type="submit" id="lock" value="{{"Unlock topic" if topic['is_locked'] else "Lock topic"}}">
|
||||
{% from 'common/macros.html' import timestamp, subheader, pager %}
|
||||
{%- extends 'base.html' -%}
|
||||
{%- block title -%}browsing topic {{topic.name}}{%- endblock -%}
|
||||
{%- block content -%}
|
||||
{%- set td -%}
|
||||
<ul class="horizontal">
|
||||
<li>{{topic.description}}</li>
|
||||
{%- if topic.locked() -%}
|
||||
<li class="visible">Locked</li>
|
||||
{%- endif -%}
|
||||
</ul>
|
||||
{%- endset -%}
|
||||
{%- call() subheader(('Threads in "%s"' % topic.name), td) -%}
|
||||
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||
<legend>Actions</legend>
|
||||
{%- if is_logged_in() and get_active_user().can_post_to_topic(topic) -%}
|
||||
<a href="{{url_for('threads.new', topic_id=topic.id)}}" class="linkbutton">New thread</a>
|
||||
{%- endif -%}
|
||||
<a href="{{url_for('topics.feed', topic_id=topic.id)}}" class="linkbutton rss">Subscribe via RSS</a>
|
||||
<form method="GET">
|
||||
<select name="sort_by">
|
||||
<option value="activity"{% if sort_by == 'activity' %}selected{% endif %}>Sorted by activity</option>
|
||||
<option value="thread" {% if sort_by == 'thread' %}selected{% endif %}>Sorted by newest</option>
|
||||
</select>
|
||||
<input type="submit" value="Sort">
|
||||
</form>
|
||||
<button type="button" class="critical" id="topic-delete-dialog-open">Delete</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
{%- if is_mod() -%}
|
||||
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||
<legend>Moderation actions</legend>
|
||||
<a href="{{url_for('mod.edit_topic', topic_id=topic.id)}}" class="linkbutton">Edit</a>
|
||||
<form action="{{url_for('mod.lock_topic', topic_id=topic.id)}}" method="POST">
|
||||
<input type="hidden" value="{{(not topic.locked()) | int}}" name="lock">
|
||||
<input type="submit" class="warn" value="{{'Unlock' if topic.locked() else 'Lock'}}">
|
||||
</form>
|
||||
</fieldset>
|
||||
{%- endif -%}
|
||||
{%- if threads | length > 0 -%}
|
||||
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||
<legend>Page</legend>
|
||||
{{- pager(page, page_count, args=request.args) -}}
|
||||
</fieldset>
|
||||
{%- endif -%}
|
||||
{%- endcall -%}
|
||||
{%- if threads | length == 0 -%}
|
||||
<div class="plank"><p>There are no threads in this topic yet.{%- if is_logged_in() and get_active_user().can_post_to_topic(topic) %} Be the first to start a discussion!{%- endif -%}</p></div>
|
||||
{%- endif -%}
|
||||
{%- for thread in threads -%}
|
||||
<div class="topic-info plank">
|
||||
<div class="title-container">
|
||||
<span class="info thread-title-counter"><a href="{{url_for('threads.thread_by_id', thread_id=thread.id)}}">{{thread.title}}</a></span>
|
||||
<ul class="horizontal"></ul>
|
||||
{%- if thread.posts_count / 10 > 1 -%}
|
||||
{{pager(0, (((thread.posts_count / 10) | round(0, 'ceil') )| int), 'flex-last', url=url_for('threads.thread_by_id', thread_id=thread.id))}}
|
||||
{%- endif -%}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{% if topic['is_locked'] %}
|
||||
{{ infobox("This topic is locked. Only moderators can create new threads.", InfoboxKind.INFO) }}
|
||||
{% endif %}
|
||||
|
||||
{% if threads_list | length == 0 %}
|
||||
<p>There are no threads in this topic.</p>
|
||||
{% else %}
|
||||
{% for thread in threads_list %}
|
||||
<div class="thread">
|
||||
<div class="thread-sticky-container contain-svg">
|
||||
{% if thread['is_stickied'] %}
|
||||
<img src="/static/misc/sticky.svg">
|
||||
<i>Stickied</i>
|
||||
{% endif %}
|
||||
<span>Started by <a href="{{url_for('users.user_page', username=thread.started_by)}}">{{thread.started_by_display_name if thread.started_by_display_name else thread.started_by}}</a> on {{timestamp(thread.created_at)}}</span>
|
||||
<span>{{thread.posts_count}} {{'repl' | pluralize(thread.posts_count, 'y', 'ies')}}</span>
|
||||
<span>Latest post by <a href="{{get_post_url(thread.latest_post_id, _anchor=true)}}">{{thread.latest_post_display_name if thread.latest_post_display_name else thread.latest_post_username}} on {{timestamp(thread.latest_post_created_at)}}</a>{{' (OP)' if thread.posts_count == 1 else ''}}</span>
|
||||
</div>
|
||||
<div class="thread-info-container">
|
||||
<span>
|
||||
<span class="thread-title"><a href="{{ url_for("threads.thread", slug=thread['slug']) }}">{{thread['title']}}</a></span>
|
||||
•
|
||||
Started by <a href="{{ url_for("users.page", username=thread['started_by']) }}">{{ thread['started_by'] }}</a> on {{ timestamp(thread['created_at'])}}
|
||||
</span>
|
||||
<span>
|
||||
Latest post by <a href="{{ url_for("users.page", username=thread['latest_post_username']) }}">{{ thread['latest_post_username'] }}</a>
|
||||
on <a href="{{ url_for("threads.thread", slug=thread['slug'], after=thread['latest_post_id']) }}">on {{ timestamp(thread['latest_post_created_at']) }}</a>:
|
||||
</span>
|
||||
<span class="thread-info-post-preview">
|
||||
{{ thread['latest_post_content'] | safe }}
|
||||
</span>
|
||||
{%- endfor -%}
|
||||
{%- if threads | length > 0 -%}
|
||||
<div class="plank secondary-bg">
|
||||
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||
<legend>Page</legend>
|
||||
{{- pager(page, page_count, args=request.args) -}}
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="thread-locked-container contain-svg">
|
||||
{% if thread['is_locked'] %}
|
||||
<img src="/static/misc/lock.svg">
|
||||
<i>Locked</i>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<nav id="bottomnav">
|
||||
{{ pager(current_page = current_page, page_count = page_count) }}
|
||||
</nav>
|
||||
|
||||
<dialog id="delete-dialog">
|
||||
<div class="delete-dialog-inner">
|
||||
Are you sure you want to delete this topic?
|
||||
<span>
|
||||
<button id=topic-delete-dialog-close>Cancel</button>
|
||||
<button class="critical" form=topic-delete-form>Delete</button>
|
||||
<form id="topic-delete-form" method="post" action="{{ url_for("topics.delete", slug = topic.slug) }}"></form>
|
||||
</span>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script src="{{ "/static/js/topic.js" | cachebust }}"></script>
|
||||
{% endblock %}
|
||||
{%- endif -%}
|
||||
{%- endblock -%}
|
||||
|
||||
@@ -1,43 +1,32 @@
|
||||
{% from 'common/macros.html' import timestamp %}
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<nav class="darkbg">
|
||||
<h1 class="thread-title">All topics</h1>
|
||||
{% if active_user and active_user.is_mod() %}
|
||||
<a class="linkbutton" href={{ url_for("topics.create") }}>Create new topic</a>
|
||||
<a class="linkbutton" href={{ url_for("mod.sort_topics") }}>Sort topics</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% if topic_list | length == 0 %}
|
||||
<p>There are no topics.</p>
|
||||
{% else %}
|
||||
{% for topic in topic_list %}
|
||||
<div class="topic">
|
||||
<div class="topic-info-container">
|
||||
<a class="thread-title" href="{{ url_for("topics.topic", slug=topic['slug']) }}">{{ topic['name'] }}</a>
|
||||
{{ topic['description'] }}
|
||||
{% if topic['latest_thread_username'] %}
|
||||
<span>
|
||||
Latest thread: <a href="{{ url_for("threads.thread", slug=topic['latest_thread_slug'])}}">{{topic['latest_thread_title']}}</a> by <a href="{{url_for("users.page", username=topic['latest_thread_username'])}}">{{topic['latest_thread_username']}}</a> on {{ timestamp(topic['latest_thread_created_at']) }}
|
||||
</span>
|
||||
{% if topic['id'] in active_threads %}
|
||||
{% with thread=active_threads[topic['id']] %}
|
||||
<span>
|
||||
Latest post in: <a href="{{ url_for("threads.thread", slug=thread['thread_slug'])}}">{{ thread['thread_title'] }}</a> by <a href="{{ url_for("users.page", username=thread['username'])}}">{{ thread['username'] }}</a> at <a href="">{{ timestamp(thread['post_created_at']) }}</a>
|
||||
</span>
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<i>No threads yet.</i>
|
||||
{% endif %}
|
||||
{% from 'common/macros.html' import timestamp, subheader %}
|
||||
{%- extends 'base.html' -%}
|
||||
{%- block content -%}
|
||||
{%- call() subheader('All topics') -%}
|
||||
{%- if is_mod() -%}
|
||||
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||
<legend>Moderation actions</legend>
|
||||
<a href="{{url_for('mod.new_topic')}}" class="linkbutton">New topic</a>
|
||||
<a href="{{url_for('mod.sort_topics')}}" class="linkbutton">Sort topics</a>
|
||||
</fieldset>
|
||||
{%- endif -%}
|
||||
{%- endcall -%}
|
||||
{%- for topic in topics -%}
|
||||
<div class="topic-info plank">
|
||||
<div class="title-container">
|
||||
<a class="info" href="{{url_for('topics.topic_by_id', topic_id=topic.id)}}">{{topic.name}}</a>
|
||||
</div>
|
||||
<div class="topic-locked-container contain-svg">
|
||||
{% if topic['is_locked'] %}
|
||||
<img src="/static/misc/lock.svg"></img>
|
||||
<i>Locked</i>
|
||||
{% endif %}
|
||||
<div>{{topic.description}}</div>
|
||||
<ul class="horizontal">
|
||||
<li>{{topic.threads_count}} {{"thread" | pluralize(topic.threads_count)}}</li>
|
||||
<li>{{topic.posts_count}} {{"post" | pluralize(topic.posts_count)}}</li>
|
||||
</ul>
|
||||
<div>
|
||||
{%- if topic.latest_post_timestamp -%}
|
||||
Latest post at: {{timestamp(topic.latest_post_timestamp)}}
|
||||
{%- else -%}
|
||||
No posts yet
|
||||
{%- endif -%}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{%- endfor -%}
|
||||
{%- endblock -%}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
{% from "common/macros.html" import timestamp, full_post, accordion %}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}inbox{% endblock %}
|
||||
{% block content %}
|
||||
<div class="darkbg inbox-container">
|
||||
{% set has_subscriptions = all_subscriptions is not none %}
|
||||
{% call(section) accordion(disabled=not has_subscriptions) %}
|
||||
{% if section == "header" %}
|
||||
{% if not has_subscriptions %}
|
||||
(You have no subscriptions)
|
||||
{% else %}
|
||||
Your subscriptions
|
||||
{% endif %}
|
||||
{% elif section == "content" and has_subscriptions %}
|
||||
<table class="colorful-table">
|
||||
<thead>
|
||||
<th>Thread</th>
|
||||
<th class="small">Unsubscribe</th>
|
||||
</thead>
|
||||
{% for sub in all_subscriptions %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href=" {{ url_for("threads.thread", slug=sub.thread_slug) }} ">{{ sub.thread_title }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<form class="modform" method="post" action="{{ url_for("threads.subscribe", slug = sub.thread_slug) }}">
|
||||
<input type="hidden" name="subscribe" value="unsubscribe">
|
||||
<input class="warn" type="submit" value="Unsubscribe">
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endif %}
|
||||
{% endcall %}
|
||||
{% if has_subscriptions %}
|
||||
{% if not new_posts %}
|
||||
You have no unread posts.
|
||||
{% else %}
|
||||
You have {{ total_unreads_count }} total unread {{("post" | pluralize(num=total_unreads_count))}}:
|
||||
{% for thread in new_posts %}
|
||||
{% call(section) accordion() %}
|
||||
{% if section == "header" %}
|
||||
{% set latest_post_id = thread.posts[-1].id %}
|
||||
{% set unread_posts_text = " (" + (thread.unread_count | string) + (" unread post" | pluralize(num=thread.unread_count)) %}
|
||||
<a class="accordion-title" href="{{ url_for("threads.thread", slug=latest_post_slug, after=latest_post_id, _anchor="post-" + (latest_post_id | string)) }}" title="Jump to latest post">{{thread.thread_title + unread_posts_text}}, latest at {{ timestamp(thread.newest_post_time) }})</a>
|
||||
<form class="modform" method="post" action="{{ url_for("threads.subscribe", slug = thread.thread_slug) }}">
|
||||
<input type="hidden" name="subscribe" value="read">
|
||||
<input type="submit" value="Mark thread as Read">
|
||||
</form>
|
||||
<form class="modform" method="post" action="{{ url_for("threads.subscribe", slug = thread.thread_slug) }}">
|
||||
<input type="hidden" name="subscribe" value="unsubscribe">
|
||||
<input class="warn" type="submit" value="Unsubscribe">
|
||||
</form>
|
||||
{% elif section == "content" %}
|
||||
{% for post in thread.posts %}
|
||||
{{ full_post(post, no_reply = true) }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endcall %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,14 +1,22 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Log in{% endblock %}
|
||||
{% block content %}
|
||||
<div class="darkbg login-container">
|
||||
<h1>Log in</h1>
|
||||
<form method="post">
|
||||
<label for="username">Username</label><br>
|
||||
<input type="text" id="username" name="username" required autocomplete="username"><br>
|
||||
<label for="password">Password</label><br>
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password"><br>
|
||||
{% from 'common/macros.html' import subheader %}
|
||||
{%- extends 'base.html' -%}
|
||||
{%- block title -%}log in{%- endblock -%}
|
||||
{%- block content -%}
|
||||
{%- set welcome -%}
|
||||
Welcome back! No account yet? <a href="{{url_for('users.sign_up')}}">Sign up</a>
|
||||
{%- endset -%}
|
||||
{{ subheader('Log in', welcome)}}
|
||||
{%- if request.args.get('error') -%}
|
||||
<div class="infobox plank critical">
|
||||
{{request.args.get('error')}}
|
||||
</div>
|
||||
{%- endif -%}
|
||||
<form class="plank primary-bg full-width" method="POST">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" autocomplete="username" required>
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" autocomplete="current-password" required>
|
||||
<span><input type="checkbox" name="remember" id="remember"> <label for="remember">Remember me</label></span>
|
||||
<input type="submit" value="Log in">
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{%- endblock -%}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
{% from 'common/macros.html' import babycode_editor_component %}
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}settings{% endblock %}
|
||||
{% block content %}
|
||||
{% set disable_avatar = not is_logged_in() %}
|
||||
<div class='darkbg settings-container'>
|
||||
<h1>User settings</h1>
|
||||
<form class='avatar-form' method='post' action='{{ url_for('users.set_avatar', username=active_user.username) }}' enctype='multipart/form-data'>
|
||||
<span>Set avatar (1mb max)</span>
|
||||
<img src='{{ active_user.get_avatar_url() }}'>
|
||||
<input id='file' type='file' name='avatar' accept='image/*' required>
|
||||
<div>
|
||||
<input type='submit' value='Upload 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>
|
||||
</form>
|
||||
<form method='post'>
|
||||
<label for='topic_sort_by'>Sort threads by:</label>
|
||||
<select id='topic_sort_by' name='topic_sort_by'>
|
||||
<option value='activity' {{ 'selected' if session['sort_by'] == 'activity' else '' }}>Latest activity</option>
|
||||
<option value='thread' {{ 'selected' if session['sort_by'] == 'thread' else '' }}>Thread creation date</option>
|
||||
</select>
|
||||
<label for='status'>Status</label>
|
||||
<input type='text' id='status' name='status' value='{{ active_user.status }}' maxlength=100 placeholder='Will be shown under your name. Max 100 characters.'>
|
||||
<label for='babycode-content'>Signature</label>
|
||||
{{ babycode_editor_component(ta_name='signature', prefill=active_user.signature_original_markup, ta_placeholder='Will be shown under each of your posts', optional=true) }}
|
||||
<input autocomplete='off' type='checkbox' id='subscribe_by_default' name='subscribe_by_default' {{ 'checked' if session.get('subscribe_by_default', default=true) else '' }}>
|
||||
<label for='subscribe_by_default'>Subscribe to thread by default when responding</label><br>
|
||||
<input type='submit' value='Save settings'>
|
||||
</form>
|
||||
<form method='post' action='{{ url_for('users.change_password', username=active_user.username) }}'>
|
||||
<label for="new_password">Change 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>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,17 +1,24 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Sign up{% endblock %}
|
||||
{% block content %}
|
||||
<div class="darkbg login-container">
|
||||
<h1>Sign up</h1>
|
||||
<form method="post">
|
||||
<label for="username">Username</label><br>
|
||||
<input type="text" id="username" name="username" pattern="[a-zA-Z0-9_-]{3,20}" title="3-20 characters. Only upper and lowercase letters, digits, hyphens, and underscores" required autocomplete="username"><br>
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,255}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required autocomplete="new-password"><br>
|
||||
<label for="password-confirm">Confirm Password</label>
|
||||
<input type="password" id="password-confirm" name="password-confirm" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,255}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required autocomplete="new-password"><br>
|
||||
{% from 'common/macros.html' import subheader %}
|
||||
{%- extends 'base.html' -%}
|
||||
{%- block title -%}sign up{%- endblock -%}
|
||||
{%- block content -%}
|
||||
{%- set welcome -%}
|
||||
Please read the rules etc. stub
|
||||
{%- endset -%}
|
||||
{{ subheader('Sign up', welcome)}}
|
||||
{%- if request.args.get('error') -%}
|
||||
<div class="infobox plank critical">
|
||||
{{request.args.get('error')}}
|
||||
</div>
|
||||
{%- endif -%}
|
||||
<form class="plank primary-bg full-width" method="POST">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" pattern="[a-zA-Z0-9_\-]{3,24}" title="3-24 characters. Only upper and lowercase letters, digits, hyphens, and underscores" autocomplete="username" required>
|
||||
<label for="password">Create password</label>
|
||||
<input type="password" id="password" name="password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,255}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" autocomplete="new-password" required>
|
||||
<label for="password2">Confirm password</label>
|
||||
<input type="password" id="password2" name="password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,255}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" autocomplete="new-password" required>
|
||||
<span><input type="checkbox" name="remember" id="remember"> <label for="remember">Remember me</label></span>
|
||||
<input type="submit" value="Sign up">
|
||||
</form>
|
||||
<span>After you sign up, a moderator will need to confirm your account before you will be allowed to post.</span>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{%- endblock -%}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
{% from 'common/macros.html' import timestamp %}
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{{ target_user.username }}'s profile{% endblock %}
|
||||
{% block content %}
|
||||
<div class="darkbg">
|
||||
<h1 class="thread-title"><i>{{ target_user.username }}</i>'s profile</h1>
|
||||
{% if active_user.id == target_user.id %}
|
||||
<div class="user-actions">
|
||||
<a class="linkbutton" href="{{ url_for("users.settings", username = active_user.username) }}">Settings</a>
|
||||
<form method="post" action="{{ url_for("users.log_out") }}">
|
||||
<input class="warn" type="submit" value="Log out">
|
||||
</form>
|
||||
</div>
|
||||
{% if active_user.is_guest() %}
|
||||
<h2>You are a guest. A Moderator needs to approve your account before you will be able to post.</h2>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if active_user and active_user.is_mod() and not target_user.is_system() %}
|
||||
<h1 class="thread-title">Moderation controls</h1>
|
||||
{% if target_user.is_guest() %}
|
||||
<p>This user is a guest. They signed up on {{ timestamp(target_user['created_at']) }}</p>
|
||||
<form class="modform" method="post" action="{{ url_for("users.confirm_user", user_id=target_user.id) }}">
|
||||
<input type="submit" value="Confirm user">
|
||||
</form>
|
||||
{% else %}
|
||||
<p>This user signed up on {{ timestamp(target_user['created_at']) }} and was confirmed on {{ timestamp(target_user['confirmed_on']) }}</p>
|
||||
{% if (target_user.permission | int) < (active_user.permission | int) %}
|
||||
<form class="modform" method="post" action="{{ url_for("users.guest_user", user_id=target_user.id) }}">
|
||||
<input class="warn" type="submit" value="Demote user to guest (soft ban)">
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if active_user.is_admin() and not target_user.is_mod() %}
|
||||
<form class="modform" method="post" action="{{ url_for("users.mod_user", user_id=target_user.id) }}">
|
||||
<input class="warn" type="submit" value="Promote user to moderator">
|
||||
</form>
|
||||
{% elif target_user.is_mod() and (target_user.permission | int) < (active_user.permission | int) %}
|
||||
<form class="modform" method="post" action="{{ url_for("users.demod_user", user_id=target_user.id) }}">
|
||||
<input class="critical" type="submit" value="Demote user to regular user">
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="user-page-usercard">
|
||||
<div class="usercard-inner">
|
||||
<img class="avatar" src="{{ target_user.get_avatar_url() }}">
|
||||
<strong class="big">{{ target_user.username }}</strong>
|
||||
{% if target_user.status %}
|
||||
<em class="user-status">{{ target_user.status }}</em>
|
||||
{% endif %}
|
||||
{% if target_user.signature_rendered %}
|
||||
Signature:
|
||||
<div>{{ target_user.signature_rendered | safe }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-page-stats">
|
||||
{% with stats = target_user.get_post_stats() %}
|
||||
<ul class="user-stats-list">
|
||||
<li>Permission: {{ target_user.permission | permission_string }}</li>
|
||||
<li>Posts created: {{ stats.post_count }}</li>
|
||||
<li>Threads started: {{ stats.thread_count }}</li>
|
||||
{% if stats.latest_thread_title %}
|
||||
<li>Latest started thread: <a href="{{ url_for("threads.thread", slug = stats.latest_thread_slug) }}">{{ stats.latest_thread_title }}</a>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% endwith %}
|
||||
Latest posts:
|
||||
{% with posts = target_user.get_latest_posts() %}
|
||||
<div class="user-page-posts">
|
||||
{% for post in posts %}
|
||||
<div class="post-content-container">
|
||||
<div class="post-info">
|
||||
<a href="{{ url_for("threads.thread", slug=post.thread_slug, after=post.id) }}" title="permalink"><i>
|
||||
{% if (post.edited_at | int) > (post.created_at | int) %}
|
||||
Edited on {{ timestamp(post.edited_at) }}
|
||||
{% else %}
|
||||
Posted on {{ timestamp(post.edited_at) }}
|
||||
{% endif %}
|
||||
</i></a>
|
||||
</div>
|
||||
<div class="post-content wider user-page-post-preview">
|
||||
<div class="post-inner">{{ post.content | safe }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
98
app/templates/users/user_page.html
Normal file
@@ -0,0 +1,98 @@
|
||||
{%- from 'common/macros.html' import subheader, timestamp, pager -%}
|
||||
{%- extends 'base.html' -%}
|
||||
{%- block title -%}{{ target_user.get_readable_name() }}'s profile{%- endblock -%}
|
||||
{%- set stats = target_user.get_post_stats() -%}
|
||||
{%- block content -%}
|
||||
{%- call() subheader("%s's profile" % target_user.get_readable_name()) -%}
|
||||
{%- if is_logged_in() -%}
|
||||
|
||||
{%- if target_user.id == get_active_user().id -%}
|
||||
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||
<legend>Actions</legend>
|
||||
<form action="{{url_for('users.log_out')}}" method="POST">
|
||||
<input type="submit" class="warn" value="Log out">
|
||||
</form>
|
||||
</fieldset>
|
||||
{%- endif -%}
|
||||
|
||||
{%- if get_active_user().is_mod() and target_user.id != get_active_user().id -%}
|
||||
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||
<legend>Moderation actions</legend>
|
||||
<form method="POST">
|
||||
{{csrf_input() | safe}}
|
||||
{%- if target_user.is_guest() -%}
|
||||
<input class="warn" type="submit" value="Approve user" formaction="{{url_for('mod.make_user_regular', user_id=target_user.id)}}">
|
||||
{%- else -%}
|
||||
<input class="warn" type="submit" value="Demote to guest (soft ban)" formaction="{{url_for('mod.make_user_guest', user_id=target_user.id)}}">
|
||||
{%- if get_active_user().is_admin() -%}
|
||||
{%- if not target_user.is_mod_only() -%}
|
||||
<input class="warn" type="submit" value="Promote to moderator" formaction="{{url_for('mod.make_user_mod', user_id=target_user.id)}}">
|
||||
{%- else -%}
|
||||
<input class="warn" type="submit" value="Demote from moderator" formaction="{{url_for('mod.make_user_regular', user_id=target_user.id)}}">
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
</form>
|
||||
</fieldset>
|
||||
{%- endif -%}
|
||||
|
||||
{%- endif -%}
|
||||
{%- endcall -%}
|
||||
<div class="userpage-usercard">
|
||||
<div class="usercard plank even contrast-bg minimal no-shadow">
|
||||
<div class="usercard-inner">
|
||||
<img src="{{target_user.get_avatar_url()}}" class="avatar">
|
||||
</div>
|
||||
</div>
|
||||
<div class="plank even minimal no-shadow user-stats">
|
||||
<h3 class="info">{{target_user.get_readable_name()}}</h3>
|
||||
<span>Display name: {{target_user.get_readable_name()}}</span>
|
||||
<span>Mention: @{{target_user.username}}</span>
|
||||
<span>Status: <em>{{target_user.status}}</em></span>
|
||||
<span>Rank: {{target_user.permission | permission_string}}</span>
|
||||
{%- set time = target_user.created_at -%}
|
||||
{%- if target_user.approved_at -%}
|
||||
{%- set time = target_user.approved_at -%}
|
||||
{%- endif -%}
|
||||
<span>Joined: {{timestamp(target_user.created_at)}}</span>
|
||||
{%- if not target_user.is_guest() -%}
|
||||
<span>Posts: <a href="{{url_for('users.posts', username=target_user.username)}}">{{stats.post_count}}</a></span>
|
||||
<span>Threads started: <a href="{{url_for('users.threads', username=target_user.username)}}">{{stats.thread_count}}</a></span>
|
||||
{%- set badges = target_user.get_badges() -%}
|
||||
|
||||
{%- if badges -%}
|
||||
<div class="badges-container nocenter">
|
||||
Badges:
|
||||
{%- for badge in badges -%}
|
||||
{%- if badge.link -%}<a href="{{badge.link}}">{%- endif -%}
|
||||
<img src="{{badge.get_image_url()}}" alt="{{badge.label}}" title="{{badge.label}}" class="badge-button">
|
||||
{%- if badge.link -%}</a>{%- endif -%}
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
{%- endif -%}
|
||||
<fieldset class="plank secondary-bg minimal even no-shadow">
|
||||
<legend>About me</legend>
|
||||
<p>stub</p>
|
||||
</fieldset>
|
||||
{%- if target_user.signature_rendered -%}
|
||||
<fieldset class="plank secondary-bg minimal even no-shadow">
|
||||
<legend>Signature</legend>
|
||||
{{target_user.signature_rendered | safe}}
|
||||
</fieldset>
|
||||
{%- endif -%}
|
||||
{#
|
||||
<fieldset class="plank secondary-bg minimal even no-shadow">
|
||||
<legend>Profile comments</legend>
|
||||
<fieldset class="plank minimal even no-shadow">
|
||||
<legend>Page</legend>
|
||||
{{pager(0, 3, url=url_for('users.log_in'))}}
|
||||
</fieldset>
|
||||
<div class="post plank">
|
||||
<p>stub</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
#}
|
||||
{%- endif -%}
|
||||
</div>
|
||||
</div>
|
||||
{%- endblock -%}
|
||||
26
app/util.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from flask import url_for, session
|
||||
from .models import Posts, Threads
|
||||
from .auth import is_logged_in
|
||||
|
||||
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})
|
||||
|
||||
anchor = None if not _anchor else f'post-{post_id}'
|
||||
|
||||
return url_for('threads.thread_by_id', thread_id=thread.id, after=post_id, _external=external, _anchor=anchor)
|
||||
|
||||
def dict_to_query_string(d) -> str:
|
||||
return '?' + '&'.join([f'{key}={str(value)}' for key, value in d.items()])
|
||||
|
||||
def get_csrf_token():
|
||||
if not is_logged_in():
|
||||
return ''
|
||||
|
||||
return session.get('csrf', '')
|
||||
|
||||
def csrf_input():
|
||||
return f'<input type="hidden" name="csrf" value="{get_csrf_token()}">'
|
||||
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 |
223
data/static/css/normalize.css
vendored
Normal file
@@ -0,0 +1,223 @@
|
||||
/*! modern-normalize v3.0.1 | MIT License | https://github.com/sindresorhus/modern-normalize */
|
||||
|
||||
/*
|
||||
* Document
|
||||
* ========
|
||||
*/
|
||||
|
||||
/**
|
||||
* Use a better box model (opinionated).
|
||||
*/
|
||||
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3)
|
||||
* 2. Correct the line height in all browsers.
|
||||
* 3. Prevent adjustments of font size after orientation changes in iOS.
|
||||
* 4. Use a more readable tab size (opinionated).
|
||||
*/
|
||||
|
||||
html {
|
||||
font-family:
|
||||
system-ui,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Helvetica,
|
||||
Arial,
|
||||
sans-serif,
|
||||
'Apple Color Emoji',
|
||||
'Segoe UI Emoji'; /* 1 */
|
||||
line-height: 1.15; /* 2 */
|
||||
-webkit-text-size-adjust: 100%; /* 3 */
|
||||
tab-size: 4; /* 4 */
|
||||
}
|
||||
|
||||
/*
|
||||
* Sections
|
||||
* ========
|
||||
*/
|
||||
|
||||
/**
|
||||
* Remove the margin in all browsers.
|
||||
*/
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Text-level semantics
|
||||
* ====================
|
||||
*/
|
||||
|
||||
/**
|
||||
* Add the correct font weight in Chrome and Safari.
|
||||
*/
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3)
|
||||
* 2. Correct the odd 'em' font sizing in all browsers.
|
||||
*/
|
||||
|
||||
code,
|
||||
kbd,
|
||||
samp,
|
||||
pre {
|
||||
font-family:
|
||||
ui-monospace,
|
||||
SFMono-Regular,
|
||||
Consolas,
|
||||
'Liberation Mono',
|
||||
Menlo,
|
||||
monospace; /* 1 */
|
||||
font-size: 1em; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct font size in all browsers.
|
||||
*/
|
||||
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent 'sub' and 'sup' elements from affecting the line height in all browsers.
|
||||
*/
|
||||
|
||||
sub,
|
||||
sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
/*
|
||||
* Tabular data
|
||||
* ============
|
||||
*/
|
||||
|
||||
/**
|
||||
* Correct table border color inheritance in Chrome and Safari. (https://issues.chromium.org/issues/40615503, https://bugs.webkit.org/show_bug.cgi?id=195016)
|
||||
*/
|
||||
|
||||
table {
|
||||
border-color: currentcolor;
|
||||
}
|
||||
|
||||
/*
|
||||
* Forms
|
||||
* =====
|
||||
*/
|
||||
|
||||
/**
|
||||
* 1. Change the font styles in all browsers.
|
||||
* 2. Remove the margin in Firefox and Safari.
|
||||
*/
|
||||
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit; /* 1 */
|
||||
font-size: 100%; /* 1 */
|
||||
line-height: normal; /* 1 */
|
||||
margin: 0; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the inability to style clickable types in iOS and Safari.
|
||||
*/
|
||||
|
||||
button,
|
||||
[type='button'],
|
||||
[type='reset'],
|
||||
[type='submit'] {
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the padding so developers are not caught out when they zero out 'fieldset' elements in all browsers.
|
||||
*/
|
||||
|
||||
legend {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct vertical alignment in Chrome and Firefox.
|
||||
*/
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the cursor style of increment and decrement buttons in Safari.
|
||||
*/
|
||||
|
||||
::-webkit-inner-spin-button,
|
||||
::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the odd appearance in Chrome and Safari.
|
||||
* 2. Correct the outline style in Safari.
|
||||
*/
|
||||
|
||||
[type='search'] {
|
||||
-webkit-appearance: textfield; /* 1 */
|
||||
outline-offset: -2px; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inner padding in Chrome and Safari on macOS.
|
||||
*/
|
||||
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the inability to style clickable types in iOS and Safari.
|
||||
* 2. Change font properties to 'inherit' in Safari.
|
||||
*/
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
-webkit-appearance: button; /* 1 */
|
||||
font: inherit; /* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
* Interactive
|
||||
* ===========
|
||||
*/
|
||||
|
||||
/*
|
||||
* Add the correct display in Chrome and Safari.
|
||||
*/
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
774
data/static/css/style.css
Normal file
@@ -0,0 +1,774 @@
|
||||
@import url("/static/css/normalize.css");
|
||||
|
||||
@font-face {
|
||||
font-family: "Cadman";
|
||||
src: url("/static/fonts/Cadman_Roman.woff2");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Cadman";
|
||||
src: url("/static/fonts/Cadman_Bold.woff2");;
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Cadman";
|
||||
src: url("/static/fonts/Cadman_Italic.woff2");
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Cadman";
|
||||
src: url("/static/fonts/Cadman_BoldItalic.woff2");
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Atkinson Hyperlegible Mono";
|
||||
src: url("/static/fonts/AtkinsonHyperlegibleMono-VariableFont_wght.ttf");
|
||||
font-weight: 125 950;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Atkinson Hyperlegible Mono";
|
||||
src: url("/static/fonts/AtkinsonHyperlegibleMono-Italic-VariableFont_wght.ttf");
|
||||
font-weight: 125 950;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "site-title";
|
||||
src: url("/static/fonts/ChicagoFLF.woff2");
|
||||
}
|
||||
|
||||
:root {
|
||||
--base-padding: 6px;
|
||||
--border-radius: 3px;
|
||||
--border-thickness: 1px;
|
||||
--wrapper-side-margin: 36px;
|
||||
|
||||
/* colors */
|
||||
--bg-color-primary: #c1ceb1;
|
||||
--bg-color-secondary: #aeb8a1;
|
||||
--bg-color-tertiary: #797976;
|
||||
--bg-color-contrast: #bfb1ce;
|
||||
|
||||
--font-color-main: black;
|
||||
--font-color-anti: white;
|
||||
--font-color-link: #c11c1c;
|
||||
--font-color-link-visited: hsl(from var(--font-color-link) h calc(s * 0.5) calc(l * 0.7));
|
||||
|
||||
--critical-color: #f73030;
|
||||
--warn-color: #dfdf61;
|
||||
--infobox-color: #97b3ec;
|
||||
|
||||
--button-color-primary: #b1cecd;
|
||||
}
|
||||
|
||||
body {
|
||||
--small-padding: calc(var(--base-padding) / 2);
|
||||
--medium-padding: calc(var(--base-padding) * 2);
|
||||
--big-padding: calc(var(--base-padding) * 3);
|
||||
--huge-padding: calc(var(--base-padding) * 4);
|
||||
|
||||
--code-bg-color: hsl(from var(--bg-color-primary) h calc(s * 0.2) calc(l * 0.2));
|
||||
|
||||
background-color: var(--bg-color-tertiary);
|
||||
font-family: Cadman;
|
||||
color: var(--font-color-main);
|
||||
margin: var(--big-padding) var(--wrapper-side-margin);
|
||||
}
|
||||
|
||||
button, .linkbutton, input[type="submit"] {
|
||||
--main-color: var(--button-color-primary);
|
||||
--font-color: var(--font-color-main);
|
||||
--border-color: hsl(from var(--main-color) h calc(s * 1.3) 25);
|
||||
--hover-color: hsl(from var(--main-color) h s calc(l * 1.05));
|
||||
--active-color: hsl(from var(--main-color) h s calc(l * 0.8));
|
||||
--disabled-color: hsl(from var(--main-color) h calc(s * 0.5) l);
|
||||
--bottom-color: hsl(from var(--main-color) h s calc(l * 0.7));
|
||||
--top-color: hsl(from var(--main-color) h s calc(l * 1.2));
|
||||
--top-color2: hsl(from var(--main-color) h s calc(l * 1.1));
|
||||
--inset-color: #fff7;
|
||||
/* position: relative; */
|
||||
/* display: inline-block; */
|
||||
padding: var(--small-padding) var(--big-padding);
|
||||
margin: var(--base-padding) 0px;
|
||||
border-radius: var(--border-radius);
|
||||
border: solid var(--border-thickness) var(--border-color);
|
||||
background: linear-gradient(var(--top-color) 0%, var(--top-color2) 25%, var(--main-color) 26%, var(--main-color) 50%, var(--bottom-color) 100%);
|
||||
box-shadow: inset 0px 2px 5px 3px var(--inset-color);
|
||||
color: var(--font-color);
|
||||
text-decoration: none;
|
||||
user-select: none;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
line-height: normal;
|
||||
display: inline flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.minimal {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&.critical {
|
||||
--main-color: var(--critical-color);
|
||||
--font-color: var(--font-color-anti);
|
||||
}
|
||||
|
||||
&.warn {
|
||||
--main-color: var(--warn-color);
|
||||
}
|
||||
|
||||
&.rss {
|
||||
--main-color: #fba668;
|
||||
}
|
||||
|
||||
&.alt {
|
||||
--main-color: var(--bg-color-contrast);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(var(--top-color) 0%, var(--top-color2) 25%, var(--hover-color) 26%, var(--hover-color) 80%, var(--bottom-color) 100%);
|
||||
}
|
||||
|
||||
&:is(:active, .active, [aria-selected='true']) {
|
||||
background: linear-gradient(var(--active-color) 0%, var(--active-color) 50%, var(--main-color) 100%);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: var(--disabled-color);
|
||||
--inset-color: #fff3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
|
||||
&[aria-selected='true'] {
|
||||
padding-top: calc(var(--base-padding) * 1.5);
|
||||
}
|
||||
}
|
||||
|
||||
.tab-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: var(--base-padding);
|
||||
|
||||
&> * {
|
||||
margin-top: auto;
|
||||
position: relative;
|
||||
bottom: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.babycode-editor {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.post-edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
input[type="text"], input[type="password"], textarea, select {
|
||||
--main-color: hsl(from var(--bg-color-primary) h s calc(l + 10));
|
||||
--active-color: hsl(from var(--main-color) h s calc(l + 5));
|
||||
--border-color: hsl(from var(--main-color) h calc(s * 1.3) 25);
|
||||
background-color: var(--main-color);
|
||||
border-radius: var(--border-radius);
|
||||
border: solid var(--border-thickness) var(--border-color);
|
||||
resize: vertical;
|
||||
|
||||
padding: var(--small-padding) var(--medium-padding);
|
||||
margin: var(--base-padding) 0px;
|
||||
|
||||
&:focus {
|
||||
background-color: var(--active-color);
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
font-family: 'Atkinson Hyperlegible Mono'
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:where(a:link) {
|
||||
color: var(--font-color-link);
|
||||
}
|
||||
:where(a:visited) {
|
||||
color: var(--font-color-link-visited);
|
||||
}
|
||||
|
||||
a.site-title {
|
||||
font-family: site-title;
|
||||
font-size: 3em;
|
||||
text-decoration: none;
|
||||
color: var(--font-color-main);
|
||||
}
|
||||
|
||||
#header {
|
||||
background-color: var(--bg-color-primary);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&>.site-title {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
&>ul {
|
||||
margin-top: var(--base-padding);
|
||||
margin-bottom: var(--base-padding);
|
||||
}
|
||||
}
|
||||
|
||||
.plank {
|
||||
--main-color: var(--bg-color-primary);
|
||||
--lighter-color: hsl(from var(--main-color) h s calc(l*1.1));
|
||||
--darker-color: hsl(from var(--main-color) h s calc(l*0.9));
|
||||
--border-color: hsl(from var(--main-color) h s 90);
|
||||
--rotation: 180deg;
|
||||
padding: var(--medium-padding) var(--huge-padding);
|
||||
|
||||
background: linear-gradient(var(--rotation), var(--lighter-color) 0%, var(--main-color) 30%, var(--main-color) 70%, var(--darker-color) 100%);
|
||||
background-color: var(--main-color);
|
||||
|
||||
border: 2px groove var(--border-color);
|
||||
|
||||
&:not(.no-shadow) {
|
||||
box-shadow: 0px 6px 3px 0px #0004;
|
||||
}
|
||||
|
||||
&.minimal {
|
||||
padding: var(--small-padding) var(--big-padding);
|
||||
}
|
||||
|
||||
&:not(.even){
|
||||
margin-bottom: var(--small-padding);
|
||||
}
|
||||
|
||||
&.top {
|
||||
border-top-left-radius: var(--border-radius);
|
||||
border-top-right-radius: var(--border-radius);
|
||||
|
||||
&:not(.even){
|
||||
margin-bottom: var(--medium-padding);
|
||||
}
|
||||
}
|
||||
|
||||
&.bottom {
|
||||
border-bottom-left-radius: var(--border-radius);
|
||||
border-bottom-right-radius: var(--border-radius);
|
||||
|
||||
&:not(.even){
|
||||
margin-top: var(--medium-padding);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
margin: 0;
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
form.horizontal {
|
||||
display: flex;
|
||||
gap: var(--base-padding);
|
||||
align-items: center;
|
||||
|
||||
&.wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&> fieldset {
|
||||
display: flex;
|
||||
gap: var(--base-padding);
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border-radius: var(--border-radius);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
legend {
|
||||
padding: var(--small-padding);
|
||||
border-radius: var(--border-radius);
|
||||
border: 2px groove var(--border-color);
|
||||
margin-top: var(--small-padding);
|
||||
|
||||
.plank:not(.secondary-bg) > & {
|
||||
background-color: var(--bg-color-secondary);
|
||||
}
|
||||
.plank.secondary-bg > & {
|
||||
background-color: var(--bg-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
ul.horizontal, ol.horizontal {
|
||||
display: inline flex;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
gap: var(--base-padding);
|
||||
|
||||
& li:not(.visible) {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
& li.visible {
|
||||
margin-left: var(--big-padding);
|
||||
}
|
||||
|
||||
&.wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&.bullet li::before {
|
||||
content: '\2022';
|
||||
}
|
||||
|
||||
& li > button, li > .linkbutton {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.primary-bg {
|
||||
--main-color: var(--bg-color-primary);
|
||||
background-color: var(--bg-color-primary);
|
||||
}
|
||||
|
||||
.secondary-bg {
|
||||
--main-color: var(--bg-color-secondary);
|
||||
--rotation: 0deg;
|
||||
}
|
||||
|
||||
.tertiary-bg {
|
||||
--main-color: var(--bg-color-tertiary);
|
||||
--rotation: 0deg;
|
||||
}
|
||||
|
||||
.contrast-bg {
|
||||
--main-color: var(--bg-color-contrast);
|
||||
}
|
||||
|
||||
.motd {
|
||||
display: flex;
|
||||
gap: var(--base-padding);
|
||||
}
|
||||
|
||||
.contain-svg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
|
||||
&.horizontal {
|
||||
flex-direction: row;
|
||||
gap: var(--base-padding);
|
||||
}
|
||||
}
|
||||
|
||||
.infobox {
|
||||
--main-color: var(--infobox-color);
|
||||
justify-content: start;
|
||||
|
||||
&.critical {
|
||||
--main-color: hsl(from var(--critical-color) h 50% calc(l * 0.7));
|
||||
color: var(--font-color-anti);
|
||||
}
|
||||
|
||||
&.warn {
|
||||
--main-color: hsl(from var(--warn-color) h 50% calc(l * 1.2));
|
||||
}
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: flex;
|
||||
gap: var(--base-padding);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
gap: var(--base-padding);
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&> * {
|
||||
aspect-ratio: 1;
|
||||
min-height: 32px;
|
||||
width: auto;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.title-container {
|
||||
display: flex;
|
||||
gap: var(--base-padding);
|
||||
justify-content: start;
|
||||
align-items: end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.motd-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 75%
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.topic-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--base-padding);
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
gap: var(--base-padding);
|
||||
--grid-item-base-width: 600px;
|
||||
--grid-item-max-width: calc((100% - var(--grid-item-base-width)) / 2);
|
||||
grid-template-columns: repeat(auto-fill, minmax(max(var(--grid-item-base-width), var(--grid-item-max-width)), 1fr));
|
||||
|
||||
&> * {
|
||||
height: fit-content;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.thread-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--base-padding);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.actions-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.flex-last {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.flex-grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.post {
|
||||
padding: var(--base-padding);
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: min(230px, 20vw) 1fr;
|
||||
}
|
||||
|
||||
.userpage-usercard {
|
||||
display: grid;
|
||||
grid-template-columns: min(300px, 30vw) 1fr;
|
||||
}
|
||||
|
||||
.usercard-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
top: var(--big-padding);
|
||||
position: sticky;
|
||||
gap: var(--base-padding);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.post-content {
|
||||
display: grid;
|
||||
grid-template-rows: min-content 1fr min-content;
|
||||
&> * {
|
||||
min-width: 0;
|
||||
min-height: 54px;
|
||||
}
|
||||
}
|
||||
|
||||
.post-signature {
|
||||
margin-top: auto;
|
||||
border-top: 2px dotted gray;
|
||||
}
|
||||
|
||||
.post-content-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.badge-button {
|
||||
min-width: 88px;
|
||||
min-height: 31px;
|
||||
max-width: 88px;
|
||||
max-height: 31px;
|
||||
}
|
||||
|
||||
.badges-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--small-padding);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
&.nocenter {
|
||||
justify-content: start;
|
||||
}
|
||||
}
|
||||
|
||||
.usercard-rest {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: inherit;
|
||||
}
|
||||
|
||||
.post-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--base-padding);
|
||||
}
|
||||
|
||||
#new-post-toast {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
right: 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--big-padding);
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--base-padding);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.babycode-editor-controls {
|
||||
display: flex;
|
||||
gap: var(--base-padding);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
form.full-width {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
&> textarea, &> select, &> input[type="text"], &> input[type="password"] {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* babycode tags */
|
||||
.inline-code {
|
||||
background-color: var(--code-bg-color);
|
||||
color: var(--font-color-anti);
|
||||
padding: var(--base-padding);
|
||||
border-radius: var(--border-radius);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.babycode-big {
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.babycode-small {
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
.babycode-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.babycode-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.post-image {
|
||||
max-height: 400px;
|
||||
max-width: 400px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.code-block-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
|
||||
&> button {
|
||||
align-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
code, kbd {
|
||||
font-family: "Atkinson Hyperlegible Mono";
|
||||
}
|
||||
|
||||
pre {
|
||||
font-family: unset;
|
||||
margin: 0;
|
||||
margin-bottom: var(--base-padding);
|
||||
}
|
||||
|
||||
pre code {
|
||||
display: block;
|
||||
background-color: var(--code-bg-color);
|
||||
color: var(--font-color-anti);
|
||||
padding: var(--base-padding);
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin: 0.5em 0;
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
.emoji {
|
||||
max-width: 15px;
|
||||
max-height: 15px;
|
||||
}
|
||||
|
||||
a.mention {
|
||||
--mention-color: var(--bg-color-contrast);
|
||||
--hover-color: hsl(from var(--mention-color) h calc(s * 0.7) calc(l * 1.1));
|
||||
|
||||
display: inline-block;
|
||||
border-radius: var(--border-radius);
|
||||
padding: var(--base-padding);
|
||||
background-color: var(--mention-color);
|
||||
color: black;
|
||||
border: 1px dashed;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--hover-color);
|
||||
}
|
||||
|
||||
&.me {
|
||||
--mention-color: hsl(from var(--bg-color-contrast) calc(h + 90) s l);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
--grid-item-base-width: 400px;
|
||||
}
|
||||
|
||||
.mobile-fill-flex {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.post, .userpage-usercard {
|
||||
grid-template-columns: unset;
|
||||
grid-template-rows: min-content 1fr;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
max-width: 180px;
|
||||
max-height: 180px;
|
||||
min-width: 140px;
|
||||
min-height: 140px;
|
||||
}
|
||||
|
||||
.usercard-inner {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.thread-title-counter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--base-padding);
|
||||
}
|
||||
|
||||
.title-container {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
#new-post-toast {
|
||||
right: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.post-image {
|
||||
max-width: min(75vw, 400px);
|
||||
max-height: 50vh;
|
||||
}
|
||||
}
|
||||
BIN
data/static/fonts/AtkinsonHyperlegibleMono-VariableFont_wght.ttf
Normal file
@@ -1,172 +0,0 @@
|
||||
{
|
||||
let ta = document.getElementById("babycode-content");
|
||||
|
||||
ta.addEventListener("keydown", (e) => {
|
||||
if(e.key === "Enter" && e.ctrlKey) {
|
||||
// console.log(e.target.form)
|
||||
if (inThread()) {
|
||||
localStorage.removeItem(window.location.pathname);
|
||||
}
|
||||
e.target.form?.submit();
|
||||
}
|
||||
})
|
||||
|
||||
const inThread = () => {
|
||||
const scheme = window.location.pathname.split("/");
|
||||
return scheme[1] === "threads" && scheme[2] !== "create";
|
||||
}
|
||||
|
||||
ta.addEventListener("input", () => {
|
||||
if (!inThread()) return;
|
||||
|
||||
localStorage.setItem(window.location.pathname, ta.value);
|
||||
})
|
||||
|
||||
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);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
if (!inThread()) return;
|
||||
const prevContent = localStorage.getItem(window.location.pathname);
|
||||
if (!prevContent) return;
|
||||
ta.value = prevContent;
|
||||
})
|
||||
|
||||
const buttonBold = document.getElementById("post-editor-bold");
|
||||
const buttonItalics = document.getElementById("post-editor-italics");
|
||||
const buttonStrike = document.getElementById("post-editor-strike");
|
||||
const buttonUrl = document.getElementById("post-editor-url");
|
||||
const buttonCode = document.getElementById("post-editor-code");
|
||||
const buttonImg = document.getElementById("post-editor-img");
|
||||
const buttonOl = document.getElementById("post-editor-ol");
|
||||
const buttonUl = document.getElementById("post-editor-ul");
|
||||
|
||||
function insertTag(tagStart, newline = false, prefill = "") {
|
||||
const hasAttr = tagStart[tagStart.length - 1] === "=";
|
||||
let tagEnd = tagStart;
|
||||
let tagInsertStart = `[${tagStart}]${newline ? "\n" : ""}`;
|
||||
if (hasAttr) {
|
||||
tagEnd = tagEnd.slice(0, -1);
|
||||
}
|
||||
const tagInsertEnd = `${newline ? "\n" : ""}[/${tagEnd}]`;
|
||||
const hasSelection = ta.selectionStart !== ta.selectionEnd;
|
||||
const text = ta.value;
|
||||
if (hasSelection) {
|
||||
const realStart = Math.min(ta.selectionStart, ta.selectionEnd);
|
||||
const realEnd = Math.max(ta.selectionStart, ta.selectionEnd);
|
||||
const selectionLength = realEnd - realStart;
|
||||
|
||||
const strStart = text.slice(0, realStart);
|
||||
const strEnd = text.substring(realEnd);
|
||||
const frag = `${tagInsertStart}${text.slice(realStart, realEnd)}${tagInsertEnd}`;
|
||||
const reconst = `${strStart}${frag}${strEnd}`;
|
||||
ta.value = reconst;
|
||||
if (!hasAttr){
|
||||
ta.setSelectionRange(realStart + tagInsertStart.length, realStart + tagInsertStart.length + selectionLength);
|
||||
} else {
|
||||
ta.setSelectionRange(realStart + tagInsertEnd.length - 1, realStart + tagInsertEnd.length - 1); // cursor on attr
|
||||
}
|
||||
ta.focus()
|
||||
} else {
|
||||
if (hasAttr) {
|
||||
tagInsertStart += prefill;
|
||||
}
|
||||
const cursor = ta.selectionStart;
|
||||
const strStart = text.slice(0, cursor);
|
||||
const strEnd = text.substr(cursor);
|
||||
|
||||
let newCursor = strStart.length + tagInsertStart.length;
|
||||
if (hasAttr) {
|
||||
newCursor = cursor + tagInsertStart.length - prefill.length - 1;
|
||||
}
|
||||
const reconst = `${strStart}${tagInsertStart}${tagInsertEnd}${strEnd}`;
|
||||
ta.value = reconst;
|
||||
ta.setSelectionRange(newCursor, newCursor);
|
||||
ta.focus()
|
||||
}
|
||||
}
|
||||
|
||||
buttonBold.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
insertTag("b")
|
||||
})
|
||||
buttonItalics.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
insertTag("i")
|
||||
})
|
||||
buttonStrike.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
insertTag("s")
|
||||
})
|
||||
buttonUrl.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
insertTag("url=", false, "link label");
|
||||
})
|
||||
buttonCode.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
insertTag("code", true)
|
||||
})
|
||||
buttonImg.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
insertTag("img=", false, "alt text");
|
||||
})
|
||||
buttonOl.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
insertTag("ol", true);
|
||||
})
|
||||
buttonUl.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
insertTag("ul", true);
|
||||
})
|
||||
|
||||
const previewEndpoint = "/api/babycode-preview";
|
||||
let previousMarkup = "";
|
||||
const previewTab = document.getElementById("tab-preview");
|
||||
previewTab.addEventListener("tab-activated", async () => {
|
||||
const previewContainer = document.getElementById("babycode-preview-container");
|
||||
const previewErrorsContainer = document.getElementById("babycode-preview-errors-container");
|
||||
// previewErrorsContainer.textContent = "";
|
||||
const markup = ta.value.trim();
|
||||
if (markup === "" || markup === previousMarkup) {
|
||||
return;
|
||||
}
|
||||
previousMarkup = markup;
|
||||
const req = await fetch(previewEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({markup: markup})
|
||||
})
|
||||
if (!req.ok) {
|
||||
switch (req.status) {
|
||||
case 429:
|
||||
previewErrorsContainer.textContent = "(Old preview, try again in a few seconds.)"
|
||||
previousMarkup = "";
|
||||
break;
|
||||
case 400:
|
||||
previewErrorsContainer.textContent = "(Request got malformed.)"
|
||||
break;
|
||||
case 401:
|
||||
previewErrorsContainer.textContent = "(You are not logged in.)"
|
||||
break;
|
||||
default:
|
||||
previewErrorsContainer.textContent = "(Error. Check console.)"
|
||||
console.error(req.error);
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const json_resp = await req.json();
|
||||
previewContainer.innerHTML = json_resp.html;
|
||||
previewErrorsContainer.textContent = "";
|
||||
});
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const timestampSpans = document.getElementsByClassName("timestamp");
|
||||
for (let timestampSpan of timestampSpans) {
|
||||
const timestamp = parseInt(timestampSpan.dataset.utc);
|
||||
if (!isNaN(timestamp)) {
|
||||
const date = new Date(timestamp * 1000);
|
||||
timestampSpan.textContent = date.toLocaleString();
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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")
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
{
|
||||
const ta = document.getElementById("babycode-content");
|
||||
|
||||
for (let button of document.querySelectorAll(".reply-button")) {
|
||||
button.addEventListener("click", (e) => {
|
||||
ta.value += button.value;
|
||||
ta.scrollIntoView()
|
||||
ta.focus();
|
||||
})
|
||||
}
|
||||
|
||||
function supportsPopover() {
|
||||
return Object.hasOwn(HTMLElement.prototype, "popover");
|
||||
}
|
||||
|
||||
if (supportsPopover()){
|
||||
let quotedPostContainer = null;
|
||||
function isQuoteSelectionValid() {
|
||||
const selection = document.getSelection();
|
||||
|
||||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const commonAncestor = range.commonAncestorContainer;
|
||||
|
||||
const ancestorElement = commonAncestor.nodeType === Node.TEXT_NODE
|
||||
? commonAncestor.parentNode
|
||||
: commonAncestor;
|
||||
|
||||
const container = ancestorElement.closest(".post-inner");
|
||||
if (!container) {
|
||||
return false;
|
||||
}
|
||||
const success = container.contains(ancestorElement);
|
||||
if (success) {
|
||||
quotedPostContainer = container;
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
let quotePopover = null;
|
||||
let isSelecting = false;
|
||||
|
||||
document.addEventListener("mousedown", () => {
|
||||
isSelecting = true;
|
||||
})
|
||||
|
||||
document.addEventListener("mouseup", () => {
|
||||
isSelecting = false;
|
||||
handlePossibleSelection();
|
||||
})
|
||||
|
||||
document.addEventListener("keyup", (e) => {
|
||||
if (e.shiftKey && (e.key.startsWith('Arrow') || e.key === 'Home' || e.key === 'End')) {
|
||||
handlePossibleSelection();
|
||||
}
|
||||
})
|
||||
|
||||
function handlePossibleSelection() {
|
||||
setTimeout(() => {
|
||||
const valid = isQuoteSelectionValid();
|
||||
if (isSelecting || !valid) {
|
||||
removePopover();
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = document.getSelection();
|
||||
const selectionStr = selection.toString().trim();
|
||||
if (selection.isCollapsed || selectionStr === "") {
|
||||
removePopover();
|
||||
return;
|
||||
}
|
||||
|
||||
showPopover();
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function removePopover() {
|
||||
quotePopover?.hidePopover();
|
||||
}
|
||||
|
||||
function createPopover() {
|
||||
quotePopover = document.createElement("div");
|
||||
quotePopover.popover = "auto";
|
||||
quotePopover.className = "quote-popover";
|
||||
|
||||
const quoteButton = document.createElement("button");
|
||||
quoteButton.textContent = "Quote fragment"
|
||||
quoteButton.className = "reduced"
|
||||
quotePopover.appendChild(quoteButton);
|
||||
|
||||
document.body.appendChild(quotePopover);
|
||||
return quoteButton;
|
||||
}
|
||||
|
||||
function showPopover() {
|
||||
if (!quotePopover) {
|
||||
const quoteButton = createPopover();
|
||||
quoteButton.addEventListener("click", () => {
|
||||
console.log("Quoting:", document.getSelection().toString());
|
||||
const postPermalink = quotedPostContainer.dataset.postPermalink;
|
||||
const authorUsername = quotedPostContainer.dataset.authorUsername;
|
||||
console.log(postPermalink, authorUsername);
|
||||
if (ta.value.trim() !== "") {
|
||||
ta.value += "\n"
|
||||
}
|
||||
ta.value += `[url=${postPermalink}]${authorUsername} said:[/url]\n[quote]< :scissors: > ${document.getSelection().toString()} < :scissors: >[/quote]\n`;
|
||||
ta.scrollIntoView()
|
||||
ta.focus();
|
||||
|
||||
document.getSelection().empty();
|
||||
removePopover();
|
||||
})
|
||||
}
|
||||
|
||||
const range = document.getSelection().getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
const scrollY = window.scrollY || window.pageYOffset;
|
||||
|
||||
quotePopover.style.setProperty("top", `${rect.top + scrollY - 55}px`)
|
||||
quotePopover.style.setProperty("left", `${rect.left + rect.width/2}px`)
|
||||
|
||||
if (!quotePopover.matches(':popover-open')) {
|
||||
quotePopover.showPopover();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deleteDialog = document.getElementById("delete-dialog");
|
||||
const deleteDialogCloseButton = document.getElementById("post-delete-dialog-close");
|
||||
let deletionTargetPostContainer;
|
||||
|
||||
function closeDeleteDialog() {
|
||||
deletionTargetPostContainer.style.removeProperty("background-color");
|
||||
deleteDialog.close();
|
||||
}
|
||||
|
||||
deleteDialogCloseButton.addEventListener("click", (e) => {
|
||||
closeDeleteDialog();
|
||||
})
|
||||
deleteDialog.addEventListener("click", (e) => {
|
||||
if (e.target === deleteDialog) {
|
||||
closeDeleteDialog();
|
||||
}
|
||||
})
|
||||
for (let button of document.querySelectorAll(".post-delete-button")) {
|
||||
button.addEventListener("click", (e) => {
|
||||
deleteDialog.showModal();
|
||||
const postId = button.value;
|
||||
deletionTargetPostContainer = document.getElementById("post-" + postId).querySelector(".post-content-container");
|
||||
deletionTargetPostContainer.style.setProperty("background-color", "#fff");
|
||||
const form = document.getElementById("post-delete-form");
|
||||
form.action = `/post/${postId}/delete`
|
||||
})
|
||||
}
|
||||
|
||||
const threadEndpoint = document.getElementById("thread-subscribe-endpoint").value;
|
||||
let now = Math.floor(new Date() / 1000);
|
||||
function hideNotification() {
|
||||
const notification = document.getElementById('new-post-notification');
|
||||
notification.classList.add('hidden');
|
||||
}
|
||||
|
||||
function showNewPostNotification(url) {
|
||||
const notification = document.getElementById("new-post-notification");
|
||||
|
||||
notification.classList.remove("hidden");
|
||||
|
||||
document.getElementById("dismiss-new-post-button").onclick = () => {
|
||||
now = Math.floor(new Date() / 1000);
|
||||
hideNotification();
|
||||
tryFetchUpdate();
|
||||
}
|
||||
|
||||
document.getElementById("go-to-new-post-button").href = url;
|
||||
|
||||
document.getElementById("unsub-new-post-button").onclick = () => {
|
||||
hideNotification();
|
||||
}
|
||||
}
|
||||
|
||||
function tryFetchUpdate() {
|
||||
if (!threadEndpoint) return;
|
||||
const body = JSON.stringify({'since': now});
|
||||
fetch(threadEndpoint, {method: "POST", headers: {"Content-Type": "application/json"}, body: body})
|
||||
.then(res => res.json())
|
||||
.then(json => {
|
||||
if (json.status === "none") {
|
||||
setTimeout(tryFetchUpdate, 5000);
|
||||
} else if (json.status === "new_post") {
|
||||
showNewPostNotification(json.url);
|
||||
}
|
||||
})
|
||||
.catch(error => console.log(error))
|
||||
}
|
||||
tryFetchUpdate();
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
const deleteDialog = document.getElementById("delete-dialog");
|
||||
const deleteDialogOpenButton = document.getElementById("topic-delete-dialog-open");
|
||||
deleteDialogOpenButton.addEventListener("click", (e) => {
|
||||
deleteDialog.showModal();
|
||||
});
|
||||
const deleteDialogCloseButton = document.getElementById("topic-delete-dialog-close");
|
||||
deleteDialogCloseButton.addEventListener("click", (e) => {
|
||||
deleteDialog.close();
|
||||
})
|
||||
deleteDialog.addEventListener("click", (e) => {
|
||||
if (e.target === deleteDialog) {
|
||||
deleteDialog.close();
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
function activateSelfDeactivateSibs(button) {
|
||||
if (button.classList.contains("active")) return;
|
||||
|
||||
Array.from(button.parentNode.children).forEach(s => {
|
||||
if (s === button){
|
||||
button.classList.add('active');
|
||||
} else {
|
||||
s.classList.remove('active');
|
||||
}
|
||||
const targetId = s.dataset.targetId;
|
||||
const target = document.getElementById(targetId);
|
||||
|
||||
if (!target) return;
|
||||
|
||||
if (s.classList.contains('active')) {
|
||||
target.classList.add('active');
|
||||
target.dispatchEvent(new CustomEvent("tab-activated", {bubbles: false}))
|
||||
} else {
|
||||
target.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openLightbox(post, idx) {
|
||||
lightboxCurrentPost = post;
|
||||
lightboxCurrentIdx = idx;
|
||||
lightboxObj.img.src = lightboxImages.get(post)[idx].src;
|
||||
lightboxObj.openOriginalAnchor.href = lightboxImages.get(post)[idx].src
|
||||
lightboxObj.prevButton.disabled = lightboxImages.get(post).length === 1
|
||||
lightboxObj.nextButton.disabled = lightboxImages.get(post).length === 1
|
||||
lightboxObj.imageCount.textContent = `Image ${idx + 1} of ${lightboxImages.get(post).length}`
|
||||
|
||||
if (!lightboxObj.dialog.open) {
|
||||
lightboxObj.dialog.showModal();
|
||||
}
|
||||
}
|
||||
|
||||
const modulo = (n, m) => ((n % m) + m) % m
|
||||
|
||||
function lightboxNext() {
|
||||
const l = lightboxImages.get(lightboxCurrentPost).length;
|
||||
const target = modulo(lightboxCurrentIdx + 1, l);
|
||||
openLightbox(lightboxCurrentPost, target);
|
||||
}
|
||||
|
||||
function lightboxPrev() {
|
||||
const l = lightboxImages.get(lightboxCurrentPost).length;
|
||||
const target = modulo(lightboxCurrentIdx - 1, l);
|
||||
openLightbox(lightboxCurrentPost, target);
|
||||
}
|
||||
|
||||
function constructLightbox() {
|
||||
const dialog = document.createElement("dialog");
|
||||
dialog.classList.add("lightbox-dialog");
|
||||
dialog.addEventListener("click", (e) => {
|
||||
if (e.target === dialog) {
|
||||
dialog.close();
|
||||
}
|
||||
})
|
||||
const dialogInner = document.createElement("div");
|
||||
dialogInner.classList.add("lightbox-inner");
|
||||
dialog.appendChild(dialogInner);
|
||||
const img = document.createElement("img");
|
||||
img.classList.add("lightbox-image")
|
||||
dialogInner.appendChild(img);
|
||||
const openOriginalAnchor = document.createElement("a")
|
||||
openOriginalAnchor.text = "Open original in new window"
|
||||
openOriginalAnchor.target = "_blank"
|
||||
openOriginalAnchor.rel = "noopener noreferrer nofollow"
|
||||
dialogInner.appendChild(openOriginalAnchor);
|
||||
|
||||
const navSpan = document.createElement("span");
|
||||
navSpan.classList.add("lightbox-nav");
|
||||
const prevButton = document.createElement("button");
|
||||
prevButton.type = "button";
|
||||
prevButton.textContent = "Previous";
|
||||
prevButton.addEventListener("click", lightboxPrev);
|
||||
const nextButton = document.createElement("button");
|
||||
nextButton.type = "button";
|
||||
nextButton.textContent = "Next";
|
||||
nextButton.addEventListener("click", lightboxNext);
|
||||
const imageCount = document.createElement("span");
|
||||
imageCount.textContent = "Image of ";
|
||||
navSpan.appendChild(prevButton);
|
||||
navSpan.appendChild(imageCount);
|
||||
navSpan.appendChild(nextButton);
|
||||
|
||||
dialogInner.appendChild(navSpan);
|
||||
return {
|
||||
img: img,
|
||||
dialog: dialog,
|
||||
openOriginalAnchor: openOriginalAnchor,
|
||||
prevButton: prevButton,
|
||||
nextButton: nextButton,
|
||||
imageCount: imageCount,
|
||||
}
|
||||
}
|
||||
|
||||
let lightboxImages = new Map(); //.post-inner : Array<Object>
|
||||
let lightboxObj = null;
|
||||
let lightboxCurrentPost = null;
|
||||
let lightboxCurrentIdx = -1;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// tabs
|
||||
document.querySelectorAll(".tab-button").forEach(button => {
|
||||
button.addEventListener("click", () => {
|
||||
activateSelfDeactivateSibs(button);
|
||||
});
|
||||
});
|
||||
|
||||
// accordions
|
||||
const accordions = document.querySelectorAll(".accordion");
|
||||
accordions.forEach(accordion => {
|
||||
const header = accordion.querySelector(".accordion-header");
|
||||
const toggleButton = header.querySelector(".accordion-toggle");
|
||||
const content = accordion.querySelector(".accordion-content");
|
||||
|
||||
const toggle = (e) => {
|
||||
e.stopPropagation();
|
||||
accordion.classList.toggle("hidden");
|
||||
content.classList.toggle("hidden");
|
||||
toggleButton.textContent = content.classList.contains("hidden") ? "+" : "-"
|
||||
}
|
||||
|
||||
toggleButton.addEventListener("click", toggle);
|
||||
});
|
||||
|
||||
//lightboxes
|
||||
lightboxObj = constructLightbox();
|
||||
document.body.appendChild(lightboxObj.dialog);
|
||||
const postImages = document.querySelectorAll(".post-inner img.block-img");
|
||||
postImages.forEach(postImage => {
|
||||
const belongingTo = postImage.closest(".post-inner");
|
||||
const images = lightboxImages.get(belongingTo) ?? [];
|
||||
images.push({
|
||||
src: postImage.src,
|
||||
alt: postImage.alt,
|
||||
});
|
||||
const idx = images.length - 1;
|
||||
lightboxImages.set(belongingTo, images);
|
||||
postImage.style.cursor = "pointer";
|
||||
postImage.addEventListener("click", () => {
|
||||
openLightbox(belongingTo, idx);
|
||||
});
|
||||
});
|
||||
|
||||
// copy code blocks
|
||||
for (let button of document.querySelectorAll(".copy-code")) {
|
||||
button.addEventListener("click", async () => {
|
||||
await navigator.clipboard.writeText(button.value)
|
||||
button.textContent = "Copied!"
|
||||
setTimeout(() => {button.textContent = "Copy"}, 1000.0)
|
||||
})
|
||||
};
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg width="60px" height="60px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.364 5.63604C19.9926 7.26472 21 9.51472 21 12C21 16.9706 16.9706 21 12 21C9.51472 21 7.26472 19.9926 5.63604 18.364M18.364 5.63604C16.7353 4.00736 14.4853 3 12 3C7.02944 3 3 7.02944 3 12C3 14.4853 4.00736 16.7353 5.63604 18.364M18.364 5.63604L5.63604 18.364" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<!-- https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license -->
|
||||
|
Before Width: | Height: | Size: 609 B |
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 17L7.58959 13.7694C8.38025 13.0578 9.58958 13.0896 10.3417 13.8417L11.5 15L15.0858 11.4142C15.8668 10.6332 17.1332 10.6332 17.9142 11.4142L20 13.5M11 9C11 9.55228 10.5523 10 10 10C9.44772 10 9 9.55228 9 9C9 8.44772 9.44772 8 10 8C10.5523 8 11 8.44772 11 9ZM6 20H18C19.1046 20 20 19.1046 20 18V6C20 4.89543 19.1046 4 18 4H6C4.89543 4 4 4.89543 4 6V18C4 19.1046 4.89543 20 6 20Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<!-- https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license -->
|
||||
|
Before Width: | Height: | Size: 728 B |
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg width="60px" height="60px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 8V8.5M12 12V16M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<!-- https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license -->
|
||||
|
Before Width: | Height: | Size: 480 B |
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg width="60px" height="60px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 14V16M8 9V6C8 3.79086 9.79086 2 12 2C14.2091 2 16 3.79086 16 6V9M7 21H17C18.1046 21 19 20.1046 19 19V11C19 9.89543 18.1046 9 17 9H7C5.89543 9 5 9.89543 5 11V19C5 20.1046 5.89543 21 7 21Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<!-- https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license -->
|
||||
|
Before Width: | Height: | Size: 539 B |
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13 20H6C4.89543 20 4 19.1046 4 18V6C4 4.89543 4.89543 4 6 4H18C19.1046 4 20 4.89543 20 6V13M13 20L20 13M13 20V14C13 13.4477 13.4477 13 14 13H20" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<!-- https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license -->
|
||||
|
Before Width: | Height: | Size: 498 B |
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg width="60px" height="60px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 15H12.01M12 12V9M4.98207 19H19.0179C20.5615 19 21.5233 17.3256 20.7455 15.9923L13.7276 3.96153C12.9558 2.63852 11.0442 2.63852 10.2724 3.96153L3.25452 15.9923C2.47675 17.3256 3.43849 19 4.98207 19Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<!-- https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license -->
|
||||
|
Before Width: | Height: | Size: 550 B |
@@ -1,799 +0,0 @@
|
||||
@font-face {
|
||||
font-family: "site-title";
|
||||
src: url("/static/fonts/ChicagoFLF.woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Cadman";
|
||||
src: url("/static/fonts/Cadman_Roman.woff2");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Cadman";
|
||||
src: url("/static/fonts/Cadman_Bold.woff2");
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Cadman";
|
||||
src: url("/static/fonts/Cadman_Italic.woff2");
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Cadman";
|
||||
src: url("/static/fonts/Cadman_BoldItalic.woff2");
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
}
|
||||
.tab-button, .currentpage, .pagebutton, input[type=file]::file-selector-button, button.warn, input[type=submit].warn, .linkbutton.warn, button.critical, input[type=submit].critical, .linkbutton.critical, button, input[type=submit], .linkbutton {
|
||||
cursor: default;
|
||||
color: black;
|
||||
font-size: 0.9em;
|
||||
font-family: "Cadman";
|
||||
text-decoration: none;
|
||||
border: 1px solid black;
|
||||
border-radius: 3px;
|
||||
padding: 5px 20px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Cadman";
|
||||
margin: 20px 100px;
|
||||
background-color: rgb(173.5214173228, 183.6737007874, 161.0262992126);
|
||||
}
|
||||
|
||||
.big {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
#topnav {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
background-color: #c1ceb1;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
#bottomnav {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
background-color: rgb(143.7039271654, 144.3879625984, 142.8620374016);
|
||||
}
|
||||
|
||||
.darkbg {
|
||||
padding-bottom: 10px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
background-color: rgb(143.7039271654, 144.3879625984, 142.8620374016);
|
||||
}
|
||||
|
||||
.user-actions {
|
||||
display: flex;
|
||||
column-gap: 15px;
|
||||
}
|
||||
|
||||
.site-title {
|
||||
font-family: "site-title";
|
||||
font-size: 3rem;
|
||||
margin: 0 20px;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.thread-title {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.post {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
gap: 0;
|
||||
grid-auto-flow: row;
|
||||
grid-template-areas: "usercard post-content-container";
|
||||
border: 2px outset rgb(135.1928346457, 145.0974015748, 123.0025984252);
|
||||
}
|
||||
|
||||
.usercard {
|
||||
grid-area: usercard;
|
||||
padding: 20px 10px;
|
||||
border: 4px outset rgb(217.26, 220.38, 213.42);
|
||||
background-color: rgb(143.7039271654, 144.3879625984, 142.8620374016);
|
||||
border-right: solid 2px;
|
||||
}
|
||||
|
||||
.usercard-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
top: 10px;
|
||||
position: sticky;
|
||||
}
|
||||
|
||||
.post-content-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 70px 2.5fr;
|
||||
gap: 0px 0px;
|
||||
grid-auto-flow: row;
|
||||
grid-template-areas: "post-info" "post-content";
|
||||
grid-area: post-content-container;
|
||||
}
|
||||
|
||||
.post-info {
|
||||
grid-area: post-info;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 5px 20px;
|
||||
align-items: center;
|
||||
border-top: 1px solid black;
|
||||
border-bottom: 1px solid black;
|
||||
}
|
||||
|
||||
.post-content {
|
||||
grid-area: post-content;
|
||||
padding: 20px;
|
||||
margin-right: 25%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.post-content.wider {
|
||||
margin-right: 12.5%;
|
||||
}
|
||||
|
||||
.post-inner {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
pre code {
|
||||
display: block;
|
||||
background-color: rgb(38.5714173228, 40.9237007874, 35.6762992126);
|
||||
font-size: 1rem;
|
||||
color: white;
|
||||
border-bottom-right-radius: 8px;
|
||||
border-bottom-left-radius: 8px;
|
||||
border-left: 10px solid rgb(229.84, 231.92, 227.28);
|
||||
padding: 20px;
|
||||
overflow: scroll;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
.inline-code {
|
||||
background-color: rgb(38.5714173228, 40.9237007874, 35.6762992126);
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
display: inline-block;
|
||||
margin: 4px;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
#delete-dialog, .lightbox-dialog {
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
border: 2px solid black;
|
||||
box-shadow: 0 0 30px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.delete-dialog-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.lightbox-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
min-width: 400px;
|
||||
background-color: #c1ceb1;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.lightbox-image {
|
||||
max-width: 70vw;
|
||||
max-height: 70vh;
|
||||
object-fit: scale-down;
|
||||
}
|
||||
|
||||
.lightbox-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.copy-code-container {
|
||||
position: sticky;
|
||||
width: calc(100% - 4px);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: last baseline;
|
||||
font-family: "Cadman";
|
||||
border-top-right-radius: 8px;
|
||||
border-top-left-radius: 8px;
|
||||
background-color: #c1ceb1;
|
||||
border-left: 2px solid black;
|
||||
border-right: 2px solid black;
|
||||
border-top: 2px solid black;
|
||||
}
|
||||
.copy-code-container::before {
|
||||
content: "code block";
|
||||
font-style: italic;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.copy-code {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
padding: 10px 20px;
|
||||
margin: 10px;
|
||||
border-radius: 4px;
|
||||
border-left: 10px solid rgb(229.84, 231.92, 227.28);
|
||||
background-color: rgba(0, 0, 0, 0.1490196078);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
gap: 0;
|
||||
grid-template-areas: "user-page-usercard user-page-stats";
|
||||
}
|
||||
|
||||
.user-page-usercard {
|
||||
grid-area: user-page-usercard;
|
||||
padding: 20px 10px;
|
||||
border: 4px outset rgb(217.26, 220.38, 213.42);
|
||||
background-color: rgb(143.7039271654, 144.3879625984, 142.8620374016);
|
||||
border-right: solid 2px;
|
||||
}
|
||||
|
||||
.user-page-stats {
|
||||
grid-area: user-page-stats;
|
||||
padding: 20px 30px;
|
||||
border: 1px solid black;
|
||||
}
|
||||
|
||||
.user-stats-list {
|
||||
list-style: none;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.user-page-posts {
|
||||
border-left: solid 1px black;
|
||||
border-right: solid 1px black;
|
||||
border-bottom: solid 1px black;
|
||||
background-color: #c1ceb1;
|
||||
}
|
||||
|
||||
.user-page-post-preview {
|
||||
max-height: 200px;
|
||||
mask-image: linear-gradient(180deg, #000 60%, transparent);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 90%;
|
||||
height: 90%;
|
||||
object-fit: contain;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.username-link {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.user-status {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
button, input[type=submit], .linkbutton {
|
||||
display: inline-block;
|
||||
background-color: rgb(177, 206, 204.5);
|
||||
}
|
||||
button:hover, input[type=submit]:hover, .linkbutton:hover {
|
||||
background-color: rgb(192.6, 215.8, 214.6);
|
||||
}
|
||||
button:active, input[type=submit]:active, .linkbutton:active {
|
||||
background-color: rgb(166.6881496063, 178.0118503937, 177.4261417323);
|
||||
}
|
||||
button:disabled, input[type=submit]:disabled, .linkbutton:disabled {
|
||||
background-color: rgb(209.535, 211.565, 211.46);
|
||||
}
|
||||
button.reduced, input[type=submit].reduced, .linkbutton.reduced {
|
||||
margin: 0;
|
||||
padding: 5px;
|
||||
}
|
||||
button.critical, input[type=submit].critical, .linkbutton.critical {
|
||||
color: white;
|
||||
background-color: red;
|
||||
}
|
||||
button.critical:hover, input[type=submit].critical:hover, .linkbutton.critical:hover {
|
||||
background-color: #ff3333;
|
||||
}
|
||||
button.critical:active, input[type=submit].critical:active, .linkbutton.critical:active {
|
||||
background-color: rgb(149.175, 80.325, 80.325);
|
||||
}
|
||||
button.critical:disabled, input[type=submit].critical:disabled, .linkbutton.critical:disabled {
|
||||
background-color: rgb(174.675, 156.825, 156.825);
|
||||
}
|
||||
button.critical.reduced, input[type=submit].critical.reduced, .linkbutton.critical.reduced {
|
||||
margin: 0;
|
||||
padding: 5px;
|
||||
}
|
||||
button.warn, input[type=submit].warn, .linkbutton.warn {
|
||||
background-color: #fbfb8d;
|
||||
}
|
||||
button.warn:hover, input[type=submit].warn:hover, .linkbutton.warn:hover {
|
||||
background-color: rgb(251.8, 251.8, 163.8);
|
||||
}
|
||||
button.warn:active, input[type=submit].warn:active, .linkbutton.warn:active {
|
||||
background-color: rgb(198.3813559322, 198.3813559322, 154.4186440678);
|
||||
}
|
||||
button.warn:disabled, input[type=submit].warn:disabled, .linkbutton.warn:disabled {
|
||||
background-color: rgb(217.55, 217.55, 209.85);
|
||||
}
|
||||
button.warn.reduced, input[type=submit].warn.reduced, .linkbutton.warn.reduced {
|
||||
margin: 0;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
input[type=file]::file-selector-button {
|
||||
background-color: rgb(177, 206, 204.5);
|
||||
margin: 10px 10px;
|
||||
}
|
||||
input[type=file]::file-selector-button:hover {
|
||||
background-color: rgb(192.6, 215.8, 214.6);
|
||||
}
|
||||
input[type=file]::file-selector-button:active {
|
||||
background-color: rgb(166.6881496063, 178.0118503937, 177.4261417323);
|
||||
}
|
||||
input[type=file]::file-selector-button:disabled {
|
||||
background-color: rgb(209.535, 211.565, 211.46);
|
||||
}
|
||||
input[type=file]::file-selector-button.reduced {
|
||||
margin: 0;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.pagebutton {
|
||||
background-color: rgb(177, 206, 204.5);
|
||||
padding: 5px 5px;
|
||||
margin: 0;
|
||||
display: inline-block;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.pagebutton:hover {
|
||||
background-color: rgb(192.6, 215.8, 214.6);
|
||||
}
|
||||
.pagebutton:active {
|
||||
background-color: rgb(166.6881496063, 178.0118503937, 177.4261417323);
|
||||
}
|
||||
.pagebutton:disabled {
|
||||
background-color: rgb(209.535, 211.565, 211.46);
|
||||
}
|
||||
.pagebutton.reduced {
|
||||
margin: 0;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.currentpage {
|
||||
border: none;
|
||||
padding: 5px 5px;
|
||||
margin: 0;
|
||||
display: inline-block;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modform {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.login-container > * {
|
||||
width: 25%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.settings-container > * {
|
||||
width: 40%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.avatar-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
input[type=text], input[type=password], textarea, select {
|
||||
border: 1px solid black;
|
||||
border-radius: 3px;
|
||||
padding: 7px 10px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
background-color: rgb(217.8, 225.6, 208.2);
|
||||
}
|
||||
input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus {
|
||||
background-color: rgb(230.2, 235.4, 223.8);
|
||||
}
|
||||
|
||||
.infobox {
|
||||
border: 2px solid black;
|
||||
background-color: #81a3e6;
|
||||
padding: 20px 15px;
|
||||
}
|
||||
.infobox.critical {
|
||||
background-color: rgb(237, 129, 129);
|
||||
}
|
||||
.infobox.warn {
|
||||
background-color: #fbfb8d;
|
||||
}
|
||||
|
||||
.infobox > span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.infobox-icon-container {
|
||||
min-width: 60px;
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
.thread {
|
||||
display: grid;
|
||||
grid-template-columns: 96px 1.6fr 96px;
|
||||
grid-template-rows: 1fr;
|
||||
gap: 0px 0px;
|
||||
grid-auto-flow: row;
|
||||
min-height: 96px;
|
||||
grid-template-areas: "thread-sticky-container thread-info-container thread-locked-container";
|
||||
}
|
||||
|
||||
.thread-sticky-container {
|
||||
grid-area: thread-sticky-container;
|
||||
border: 2px outset rgb(217.26, 220.38, 213.42);
|
||||
}
|
||||
|
||||
.thread-locked-container {
|
||||
grid-area: thread-locked-container;
|
||||
border: 2px outset rgb(217.26, 220.38, 213.42);
|
||||
}
|
||||
|
||||
.contain-svg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
.contain-svg:not(.full) > svg, .contain-svg:not(.full) > img {
|
||||
height: 50%;
|
||||
width: 50%;
|
||||
}
|
||||
.contain-svg.full > svg, .contain-svg.full > img {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.block-img {
|
||||
object-fit: contain;
|
||||
max-width: 400px;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.thread-info-container {
|
||||
grid-area: thread-info-container;
|
||||
background-color: #c1ceb1;
|
||||
padding: 5px 20px;
|
||||
border-top: 1px solid black;
|
||||
border-bottom: 1px solid black;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
max-height: 110px;
|
||||
mask-image: linear-gradient(180deg, #000 60%, transparent);
|
||||
}
|
||||
|
||||
.thread-info-post-preview {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: inline;
|
||||
margin-right: 25%;
|
||||
}
|
||||
|
||||
.babycode-guide-section {
|
||||
background-color: #c1ceb1;
|
||||
padding: 5px 20px;
|
||||
border: 1px solid black;
|
||||
padding-right: 25%;
|
||||
}
|
||||
|
||||
.babycode-guide-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 300px;
|
||||
grid-template-rows: 1fr;
|
||||
gap: 0px 0px;
|
||||
grid-auto-flow: row;
|
||||
grid-template-areas: "guide-topics guide-toc";
|
||||
}
|
||||
|
||||
.guide-topics {
|
||||
grid-area: guide-topics;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.guide-toc {
|
||||
grid-area: guide-toc;
|
||||
position: sticky;
|
||||
top: 100px;
|
||||
align-self: start;
|
||||
padding: 10px;
|
||||
border-bottom-right-radius: 8px;
|
||||
background-color: rgb(177, 206, 204.5);
|
||||
border-right: 1px solid black;
|
||||
border-top: 1px solid black;
|
||||
border-bottom: 1px solid black;
|
||||
}
|
||||
|
||||
.emoji-table tr td {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emoji-table tr th {
|
||||
padding-left: 50px;
|
||||
padding-right: 50px;
|
||||
}
|
||||
|
||||
.emoji-table {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.emoji-table, th, td {
|
||||
border: 1px solid black;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.colorful-table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 10px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.colorful-table tr th {
|
||||
background-color: #beb1ce;
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.colorful-table tr td {
|
||||
background-color: rgb(177, 206, 204.5);
|
||||
padding: 5px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.colorful-table .small {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.topic {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 96px;
|
||||
grid-template-rows: 1fr;
|
||||
gap: 0px 0px;
|
||||
grid-auto-flow: row;
|
||||
grid-template-areas: "topic-info-container topic-locked-container";
|
||||
}
|
||||
|
||||
.topic-info-container {
|
||||
grid-area: topic-info-container;
|
||||
background-color: #c1ceb1;
|
||||
padding: 5px 20px;
|
||||
border: 1px solid black;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topic-locked-container {
|
||||
grid-area: topic-locked-container;
|
||||
border: 2px outset rgb(217.26, 220.38, 213.42);
|
||||
}
|
||||
|
||||
.draggable-topic {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background-color: #c1ceb1;
|
||||
padding: 20px;
|
||||
margin: 12px 0;
|
||||
border-top: 6px outset rgb(217.26, 220.38, 213.42);
|
||||
border-bottom: 6px outset rgb(135.1928346457, 145.0974015748, 123.0025984252);
|
||||
}
|
||||
.draggable-topic.dragged {
|
||||
background-color: rgb(177, 206, 204.5);
|
||||
}
|
||||
|
||||
.editing {
|
||||
background-color: rgb(217.26, 220.38, 213.42);
|
||||
}
|
||||
|
||||
.context-explain {
|
||||
margin: 20px 0;
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
.post-edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: baseline;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.babycode-editor {
|
||||
height: 150px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.babycode-editor-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.babycode-preview-errors-container {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
background-color: rgb(177, 206, 204.5);
|
||||
border-bottom: none;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.tab-button:hover {
|
||||
background-color: rgb(192.6, 215.8, 214.6);
|
||||
}
|
||||
.tab-button:active {
|
||||
background-color: rgb(166.6881496063, 178.0118503937, 177.4261417323);
|
||||
}
|
||||
.tab-button:disabled {
|
||||
background-color: rgb(209.535, 211.565, 211.46);
|
||||
}
|
||||
.tab-button.reduced {
|
||||
margin: 0;
|
||||
padding: 5px;
|
||||
}
|
||||
.tab-button.active {
|
||||
background-color: #beb1ce;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
.tab-content.active {
|
||||
min-height: 250px;
|
||||
display: block;
|
||||
background-color: rgb(191.3137931034, 189.7, 193.3);
|
||||
border: 1px solid black;
|
||||
padding: 10px;
|
||||
border-top-right-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
border-bottom-left-radius: 3px;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin: 10px 0 10px 30px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.new-concept-notification.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.new-concept-notification {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
right: 80px;
|
||||
border: 2px solid black;
|
||||
background-color: #81a3e6;
|
||||
padding: 20px 15px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 30px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.emoji {
|
||||
max-width: 15px;
|
||||
max-height: 15px;
|
||||
}
|
||||
|
||||
.accordion {
|
||||
border-top-right-radius: 3px;
|
||||
border-top-left-radius: 3px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid black;
|
||||
margin: 10px 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.accordion.hidden {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.accordion-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: rgb(159.0271653543, 162.0727712915, 172.9728346457);
|
||||
padding: 0 10px;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid black;
|
||||
}
|
||||
|
||||
.accordion-toggle {
|
||||
padding: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.accordion-title {
|
||||
margin-right: auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.accordion-content {
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.accordion-content.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.inbox-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.babycode-button-container {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.babycode-button {
|
||||
padding: 5px 10px;
|
||||
min-width: 36px;
|
||||
}
|
||||
.babycode-button > * {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.quote-popover {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(0, 0, 0, 0.5019607843);
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
footer {
|
||||
border-top: 1px solid black;
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
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
|
||||
dotenv==0.9.9
|
||||
Flask==3.1.1
|
||||
Flask-Caching==2.3.1
|
||||
itsdangerous==2.2.0
|
||||
Jinja2==3.1.6
|
||||
MarkupSafe==3.0.2
|
||||
pycparser==2.22
|
||||
Pygments==2.19.2
|
||||
python-dotenv==1.1.1
|
||||
python-slugify==8.0.4
|
||||
text-unidecode==1.3
|
||||
|
||||
790
sass/style.scss
@@ -1,790 +0,0 @@
|
||||
@use "sass:color";
|
||||
|
||||
@font-face {
|
||||
font-family: "site-title";
|
||||
src: url("/static/fonts/ChicagoFLF.woff2");
|
||||
}
|
||||
|
||||
@mixin cadman($var) {
|
||||
font-family: "Cadman";
|
||||
src: url("/static/fonts/Cadman_#{$var}.woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
@include cadman("Roman");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
@include cadman("Bold");
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
@include cadman("Italic");
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
@include cadman("BoldItalic");
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
$accent_color: #c1ceb1;
|
||||
|
||||
$dark_bg: color.scale($accent_color, $lightness: -25%, $saturation: -97%);
|
||||
$dark2: color.scale($accent_color, $lightness: -30%, $saturation: -60%);
|
||||
$verydark: color.scale($accent_color, $lightness: -80%, $saturation: -70%);
|
||||
|
||||
$light: color.scale($accent_color, $lightness: 40%, $saturation: -60%);
|
||||
$lighter: color.scale($accent_color, $lightness: 60%, $saturation: -60%);
|
||||
|
||||
$main_bg: color.scale($accent_color, $lightness: -10%, $saturation: -40%);
|
||||
$button_color: color.adjust($accent_color, $hue: 90);
|
||||
$button_color2: color.adjust($accent_color, $hue: 180);
|
||||
$accordion_color: color.adjust($accent_color, $hue: 140, $lightness: -10%, $saturation: -15%);
|
||||
|
||||
%button-base {
|
||||
cursor: default;
|
||||
color: black;
|
||||
font-size: 0.9em;
|
||||
font-family: "Cadman";
|
||||
text-decoration: none;
|
||||
border: 1px solid black;
|
||||
border-radius: 3px;
|
||||
padding: 5px 20px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
@mixin button($color) {
|
||||
@extend %button-base;
|
||||
background-color: $color;
|
||||
|
||||
&:hover {
|
||||
background-color: color.scale($color, $lightness: 20%);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: color.scale($color, $lightness: -10%, $saturation: -70%);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: color.scale($color, $lightness: 30%, $saturation: -90%);
|
||||
}
|
||||
|
||||
&.reduced {
|
||||
margin: 0;
|
||||
padding: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin navbar($color) {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
background-color: $color;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Cadman";
|
||||
// font-size: 18px;
|
||||
margin: 20px 100px;
|
||||
background-color: $main_bg;
|
||||
}
|
||||
|
||||
.big {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
#topnav {
|
||||
@include navbar($accent_color);
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
#bottomnav {
|
||||
@include navbar($dark_bg);
|
||||
}
|
||||
|
||||
.darkbg {
|
||||
padding-bottom: 10px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
background-color: $dark_bg;
|
||||
}
|
||||
|
||||
.user-actions {
|
||||
display: flex;
|
||||
column-gap: 15px;
|
||||
}
|
||||
|
||||
.site-title {
|
||||
font-family: "site-title";
|
||||
font-size: 3rem;
|
||||
margin: 0 20px;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.thread-title {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.post {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
gap: 0;
|
||||
grid-auto-flow: row;
|
||||
grid-template-areas:
|
||||
"usercard post-content-container";
|
||||
border: 2px outset $dark2;
|
||||
}
|
||||
|
||||
.usercard {
|
||||
grid-area: usercard;
|
||||
padding: 20px 10px;
|
||||
border: 4px outset $light;
|
||||
background-color: $dark_bg;
|
||||
border-right: solid 2px;
|
||||
}
|
||||
|
||||
.usercard-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
top: 10px;
|
||||
position: sticky;
|
||||
}
|
||||
|
||||
.post-content-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 70px 2.5fr;
|
||||
gap: 0px 0px;
|
||||
grid-auto-flow: row;
|
||||
grid-template-areas:
|
||||
"post-info"
|
||||
"post-content";
|
||||
grid-area: post-content-container;
|
||||
}
|
||||
|
||||
.post-info {
|
||||
grid-area: post-info;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 5px 20px;
|
||||
align-items: center;
|
||||
border-top: 1px solid black;
|
||||
border-bottom: 1px solid black;
|
||||
}
|
||||
|
||||
.post-content {
|
||||
grid-area: post-content;
|
||||
padding: 20px;
|
||||
margin-right: 25%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.post-content.wider {
|
||||
margin-right: 12.5%;
|
||||
}
|
||||
|
||||
.post-inner {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
pre code {
|
||||
display: block;
|
||||
background-color: $verydark;
|
||||
font-size: 1rem;
|
||||
color: white;
|
||||
border-bottom-right-radius: 8px;
|
||||
border-bottom-left-radius: 8px;
|
||||
border-left: 10px solid $lighter;
|
||||
padding: 20px;
|
||||
overflow: scroll;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
.inline-code {
|
||||
background-color: $verydark;
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
display: inline-block;
|
||||
margin: 4px;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
#delete-dialog, .lightbox-dialog {
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
border: 2px solid black;
|
||||
box-shadow: 0 0 30px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.delete-dialog-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.lightbox-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
min-width: 400px;
|
||||
background-color: $accent_color;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.lightbox-image {
|
||||
max-width: 70vw;
|
||||
max-height: 70vh;
|
||||
object-fit: scale-down;
|
||||
}
|
||||
|
||||
.lightbox-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.copy-code-container {
|
||||
position: sticky;
|
||||
// width: 100%;
|
||||
width: calc(100% - 4px);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: last baseline;
|
||||
font-family: "Cadman";
|
||||
border-top-right-radius: 8px;
|
||||
border-top-left-radius: 8px;
|
||||
background-color: $accent_color;
|
||||
border-left: 2px solid black;
|
||||
border-right: 2px solid black;
|
||||
border-top: 2px solid black;
|
||||
|
||||
&::before {
|
||||
content: "code block";
|
||||
font-style: italic;
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.copy-code {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
padding: 10px 20px;
|
||||
margin: 10px;
|
||||
border-radius: 4px;
|
||||
border-left: 10px solid $lighter;
|
||||
// background-color: $dark2;
|
||||
background-color: #00000026;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
gap: 0;
|
||||
grid-template-areas:
|
||||
"user-page-usercard user-page-stats";
|
||||
}
|
||||
|
||||
.user-page-usercard {
|
||||
grid-area: user-page-usercard;
|
||||
padding: 20px 10px;
|
||||
border: 4px outset $light;
|
||||
background-color: $dark_bg;
|
||||
border-right: solid 2px;
|
||||
}
|
||||
|
||||
.user-page-stats {
|
||||
grid-area: user-page-stats;
|
||||
padding: 20px 30px;
|
||||
border: 1px solid black;
|
||||
}
|
||||
|
||||
.user-stats-list {
|
||||
list-style: none;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.user-page-posts {
|
||||
border-left: solid 1px black;
|
||||
border-right: solid 1px black;
|
||||
border-bottom: solid 1px black;
|
||||
background-color: $accent_color;
|
||||
}
|
||||
|
||||
.user-page-post-preview {
|
||||
max-height: 200px;
|
||||
mask-image: linear-gradient(180deg,#000 60%,transparent);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 90%;
|
||||
height: 90%;
|
||||
object-fit: contain;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.username-link {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.user-status {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
button, input[type="submit"], .linkbutton {
|
||||
display: inline-block;
|
||||
@include button($button_color);
|
||||
|
||||
&.critical {
|
||||
color: white;
|
||||
@include button(red);
|
||||
}
|
||||
|
||||
&.warn {
|
||||
@include button(#fbfb8d);
|
||||
}
|
||||
}
|
||||
|
||||
// not sure why this one has to be separate, but if it's included in the rule above everything breaks
|
||||
input[type="file"]::file-selector-button {
|
||||
@include button($button_color);
|
||||
margin: 10px 10px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.pagebutton {
|
||||
@include button($button_color);
|
||||
padding: 5px 5px;
|
||||
margin: 0;
|
||||
display: inline-block;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.currentpage {
|
||||
@extend %button-base;
|
||||
border: none;
|
||||
padding: 5px 5px;
|
||||
margin: 0;
|
||||
display: inline-block;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modform {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.login-container > * {
|
||||
width: 25%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.settings-container > * {
|
||||
width: 40%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.avatar-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
input[type="text"], input[type="password"], textarea, select {
|
||||
border: 1px solid black;
|
||||
border-radius: 3px;
|
||||
padding: 7px 10px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
background-color: color.scale($accent_color, $lightness: 40%);
|
||||
|
||||
&:focus {
|
||||
background-color: color.scale($accent_color, $lightness: 60%);
|
||||
}
|
||||
}
|
||||
|
||||
.infobox {
|
||||
border: 2px solid black;
|
||||
background-color: #81a3e6;
|
||||
padding: 20px 15px;
|
||||
|
||||
&.critical {
|
||||
background-color: rgb(237, 129, 129);
|
||||
}
|
||||
|
||||
&.warn {
|
||||
background-color: #fbfb8d;
|
||||
}
|
||||
}
|
||||
|
||||
.infobox > span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.infobox-icon-container {
|
||||
min-width: 60px;
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
.thread {
|
||||
display: grid;
|
||||
grid-template-columns: 96px 1.6fr 96px;
|
||||
grid-template-rows: 1fr;
|
||||
gap: 0px 0px;
|
||||
grid-auto-flow: row;
|
||||
min-height: 96px;
|
||||
grid-template-areas:
|
||||
"thread-sticky-container thread-info-container thread-locked-container";
|
||||
}
|
||||
|
||||
.thread-sticky-container {
|
||||
grid-area: thread-sticky-container;
|
||||
border: 2px outset $light;
|
||||
}
|
||||
|
||||
.thread-locked-container {
|
||||
grid-area: thread-locked-container;
|
||||
border: 2px outset $light;
|
||||
}
|
||||
|
||||
.contain-svg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
&:not(.full) > svg, &:not(.full) > img {
|
||||
height: 50%;
|
||||
width: 50%;
|
||||
}
|
||||
&.full > svg, &.full > img {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.block-img {
|
||||
object-fit: contain;
|
||||
max-width: 400px;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.thread-info-container {
|
||||
grid-area: thread-info-container;
|
||||
background-color: $accent_color;
|
||||
padding: 5px 20px;
|
||||
border-top: 1px solid black;
|
||||
border-bottom: 1px solid black;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
max-height: 110px;
|
||||
mask-image: linear-gradient(180deg,#000 60%,transparent);
|
||||
}
|
||||
|
||||
.thread-info-post-preview {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: inline;
|
||||
margin-right: 25%;
|
||||
}
|
||||
|
||||
.babycode-guide-section {
|
||||
background-color: $accent_color;
|
||||
padding: 5px 20px;
|
||||
border: 1px solid black;
|
||||
padding-right: 25%;
|
||||
}
|
||||
|
||||
.babycode-guide-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 300px;
|
||||
grid-template-rows: 1fr;
|
||||
gap: 0px 0px;
|
||||
grid-auto-flow: row;
|
||||
grid-template-areas:
|
||||
"guide-topics guide-toc";
|
||||
}
|
||||
|
||||
.guide-topics {
|
||||
grid-area: guide-topics;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.guide-toc {
|
||||
grid-area: guide-toc;
|
||||
position: sticky;
|
||||
top: 100px;
|
||||
align-self: start;
|
||||
padding: 10px;
|
||||
// border-top-right-radius: 16px;
|
||||
border-bottom-right-radius: 8px;
|
||||
background-color: $button_color;
|
||||
border-right: 1px solid black;
|
||||
border-top: 1px solid black;
|
||||
border-bottom: 1px solid black;
|
||||
}
|
||||
|
||||
.emoji-table tr td {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emoji-table tr th {
|
||||
padding-left: 50px;
|
||||
padding-right: 50px;
|
||||
}
|
||||
|
||||
.emoji-table {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.emoji-table, th, td {
|
||||
border: 1px solid black;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.colorful-table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 10px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.colorful-table tr th {
|
||||
background-color: $button_color2;
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.colorful-table tr td {
|
||||
background-color: $button_color;
|
||||
padding: 5px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.colorful-table .small {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.topic {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 96px;
|
||||
grid-template-rows: 1fr;
|
||||
gap: 0px 0px;
|
||||
grid-auto-flow: row;
|
||||
grid-template-areas:
|
||||
"topic-info-container topic-locked-container";
|
||||
}
|
||||
|
||||
.topic-info-container {
|
||||
grid-area: topic-info-container;
|
||||
background-color: $accent_color;
|
||||
padding: 5px 20px;
|
||||
border: 1px solid black;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topic-locked-container {
|
||||
grid-area: topic-locked-container;
|
||||
border: 2px outset $light;
|
||||
}
|
||||
|
||||
|
||||
.draggable-topic {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background-color: $accent_color;
|
||||
padding: 20px;
|
||||
margin: 12px 0;
|
||||
border-top: 6px outset $light;
|
||||
border-bottom: 6px outset $dark2;
|
||||
|
||||
&.dragged {
|
||||
background-color: $button_color;
|
||||
}
|
||||
}
|
||||
|
||||
.editing {
|
||||
background-color: $light;
|
||||
}
|
||||
|
||||
.context-explain {
|
||||
margin: 20px 0;
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
.post-edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: baseline;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.babycode-editor {
|
||||
height: 150px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.babycode-editor-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.babycode-preview-errors-container {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
@include button($button_color);
|
||||
border-bottom: none;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
margin-bottom: 0;
|
||||
|
||||
&.active {
|
||||
background-color: $button_color2;
|
||||
padding-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
|
||||
&.active {
|
||||
min-height: 250px;
|
||||
display: block;
|
||||
background-color: color.adjust($button_color2, $saturation: -20%);
|
||||
border: 1px solid black;
|
||||
padding: 10px;
|
||||
border-top-right-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
border-bottom-left-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin: 10px 0 10px 30px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.new-concept-notification.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.new-concept-notification {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
right: 80px;
|
||||
border: 2px solid black;
|
||||
background-color: #81a3e6;
|
||||
padding: 20px 15px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 30px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.emoji {
|
||||
max-width: 15px;
|
||||
max-height: 15px;
|
||||
}
|
||||
|
||||
.accordion {
|
||||
border-top-right-radius: 3px;
|
||||
border-top-left-radius: 3px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid black;
|
||||
margin: 10px 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.accordion.hidden {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.accordion-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: $accordion_color;
|
||||
padding: 0 10px;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid black;
|
||||
}
|
||||
|
||||
.accordion-toggle {
|
||||
padding: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.accordion-title {
|
||||
margin-right: auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.accordion-content {
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.accordion-content.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.inbox-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.babycode-button-container {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.babycode-button {
|
||||
padding: 5px 10px;
|
||||
min-width: 36px;
|
||||
|
||||
&> * {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.quote-popover {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: #00000080;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
footer {
|
||||
border-top: 1px solid black;
|
||||
}
|
||||