Compare commits
98 Commits
main
...
6fab93ebeb
| Author | SHA1 | Date | |
|---|---|---|---|
|
6fab93ebeb
|
|||
|
c7ba23ad22
|
|||
|
3c237df93f
|
|||
|
22ca768ad1
|
|||
|
c311fba500
|
|||
|
4083c950c5
|
|||
|
5853c8b7a8
|
|||
|
93ee829405
|
|||
|
7247ac4cf8
|
|||
|
2c8bc6dca8
|
|||
|
edfa2e232f
|
|||
|
5676ced836
|
|||
|
7defd249b5
|
|||
|
74a95075f7
|
|||
|
c0eb867b2d
|
|||
|
ae9d33473c
|
|||
|
d87d9c2977
|
|||
|
8c87489f70
|
|||
|
07623b294e
|
|||
|
4d2f87baf5
|
|||
|
af5e838232
|
|||
|
81fa054ddf
|
|||
|
818e43dd1b
|
|||
|
3d633bd529
|
|||
|
2f78c7459c
|
|||
|
b0793b8a86
|
|||
|
dc1ff4446e
|
|||
|
06b417f9a1
|
|||
|
594272d298
|
|||
|
cd3fce17ae
|
|||
|
572e6e86c4
|
|||
|
0224e2e390
|
|||
|
27314f34a5
|
|||
|
daf205f200
|
|||
|
687d72e5ab
|
|||
|
ed395a0175
|
|||
|
160629fca7
|
|||
|
d2ea0bbd51
|
|||
|
6b7a0e7a17
|
|||
|
303750a281
|
|||
|
e74b9403e9
|
|||
|
e729b43a28
|
|||
|
9ca40e1814
|
|||
|
84ee969e7a
|
|||
|
13667d5f6c
|
|||
|
72172dbb1c
|
|||
|
5db63d6907
|
|||
|
daed16f099
|
|||
|
a5a3565496
|
|||
|
d74dd6c5f3
|
|||
|
648b310e13
|
|||
|
4bcea261b1
|
|||
|
4edc4f4650
|
|||
|
e670c176e8
|
|||
|
3870356ffa
|
|||
|
d5e627ed7f
|
|||
|
afdf182bd1
|
|||
|
ff2c6606f8
|
|||
|
f3acf64e6d
|
|||
|
10ea1f03cd
|
|||
|
9a10f30634
|
|||
|
612d69c157
|
|||
|
286a3641eb
|
|||
|
b53556871f
|
|||
|
29f2318cba
|
|||
|
3d7188eb71
|
|||
|
ed4d4191d7
|
|||
|
66f381a434
|
|||
|
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
|
1
.gitignore
vendored
@@ -6,6 +6,7 @@ data/static/avatars/*
|
|||||||
!data/static/avatars/default.webp
|
!data/static/avatars/default.webp
|
||||||
data/static/badges/user
|
data/static/badges/user
|
||||||
data/_cached
|
data/_cached
|
||||||
|
data/static/js/vnd/*.source.*
|
||||||
|
|
||||||
config/secrets.prod.env
|
config/secrets.prod.env
|
||||||
config/pyrom_config.toml
|
config/pyrom_config.toml
|
||||||
|
|||||||
12
README.md
@@ -8,7 +8,7 @@ a live example can be seen in action over at [Porom](https://forum.poto.cafe/).
|
|||||||
## stack & structure
|
## 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 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.
|
on the client side, JS with only one library ([Bitty](https://bittyjs.com)) is used. for CSS, pyrom uses Sass.
|
||||||
|
|
||||||
below is an explanation of the folder structure:
|
below is an explanation of the folder structure:
|
||||||
|
|
||||||
@@ -32,13 +32,10 @@ below is an explanation of the folder structure:
|
|||||||
- `static/` - static files
|
- `static/` - static files
|
||||||
- `avatars/` - user avatar uploads
|
- `avatars/` - user avatar uploads
|
||||||
- `badges/` - user badge uploads
|
- `badges/` - user badge uploads
|
||||||
- `css/` - CSS files generated from Sass sources
|
- `css/` - stylesheets
|
||||||
- `emoji/` - emoji images used on the forum
|
- `emoji/` - emoji images used on the forum
|
||||||
- `fonts/`
|
- `fonts/`
|
||||||
- `js/`
|
- `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)
|
- `nginx.conf` - nginx config (production only)
|
||||||
- `uwsgi.ini` - uwsgi config (production only)
|
- `uwsgi.ini` - uwsgi config (production only)
|
||||||
|
|
||||||
@@ -92,10 +89,10 @@ $ pip install -r requirements.txt
|
|||||||
4. run dev server:
|
4. run dev server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ python -m app.run
|
$ python -m app.run -d
|
||||||
```
|
```
|
||||||
|
|
||||||
the server will run on localhost:8080. when run for the first time, it will create an admin account and print its credentials to the terminal, so make sure to run this in an interactive session.
|
the server will run on localhost:8080 (and will be available on the local network as well; if this is not desired, drop the `-d` flag). when run for the first time, it will create an admin account and print its credentials to the terminal, so make sure to run this in an interactive session.
|
||||||
|
|
||||||
press <kbd>Ctrl</kbd>+<kbd>C</kbd> to stop the server.
|
press <kbd>Ctrl</kbd>+<kbd>C</kbd> to stop the server.
|
||||||
|
|
||||||
@@ -110,4 +107,3 @@ when you want to run the server again, make sure to activate the venv first:
|
|||||||
$ source .venv/bin/activate
|
$ source .venv/bin/activate
|
||||||
$ python -m app.run
|
$ python -m app.run
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -30,13 +30,6 @@ Copyright: Copyright 2020-2024 The Atkinson Hyperlegible Mono Project Authors (h
|
|||||||
License: SIL Open Font License 1.1
|
License: SIL Open Font License 1.1
|
||||||
Designers: Elliott Scott, Megan Eiswerth, Braille Institute, Applied Design Works, Letters From Sweden
|
Designers: Elliott Scott, Megan Eiswerth, Braille Institute, Applied Design Works, Letters From Sweden
|
||||||
|
|
||||||
## ICONCINO
|
|
||||||
|
|
||||||
Affected files: [`app/templates/common/icons.html`](./app/templates/common/icons.html)
|
|
||||||
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
|
|
||||||
|
|
||||||
## Forumoji
|
## Forumoji
|
||||||
|
|
||||||
Affected files: everything in [`data/static/emoji`](./data/static/emoji) except [`data/static/emoji/scissors.png`](data/static/emoji/scissors.png)
|
Affected files: everything in [`data/static/emoji`](./data/static/emoji) except [`data/static/emoji/scissors.png`](data/static/emoji/scissors.png)
|
||||||
@@ -80,8 +73,8 @@ Repo: https://github.com/emcconville/wand
|
|||||||
|
|
||||||
## Bitty
|
## Bitty
|
||||||
|
|
||||||
Affected files: [`data/static/js/vnd/bitty-7.0.0.js`](./data/static/js/vnd/bitty-7.0.0.js)
|
Affected files: [`data/static/js/vnd/bitty-8.0.0.js`](./data/static/js/vnd/bitty-8.0.0.js)
|
||||||
URL: https://bitty-js.com/
|
URL: https://bittyjs.com/
|
||||||
License: CC0 1.0
|
License: CC0 1.0
|
||||||
Author: alan w smith https://www.alanwsmith.com/
|
Author: alan w smith https://www.alanwsmith.com/
|
||||||
Repo: https://github.com/alanwsmith/bitty
|
Repo: https://github.com/alanwsmith/bitty
|
||||||
@@ -100,3 +93,13 @@ Some rights reserved.
|
|||||||
|
|
||||||
License: BSD-3-Clause ([see more](https://github.com/pallets-eco/flask-caching/blob/e59bc040cd47cd2b43e501d636d43d442c50b3ff/LICENSE))
|
License: BSD-3-Clause ([see more](https://github.com/pallets-eco/flask-caching/blob/e59bc040cd47cd2b43e501d636d43d442c50b3ff/LICENSE))
|
||||||
Repo: https://github.com/pallets-eco/flask-caching
|
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
|
||||||
|
|||||||
216
app/__init__.py
@@ -1,8 +1,7 @@
|
|||||||
from flask import Flask, session, request, render_template
|
from flask import Flask, session, request, render_template, redirect, url_for
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from .models import Avatars, Users, PostHistory, Posts, MOTD, BadgeUploads, Sessions
|
from .models import Avatars, Users, PostHistory, Posts, MOTD, BadgeUploads, Sessions
|
||||||
from .auth import digest
|
from .auth import digest, is_logged_in, get_active_user
|
||||||
from .routes.users import is_logged_in, get_active_user, get_prefers_theme
|
|
||||||
from .constants import (
|
from .constants import (
|
||||||
PermissionLevel, permission_level_string,
|
PermissionLevel, permission_level_string,
|
||||||
InfoboxKind, InfoboxHTMLClass,
|
InfoboxKind, InfoboxHTMLClass,
|
||||||
@@ -11,45 +10,47 @@ from .constants import (
|
|||||||
)
|
)
|
||||||
from .lib.babycode import babycode_to_html, babycode_to_rssxml, EMOJI, BABYCODE_VERSION
|
from .lib.babycode import babycode_to_html, babycode_to_rssxml, EMOJI, BABYCODE_VERSION
|
||||||
from .lib.exceptions import SiteNameMissingException
|
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 datetime import datetime, timezone
|
||||||
from flask_caching import Cache
|
from flask_caching import Cache
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import secrets
|
import secrets
|
||||||
|
import hmac
|
||||||
import tomllib
|
import tomllib
|
||||||
import json
|
import json
|
||||||
|
|
||||||
def create_default_avatar():
|
def create_default_avatar():
|
||||||
if Avatars.count() == 0:
|
if Avatars.count() == 0:
|
||||||
print("Creating default avatar reference")
|
print('Creating default avatar reference')
|
||||||
Avatars.create({
|
Avatars.create({
|
||||||
"file_path": "/static/avatars/default.webp",
|
'file_path': '/static/avatars/default.webp',
|
||||||
"uploaded_at": int(time.time())
|
'uploaded_at': int(time.time())
|
||||||
})
|
})
|
||||||
|
|
||||||
def create_admin():
|
def create_admin():
|
||||||
username = "admin"
|
username = 'admin'
|
||||||
if Users.count({"username": username}) == 0:
|
if Users.count({'username': username}) == 0:
|
||||||
print("!!!!!Creating admin account!!!!!")
|
print('!!!!!Creating admin account!!!!!')
|
||||||
password_length = 16
|
password_length = 16
|
||||||
password = secrets.token_urlsafe(password_length)
|
password = secrets.token_urlsafe(password_length)
|
||||||
hashed = digest(password)
|
hashed = digest(password)
|
||||||
Users.create({
|
Users.create({
|
||||||
"username": username,
|
'username': username,
|
||||||
"password_hash": hashed,
|
'password_hash': hashed,
|
||||||
"permission": PermissionLevel.ADMIN.value,
|
'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!!!!!")
|
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():
|
def create_deleted_user():
|
||||||
username = "DeletedUser"
|
username = 'DeletedUser'
|
||||||
if Users.count({"username": username.lower()}) == 0:
|
if Users.count({'username': username.lower()}) == 0:
|
||||||
print("Creating DeletedUser")
|
print('Creating DeletedUser')
|
||||||
Users.create({
|
Users.create({
|
||||||
"username": username.lower(),
|
'username': username.lower(),
|
||||||
"display_name": username,
|
'display_name': username,
|
||||||
"password_hash": "",
|
'password_hash': '',
|
||||||
"permission": PermissionLevel.SYSTEM.value,
|
'permission': PermissionLevel.SYSTEM.value,
|
||||||
})
|
})
|
||||||
|
|
||||||
def reparse_babycode():
|
def reparse_babycode():
|
||||||
@@ -138,6 +139,11 @@ def bind_default_badges(path):
|
|||||||
'uploaded_at': int(os.path.getmtime(real_path)),
|
'uploaded_at': int(os.path.getmtime(real_path)),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
def clear_stale_invites():
|
||||||
|
from .db import db
|
||||||
|
from .util import time_now
|
||||||
|
db.execute('DELETE FROM "invite_keys" WHERE expires_at < ?', time_now())
|
||||||
|
|
||||||
def clear_stale_sessions():
|
def clear_stale_sessions():
|
||||||
from .db import db
|
from .db import db
|
||||||
with db.transaction():
|
with db.transaction():
|
||||||
@@ -148,12 +154,20 @@ def clear_stale_sessions():
|
|||||||
for sess in stale_sessions:
|
for sess in stale_sessions:
|
||||||
sess.delete()
|
sess.delete()
|
||||||
|
|
||||||
|
def clear_api_limits():
|
||||||
|
from .db import db
|
||||||
|
from .models import APIRateLimits
|
||||||
|
with db.transaction():
|
||||||
|
limits = APIRateLimits.select()
|
||||||
|
for l in limits:
|
||||||
|
l.delete()
|
||||||
|
|
||||||
cache = Cache()
|
cache = Cache()
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config['SITE_NAME'] = 'Pyrom'
|
app.config['SITE_NAME'] = 'Pyrom'
|
||||||
|
app.config['SITE_TAGLINE'] = 'anti-social media'
|
||||||
app.config['DISABLE_SIGNUP'] = False
|
app.config['DISABLE_SIGNUP'] = False
|
||||||
app.config['MODS_CAN_INVITE'] = True
|
app.config['MODS_CAN_INVITE'] = True
|
||||||
app.config['USERS_CAN_INVITE'] = False
|
app.config['USERS_CAN_INVITE'] = False
|
||||||
@@ -168,26 +182,26 @@ def create_app():
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
print('No configuration file found, leaving defaults.')
|
print('No configuration file found, leaving defaults.')
|
||||||
|
|
||||||
if os.getenv("PYROM_PROD") is None:
|
if os.getenv('PYROM_PROD') is None:
|
||||||
app.static_folder = os.path.join(os.path.dirname(__file__), "../data/static")
|
app.static_folder = os.path.join(os.path.dirname(__file__), '../data/static')
|
||||||
app.debug = True
|
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"
|
app.config['SERVER_NAME'] = 'localhost:8080'
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
else:
|
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"]:
|
if not app.config['SERVER_NAME']:
|
||||||
raise SiteNameMissingException()
|
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['AVATAR_UPLOAD_PATH'] = 'data/static/avatars/'
|
||||||
app.config['BADGES_PATH'] = 'data/static/badges/'
|
app.config['BADGES_PATH'] = 'data/static/badges/'
|
||||||
app.config['BADGES_UPLOAD_PATH'] = 'data/static/badges/user/'
|
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
|
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['DB_PATH']), exist_ok = True)
|
||||||
os.makedirs(os.path.dirname(app.config["BADGES_UPLOAD_PATH"]), exist_ok = True)
|
os.makedirs(os.path.dirname(app.config['BADGES_UPLOAD_PATH']), exist_ok = True)
|
||||||
|
|
||||||
if app.config['CACHE_TYPE'] == 'FileSystemCache':
|
if app.config['CACHE_TYPE'] == 'FileSystemCache':
|
||||||
cache_dir = app.config.get('CACHE_DIR', 'data/_cached')
|
cache_dir = app.config.get('CACHE_DIR', 'data/_cached')
|
||||||
@@ -196,35 +210,22 @@ def create_app():
|
|||||||
|
|
||||||
cache.init_app(app)
|
cache.init_app(app)
|
||||||
|
|
||||||
css_dir = 'data/static/css/'
|
|
||||||
allowed_themes = []
|
|
||||||
for f in os.listdir(css_dir):
|
|
||||||
if not os.path.isfile(os.path.join(css_dir, f)):
|
|
||||||
continue
|
|
||||||
theme_name = os.path.splitext(os.path.basename(f))[0]
|
|
||||||
allowed_themes.append(theme_name)
|
|
||||||
|
|
||||||
allowed_themes.sort(key=(lambda x: (x != 'style', x)))
|
|
||||||
app.config['allowed_themes'] = allowed_themes
|
|
||||||
|
|
||||||
from app.routes.app import bp as app_bp
|
|
||||||
from app.routes.topics import bp as topics_bp
|
from app.routes.topics import bp as topics_bp
|
||||||
from app.routes.threads import bp as threads_bp
|
from app.routes.threads import bp as threads_bp
|
||||||
from app.routes.users import bp as users_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
|
|
||||||
from app.routes.hyperapi import bp as hyperapi_bp
|
|
||||||
from app.routes.guides import bp as guides_bp
|
from app.routes.guides import bp as guides_bp
|
||||||
app.register_blueprint(app_bp)
|
from app.routes.mod import bp as mod_bp
|
||||||
|
from app.routes.posts import bp as posts_bp
|
||||||
|
from app.routes.api import bp as api_bp
|
||||||
|
from app.routes.hyperapi import bp as hyperapi_bp
|
||||||
app.register_blueprint(topics_bp)
|
app.register_blueprint(topics_bp)
|
||||||
app.register_blueprint(threads_bp)
|
app.register_blueprint(threads_bp)
|
||||||
app.register_blueprint(users_bp)
|
app.register_blueprint(users_bp)
|
||||||
app.register_blueprint(mod_bp)
|
|
||||||
app.register_blueprint(api_bp)
|
|
||||||
app.register_blueprint(posts_bp)
|
|
||||||
app.register_blueprint(hyperapi_bp)
|
|
||||||
app.register_blueprint(guides_bp)
|
app.register_blueprint(guides_bp)
|
||||||
|
app.register_blueprint(mod_bp)
|
||||||
|
app.register_blueprint(posts_bp)
|
||||||
|
app.register_blueprint(api_bp)
|
||||||
|
app.register_blueprint(hyperapi_bp)
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
from .schema import create as create_tables
|
from .schema import create as create_tables
|
||||||
@@ -237,6 +238,8 @@ def create_app():
|
|||||||
create_deleted_user()
|
create_deleted_user()
|
||||||
|
|
||||||
clear_stale_sessions()
|
clear_stale_sessions()
|
||||||
|
clear_api_limits()
|
||||||
|
clear_stale_invites()
|
||||||
|
|
||||||
reparse_babycode()
|
reparse_babycode()
|
||||||
|
|
||||||
@@ -245,52 +248,71 @@ def create_app():
|
|||||||
app.config['SESSION_COOKIE_SECURE'] = True
|
app.config['SESSION_COOKIE_SECURE'] = True
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def make_session_permanent():
|
def revoke_session():
|
||||||
session.permanent = True
|
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:
|
with open('.git/refs/heads/main') as f:
|
||||||
commit = f.read().strip()
|
commit = f.read().strip()
|
||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
def inject_constants():
|
def inject_constants():
|
||||||
return {
|
return {
|
||||||
"InfoboxHTMLClass": InfoboxHTMLClass,
|
'InfoboxHTMLClass': InfoboxHTMLClass,
|
||||||
"InfoboxKind": InfoboxKind,
|
'InfoboxKind': InfoboxKind,
|
||||||
"PermissionLevel": PermissionLevel,
|
'PermissionLevel': PermissionLevel,
|
||||||
"__commit": commit,
|
'__commit': commit,
|
||||||
"__emoji": EMOJI,
|
'__emoji': EMOJI,
|
||||||
"REACTION_EMOJI": REACTION_EMOJI,
|
'REACTION_EMOJI': REACTION_EMOJI,
|
||||||
"MOTD_BANNED_TAGS": MOTD_BANNED_TAGS,
|
'MOTD_BANNED_TAGS': MOTD_BANNED_TAGS,
|
||||||
"SIG_BANNED_TAGS": SIG_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()}
|
|
||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
def inject_funcs():
|
def inject_funcs():
|
||||||
from .routes.threads import get_post_url
|
|
||||||
return {
|
return {
|
||||||
'get_post_url': get_post_url,
|
|
||||||
'get_prefers_theme': get_prefers_theme,
|
|
||||||
'get_motds': MOTD.get_all,
|
'get_motds': MOTD.get_all,
|
||||||
'get_time_now': lambda: int(time.time()),
|
'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):
|
def ts_datetime(ts, format):
|
||||||
return datetime.utcfromtimestamp(ts or int(time.time())).strftime(format)
|
return datetime.utcfromtimestamp(ts or int(time.time())).strftime(format)
|
||||||
|
|
||||||
@app.template_filter("pluralize")
|
@app.template_filter('dict_to_query_string')
|
||||||
def pluralize(subject, num=1, singular = "", plural = "s"):
|
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:
|
if int(num) == 1:
|
||||||
return subject + singular
|
return subject + singular
|
||||||
|
|
||||||
return subject + plural
|
return subject + plural
|
||||||
|
|
||||||
@app.template_filter("permission_string")
|
@app.template_filter('permission_string')
|
||||||
def permission_string(term):
|
def permission_string(term):
|
||||||
return permission_level_string(term)
|
return permission_level_string(term)
|
||||||
|
|
||||||
@@ -302,16 +324,6 @@ def create_app():
|
|||||||
def babycode_strict_filter(markup, nofrag=False):
|
def babycode_strict_filter(markup, nofrag=False):
|
||||||
return babycode_to_html(markup, banned_tags=STRICT_BANNED_TAGS, fragment=not nofrag).result
|
return babycode_to_html(markup, banned_tags=STRICT_BANNED_TAGS, 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('basename_noext')
|
@app.template_filter('basename_noext')
|
||||||
def basename_noext(subj):
|
def basename_noext(subj):
|
||||||
return os.path.splitext(os.path.basename(subj))[0]
|
return os.path.splitext(os.path.basename(subj))[0]
|
||||||
@@ -325,28 +337,44 @@ def create_app():
|
|||||||
else:
|
else:
|
||||||
return render_template('common/404.html'), e.code
|
return render_template('common/404.html'), e.code
|
||||||
|
|
||||||
@app.errorhandler(413)
|
@app.errorhandler(405)
|
||||||
def _handle_413(e):
|
def _handle_405(e):
|
||||||
if request.path.startswith('/hyperapi/'):
|
if request.path.startswith('/hyperapi/'):
|
||||||
return '<h1>request body too large</h1>', e.code
|
return '<h1>method not allowed</h1>', e.code
|
||||||
elif request.path.startswith('/api/'):
|
elif request.path.startswith('/api/'):
|
||||||
return {'error': 'body too large'}, e.code
|
return {'error': 'method not allowed'}, e.code
|
||||||
else:
|
else:
|
||||||
return render_template('common/413.html'), e.code
|
return render_template('common/404.html'), e.code
|
||||||
|
|
||||||
|
@app.errorhandler(403)
|
||||||
|
def _handle_403(e):
|
||||||
|
if request.path.startswith('/hyperapi/'):
|
||||||
|
return '<h1>forbiddedn</h1>', e.code
|
||||||
|
elif request.path.startswith('/api/'):
|
||||||
|
return {'error': 'forbidden'}, e.code
|
||||||
|
else:
|
||||||
|
return render_template('common/403.html'), e.code
|
||||||
|
|
||||||
|
@app.get('/')
|
||||||
|
def index():
|
||||||
|
return redirect(url_for('topics.all_topics'))
|
||||||
|
|
||||||
|
#
|
||||||
|
# @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
|
# this only happens at build time but
|
||||||
# build time is when updates are done anyway
|
# build time is when updates are done anyway
|
||||||
# sooo... /shrug
|
# sooo... /shrug
|
||||||
@app.template_filter('cachebust')
|
@app.template_filter('cachebust')
|
||||||
def cachebust(subject):
|
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')
|
@app.template_filter('fromjson')
|
||||||
def fromjson(subject: str):
|
def fromjson(subject: str):
|
||||||
|
|||||||
144
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 argon2 import PasswordHasher
|
||||||
|
from functools import wraps
|
||||||
|
import secrets
|
||||||
|
import hmac
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
|
||||||
ph = PasswordHasher()
|
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):
|
def digest(password):
|
||||||
return ph.hash(password)
|
return ph.hash(password)
|
||||||
|
|
||||||
@@ -10,3 +28,129 @@ def verify(expected, given):
|
|||||||
return ph.verify(expected, given)
|
return ph.verify(expected, given)
|
||||||
except:
|
except:
|
||||||
return False
|
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 revoke_session(user_id):
|
||||||
|
if not is_logged_in():
|
||||||
|
return
|
||||||
|
sess = Sessions.find({'key': session['pyrom_session_key']})
|
||||||
|
if not sess:
|
||||||
|
return
|
||||||
|
sess.delete()
|
||||||
|
session.clear()
|
||||||
|
|
||||||
|
def revoke_all_sessions(user_id):
|
||||||
|
if not is_logged_in():
|
||||||
|
return
|
||||||
|
|
||||||
|
Sessions.revoke_all(user_id)
|
||||||
|
session.clear()
|
||||||
|
|
||||||
|
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 parse_display_name(display_name: str) -> str:
|
||||||
|
if len(display_name) == 0:
|
||||||
|
return display_name
|
||||||
|
invalid_regex = r'[@<>&]'
|
||||||
|
res = re.sub(invalid_regex, '_', display_name)[:50]
|
||||||
|
while len(res) < 3:
|
||||||
|
res += '_'
|
||||||
|
return res
|
||||||
|
|
||||||
|
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 hard_login_required(view_func):
|
||||||
|
@wraps(view_func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
if not is_logged_in():
|
||||||
|
abort(403)
|
||||||
|
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
|
||||||
|
|||||||
@@ -70,8 +70,8 @@ class InfoboxKind(IntEnum):
|
|||||||
ERROR = 3
|
ERROR = 3
|
||||||
|
|
||||||
InfoboxHTMLClass = {
|
InfoboxHTMLClass = {
|
||||||
InfoboxKind.INFO: "",
|
InfoboxKind.INFO: '',
|
||||||
InfoboxKind.LOCK: "warn",
|
InfoboxKind.LOCK: 'warn',
|
||||||
InfoboxKind.WARN: "warn",
|
InfoboxKind.WARN: 'warn',
|
||||||
InfoboxKind.ERROR: "critical",
|
InfoboxKind.ERROR: 'critical',
|
||||||
}
|
}
|
||||||
|
|||||||
10
app/db.py
@@ -49,6 +49,12 @@ class DB:
|
|||||||
yield conn
|
yield conn
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def binding_list(num: int) -> str:
|
||||||
|
"""Returns a bindings list string for the given number of bindings."""
|
||||||
|
return '(%s)' % ','.join('?' * num)
|
||||||
|
|
||||||
|
|
||||||
def query(self, sql, *args):
|
def query(self, sql, *args):
|
||||||
"""Executes a query and returns a list of dictionaries."""
|
"""Executes a query and returns a list of dictionaries."""
|
||||||
with self.connection() as conn:
|
with self.connection() as conn:
|
||||||
@@ -104,6 +110,10 @@ class DB:
|
|||||||
conditions = []
|
conditions = []
|
||||||
params = []
|
params = []
|
||||||
for col, op, val in self._where:
|
for col, op, val in self._where:
|
||||||
|
if isinstance(val, tuple) or isinstance(val, list):
|
||||||
|
conditions.append(f"{col} {op} {db.binding_list(len(val))}")
|
||||||
|
params.extend(val)
|
||||||
|
else:
|
||||||
conditions.append(f"{col} {op} ?")
|
conditions.append(f"{col} {op} ?")
|
||||||
params.append(val)
|
params.append(val)
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from pygments.lexers import get_lexer_by_name
|
|||||||
from pygments.util import ClassNotFound as PygmentsClassNotFound
|
from pygments.util import ClassNotFound as PygmentsClassNotFound
|
||||||
import re
|
import re
|
||||||
|
|
||||||
BABYCODE_VERSION = 8
|
BABYCODE_VERSION = 10
|
||||||
|
|
||||||
|
|
||||||
class BabycodeError(Exception):
|
class BabycodeError(Exception):
|
||||||
@@ -183,7 +183,7 @@ class HTMLRenderer(BabycodeRenderer):
|
|||||||
if mention_data not in self.mentions:
|
if mention_data not in self.mentions:
|
||||||
self.mentions.append(mention_data)
|
self.mentions.append(mention_data)
|
||||||
|
|
||||||
return f"<a class='mention{' display' if target_user.has_display_name() else ''}' href='{url_for('users.page', username=target_user.username)}' title='@{target_user.username}' data-init='highlightMentions' data-username='{target_user.username}'>{'@' if not target_user.has_display_name() else ''}{target_user.get_readable_name()}</a>"
|
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):
|
def render(self, ast):
|
||||||
out = super().render(ast)
|
out = super().render(ast)
|
||||||
@@ -201,7 +201,7 @@ class RSSXMLRenderer(BabycodeRenderer):
|
|||||||
if not target_user:
|
if not target_user:
|
||||||
return f"@{e['name']}"
|
return f"@{e['name']}"
|
||||||
|
|
||||||
return f'<a href="{url_for('users.page', username=target_user.username, _external=True)}" title="@{target_user.username}">{'@' if not target_user.has_display_name() else ''}{target_user.get_readable_name()}</a>'
|
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 = [
|
NAMED_COLORS = [
|
||||||
@@ -345,16 +345,21 @@ def tag_code(children, attr):
|
|||||||
return f"<code class=\"inline-code\">{children}</code>"
|
return f"<code class=\"inline-code\">{children}</code>"
|
||||||
else:
|
else:
|
||||||
input_code = children.strip()
|
input_code = children.strip()
|
||||||
button = f"<button type=button class=\"copy-code\" value=\"{input_code}\" data-send=\"copyCode\" data-receive=\"copyCode\">Copy</button>"
|
language = 'code block'
|
||||||
unhighlighted = f"<pre><span class=\"copy-code-container\"><span class=\"code-language-identifier\">code block</span>{button}</span><code>{input_code}</code></pre>"
|
if attr:
|
||||||
if not attr:
|
|
||||||
return unhighlighted
|
|
||||||
try:
|
try:
|
||||||
lexer = get_lexer_by_name(attr.strip())
|
lexer = get_lexer_by_name(attr.strip())
|
||||||
formatter = HtmlFormatter(nowrap=True)
|
formatter = HtmlFormatter(nowrap=True)
|
||||||
return f"<pre><span class=\"copy-code-container\"><span class=\"code-language-identifier\">{lexer.name}</span>{button}</span><code>{highlight(Markup(input_code).unescape(), lexer, formatter)}</code></pre>"
|
language = lexer.name
|
||||||
|
code = highlight(Markup(input_code).unescape(), lexer, formatter)
|
||||||
except PygmentsClassNotFound:
|
except PygmentsClassNotFound:
|
||||||
return unhighlighted
|
code = input_code
|
||||||
|
else:
|
||||||
|
code = input_code
|
||||||
|
|
||||||
|
button = f'<button autocomplete=off disabled data-r="enhance" title="This feature requires JavaScript to be enabled." type=button class="copy-code" data-s="copyCode">Copy</button>'
|
||||||
|
block = f'<fieldset data-r="copyCode" data-code="{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):
|
def tag_list(children):
|
||||||
@@ -383,16 +388,24 @@ def tag_color(children, attr):
|
|||||||
|
|
||||||
def tag_spoiler(children, attr):
|
def tag_spoiler(children, attr):
|
||||||
spoiler_name = attr if attr else "Spoiler"
|
spoiler_name = attr if attr else "Spoiler"
|
||||||
content = f"<div class='accordion-content post-accordion-content hidden'>{children}</div>"
|
content = f"<div class='plank minimal even no-shadow hidden'>{children}</div>"
|
||||||
container = f"""<div class='accordion hidden' data-receive='toggleAccordion'><div class='accordion-header'><button type='button' class='accordion-toggle' data-send='toggleAccordion'>+</button><span>{spoiler_name}</span></div>{content}</div>"""
|
container = f"""<details><summary class='plank secondary-bg no-shadow even'>{spoiler_name}</summary>{content}</details>"""
|
||||||
return container
|
return container
|
||||||
|
|
||||||
|
|
||||||
def tag_image(children, attr):
|
def tag_image(children, attr):
|
||||||
img = f"<img class=\"post-image\" src=\"{attr}\" alt=\"{children}\">"
|
img = f"<img class=\"post-image\" src=\"{attr}\" alt=\"{children}\">"
|
||||||
return f"<div class=post-img-container>{img}</div>"
|
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 = {
|
TAGS = {
|
||||||
"b": lambda children, attr: f"<strong>{children}</strong>",
|
"b": lambda children, attr: f"<strong>{children}</strong>",
|
||||||
"i": lambda children, attr: f"<em>{children}</em>",
|
"i": lambda children, attr: f"<em>{children}</em>",
|
||||||
@@ -401,7 +414,7 @@ TAGS = {
|
|||||||
|
|
||||||
"img": tag_image,
|
"img": tag_image,
|
||||||
"url": lambda children, attr: f"<a href={attr}>{children}</a>",
|
"url": lambda children, attr: f"<a href={attr}>{children}</a>",
|
||||||
"quote": lambda children, attr: f"<blockquote>{children}</blockquote>",
|
"quote": tag_quote,
|
||||||
"code": tag_code,
|
"code": tag_code,
|
||||||
"ul": lambda children, attr: f"<ul>{tag_list(children)}</ul>",
|
"ul": lambda children, attr: f"<ul>{tag_list(children)}</ul>",
|
||||||
"ol": lambda children, attr: f"<ol>{tag_list(children)}</ol>",
|
"ol": lambda children, attr: f"<ol>{tag_list(children)}</ol>",
|
||||||
@@ -463,11 +476,8 @@ VOID_TAGS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# [img] is considered block for the purposes of collapsing whitespace,
|
|
||||||
# despite being potentially inline (since the resulting <img> tag is inline, but creates a block container around itself and sibling images).
|
|
||||||
# [code] has a special case in is_inline().
|
|
||||||
INLINE_TAGS = {
|
INLINE_TAGS = {
|
||||||
'b', 'i', 's', 'u', 'color', 'big', 'small', 'url', 'lb', 'rb', 'at', 'd'
|
'b', 'i', 's', 'u', 'color', 'big', 'small', 'url', 'lb', 'rb', 'at', 'd', 'img'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ MIGRATIONS = [
|
|||||||
add_signature_format,
|
add_signature_format,
|
||||||
create_default_bookmark_collections,
|
create_default_bookmark_collections,
|
||||||
add_display_name,
|
add_display_name,
|
||||||
'ALTER TABLE "post_history" ADD COLUMN "content_rss" STRING DEFAULT NULL'
|
'ALTER TABLE "post_history" ADD COLUMN "content_rss" STRING DEFAULT NULL',
|
||||||
|
'ALTER TABLE "invite_keys" ADD COLUMN "expires_at" INTEGER NOT NULL DEFAULT 0',
|
||||||
]
|
]
|
||||||
|
|
||||||
def run_migrations():
|
def run_migrations():
|
||||||
@@ -53,11 +54,11 @@ def run_migrations():
|
|||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
if len(MIGRATIONS) == 0:
|
if len(MIGRATIONS) == 0:
|
||||||
print("No migrations defined.")
|
print('No migrations defined.')
|
||||||
return
|
return
|
||||||
print("Running migrations...")
|
print('Running migrations...')
|
||||||
ran = 0
|
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}
|
to_run = {idx: migration_obj for idx, migration_obj in enumerate(MIGRATIONS) if idx not in completed}
|
||||||
if not to_run:
|
if not to_run:
|
||||||
print('No migrations need to run.')
|
print('No migrations need to run.')
|
||||||
@@ -74,4 +75,4 @@ def run_migrations():
|
|||||||
|
|
||||||
db.execute('INSERT INTO _migrations (id) VALUES (?)', migration_id)
|
db.execute('INSERT INTO _migrations (id) VALUES (?)', migration_id)
|
||||||
ran += 1
|
ran += 1
|
||||||
print(f"Ran {ran} migrations.")
|
print(f'Ran {ran} migrations.')
|
||||||
|
|||||||
410
app/models.py
@@ -4,10 +4,10 @@ from flask import current_app
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
class Users(Model):
|
class Users(Model):
|
||||||
table = "users"
|
table = 'users'
|
||||||
|
|
||||||
def get_avatar_url(self):
|
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):
|
def is_default_avatar(self):
|
||||||
return int(Avatars.find({'id': self.avatar_id}).id) == 1
|
return int(Avatars.find({'id': self.avatar_id}).id) == 1
|
||||||
@@ -30,23 +30,6 @@ class Users(Model):
|
|||||||
def is_default_avatar(self):
|
def is_default_avatar(self):
|
||||||
return self.avatar_id == 1
|
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):
|
def get_post_stats(self):
|
||||||
q = """SELECT
|
q = """SELECT
|
||||||
COUNT(DISTINCT posts.id) AS post_count,
|
COUNT(DISTINCT posts.id) AS post_count,
|
||||||
@@ -67,25 +50,29 @@ class Users(Model):
|
|||||||
WHERE users.id = ?"""
|
WHERE users.id = ?"""
|
||||||
return db.fetch_one(q, self.id)
|
return db.fetch_one(q, self.id)
|
||||||
|
|
||||||
def get_all_subscriptions(self):
|
def get_posts(self, per_page: int, page: int) -> list:
|
||||||
q = """
|
q = f'{Posts.FULL_POSTS_QUERY} WHERE posts.user_id = ? ORDER BY posts.created_at DESC LIMIT ? OFFSET ?'
|
||||||
SELECT threads.title AS thread_title, threads.slug AS thread_slug
|
return db.query(q, self.id, per_page, (page - 1) * per_page)
|
||||||
FROM
|
|
||||||
threads
|
|
||||||
JOIN
|
|
||||||
subscriptions ON subscriptions.thread_id = threads.id
|
|
||||||
WHERE
|
|
||||||
subscriptions.user_id = ?"""
|
|
||||||
return db.query(q, self.id)
|
|
||||||
|
|
||||||
def can_post_to_topic(self, topic):
|
def get_started_threads(self, per_page: int, page: int) -> list:
|
||||||
|
q = f"""{Posts.FULL_POSTS_QUERY} WHERE threads.user_id = ? AND posts.created_at = (
|
||||||
|
SELECT MIN(p2.created_at)
|
||||||
|
FROM posts p2
|
||||||
|
WHERE p2.thread_id = posts.thread_id
|
||||||
|
) ORDER BY threads.created_at DESC LIMIT ? OFFSET ?"""
|
||||||
|
return db.query(q, self.id, per_page, (page - 1) * per_page)
|
||||||
|
|
||||||
|
def get_all_subscriptions(self):
|
||||||
|
return Subscriptions.findall({'user_id': self.id})
|
||||||
|
|
||||||
|
def can_post_to_thread_or_topic(self, thread_or_topic):
|
||||||
if self.is_guest():
|
if self.is_guest():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if self.is_mod():
|
if self.is_mod():
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if topic['is_locked']:
|
if thread_or_topic['is_locked']:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
@@ -119,114 +106,127 @@ class Users(Model):
|
|||||||
def get_badges(self):
|
def get_badges(self):
|
||||||
return Badges.findall({'user_id': int(self.id)})
|
return Badges.findall({'user_id': int(self.id)})
|
||||||
|
|
||||||
|
def is_subscribed(self, thread_id):
|
||||||
|
return Subscriptions.count({'user_id': self.id, 'thread_id': thread_id}) > 0
|
||||||
|
|
||||||
|
def get_unread_count(self):
|
||||||
|
q = """
|
||||||
|
SELECT
|
||||||
|
COUNT(posts.id) as c
|
||||||
|
FROM
|
||||||
|
subscriptions
|
||||||
|
JOIN
|
||||||
|
threads ON subscriptions.thread_id = threads.id
|
||||||
|
JOIN
|
||||||
|
posts ON threads.id = posts.thread_id
|
||||||
|
WHERE
|
||||||
|
subscriptions.user_id = ?
|
||||||
|
AND
|
||||||
|
posts.created_at > subscriptions.last_seen
|
||||||
|
"""
|
||||||
|
res = db.fetch_one(q, self.id)
|
||||||
|
return res["c"] or 0
|
||||||
|
|
||||||
|
def set_signature(self, content:str, language: str = 'babycode'):
|
||||||
|
if not content:
|
||||||
|
self.update({
|
||||||
|
'signature_original_markup': '',
|
||||||
|
'signature_rendered': '',
|
||||||
|
'signature_format_version': None,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
from .lib.babycode import babycode_to_html, BABYCODE_VERSION
|
||||||
|
from .constants import SIG_BANNED_TAGS
|
||||||
|
signature_rendered = babycode_to_html(content, SIG_BANNED_TAGS).result
|
||||||
|
self.update({
|
||||||
|
'signature_original_markup': content,
|
||||||
|
'signature_rendered': signature_rendered,
|
||||||
|
'signature_format_version': BABYCODE_VERSION,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
class Topics(Model):
|
class Topics(Model):
|
||||||
table = "topics"
|
table = 'topics'
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_list(_cls):
|
def get_list(_cls):
|
||||||
q = """
|
q = """
|
||||||
SELECT
|
SELECT
|
||||||
topics.id, topics.name, topics.slug, topics.description, topics.is_locked,
|
topics.id, topics.name, topics.slug, topics.description, topics.is_locked,
|
||||||
users.username AS latest_thread_username,
|
COUNT(DISTINCT threads.id) as threads_count,
|
||||||
users.display_name AS latest_thread_display_name,
|
COUNT(posts.id) AS posts_count,
|
||||||
threads.title AS latest_thread_title,
|
MAX(posts.created_at) as latest_post_timestamp
|
||||||
threads.slug AS latest_thread_slug,
|
|
||||||
threads.created_at AS latest_thread_created_at
|
|
||||||
FROM
|
FROM
|
||||||
topics
|
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
|
LEFT JOIN
|
||||||
users on users.id = threads.user_id
|
threads ON threads.topic_id = topics.id
|
||||||
ORDER BY
|
LEFT JOIN
|
||||||
topics.sort_order ASC"""
|
posts ON posts.thread_id = threads.id
|
||||||
|
GROUP BY topics.id ORDER BY topics.sort_order ASC"""
|
||||||
return db.query(q)
|
return db.query(q)
|
||||||
|
|
||||||
@classmethod
|
@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 = slugify(name, max_length=50)
|
||||||
|
|
||||||
|
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 = """
|
q = """
|
||||||
WITH ranked_threads AS (
|
WITH latest_posts AS (
|
||||||
SELECT
|
SELECT
|
||||||
threads.topic_id, threads.id AS thread_id, threads.title AS thread_title, threads.slug AS thread_slug,
|
thread_id,
|
||||||
posts.id AS post_id, posts.created_at AS post_created_at,
|
id AS latest_post_id,
|
||||||
users.username, users.display_name,
|
user_id AS latest_post_user_id,
|
||||||
ROW_NUMBER() OVER (PARTITION BY threads.topic_id ORDER BY posts.created_at DESC) AS rn
|
created_at AS latest_post_created_at,
|
||||||
FROM
|
ROW_NUMBER() OVER (PARTITION BY thread_id ORDER BY created_at DESC) AS rn
|
||||||
threads
|
FROM posts
|
||||||
JOIN
|
),
|
||||||
posts ON threads.id = posts.thread_id
|
post_counts AS (
|
||||||
LEFT JOIN
|
SELECT
|
||||||
users ON posts.user_id = users.id
|
thread_id,
|
||||||
|
COUNT(*) AS posts_count
|
||||||
|
FROM posts
|
||||||
|
GROUP BY thread_id
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
topic_id,
|
threads.id,
|
||||||
thread_id, thread_title, thread_slug,
|
threads.title,
|
||||||
post_id, post_created_at,
|
threads.slug,
|
||||||
username, display_name
|
threads.created_at,
|
||||||
FROM
|
threads.is_locked,
|
||||||
ranked_threads
|
threads.is_stickied,
|
||||||
WHERE
|
starter.username AS started_by,
|
||||||
rn = 1
|
starter.display_name AS started_by_display_name,
|
||||||
ORDER BY
|
latest_poster.username AS latest_post_username,
|
||||||
topic_id"""
|
latest_poster.display_name AS latest_post_display_name,
|
||||||
|
latest_posts.latest_post_created_at,
|
||||||
active_threads_raw = db.query(q)
|
latest_posts.latest_post_id,
|
||||||
active_threads = {}
|
COALESCE(post_counts.posts_count, 0) AS posts_count
|
||||||
for thread in active_threads_raw:
|
FROM threads
|
||||||
active_threads[int(thread['topic_id'])] = {
|
JOIN users AS starter ON starter.id = threads.user_id
|
||||||
'thread_title': thread['thread_title'],
|
LEFT JOIN latest_posts ON latest_posts.thread_id = threads.id AND latest_posts.rn = 1
|
||||||
'thread_slug': thread['thread_slug'],
|
LEFT JOIN users AS latest_poster ON latest_poster.id = latest_posts.latest_post_user_id
|
||||||
'post_id': thread['post_id'],
|
LEFT JOIN post_counts ON post_counts.thread_id = threads.id
|
||||||
'username': thread['username'],
|
WHERE threads.topic_id = ?
|
||||||
'display_name': thread['display_name'],
|
""" + order_clause + ' LIMIT ? OFFSET ?'
|
||||||
'post_created_at': thread['post_created_at']
|
|
||||||
}
|
|
||||||
return active_threads
|
|
||||||
|
|
||||||
def get_threads(self, per_page, page, sort_by = "activity"):
|
|
||||||
order_clause = ""
|
|
||||||
if sort_by == "thread":
|
|
||||||
order_clause = "ORDER BY threads.is_stickied DESC, threads.created_at DESC"
|
|
||||||
else:
|
|
||||||
order_clause = "ORDER BY threads.is_stickied DESC, latest_post_created_at DESC"
|
|
||||||
|
|
||||||
q = """
|
|
||||||
SELECT
|
|
||||||
threads.id, threads.title, threads.slug, threads.created_at, threads.is_locked, threads.is_stickied,
|
|
||||||
users.username AS started_by,
|
|
||||||
users.display_name AS started_by_display_name,
|
|
||||||
u.username AS latest_post_username,
|
|
||||||
u.display_name AS latest_post_display_name,
|
|
||||||
ph.content AS latest_post_content,
|
|
||||||
posts.created_at AS latest_post_created_at,
|
|
||||||
posts.id AS latest_post_id
|
|
||||||
FROM
|
|
||||||
threads
|
|
||||||
JOIN users ON users.id = threads.user_id
|
|
||||||
JOIN (
|
|
||||||
SELECT
|
|
||||||
posts.thread_id,
|
|
||||||
posts.id,
|
|
||||||
posts.user_id,
|
|
||||||
posts.created_at,
|
|
||||||
posts.current_revision_id,
|
|
||||||
ROW_NUMBER() OVER (PARTITION BY posts.thread_id ORDER BY posts.created_at DESC) AS rn
|
|
||||||
FROM
|
|
||||||
posts
|
|
||||||
) posts ON posts.thread_id = threads.id AND posts.rn = 1
|
|
||||||
JOIN
|
|
||||||
post_history ph ON ph.id = posts.current_revision_id
|
|
||||||
JOIN
|
|
||||||
users u ON u.id = posts.user_id
|
|
||||||
WHERE
|
|
||||||
threads.topic_id = ?
|
|
||||||
""" + order_clause + " LIMIT ? OFFSET ?"
|
|
||||||
|
|
||||||
return db.query(q, self.id, per_page, (page - 1) * per_page)
|
return db.query(q, self.id, per_page, (page - 1) * per_page)
|
||||||
|
|
||||||
@@ -262,13 +262,16 @@ class Topics(Model):
|
|||||||
|
|
||||||
return db.query(q, self.id)
|
return db.query(q, self.id)
|
||||||
|
|
||||||
|
def locked(self):
|
||||||
|
return bool(self.is_locked)
|
||||||
|
|
||||||
|
|
||||||
class Threads(Model):
|
class Threads(Model):
|
||||||
table = "threads"
|
table = 'threads'
|
||||||
|
|
||||||
def get_posts(self, 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 ?"
|
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)
|
return db.query(q, self.id, per_page, (page - 1) * per_page)
|
||||||
|
|
||||||
def get_posts_rss(self):
|
def get_posts_rss(self):
|
||||||
q = Posts.FULL_POSTS_QUERY + ' WHERE posts.thread_id = ?'
|
q = Posts.FULL_POSTS_QUERY + ' WHERE posts.thread_id = ?'
|
||||||
@@ -280,6 +283,21 @@ class Threads(Model):
|
|||||||
def stickied(self):
|
def stickied(self):
|
||||||
return bool(self.is_stickied)
|
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 = slugify(title, max_length=50)
|
||||||
|
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):
|
class Posts(Model):
|
||||||
FULL_POSTS_QUERY = """
|
FULL_POSTS_QUERY = """
|
||||||
WITH user_badges AS (
|
WITH user_badges AS (
|
||||||
@@ -307,6 +325,7 @@ class Posts(Model):
|
|||||||
users.id AS user_id, post_history.original_markup,
|
users.id AS user_id, post_history.original_markup,
|
||||||
users.signature_rendered, threads.slug AS thread_slug,
|
users.signature_rendered, threads.slug AS thread_slug,
|
||||||
threads.is_locked AS thread_is_locked, threads.title AS thread_title,
|
threads.is_locked AS thread_is_locked, threads.title AS thread_title,
|
||||||
|
topics.id AS topic_id, topics.name AS topic_name,
|
||||||
COALESCE(user_badges.badges_json, '[]') AS badges_json
|
COALESCE(user_badges.badges_json, '[]') AS badges_json
|
||||||
FROM
|
FROM
|
||||||
posts
|
posts
|
||||||
@@ -314,6 +333,8 @@ class Posts(Model):
|
|||||||
post_history ON posts.current_revision_id = post_history.id
|
post_history ON posts.current_revision_id = post_history.id
|
||||||
JOIN
|
JOIN
|
||||||
users ON posts.user_id = users.id
|
users ON posts.user_id = users.id
|
||||||
|
JOIN
|
||||||
|
topics ON threads.topic_id = topics.id
|
||||||
JOIN
|
JOIN
|
||||||
threads ON posts.thread_id = threads.id
|
threads ON posts.thread_id = threads.id
|
||||||
LEFT JOIN
|
LEFT JOIN
|
||||||
@@ -321,23 +342,89 @@ class Posts(Model):
|
|||||||
LEFT JOIN
|
LEFT JOIN
|
||||||
user_badges ON users.id = user_badges.user_id"""
|
user_badges ON users.id = user_badges.user_id"""
|
||||||
|
|
||||||
table = "posts"
|
table = 'posts'
|
||||||
|
|
||||||
def get_full_post_view(self):
|
def get_full_post_view(self):
|
||||||
q = f'{self.FULL_POSTS_QUERY} WHERE posts.id = ?'
|
q = f'{self.FULL_POSTS_QUERY} WHERE posts.id = ?'
|
||||||
return db.fetch_one(q, self.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_user_id': mention['mentioned_user_id'],
|
||||||
|
'original_mention_text': mention['mention_text'],
|
||||||
|
'start_index': mention['start'],
|
||||||
|
'end_index': mention['end'],
|
||||||
|
})
|
||||||
|
|
||||||
|
post.update({'current_revision_id': revision.id})
|
||||||
|
return post
|
||||||
|
|
||||||
|
def edit(self, new_content: str, language: str = 'babycode'):
|
||||||
|
from .lib.babycode import babycode_to_html, babycode_to_rssxml, BABYCODE_VERSION
|
||||||
|
html_content = babycode_to_html(new_content)
|
||||||
|
rssxml_content = babycode_to_rssxml(new_content)
|
||||||
|
with db.transaction():
|
||||||
|
revision = PostHistory.create({
|
||||||
|
'post_id': self.id,
|
||||||
|
'content': html_content.result,
|
||||||
|
'content_rss': rssxml_content,
|
||||||
|
'is_initial_revision': False,
|
||||||
|
'original_markup': new_content,
|
||||||
|
'markup_language': language,
|
||||||
|
'format_version': BABYCODE_VERSION,
|
||||||
|
})
|
||||||
|
|
||||||
|
for mention in html_content.mentions:
|
||||||
|
Mentions.create({
|
||||||
|
'revision_id': revision.id,
|
||||||
|
'mentioned_user_id': mention['mentioned_user_id'],
|
||||||
|
'original_mention_text': mention['mention_text'],
|
||||||
|
'start_index': mention['start'],
|
||||||
|
'end_index': mention['end'],
|
||||||
|
})
|
||||||
|
|
||||||
|
self.update({'current_revision_id': revision.id})
|
||||||
|
|
||||||
class PostHistory(Model):
|
class PostHistory(Model):
|
||||||
table = "post_history"
|
table = 'post_history'
|
||||||
|
|
||||||
class Sessions(Model):
|
class Sessions(Model):
|
||||||
table = "sessions"
|
table = 'sessions'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def revoke_all(cls, user_id: int):
|
||||||
|
qb = db.QueryBuilder(cls.table).where({'user_id': user_id})
|
||||||
|
sql, params = qb.build_delete()
|
||||||
|
db.execute(sql, *params)
|
||||||
|
|
||||||
class Avatars(Model):
|
class Avatars(Model):
|
||||||
table = "avatars"
|
table = 'avatars'
|
||||||
|
|
||||||
class Subscriptions(Model):
|
class Subscriptions(Model):
|
||||||
table = "subscriptions"
|
table = 'subscriptions'
|
||||||
|
|
||||||
def get_unread_count(self):
|
def get_unread_count(self):
|
||||||
q = """SELECT COUNT(*) AS unread_count
|
q = """SELECT COUNT(*) AS unread_count
|
||||||
@@ -349,6 +436,13 @@ class Subscriptions(Model):
|
|||||||
return res['unread_count']
|
return res['unread_count']
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_thread(self):
|
||||||
|
return Threads.find({'id': self.thread_id})
|
||||||
|
|
||||||
|
def get_full_posts_view(self):
|
||||||
|
q = f'{Posts.FULL_POSTS_QUERY} WHERE posts.thread_id = ? AND posts.created_at > ? ORDER BY posts.created_at ASC'
|
||||||
|
return db.query(q, self.thread_id, self.last_seen)
|
||||||
|
|
||||||
class APIRateLimits(Model):
|
class APIRateLimits(Model):
|
||||||
table = 'api_rate_limits'
|
table = 'api_rate_limits'
|
||||||
|
|
||||||
@@ -374,15 +468,15 @@ class APIRateLimits(Model):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
class Reactions(Model):
|
class Reactions(Model):
|
||||||
table = "reactions"
|
table = 'reactions'
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def for_post(cls, post_id):
|
def for_post(cls, post_id):
|
||||||
qb = db.QueryBuilder(cls.table)\
|
qb = db.QueryBuilder(cls.table)\
|
||||||
.select("reaction_text, COUNT(*) as c")\
|
.select('reaction_text, COUNT(*) as c')\
|
||||||
.where({"post_id": post_id})\
|
.where({'post_id': post_id})\
|
||||||
.group_by("reaction_text")\
|
.group_by('reaction_text')\
|
||||||
.order_by("c", False)
|
.order_by('c', False)
|
||||||
result = qb.all()
|
result = qb.all()
|
||||||
return result if result else []
|
return result if result else []
|
||||||
|
|
||||||
@@ -400,7 +494,7 @@ class Reactions(Model):
|
|||||||
|
|
||||||
|
|
||||||
class PasswordResetLinks(Model):
|
class PasswordResetLinks(Model):
|
||||||
table = "password_reset_links"
|
table = 'password_reset_links'
|
||||||
|
|
||||||
|
|
||||||
class InviteKeys(Model):
|
class InviteKeys(Model):
|
||||||
@@ -417,6 +511,12 @@ class BookmarkCollections(Model):
|
|||||||
"""
|
"""
|
||||||
res = db.fetch_one(q, user_id)
|
res = db.fetch_one(q, user_id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_for_user(cls, user_id):
|
||||||
|
q = """SELECT * FROM bookmark_collections WHERE user_id = ? ORDER BY sort_order ASC"""
|
||||||
|
res = db.query(q, user_id)
|
||||||
|
return [cls.from_data(row) for row in res]
|
||||||
|
|
||||||
def has_posts(self):
|
def has_posts(self):
|
||||||
q = 'SELECT EXISTS(SELECT 1 FROM bookmarked_posts WHERE collection_id = ?) as e'
|
q = 'SELECT EXISTS(SELECT 1 FROM bookmarked_posts WHERE collection_id = ?) as e'
|
||||||
res = db.fetch_one(q, self.id)['e']
|
res = db.fetch_one(q, self.id)['e']
|
||||||
@@ -464,6 +564,21 @@ class BookmarkCollections(Model):
|
|||||||
class BookmarkedPosts(Model):
|
class BookmarkedPosts(Model):
|
||||||
table = 'bookmarked_posts'
|
table = 'bookmarked_posts'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_for_user(cls, post_id, user_id):
|
||||||
|
q = """SELECT
|
||||||
|
bookmarked_posts.id, collection_id, post_id, note
|
||||||
|
FROM
|
||||||
|
bookmarked_posts
|
||||||
|
JOIN
|
||||||
|
bookmark_collections ON bookmark_collections.id = bookmarked_posts.collection_id
|
||||||
|
WHERE
|
||||||
|
post_id = ?
|
||||||
|
AND
|
||||||
|
user_id = ?"""
|
||||||
|
res = db.fetch_one(q, post_id, user_id)
|
||||||
|
return cls.from_data(res) if res is not None else None
|
||||||
|
|
||||||
def get_post(self):
|
def get_post(self):
|
||||||
return Posts.find({'id': self.post_id})
|
return Posts.find({'id': self.post_id})
|
||||||
|
|
||||||
@@ -471,6 +586,21 @@ class BookmarkedPosts(Model):
|
|||||||
class BookmarkedThreads(Model):
|
class BookmarkedThreads(Model):
|
||||||
table = 'bookmarked_threads'
|
table = 'bookmarked_threads'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_for_user(cls, thread_id, user_id):
|
||||||
|
q = """SELECT
|
||||||
|
bookmarked_threads.id, collection_id, thread_id, note
|
||||||
|
FROM
|
||||||
|
bookmarked_threads
|
||||||
|
JOIN
|
||||||
|
bookmark_collections ON bookmark_collections.id = bookmarked_threads.collection_id
|
||||||
|
WHERE
|
||||||
|
thread_id = ?
|
||||||
|
AND
|
||||||
|
user_id = ?"""
|
||||||
|
res = db.fetch_one(q, thread_id, user_id)
|
||||||
|
return cls.from_data(res) if res is not None else None
|
||||||
|
|
||||||
def get_thread(self):
|
def get_thread(self):
|
||||||
return Threads.find({'id': self.thread_id})
|
return Threads.find({'id': self.thread_id})
|
||||||
|
|
||||||
@@ -504,7 +634,7 @@ class BadgeUploads(Model):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_for_user(cls, user_id):
|
def get_for_user(cls, user_id):
|
||||||
q = "SELECT * FROM badge_uploads WHERE user_id = ? OR user_id IS NULL ORDER BY uploaded_at"
|
q = 'SELECT * FROM badge_uploads WHERE user_id = ? OR user_id IS NULL ORDER BY uploaded_at'
|
||||||
res = db.query(q, int(user_id))
|
res = db.query(q, int(user_id))
|
||||||
return [cls.from_data(row) for row in res]
|
return [cls.from_data(row) for row in res]
|
||||||
|
|
||||||
@@ -518,6 +648,12 @@ class BadgeUploads(Model):
|
|||||||
class Badges(Model):
|
class Badges(Model):
|
||||||
table = 'badges'
|
table = 'badges'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_for_user(cls, user_id):
|
||||||
|
q = 'SELECT * FROM badges WHERE user_id = ? ORDER BY sort_order ASC'
|
||||||
|
res = db.query(q, user_id)
|
||||||
|
return [cls.from_data(row) for row in res]
|
||||||
|
|
||||||
def get_image_url(self):
|
def get_image_url(self):
|
||||||
bu = BadgeUploads.find({'id': int(self.upload)})
|
bu = BadgeUploads.find({'id': int(self.upload)})
|
||||||
return bu.file_path
|
return bu.file_path
|
||||||
|
|||||||
@@ -1,228 +1,21 @@
|
|||||||
from flask import Blueprint, request, url_for, make_response
|
from flask import Blueprint, request
|
||||||
|
from ..auth import is_logged_in, hard_login_required, get_active_user
|
||||||
from ..lib.babycode import babycode_to_html
|
from ..lib.babycode import babycode_to_html
|
||||||
from ..constants import REACTION_EMOJI
|
from ..models import APIRateLimits
|
||||||
from .users import is_logged_in, get_active_user
|
|
||||||
from ..models import APIRateLimits, Threads, Reactions, Users, BookmarkCollections, BookmarkedThreads, BookmarkedPosts, BadgeUploads
|
|
||||||
from ..db import db
|
|
||||||
|
|
||||||
bp = Blueprint("api", __name__, url_prefix="/api/")
|
bp = Blueprint('api', __name__, url_prefix='/api/')
|
||||||
|
|
||||||
|
@bp.post('/babycode-preview/')
|
||||||
@bp.post('/thread-updates/<thread_id>')
|
@hard_login_required
|
||||||
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():
|
def babycode_preview():
|
||||||
if not is_logged_in():
|
|
||||||
return {'error': 'not authorized'}, 401
|
|
||||||
user = get_active_user()
|
user = get_active_user()
|
||||||
if not APIRateLimits.is_allowed(user.id, 'babycode_preview', 5):
|
if not APIRateLimits.is_allowed(user.id, 'babycode_preview', 5):
|
||||||
return {'error': 'too many requests'}, 429
|
return {'error': 'too many requests'}, 429
|
||||||
markup = request.json.get('markup')
|
markup = str(request.json.get('markup', ''))
|
||||||
if not markup or not isinstance(markup, str):
|
if not markup:
|
||||||
return {'error': 'markup field missing or invalid type'}, 400
|
return {'error': 'markup field missing or invalid type'}, 400
|
||||||
banned_tags = request.json.get('banned_tags', [])
|
banned_tags = request.json.get('banned_tags', [])
|
||||||
|
if not isinstance(banned_tags, list):
|
||||||
|
return {'error': 'banned_tags field is invalid type'}, 400
|
||||||
rendered = babycode_to_html(markup, banned_tags).result
|
rendered = babycode_to_html(markup, banned_tags).result
|
||||||
return {'html': rendered}
|
return {'html': rendered}
|
||||||
|
|
||||||
|
|
||||||
@bp.post('/add-reaction/<post_id>')
|
|
||||||
def add_reaction(post_id):
|
|
||||||
if not is_logged_in():
|
|
||||||
return {'error': 'not authorized', 'error_code': 401}, 401
|
|
||||||
user = get_active_user()
|
|
||||||
reaction_text = request.json.get('emoji')
|
|
||||||
if not reaction_text or not isinstance(reaction_text, str):
|
|
||||||
return {'error': 'emoji field missing or invalid type', 'error_code': 400}, 400
|
|
||||||
if reaction_text not in REACTION_EMOJI:
|
|
||||||
return {'error': 'unsupported reaction', 'error_code': 400}, 400
|
|
||||||
|
|
||||||
reaction = Reactions.find({
|
|
||||||
'user_id': user.id,
|
|
||||||
'post_id': int(post_id),
|
|
||||||
'reaction_text': reaction_text,
|
|
||||||
})
|
|
||||||
|
|
||||||
if reaction:
|
|
||||||
return {'error': 'reaction already exists', 'error_code': 409}, 409
|
|
||||||
|
|
||||||
reaction = Reactions.create({
|
|
||||||
'user_id': user.id,
|
|
||||||
'post_id': int(post_id),
|
|
||||||
'reaction_text': reaction_text,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {'status': 'added'}
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post('/remove-reaction/<post_id>')
|
|
||||||
def remove_reaction(post_id):
|
|
||||||
if not is_logged_in():
|
|
||||||
return {'error': 'not authorized'}, 401
|
|
||||||
user = get_active_user()
|
|
||||||
reaction_text = request.json.get('emoji')
|
|
||||||
if not reaction_text or not isinstance(reaction_text, str):
|
|
||||||
return {'error': 'emoji field missing or invalid type'}, 400
|
|
||||||
if reaction_text not in REACTION_EMOJI:
|
|
||||||
return {'error': 'unsupported reaction'}, 400
|
|
||||||
|
|
||||||
reaction = Reactions.find({
|
|
||||||
'user_id': user.id,
|
|
||||||
'post_id': int(post_id),
|
|
||||||
'reaction_text': reaction_text,
|
|
||||||
})
|
|
||||||
|
|
||||||
if not reaction:
|
|
||||||
return {'error': 'reaction does not exist'}, 404
|
|
||||||
|
|
||||||
reaction.delete()
|
|
||||||
|
|
||||||
return {'status': 'removed'}
|
|
||||||
|
|
||||||
@bp.post('/manage-bookmark-collections/<user_id>')
|
|
||||||
def manage_bookmark_collections(user_id):
|
|
||||||
if not is_logged_in():
|
|
||||||
return {'error': 'not authorized', 'error_code': 401}, 401
|
|
||||||
|
|
||||||
target_user = Users.find({'id': user_id})
|
|
||||||
if target_user.id != get_active_user().id:
|
|
||||||
return {'error': 'forbidden', 'error_code': 403}, 403
|
|
||||||
|
|
||||||
if target_user.is_guest():
|
|
||||||
return {'error': 'forbidden', 'error_code': 403}, 403
|
|
||||||
|
|
||||||
collections_data = request.json
|
|
||||||
for idx, coll_data in enumerate(collections_data.get('collections')):
|
|
||||||
if coll_data['is_new']:
|
|
||||||
collection = BookmarkCollections.create({
|
|
||||||
'name': coll_data['name'],
|
|
||||||
'user_id': target_user.id,
|
|
||||||
'sort_order': idx,
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
collection = BookmarkCollections.find({'id': coll_data['id']})
|
|
||||||
if not collection:
|
|
||||||
continue
|
|
||||||
|
|
||||||
update = {'name': coll_data['name']}
|
|
||||||
if not collection.is_default:
|
|
||||||
update['sort_order'] = idx
|
|
||||||
collection.update(update)
|
|
||||||
|
|
||||||
for removed_id in collections_data.get('removed_collections'):
|
|
||||||
collection = BookmarkCollections.find({'id': removed_id})
|
|
||||||
if not collection:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if collection.is_default:
|
|
||||||
continue
|
|
||||||
|
|
||||||
collection.delete()
|
|
||||||
|
|
||||||
|
|
||||||
return {'status': 'ok'}, 200
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post('/bookmark-post/<post_id>')
|
|
||||||
def bookmark_post(post_id):
|
|
||||||
if not is_logged_in():
|
|
||||||
return {'error': 'not authorized', 'error_code': 401}, 401
|
|
||||||
|
|
||||||
operation = request.json.get('operation')
|
|
||||||
if operation == 'remove' and request.json.get('collection_id', '') == '':
|
|
||||||
return {'status': 'not modified'}, 304
|
|
||||||
collection_id = int(request.json.get('collection_id'))
|
|
||||||
post_id = int(post_id)
|
|
||||||
memo = request.json.get('memo', '')
|
|
||||||
|
|
||||||
if operation == 'move':
|
|
||||||
bm = BookmarkedPosts.find({'post_id': post_id})
|
|
||||||
if not bm:
|
|
||||||
BookmarkedPosts.create({
|
|
||||||
'post_id': post_id,
|
|
||||||
'collection_id': collection_id,
|
|
||||||
'note': memo,
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
bm.update({
|
|
||||||
'collection_id': collection_id,
|
|
||||||
'note': memo,
|
|
||||||
})
|
|
||||||
elif operation == 'remove':
|
|
||||||
bm = BookmarkedPosts.find({'post_id': post_id})
|
|
||||||
if bm:
|
|
||||||
bm.delete()
|
|
||||||
else:
|
|
||||||
return {'error': 'bad request'}, 400
|
|
||||||
|
|
||||||
return {'status': 'ok'}, 200
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post('/bookmark-thread/<thread_id>')
|
|
||||||
def bookmark_thread(thread_id):
|
|
||||||
if not is_logged_in():
|
|
||||||
return {'error': 'not authorized', 'error_code': 401}, 401
|
|
||||||
|
|
||||||
operation = request.json.get('operation')
|
|
||||||
if operation == 'remove' and request.json.get('collection_id', '') == '':
|
|
||||||
return {'status': 'not modified'}, 304
|
|
||||||
collection_id = int(request.json.get('collection_id'))
|
|
||||||
thread_id = int(thread_id)
|
|
||||||
memo = request.json.get('memo', '')
|
|
||||||
|
|
||||||
if operation == 'move':
|
|
||||||
bm = BookmarkedThreads.find({'thread_id': thread_id})
|
|
||||||
if not bm:
|
|
||||||
BookmarkedThreads.create({
|
|
||||||
'thread_id': thread_id,
|
|
||||||
'collection_id': collection_id,
|
|
||||||
'note': memo,
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
bm.update({
|
|
||||||
'collection_id': collection_id,
|
|
||||||
'note': memo,
|
|
||||||
})
|
|
||||||
elif operation == 'remove':
|
|
||||||
bm = BookmarkedThreads.find({
|
|
||||||
'thread_id': thread_id,
|
|
||||||
'note': memo,
|
|
||||||
})
|
|
||||||
if bm:
|
|
||||||
bm.delete()
|
|
||||||
else:
|
|
||||||
return {'error': 'bad request'}, 400
|
|
||||||
|
|
||||||
return {'status': 'ok'}, 200
|
|
||||||
|
|
||||||
@bp.get('/current-user')
|
|
||||||
def get_current_user_info():
|
|
||||||
if not is_logged_in():
|
|
||||||
return {'user': None}
|
|
||||||
|
|
||||||
user = get_active_user()
|
|
||||||
return {
|
|
||||||
'user': {
|
|
||||||
'username': user.username,
|
|
||||||
'display_name': user.display_name,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
from flask import Blueprint, redirect, url_for, render_template
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
bp = Blueprint("app", __name__, url_prefix = "/")
|
|
||||||
|
|
||||||
@bp.route("/")
|
|
||||||
def index():
|
|
||||||
return redirect(url_for("topics.all_topics"))
|
|
||||||
@@ -1,111 +1,11 @@
|
|||||||
from flask import Blueprint, render_template, render_template_string, current_app, abort
|
from flask import Blueprint
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
|
|
||||||
bp = Blueprint('guides', __name__, url_prefix='/guides/')
|
|
||||||
|
|
||||||
|
|
||||||
def parse_guide_title(content: str):
|
|
||||||
lines = content.strip().split('\n', 1)
|
|
||||||
fline = lines[0].strip()
|
|
||||||
if fline.startswith('#'):
|
|
||||||
title = fline[2:].strip()
|
|
||||||
content = lines[1] if len(lines) > 1 else ''
|
|
||||||
return title, content.strip()
|
|
||||||
|
|
||||||
return None, content.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def get_guides_by_category():
|
|
||||||
guides_dir = Path(current_app.root_path) / 'templates' / 'guides'
|
|
||||||
categories = {}
|
|
||||||
|
|
||||||
for item in guides_dir.iterdir():
|
|
||||||
if item.is_dir() and not item.name.startswith('_'):
|
|
||||||
category = item.name
|
|
||||||
categories[category] = []
|
|
||||||
for guide_file in sorted(item.glob('*.html')):
|
|
||||||
if guide_file.name.startswith('_'):
|
|
||||||
continue
|
|
||||||
|
|
||||||
m = re.match(r'(\d+)-(.+)\.html', guide_file.name)
|
|
||||||
if not m:
|
|
||||||
continue
|
|
||||||
|
|
||||||
sort_num = int(m.group(1))
|
|
||||||
slug = m.group(2)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(guide_file, 'r') as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
title, template = parse_guide_title(content)
|
|
||||||
if not title:
|
|
||||||
title = slug.replace('-', ' ').title()
|
|
||||||
|
|
||||||
categories[category].append(({
|
|
||||||
'sort': sort_num,
|
|
||||||
'slug': slug,
|
|
||||||
'filename': guide_file.name,
|
|
||||||
'title': title,
|
|
||||||
'path': f'{category}/{guide_file.name}',
|
|
||||||
'url': f'/guides/{category}/{slug}',
|
|
||||||
'template': template,
|
|
||||||
}))
|
|
||||||
except Exception as e:
|
|
||||||
current_app.logger.warning(f'failed to read {guide_file}: {e}')
|
|
||||||
continue
|
|
||||||
|
|
||||||
categories[category].sort(key=lambda x: x['sort'])
|
|
||||||
|
|
||||||
return categories
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get('/babycode')
|
|
||||||
def babycode():
|
|
||||||
# print(get_guides_by_category())
|
|
||||||
return '<h1>no</h1>'
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get('/<category>/<slug>')
|
|
||||||
def guide_page(category, slug):
|
|
||||||
categories = get_guides_by_category()
|
|
||||||
if category not in categories:
|
|
||||||
abort(404)
|
|
||||||
return
|
|
||||||
|
|
||||||
for i, guide in enumerate(categories[category]):
|
|
||||||
if guide['slug'] != slug:
|
|
||||||
continue
|
|
||||||
|
|
||||||
next_guide = None
|
|
||||||
prev_guide = None
|
|
||||||
if i != 0:
|
|
||||||
prev_guide = categories[category][i - 1]
|
|
||||||
|
|
||||||
if i + 1 < len(categories[category]):
|
|
||||||
next_guide = categories[category][i + 1]
|
|
||||||
|
|
||||||
return render_template_string(guide['template'], next_guide=next_guide, prev_guide=prev_guide, category=category, guide=guide)
|
|
||||||
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get('/<category>/')
|
|
||||||
def category_index(category):
|
|
||||||
categories = get_guides_by_category()
|
|
||||||
if category not in categories:
|
|
||||||
abort(404)
|
|
||||||
return
|
|
||||||
|
|
||||||
return render_template('guides/category_index.html', category=category, pages=categories[category])
|
|
||||||
|
|
||||||
|
bp = Blueprint('guides', __name__, url_prefix = '/guides/')
|
||||||
|
|
||||||
@bp.get('/')
|
@bp.get('/')
|
||||||
def guides_index():
|
def index():
|
||||||
return render_template('guides/guides_index.html', categories=get_guides_by_category())
|
return 'stub'
|
||||||
|
|
||||||
|
|
||||||
@bp.get('/contact')
|
@bp.get('/contact')
|
||||||
def contact():
|
def contact():
|
||||||
return render_template('guides/contact.html')
|
return 'stub'
|
||||||
|
|||||||
@@ -1,62 +1,135 @@
|
|||||||
from flask import Blueprint, render_template, abort, request
|
from flask import Blueprint, render_template, request, url_for
|
||||||
from .users import get_active_user, is_logged_in
|
from ..auth import get_active_user, is_logged_in, hard_login_required
|
||||||
from ..models import BookmarkCollections, BookmarkedPosts, BookmarkedThreads, BadgeUploads, Badges
|
from ..models import BookmarkCollections, BookmarkedPosts, BookmarkedThreads, Threads, Posts, Badges, BadgeUploads
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
bp = Blueprint('hyperapi', __name__, url_prefix='/hyperapi/')
|
bp = Blueprint('hyperapi', __name__, url_prefix='/hyperapi/')
|
||||||
|
|
||||||
def login_required(view_func):
|
def user_required(view_func):
|
||||||
@wraps(view_func)
|
@wraps(view_func)
|
||||||
def dec(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
if not is_logged_in():
|
|
||||||
abort(403)
|
|
||||||
return view_func(*args, **kwargs)
|
|
||||||
return dec
|
|
||||||
|
|
||||||
def account_required(view_func):
|
|
||||||
@wraps(view_func)
|
|
||||||
def dec(*args, **kwargs):
|
|
||||||
if get_active_user().is_guest():
|
if get_active_user().is_guest():
|
||||||
abort(403)
|
return '<span>Your account must be approved by a moderator before you may perform this action.</span>', 403
|
||||||
return view_func(*args, **kwargs)
|
return view_func(*args, **kwargs)
|
||||||
return dec
|
return wrapper
|
||||||
|
|
||||||
@bp.errorhandler(403)
|
@bp.get('/bookmarks/dropdown/')
|
||||||
def handle_403(e):
|
@hard_login_required
|
||||||
return "<h1>forbidden</h1>", 403
|
@user_required
|
||||||
|
def get_bookmark_dropdown():
|
||||||
|
user = get_active_user()
|
||||||
@bp.get('/bookmarks-dropdown/<bookmark_type>')
|
concept_kind = request.args.get('concept_kind', 'thread')
|
||||||
@login_required
|
try:
|
||||||
@account_required
|
concept_id = int(request.args.get('concept_id', 0))
|
||||||
def bookmarks_dropdown(bookmark_type):
|
except ValueError:
|
||||||
collections = BookmarkCollections.findall({'user_id': get_active_user().id})
|
return 'error', 400
|
||||||
concept_id = request.args.get('id')
|
is_thread = concept_kind == 'thread'
|
||||||
require_reload = bool(int(request.args.get('require_reload', default=0)))
|
if is_thread:
|
||||||
if bookmark_type.lower() == 'thread':
|
target_thread = Threads.find({'id': concept_id})
|
||||||
selected = next(filter(lambda bc: bc.has_thread(concept_id), collections), None)
|
if not target_thread:
|
||||||
elif bookmark_type.lower() == 'post':
|
return 'This thread no longer exists. Please refresh the page.', 404
|
||||||
selected = next(filter(lambda bc: bc.has_post(concept_id), collections), None)
|
|
||||||
else:
|
else:
|
||||||
abort(400)
|
target_post = Posts.find({'id': concept_id})
|
||||||
return
|
if not target_post:
|
||||||
|
return 'This post no longer exists. Please refresh the page.', 404
|
||||||
|
collections = BookmarkCollections.get_for_user(user.id)
|
||||||
|
in_collection = None
|
||||||
|
note = ''
|
||||||
|
for collection in collections:
|
||||||
|
callable = collection.has_thread if is_thread else collection.has_post
|
||||||
|
if callable(concept_id):
|
||||||
|
in_collection = collection.id
|
||||||
|
concept = 'thread_id' if is_thread else 'post_id'
|
||||||
|
note = (BookmarkedThreads if is_thread else BookmarkedPosts).find({'collection_id': in_collection, concept: concept_id}).note
|
||||||
|
break
|
||||||
|
submit_url = url_for('.bookmark_thread' if is_thread else '.bookmark_post')
|
||||||
|
return render_template('hyper/bookmark_dropdown.html', collections=collections, in_collection=in_collection, is_thread=is_thread, concept_id=concept_id, submit_url=submit_url, note=note)
|
||||||
|
|
||||||
if selected:
|
@bp.post('/bookmarks/thread/')
|
||||||
if bookmark_type.lower() == 'thread':
|
@hard_login_required
|
||||||
memo = BookmarkedThreads.find({'collection_id': selected.id, 'thread_id': int(concept_id)}).note
|
@user_required
|
||||||
|
def bookmark_thread():
|
||||||
|
user = get_active_user()
|
||||||
|
try:
|
||||||
|
thread_id = int(request.form['concept_id'])
|
||||||
|
target_collection_id = int(request.form['target_collection'])
|
||||||
|
except ValueError, KeyError:
|
||||||
|
return 'error', 400
|
||||||
|
|
||||||
|
if target_collection_id == -1:
|
||||||
|
bt = BookmarkedThreads.get_for_user(thread_id, user.id)
|
||||||
|
if bt:
|
||||||
|
bt.delete()
|
||||||
|
return '', 204
|
||||||
|
|
||||||
|
if not Threads.find({'id': thread_id}):
|
||||||
|
return 'error', 404
|
||||||
|
|
||||||
|
target_collection = BookmarkCollections.find({'id': target_collection_id})
|
||||||
|
note = request.form.get('note', '')
|
||||||
|
if not target_collection:
|
||||||
|
return 'error', 400
|
||||||
|
|
||||||
|
if int(user.id) != int(target_collection.user_id):
|
||||||
|
return 'error', 400
|
||||||
|
|
||||||
|
bt = BookmarkedThreads.get_for_user(thread_id, user.id)
|
||||||
|
if bt:
|
||||||
|
bt.update({'collection_id': target_collection_id, 'note': note})
|
||||||
else:
|
else:
|
||||||
memo = BookmarkedPosts.find({'collection_id': selected.id, 'post_id': int(concept_id)}).note
|
BookmarkedThreads.create({
|
||||||
|
'collection_id': target_collection_id,
|
||||||
|
'thread_id': thread_id,
|
||||||
|
'note': note,
|
||||||
|
})
|
||||||
|
|
||||||
|
return '', 204
|
||||||
|
|
||||||
|
@bp.post('/bookmarks/post/')
|
||||||
|
@hard_login_required
|
||||||
|
@user_required
|
||||||
|
def bookmark_post():
|
||||||
|
user = get_active_user()
|
||||||
|
try:
|
||||||
|
post_id = int(request.form['concept_id'])
|
||||||
|
target_collection_id = int(request.form['target_collection'])
|
||||||
|
except ValueError, KeyError:
|
||||||
|
return 'error', 400
|
||||||
|
|
||||||
|
if target_collection_id == -1:
|
||||||
|
bp = BookmarkedPosts.get_for_user(post_id, user.id)
|
||||||
|
if bp:
|
||||||
|
bp.delete()
|
||||||
|
return '', 204
|
||||||
|
|
||||||
|
if not Posts.find({'id': post_id}):
|
||||||
|
return 'error', 404
|
||||||
|
|
||||||
|
target_collection = BookmarkCollections.find({'id': target_collection_id})
|
||||||
|
note = request.form.get('note', '')
|
||||||
|
if not target_collection:
|
||||||
|
return 'error', 400
|
||||||
|
|
||||||
|
if int(user.id) != int(target_collection.user_id):
|
||||||
|
return 'error', 400
|
||||||
|
|
||||||
|
bp = BookmarkedPosts.get_for_user(post_id, user.id)
|
||||||
|
if bp:
|
||||||
|
bp.update({'collection_id': target_collection_id, 'note': note})
|
||||||
else:
|
else:
|
||||||
memo = ''
|
BookmarkedPosts.create({
|
||||||
|
'collection_id': target_collection_id,
|
||||||
|
'post_id': post_id,
|
||||||
|
'note': note,
|
||||||
|
})
|
||||||
|
|
||||||
|
return '', 204
|
||||||
|
|
||||||
return render_template('components/bookmarks_dropdown.html', collections=collections, id=concept_id, selected=selected, type=bookmark_type, memo=memo, require_reload=require_reload)
|
@bp.get('/badges/editor/')
|
||||||
|
@hard_login_required
|
||||||
|
@user_required
|
||||||
@bp.get('/badge-editor')
|
def badge_editor():
|
||||||
@login_required
|
user = get_active_user()
|
||||||
@account_required
|
badges = Badges.get_for_user(user.id)
|
||||||
def get_badges():
|
badge_uploads = BadgeUploads.get_for_user(user.id)
|
||||||
uploads = BadgeUploads.get_for_user(get_active_user().id)
|
return render_template('hyper/badge_editor.html', badges=badges, badge_uploads=badge_uploads)
|
||||||
badges = sorted(Badges.findall({'user_id': int(get_active_user().id)}), key=lambda x: x['sort_order'])
|
|
||||||
return render_template('components/badge_editor_badges.html', uploads=uploads, badges=badges)
|
|
||||||
|
|||||||
@@ -1,106 +1,218 @@
|
|||||||
from flask import (
|
from flask import Blueprint, abort, redirect, url_for, request, render_template, flash
|
||||||
Blueprint, render_template, request, redirect, url_for,
|
from ..constants import InfoboxKind, PermissionLevel, MOTD_BANNED_TAGS
|
||||||
flash
|
from ..auth import is_logged_in, get_active_user, csrf_verified
|
||||||
)
|
from ..models import Topics, Threads, Users, MOTD
|
||||||
from .users import get_active_user, is_logged_in
|
|
||||||
from ..models import Users, PasswordResetLinks, MOTD
|
|
||||||
from ..constants import InfoboxKind, MOTD_BANNED_TAGS
|
|
||||||
from ..lib.babycode import babycode_to_html, BABYCODE_VERSION
|
|
||||||
from ..db import db
|
from ..db import db
|
||||||
import secrets
|
from ..lib.babycode import babycode_to_html, BABYCODE_VERSION
|
||||||
|
from slugify import slugify
|
||||||
|
from functools import wraps
|
||||||
import time
|
import time
|
||||||
|
bp = Blueprint('mod', __name__, url_prefix='/mod/')
|
||||||
bp = Blueprint("mod", __name__, url_prefix = "/mod/")
|
|
||||||
|
|
||||||
@bp.before_request
|
@bp.before_request
|
||||||
def _before_request():
|
def mod_only():
|
||||||
if not is_logged_in():
|
if not is_logged_in():
|
||||||
return redirect(url_for("users.log_in"))
|
abort(403)
|
||||||
|
|
||||||
if not get_active_user().is_mod():
|
if not get_active_user().is_mod():
|
||||||
return redirect(url_for("topics.all_topics"))
|
abort(403)
|
||||||
|
|
||||||
|
def admin_only(view_func):
|
||||||
|
@wraps(view_func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
if not get_active_user().is_admin():
|
||||||
|
abort(403)
|
||||||
|
return view_func(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
@bp.get("/sort-topics")
|
@bp.get('/')
|
||||||
def sort_topics():
|
def index():
|
||||||
topics = db.query("SELECT * FROM topics ORDER BY sort_order ASC")
|
motd = MOTD.get_all()[0] if MOTD.has_motd() else None
|
||||||
return render_template("mod/sort-topics.html", topics = topics)
|
topics = Topics.get_list()
|
||||||
|
return render_template('mod/panel.html', MOTD_BANNED_TAGS=MOTD_BANNED_TAGS, motd=motd, topics=topics)
|
||||||
|
|
||||||
|
@bp.post('/motd/set/')
|
||||||
@bp.post("/sort-topics")
|
def set_motd():
|
||||||
def sort_topics_post():
|
title = request.form.get('motd_title', '')
|
||||||
topics_list = request.form.getlist('topics[]')
|
if not title:
|
||||||
print(topics_list)
|
flash('MOTD must have a title.', InfoboxKind.ERROR)
|
||||||
with db.transaction():
|
return redirect(url_for('.index'))
|
||||||
for new_order, topic_id in enumerate(topics_list):
|
orig_body = request.form.get('babycode_content', '')
|
||||||
db.execute("UPDATE topics SET sort_order = ? WHERE id = ?", new_order, topic_id)
|
if not orig_body:
|
||||||
|
flash('MOTD must have a body.', InfoboxKind.ERROR)
|
||||||
return redirect(url_for(".sort_topics"))
|
return redirect(url_for('.index'))
|
||||||
|
user = get_active_user()
|
||||||
|
|
||||||
@bp.get("/user-list")
|
|
||||||
def user_list():
|
|
||||||
users = Users.select()
|
|
||||||
return render_template("mod/user-list.html", users = users)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/reset-pass/<user_id>")
|
|
||||||
def create_reset_pass(user_id):
|
|
||||||
now = int(time.time())
|
|
||||||
key = secrets.token_urlsafe(20)
|
|
||||||
reset_link = PasswordResetLinks.create({
|
|
||||||
'user_id': int(user_id),
|
|
||||||
'expires_at': now + 24 * 60 * 60,
|
|
||||||
'key': key,
|
|
||||||
})
|
|
||||||
|
|
||||||
return redirect(url_for('users.reset_link_login', key=key))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get('/panel')
|
|
||||||
def panel():
|
|
||||||
return render_template('mod/panel.html')
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get('/motd')
|
|
||||||
def motd_editor():
|
|
||||||
current = MOTD.get_all()[0] if MOTD.has_motd() else None
|
|
||||||
return render_template('mod/motd.html', current=current)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post('/motd')
|
|
||||||
def motd_editor_form():
|
|
||||||
orig_body = request.form.get('body', default='')
|
|
||||||
title = request.form.get('title', default='')
|
|
||||||
data = {
|
data = {
|
||||||
'title': title,
|
'title': title.strip(),
|
||||||
'body_original_markup': orig_body,
|
'body_original_markup': orig_body,
|
||||||
'body_rendered': babycode_to_html(orig_body, banned_tags=MOTD_BANNED_TAGS).result,
|
'body_rendered': babycode_to_html(orig_body, banned_tags=MOTD_BANNED_TAGS).result,
|
||||||
'format_version': BABYCODE_VERSION,
|
'format_version': BABYCODE_VERSION,
|
||||||
'edited_at': int(time.time()),
|
'edited_at': int(time.time()),
|
||||||
|
'user_id': user.id,
|
||||||
}
|
}
|
||||||
|
|
||||||
if MOTD.has_motd():
|
if MOTD.has_motd():
|
||||||
motd = MOTD.get_all()[0]
|
motd = MOTD.get_all()[0]
|
||||||
motd.update(data)
|
motd.update(data)
|
||||||
message = 'MOTD updated.'
|
message = 'MOTD updated.'
|
||||||
else:
|
else:
|
||||||
data['created_at'] = int(time.time())
|
data['created_at'] = int(time.time())
|
||||||
data['user_id'] = get_active_user().id
|
|
||||||
motd = MOTD.create(data)
|
motd = MOTD.create(data)
|
||||||
message = 'MOTD created.'
|
message = 'MOTD created.'
|
||||||
|
|
||||||
flash(message, InfoboxKind.INFO)
|
flash(message, InfoboxKind.INFO)
|
||||||
return redirect(url_for('.motd_editor'))
|
return redirect(url_for('.index'))
|
||||||
|
|
||||||
|
@bp.post('/motd/clear/')
|
||||||
@bp.post('/motd/delete')
|
def clear_motd():
|
||||||
def motd_delete():
|
|
||||||
if not MOTD.has_motd():
|
if not MOTD.has_motd():
|
||||||
flash('No MOTD to delete.', InfoboxKind.WARN)
|
return redirect(url_for('.index'))
|
||||||
return redirect(url_for('.motd_editor'))
|
|
||||||
|
|
||||||
current = MOTD.get_all()[0]
|
current = MOTD.get_all()[0]
|
||||||
current.delete()
|
current.delete()
|
||||||
flash('MOTD deleted.', InfoboxKind.INFO)
|
flash('MOTD cleared.', InfoboxKind.INFO)
|
||||||
return redirect(url_for('.motd_editor'))
|
return redirect(url_for('.index'))
|
||||||
|
|
||||||
|
@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/<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('/topics/<int:topic_id>/edit/')
|
||||||
|
def edit_topic_post(topic_id):
|
||||||
|
topic = Topics.find({'id': topic_id})
|
||||||
|
if not topic:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
target_name = request.form.get('name').strip()
|
||||||
|
|
||||||
|
name_exists = Topics.count([
|
||||||
|
('lower(name)', '=', target_name.lower()),
|
||||||
|
('id', '!=', topic.id)
|
||||||
|
]) > 0
|
||||||
|
if name_exists:
|
||||||
|
flash(f'A topic named "{target_name}" already exists.', InfoboxKind.ERROR)
|
||||||
|
return redirect(url_for('.edit_topic', topic_id=topic_id))
|
||||||
|
|
||||||
|
topic.update({
|
||||||
|
'name': target_name,
|
||||||
|
'description': request.form.get('description').strip(),
|
||||||
|
'slug': slugify(target_name, max_length=50),
|
||||||
|
})
|
||||||
|
return redirect(url_for('topics.topic_by_id', topic_id=topic.id))
|
||||||
|
|
||||||
|
@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.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('/threads/sort/')
|
||||||
|
def sort_topics():
|
||||||
|
topics_list = request.form.getlist('topics[]')
|
||||||
|
with db.transaction():
|
||||||
|
for new_order, topic_id in enumerate(topics_list):
|
||||||
|
db.execute('UPDATE topics SET sort_order = ? WHERE id = ?', new_order, topic_id)
|
||||||
|
|
||||||
|
return redirect(url_for('.index', _anchor='sort-topics'))
|
||||||
|
|
||||||
|
@bp.post('/users/<int:user_id>/make-guest/')
|
||||||
|
@csrf_verified
|
||||||
|
def make_user_guest(user_id):
|
||||||
|
mod = get_active_user()
|
||||||
|
target_user = Users.find({'id': user_id})
|
||||||
|
if not target_user:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
if target_user.is_admin() or target_user.is_system():
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
if int(target_user.permission) >= int(mod.permission):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
target_user.update({
|
||||||
|
'permission': PermissionLevel.GUEST.value,
|
||||||
|
'confirmed_on': None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return redirect(url_for('users.user_page', username=target_user.username))
|
||||||
|
|
||||||
|
@bp.post('/users/<int:user_id>/make-user/')
|
||||||
|
@csrf_verified
|
||||||
|
def make_user_regular(user_id):
|
||||||
|
mod = get_active_user()
|
||||||
|
target_user = Users.find({'id': user_id})
|
||||||
|
if not target_user:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
if target_user.is_admin() or target_user.is_system():
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
# mod -> regular user, abort if not admin
|
||||||
|
if int(target_user.permission) >= int(mod.permission):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
update_dict = {'permission': PermissionLevel.USER.value}
|
||||||
|
# set approved date if the user was guest
|
||||||
|
if target_user.is_guest():
|
||||||
|
update_dict['confirmed_on'] = int(time.time())
|
||||||
|
|
||||||
|
target_user.update(update_dict)
|
||||||
|
|
||||||
|
return redirect(url_for('users.user_page', username=target_user.username))
|
||||||
|
|
||||||
|
@bp.post('/users/<int:user_id>/make-mod/')
|
||||||
|
@admin_only
|
||||||
|
@csrf_verified
|
||||||
|
def make_user_mod(user_id):
|
||||||
|
mod = get_active_user()
|
||||||
|
target_user = Users.find({'id': user_id})
|
||||||
|
if not target_user:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
if target_user.is_admin() or target_user.is_system():
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
if int(target_user.permission) >= int(mod.permission):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
target_user.update({'permission': PermissionLevel.MODERATOR.value})
|
||||||
|
return redirect(url_for('users.user_page', username=target_user.username))
|
||||||
|
|||||||
@@ -1,153 +1,135 @@
|
|||||||
from flask import (
|
from flask import Blueprint, abort, render_template, redirect, url_for, request, flash
|
||||||
Blueprint, redirect, url_for, flash, render_template, request
|
from functools import wraps
|
||||||
)
|
from ..auth import login_required, get_active_user
|
||||||
from .users import login_required, get_active_user
|
from ..models import Posts, Threads, Topics
|
||||||
from ..lib.babycode import babycode_to_html, babycode_to_rssxml, BABYCODE_VERSION
|
from ..util import get_post_url
|
||||||
from ..constants import InfoboxKind
|
|
||||||
from ..db import db
|
from ..db import db
|
||||||
from ..models import Posts, PostHistory, Threads, Topics, Mentions
|
from ..constants import InfoboxKind
|
||||||
|
|
||||||
bp = Blueprint("posts", __name__, url_prefix = "/post")
|
bp = Blueprint('posts', __name__, url_prefix='/posts/')
|
||||||
|
|
||||||
|
def ownership_required(view_func):
|
||||||
def create_post(thread_id, user_id, content, markup_language="babycode"):
|
@wraps(view_func)
|
||||||
parsed_content = babycode_to_html(content)
|
def wrapper(*args, **kwargs):
|
||||||
parsed_rss = babycode_to_rssxml(content)
|
post = Posts.find({'id': kwargs.get('post_id', None)})
|
||||||
with db.transaction():
|
user = get_active_user()
|
||||||
post = Posts.create({
|
|
||||||
"thread_id": thread_id,
|
|
||||||
"user_id": user_id,
|
|
||||||
"current_revision_id": None,
|
|
||||||
})
|
|
||||||
|
|
||||||
revision = PostHistory.create({
|
|
||||||
"post_id": post.id,
|
|
||||||
"content": parsed_content.result,
|
|
||||||
"content_rss": parsed_rss,
|
|
||||||
"is_initial_revision": True,
|
|
||||||
"original_markup": content,
|
|
||||||
"markup_language": markup_language,
|
|
||||||
"format_version": BABYCODE_VERSION,
|
|
||||||
})
|
|
||||||
|
|
||||||
for mention in parsed_content.mentions:
|
|
||||||
Mentions.create({
|
|
||||||
'revision_id': revision.id,
|
|
||||||
'mentioned_user_id': mention['mentioned_user_id'],
|
|
||||||
'original_mention_text': mention['mention_text'],
|
|
||||||
'start_index': mention['start'],
|
|
||||||
'end_index': mention['end'],
|
|
||||||
})
|
|
||||||
|
|
||||||
post.update({"current_revision_id": revision.id})
|
|
||||||
return post
|
|
||||||
|
|
||||||
|
|
||||||
def update_post(post_id, new_content, markup_language='babycode'):
|
|
||||||
parsed_content = babycode_to_html(new_content)
|
|
||||||
parsed_rss = babycode_to_rssxml(new_content)
|
|
||||||
with db.transaction():
|
|
||||||
post = Posts.find({'id': post_id})
|
|
||||||
new_revision = PostHistory.create({
|
|
||||||
'post_id': post.id,
|
|
||||||
'content': parsed_content.result,
|
|
||||||
"content_rss": parsed_rss,
|
|
||||||
'is_initial_revision': False,
|
|
||||||
'original_markup': new_content,
|
|
||||||
'markup_language': markup_language,
|
|
||||||
'format_version': BABYCODE_VERSION,
|
|
||||||
})
|
|
||||||
|
|
||||||
for mention in parsed_content.mentions:
|
|
||||||
Mentions.create({
|
|
||||||
'revision_id': new_revision.id,
|
|
||||||
'mentioned_user_id': mention['mentioned_user_id'],
|
|
||||||
'original_mention_text': mention['mention_text'],
|
|
||||||
'start_index': mention['start'],
|
|
||||||
'end_index': mention['end'],
|
|
||||||
})
|
|
||||||
|
|
||||||
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})
|
|
||||||
if not post:
|
if not post:
|
||||||
abort(404)
|
abort(404)
|
||||||
return
|
|
||||||
|
|
||||||
thread = Threads.find({'id': post.thread_id})
|
if post.user_id != user.id:
|
||||||
user = get_active_user()
|
abort(403)
|
||||||
if not user:
|
|
||||||
return redirect(url_for('threads.thread', slug=thread.slug))
|
|
||||||
|
|
||||||
if user.is_mod() or post.user_id == user.id:
|
return view_func(*args, **kwargs)
|
||||||
post.delete()
|
return wrapper
|
||||||
|
|
||||||
post_count = Posts.count({
|
def ownership_or_mod_required(view_func):
|
||||||
'thread_id': thread.id,
|
@wraps(view_func)
|
||||||
})
|
def wrapper(*args, **kwargs):
|
||||||
|
post = Posts.find({'id': kwargs.get('post_id', None)})
|
||||||
|
if not post:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
if post_count == 0:
|
if post.user_id != get_active_user().id and not get_active_user().is_mod():
|
||||||
topic = Topics.find({
|
abort(403)
|
||||||
'id': thread.topic_id,
|
|
||||||
})
|
|
||||||
thread.delete()
|
|
||||||
flash('Thread deleted.', InfoboxKind.INFO)
|
|
||||||
return redirect(url_for('topics.topic', slug=topic.slug))
|
|
||||||
|
|
||||||
flash('Post deleted.', InfoboxKind.INFO)
|
return view_func(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
return redirect(url_for('threads.thread', slug=thread.slug))
|
@bp.get('/<int:post_id>/edit/')
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<post_id>/edit")
|
|
||||||
@login_required
|
@login_required
|
||||||
|
@ownership_required
|
||||||
def edit(post_id):
|
def edit(post_id):
|
||||||
post = Posts.find({'id': post_id})
|
post = Posts.find({'id': post_id})
|
||||||
if not post:
|
if not post:
|
||||||
abort(404)
|
abort(404)
|
||||||
return
|
|
||||||
|
thread = Threads.find({'id': post.thread_id})
|
||||||
|
if not thread:
|
||||||
|
# what?
|
||||||
|
abort(404)
|
||||||
|
|
||||||
user = get_active_user()
|
user = get_active_user()
|
||||||
q = f"{Posts.FULL_POSTS_QUERY} WHERE posts.id = ?"
|
if thread.locked() and not user.is_mod():
|
||||||
editing_post = db.fetch_one(q, post_id)
|
abort(403)
|
||||||
if not editing_post:
|
|
||||||
abort(404)
|
|
||||||
return
|
|
||||||
if editing_post['user_id'] != user.id:
|
|
||||||
return redirect(url_for('topics.all_topics'))
|
|
||||||
|
|
||||||
thread = Threads.find({'id': editing_post['thread_id']})
|
|
||||||
|
|
||||||
thread_predicate = f'{Posts.FULL_POSTS_QUERY} WHERE posts.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_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'
|
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',
|
context_next = db.query(context_next_q, thread.id, post.created_at)
|
||||||
editing_post = editing_post,
|
context_prev = db.query(context_prev_q, thread.id, post.created_at)
|
||||||
thread = thread,
|
|
||||||
prev_context = prev_context,
|
return render_template(
|
||||||
next_context = next_context,
|
'posts/edit.html', post=post.get_full_post_view(),
|
||||||
|
context_next=context_next, context_prev=context_prev
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@bp.post('/<int:post_id>/edit/')
|
||||||
@bp.post("/<post_id>/edit")
|
|
||||||
@login_required
|
@login_required
|
||||||
def edit_form(post_id):
|
@ownership_required
|
||||||
user = get_active_user()
|
def edit_post(post_id):
|
||||||
post = Posts.find({'id': post_id})
|
post = Posts.find({'id': post_id})
|
||||||
if not post:
|
if not post:
|
||||||
abort(404)
|
abort(404)
|
||||||
return
|
|
||||||
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})
|
thread = Threads.find({'id': post.thread_id})
|
||||||
return redirect(url_for('threads.thread', slug=thread.slug, after=post.id, _anchor=f'post-{post.id}'))
|
if not thread:
|
||||||
|
# what?
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
user = get_active_user()
|
||||||
|
if thread.locked() and not user.is_mod():
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
post.edit(request.form.get('babycode_content', ''))
|
||||||
|
|
||||||
|
return redirect(get_post_url(post.id, _anchor=True))
|
||||||
|
|
||||||
|
@bp.get('/<int:post_id>/delete/')
|
||||||
|
@login_required
|
||||||
|
@ownership_or_mod_required
|
||||||
|
def delete(post_id):
|
||||||
|
post = Posts.find({'id': post_id})
|
||||||
|
if not post:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
thread = Threads.find({'id': post.thread_id})
|
||||||
|
if not thread:
|
||||||
|
# what?
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
user = get_active_user()
|
||||||
|
if thread.locked() and not user.is_mod():
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
return render_template('posts/delete.html', post=post.get_full_post_view())
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post('/<int:post_id>/delete/')
|
||||||
|
@login_required
|
||||||
|
@ownership_or_mod_required
|
||||||
|
def delete_post(post_id):
|
||||||
|
post = Posts.find({'id': post_id})
|
||||||
|
if not post:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
thread = Threads.find({'id': post.thread_id})
|
||||||
|
if not thread:
|
||||||
|
# what?
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
user = get_active_user()
|
||||||
|
if thread.locked() and not user.is_mod():
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
post.delete()
|
||||||
|
|
||||||
|
if Posts.count({'thread_id': thread.id}) == 0:
|
||||||
|
topic = Topics.find({'id': thread.topic_id})
|
||||||
|
thread.delete()
|
||||||
|
flash('Thread deleted.', InfoboxKind.INFO)
|
||||||
|
return redirect(url_for('topics.topic_by_id', topic_id=topic.id))
|
||||||
|
|
||||||
|
flash('Post deleted.', InfoboxKind.INFO)
|
||||||
|
return redirect(url_for('threads.thread_by_id', thread_id=thread.id))
|
||||||
|
|||||||
@@ -1,267 +1,208 @@
|
|||||||
from flask import (
|
from flask import Blueprint, redirect, url_for, render_template, request, abort
|
||||||
Blueprint, render_template, request, redirect, url_for, flash,
|
from functools import wraps
|
||||||
abort, current_app,
|
from ..auth import login_required, get_active_user, is_logged_in
|
||||||
)
|
from ..models import Threads, Posts, Topics, Users, Reactions, Subscriptions
|
||||||
from .users import login_required, mod_only, get_active_user, is_logged_in
|
from ..util import get_form_checkbox, time_now
|
||||||
from ..db import db
|
|
||||||
from ..models import Threads, Topics, Posts, Subscriptions, Reactions
|
|
||||||
from ..constants import InfoboxKind
|
|
||||||
from ..lib.render_atom import render_atom_template
|
|
||||||
from .posts import create_post
|
|
||||||
from slugify import slugify
|
|
||||||
from app import cache
|
|
||||||
import math
|
import math
|
||||||
import time
|
|
||||||
|
|
||||||
bp = Blueprint("threads", __name__, url_prefix = "/threads/")
|
bp = Blueprint('threads', __name__, url_prefix='/threads/')
|
||||||
|
|
||||||
|
def ownership_or_mod_required(view_func):
|
||||||
def get_post_url(post_id, _anchor=False, external=False):
|
@wraps(view_func)
|
||||||
post = Posts.find({'id': post_id})
|
def wrapper(*args, **kwargs):
|
||||||
if not post:
|
thread = Threads.find({'id': kwargs.get('thread_id', None)})
|
||||||
return ""
|
|
||||||
|
|
||||||
thread = Threads.find({'id': post.thread_id})
|
|
||||||
|
|
||||||
anchor = None if not _anchor else f'post-{post_id}'
|
|
||||||
|
|
||||||
return url_for('threads.thread', slug=thread.slug, after=post_id, _external=external, _anchor=anchor)
|
|
||||||
# if not _anchor:
|
|
||||||
# return res
|
|
||||||
|
|
||||||
# return f"{res}#post-{post_id}"
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<slug>")
|
|
||||||
def thread(slug):
|
|
||||||
POSTS_PER_PAGE = 10
|
|
||||||
thread = Threads.find({"slug": slug})
|
|
||||||
if not thread:
|
if not thread:
|
||||||
abort(404)
|
abort(404)
|
||||||
return
|
|
||||||
|
|
||||||
post_count = Posts.count({"thread_id": thread.id})
|
if thread.user_id != get_active_user().id and not get_active_user().is_mod():
|
||||||
page_count = max(math.ceil(post_count / POSTS_PER_PAGE), 1)
|
abort(403)
|
||||||
|
|
||||||
page = 1
|
return view_func(*args, **kwargs)
|
||||||
after = request.args.get("after", default=None)
|
return wrapper
|
||||||
if after is not None:
|
|
||||||
after_id = int(after)
|
|
||||||
post_position = Posts.count([
|
|
||||||
("thread_id", "=", thread.id),
|
|
||||||
("id", "<=", after_id),
|
|
||||||
])
|
|
||||||
page = math.ceil((post_position) / POSTS_PER_PAGE)
|
|
||||||
else:
|
|
||||||
page = max(1, min(page_count, int(request.args.get("page", default = 1))))
|
|
||||||
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
|
@bp.get('/<int:thread_id>/')
|
||||||
unread_count = None
|
def thread_by_id(thread_id):
|
||||||
if is_logged_in():
|
thread = Threads.find({'id': thread_id})
|
||||||
subscription = Subscriptions.find({
|
|
||||||
'thread_id': thread.id,
|
|
||||||
'user_id': get_active_user().id,
|
|
||||||
})
|
|
||||||
if subscription:
|
|
||||||
unread_count = subscription.get_unread_count()
|
|
||||||
if int(posts[-1]['created_at']) > int(subscription.last_seen):
|
|
||||||
subscription.update({
|
|
||||||
'last_seen': int(posts[-1]['created_at'])
|
|
||||||
})
|
|
||||||
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,
|
|
||||||
Reactions = Reactions,
|
|
||||||
unread_count = unread_count,
|
|
||||||
__feedlink = url_for('.thread_atom', slug=slug, _external=True),
|
|
||||||
__feedtitle = f'replies to {thread.title}',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<slug>/feed.atom")
|
|
||||||
@cache.cached(timeout=5 * 60, unless=lambda: current_app.config.get('DEBUG', False))
|
|
||||||
def thread_atom(slug):
|
|
||||||
thread = Threads.find({"slug": slug})
|
|
||||||
if not thread:
|
if not thread:
|
||||||
abort(404) # TODO throw an atom friendly 404
|
abort(404)
|
||||||
return
|
return redirect(url_for('.thread', thread_id=thread_id, slug=thread.slug, **request.args))
|
||||||
|
|
||||||
|
@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.args))
|
||||||
|
|
||||||
topic = Topics.find({'id': thread.topic_id})
|
topic = Topics.find({'id': thread.topic_id})
|
||||||
posts = thread.get_posts_rss()
|
started_by = Users.find({'id': thread.user_id})
|
||||||
|
PER_PAGE = 10
|
||||||
return render_atom_template('threads/thread.atom', thread=thread, topic=topic, posts=posts, get_post_url=get_post_url)
|
posts_count = Posts.count({'thread_id': thread.id})
|
||||||
|
page_count = max(1, math.ceil(posts_count / PER_PAGE))
|
||||||
|
page = 1
|
||||||
@bp.post("/<slug>")
|
after = request.args.get('after')
|
||||||
@login_required
|
if after is not None:
|
||||||
def reply(slug):
|
try:
|
||||||
thread = Threads.find({"slug": slug})
|
after_id = int(after)
|
||||||
if not thread:
|
post_position = Posts.count([
|
||||||
|
('thread_id', '=', thread.id),
|
||||||
|
('id', '<=', after_id),
|
||||||
|
])
|
||||||
|
page = math.ceil((post_position) / PER_PAGE)
|
||||||
|
except ValueError:
|
||||||
abort(404)
|
abort(404)
|
||||||
return
|
else:
|
||||||
|
try:
|
||||||
|
page = max(1, min(int(request.args.get('page', default=1)), page_count))
|
||||||
|
except ValueError:
|
||||||
|
abort(404)
|
||||||
|
posts = thread.get_posts(PER_PAGE, page)
|
||||||
|
last_post = posts[-1]
|
||||||
user = get_active_user()
|
user = get_active_user()
|
||||||
if user.is_guest():
|
if user:
|
||||||
return redirect(url_for('.thread', slug=slug))
|
|
||||||
if thread.locked() and not user.is_mod():
|
|
||||||
return redirect(url_for('.thread', slug=slug))
|
|
||||||
|
|
||||||
post_content = request.form['post_content']
|
|
||||||
post = create_post(thread.id, user.id, post_content)
|
|
||||||
|
|
||||||
subscription = Subscriptions.find({'user_id': user.id, 'thread_id': thread.id})
|
subscription = Subscriptions.find({'user_id': user.id, 'thread_id': thread.id})
|
||||||
|
if subscription and last_post['created_at'] > int(subscription.last_seen):
|
||||||
|
subscription.update({'last_seen': last_post['created_at']})
|
||||||
|
return render_template(
|
||||||
|
'threads/thread.html', thread=thread,
|
||||||
|
posts=posts, page=page,
|
||||||
|
page_count=page_count, topic=topic,
|
||||||
|
started_by=started_by, topics=Topics.get_list(),
|
||||||
|
Reactions=Reactions, last_post=last_post
|
||||||
|
)
|
||||||
|
|
||||||
if subscription:
|
@bp.post('/<int:thread_id>/')
|
||||||
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")
|
|
||||||
@login_required
|
@login_required
|
||||||
def create():
|
def reply(thread_id):
|
||||||
all_topics = Topics.select()
|
|
||||||
return render_template("threads/create.html", all_topics = all_topics)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/create")
|
|
||||||
@login_required
|
|
||||||
def create_form():
|
|
||||||
topic = Topics.find({"id": request.form['topic_id']})
|
|
||||||
user = get_active_user()
|
user = get_active_user()
|
||||||
if not topic:
|
thread = Threads.find({'id': thread_id})
|
||||||
flash('Invalid topic', InfoboxKind.ERROR)
|
|
||||||
return redirect(url_for('.create'))
|
|
||||||
|
|
||||||
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:
|
if not thread:
|
||||||
abort(404)
|
abort(404)
|
||||||
return
|
if not user.can_post_to_thread_or_topic(thread):
|
||||||
if not ((thread.user_id == user.id) or user.is_mod()):
|
return redirect(url_for('.thread_by_id', thread_id=thread_id))
|
||||||
return redirect(url_for('.thread', slug=slug))
|
post = Posts.new(user.id, thread.id, request.form.get('babycode_content'))
|
||||||
target_op = request.form.get('target_op')
|
subscription = Subscriptions.find({'user_id': user.id, 'thread_id': thread.id})
|
||||||
thread.update({
|
if get_form_checkbox('subscribe'):
|
||||||
'is_locked': target_op
|
if not subscription:
|
||||||
})
|
subscription = Subscriptions.create({
|
||||||
return redirect(url_for('.thread', slug=slug))
|
|
||||||
|
|
||||||
|
|
||||||
@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:
|
|
||||||
abort(404)
|
|
||||||
return
|
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<slug>/move")
|
|
||||||
@login_required
|
|
||||||
@mod_only(".thread", slug = lambda slug: slug)
|
|
||||||
def move(slug):
|
|
||||||
user = get_active_user()
|
|
||||||
|
|
||||||
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,
|
'user_id': user.id,
|
||||||
'thread_id': thread.id,
|
'thread_id': thread.id,
|
||||||
|
'last_seen': time_now(),
|
||||||
})
|
})
|
||||||
if request.form['subscribe'] == 'subscribe':
|
if subscription and subscription.last_seen < time_now():
|
||||||
if subscription:
|
subscription.update({'last_seen': time_now()})
|
||||||
subscription.delete()
|
return redirect(url_for('.thread_by_id', thread_id=thread_id, after=post.id, _anchor=f'post-{post.id}'))
|
||||||
|
|
||||||
|
@bp.get('/<int:thread_id>/edit/')
|
||||||
|
@login_required
|
||||||
|
@ownership_or_mod_required
|
||||||
|
def edit(thread_id):
|
||||||
|
thread = Threads.find({'id': thread_id})
|
||||||
|
if not thread:
|
||||||
|
abort(404)
|
||||||
|
return render_template('threads/edit.html', thread=thread)
|
||||||
|
|
||||||
|
@bp.post('/<int:thread_id>/edit/')
|
||||||
|
@login_required
|
||||||
|
@ownership_or_mod_required
|
||||||
|
def edit_post(thread_id):
|
||||||
|
thread = Threads.find({'id': thread_id})
|
||||||
|
if not thread:
|
||||||
|
abort(404)
|
||||||
|
new_title = request.form.get('title', '').strip()
|
||||||
|
if not new_title:
|
||||||
|
abort(400)
|
||||||
|
|
||||||
|
if new_title != thread.title:
|
||||||
|
from slugify import slugify
|
||||||
|
thread.update({'title': new_title, 'slug': slugify(new_title, max_length=50)})
|
||||||
|
|
||||||
|
return redirect(url_for('.thread_by_id', thread_id=thread_id))
|
||||||
|
|
||||||
|
@bp.post('/<int:thread_id>/subscribe/')
|
||||||
|
@login_required
|
||||||
|
def subscribe(thread_id):
|
||||||
|
thread = Threads.find({'id': thread_id})
|
||||||
|
if not thread:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
user = get_active_user()
|
||||||
|
last_post_id = request.form.get('last_post_id', None)
|
||||||
|
if last_post_id is None:
|
||||||
|
abort(400)
|
||||||
|
|
||||||
|
if user.is_subscribed(thread_id):
|
||||||
|
return redirect(url_for('.thread_by_id', thread_id=thread_id, after=last_post_id))
|
||||||
|
|
||||||
Subscriptions.create({
|
Subscriptions.create({
|
||||||
'user_id': user.id,
|
'user_id': user.id,
|
||||||
'thread_id': thread.id,
|
'thread_id': thread_id,
|
||||||
'last_seen': int(time.time()),
|
'last_seen': request.form.get('last_post_timestamp', time_now())
|
||||||
})
|
})
|
||||||
elif request.form['subscribe'] == 'unsubscribe':
|
|
||||||
|
return redirect(url_for('.thread_by_id', thread_id=thread_id, after=last_post_id))
|
||||||
|
|
||||||
|
@bp.post('/<int:thread_id>/unsubscribe/')
|
||||||
|
@login_required
|
||||||
|
def unsubscribe(thread_id):
|
||||||
|
thread = Threads.find({'id': thread_id})
|
||||||
|
if not thread:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
user = get_active_user()
|
||||||
|
last_post_id = request.form.get('last_post_id', None)
|
||||||
|
|
||||||
|
return_to = request.form.get('return_to', None)
|
||||||
|
if return_to is None and last_post_id is None:
|
||||||
|
abort(400)
|
||||||
|
elif return_to is None and last_post_id is not None:
|
||||||
|
return_to = url_for('.thread_by_id', thread_id=thread_id, after=last_post_id)
|
||||||
|
|
||||||
|
subscription = Subscriptions.find({'user_id': user.id, 'thread_id': thread_id})
|
||||||
if not subscription:
|
if not subscription:
|
||||||
return redirect(url_for('.thread', slug=slug))
|
return redirect(return_to)
|
||||||
|
|
||||||
subscription.delete()
|
subscription.delete()
|
||||||
elif request.form['subscribe'] == 'read':
|
return redirect(return_to)
|
||||||
if not subscription:
|
|
||||||
return redirect(url_for('.thread', slug=slug))
|
@bp.get('/<int:thread_id>/feed.atom/')
|
||||||
subscription.update({
|
def feed(thread_id):
|
||||||
'last_seen': int(time.time())
|
return 'stub'
|
||||||
})
|
|
||||||
last_visible_post = request.form.get('last_visible_post', default=None)
|
@bp.get('/new/')
|
||||||
if last_visible_post is not None:
|
@login_required
|
||||||
return redirect(url_for('.thread', slug=thread.slug, after=last_visible_post))
|
def new():
|
||||||
else:
|
topics = Topics.select()
|
||||||
return redirect(url_for('users.inbox', username=user.username))
|
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('/new/')
|
||||||
|
@login_required
|
||||||
|
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:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
user = get_active_user()
|
||||||
|
if not user.can_post_to_thread_or_topic(topic):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
title = request.form.get('title', '').strip()
|
||||||
|
if not title:
|
||||||
|
abort(400)
|
||||||
|
|
||||||
|
title = title.strip()
|
||||||
|
|
||||||
|
content = request.form.get('babycode_content')
|
||||||
|
|
||||||
|
thread = Threads.new(user.id, topic.id, title, content)
|
||||||
|
return redirect(url_for('.thread_by_id', thread_id=thread.id))
|
||||||
|
|||||||
@@ -1,147 +1,49 @@
|
|||||||
from flask import (
|
from flask import Blueprint, redirect, url_for, render_template, request, session, abort
|
||||||
Blueprint, render_template, request, redirect, url_for, flash, session,
|
|
||||||
abort, current_app
|
from ..models import Topics, Threads, Subscriptions
|
||||||
)
|
from ..auth import get_active_user
|
||||||
from .users import login_required, mod_only, get_active_user, is_logged_in
|
|
||||||
from ..models import Users, Topics, Threads, Subscriptions
|
|
||||||
from ..constants import InfoboxKind
|
|
||||||
from ..lib.render_atom import render_atom_template
|
|
||||||
from slugify import slugify
|
|
||||||
from app import cache
|
|
||||||
import time
|
|
||||||
import math
|
import math
|
||||||
|
|
||||||
bp = Blueprint("topics", __name__, url_prefix = "/topics/")
|
bp = Blueprint('topics', __name__, url_prefix = '/topics/')
|
||||||
|
|
||||||
|
@bp.get('/')
|
||||||
@bp.get("/")
|
|
||||||
def all_topics():
|
def all_topics():
|
||||||
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>/')
|
||||||
@bp.get("/create")
|
def topic_by_id(topic_id):
|
||||||
@login_required
|
topic = Topics.find({'id': topic_id})
|
||||||
@mod_only(".all_topics")
|
if not topic:
|
||||||
def create():
|
|
||||||
return render_template("topics/create.html")
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/create")
|
|
||||||
@login_required
|
|
||||||
@mod_only(".all_topics")
|
|
||||||
def create_post():
|
|
||||||
topic_name = request.form['name'].strip()
|
|
||||||
now = int(time.time())
|
|
||||||
slug = f"{slugify(topic_name)}-{now}"
|
|
||||||
|
|
||||||
topic_count = Topics.count()
|
|
||||||
topic = Topics.create({
|
|
||||||
"name": topic_name,
|
|
||||||
"description": request.form['description'],
|
|
||||||
"slug": slug,
|
|
||||||
"sort_order": topic_count + 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
flash("Topic created.", InfoboxKind.INFO)
|
|
||||||
return redirect(url_for("topics.topic", slug = slug))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<slug>")
|
|
||||||
def topic(slug):
|
|
||||||
THREADS_PER_PAGE = 10
|
|
||||||
target_topic = Topics.find({
|
|
||||||
"slug": slug
|
|
||||||
})
|
|
||||||
if not target_topic:
|
|
||||||
abort(404)
|
abort(404)
|
||||||
return
|
return redirect(url_for('.topic', topic_id=topic_id, slug=topic.slug, **request.args))
|
||||||
|
|
||||||
threads_count = Threads.count({
|
@bp.get('/<int:topic_id>/<slug>/')
|
||||||
"topic_id": target_topic.id
|
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))
|
||||||
|
|
||||||
sort_by = session.get('sort_by', default="activity")
|
sort_by = request.args.get('sort_by', default=session.get('sort_by', default='activity'))
|
||||||
|
PER_PAGE = 10
|
||||||
page_count = max(math.ceil(threads_count / THREADS_PER_PAGE), 1)
|
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))
|
page = max(1, min(int(request.args.get('page', default=1)), page_count))
|
||||||
|
except ValueError:
|
||||||
threads_list = target_topic.get_threads(THREADS_PER_PAGE, page, sort_by)
|
abort(404)
|
||||||
|
threads = topic.get_threads(PER_PAGE, page, sort_by)
|
||||||
subscriptions = {}
|
subscriptions = {}
|
||||||
if is_logged_in():
|
user = get_active_user()
|
||||||
for thread in threads_list:
|
if user:
|
||||||
subscription = Subscriptions.find({
|
for thread in threads:
|
||||||
'user_id': get_active_user().id,
|
subscription = Subscriptions.find({'user_id': user.id, 'thread_id': thread['id']})
|
||||||
'thread_id': thread['id'],
|
|
||||||
})
|
|
||||||
if subscription:
|
if subscription:
|
||||||
subscriptions[thread['id']] = subscription.get_unread_count()
|
subscriptions[thread['id']] = subscription.get_unread_count()
|
||||||
|
return render_template('topics/topic.html', topic=topic, threads=threads, sort_by=sort_by, page=page, page_count=page_count, subscriptions=subscriptions)
|
||||||
|
|
||||||
return render_template(
|
@bp.get('/<int:topic_id>/feed.atom/')
|
||||||
"topics/topic.html",
|
def feed(topic_id):
|
||||||
threads_list = threads_list,
|
return 'stub'
|
||||||
subscriptions = subscriptions,
|
|
||||||
topic = target_topic,
|
|
||||||
current_page = page,
|
|
||||||
page_count = page_count,
|
|
||||||
__feedlink = url_for('.topic_atom', slug=slug, _external=True),
|
|
||||||
__feedtitle = f'latest threads in {target_topic.name}',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get('/<slug>/feed.atom')
|
|
||||||
@cache.cached(timeout=10 * 60, unless=lambda: current_app.config.get('DEBUG', False))
|
|
||||||
def topic_atom(slug):
|
|
||||||
target_topic = Topics.find({
|
|
||||||
"slug": slug
|
|
||||||
})
|
|
||||||
if not target_topic:
|
|
||||||
abort(404) # TODO throw an atom friendly 404
|
|
||||||
return
|
|
||||||
|
|
||||||
threads_list = target_topic.get_threads_with_op_rss()
|
|
||||||
|
|
||||||
return render_atom_template('topics/topic.atom', threads_list=threads_list, target_topic=target_topic)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<slug>/edit")
|
|
||||||
@login_required
|
|
||||||
@mod_only(".topic", slug = lambda slug: slug)
|
|
||||||
def edit(slug):
|
|
||||||
topic = Topics.find({"slug": slug})
|
|
||||||
if not topic:
|
|
||||||
abort(404)
|
|
||||||
return
|
|
||||||
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:
|
|
||||||
abort(404)
|
|
||||||
return
|
|
||||||
|
|
||||||
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:
|
|
||||||
abort(404)
|
|
||||||
return
|
|
||||||
|
|
||||||
topic.delete()
|
|
||||||
|
|
||||||
flash("Topic deleted.", InfoboxKind.INFO)
|
|
||||||
return redirect(url_for("topics.all_topics"))
|
|
||||||
|
|||||||
1293
app/routes/users.py
@@ -1,10 +1,15 @@
|
|||||||
from app import create_app
|
from app import create_app
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) > 1 and sys.argv[1] == '-d':
|
||||||
|
hostname = '0.0.0.0'
|
||||||
|
else:
|
||||||
|
hostname = '127.0.0.1'
|
||||||
app.run(
|
app.run(
|
||||||
host = "127.0.0.1",
|
host = hostname,
|
||||||
port = 8080
|
port = 8080
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -159,39 +159,41 @@ SCHEMA = [
|
|||||||
)""",
|
)""",
|
||||||
|
|
||||||
# INDEXES
|
# INDEXES
|
||||||
"CREATE INDEX IF NOT EXISTS idx_post_history_post_id ON post_history(post_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 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_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_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_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_slug ON threads(slug)',
|
||||||
"CREATE INDEX IF NOT EXISTS idx_threads_topic_id ON threads(topic_id)",
|
'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 idx_topics_slug ON topics(slug)',
|
||||||
"CREATE INDEX IF NOT EXISTS session_keys ON sessions(key)",
|
'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 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_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 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_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_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_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_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_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_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_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_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_upload_user ON badge_uploads(user_id)',
|
||||||
"CREATE INDEX IF NOT EXISTS idx_badge_user ON badges(user_id)",
|
'CREATE INDEX IF NOT EXISTS idx_badge_user ON badges(user_id)',
|
||||||
|
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_invite_key_user ON invite_keys(created_by, key)'
|
||||||
]
|
]
|
||||||
|
|
||||||
def create():
|
def create():
|
||||||
print("Creating schema...")
|
print('Creating schema...')
|
||||||
with db.transaction():
|
with db.transaction():
|
||||||
for stmt in SCHEMA:
|
for stmt in SCHEMA:
|
||||||
db.execute(stmt)
|
db.execute(stmt)
|
||||||
print("Schema completed.")
|
print('Schema completed.')
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
||||||
{% if self.title() %}
|
|
||||||
<title>{% block title %}{% endblock %}</title>
|
|
||||||
{% else %}
|
|
||||||
<title>{{ config.SITE_NAME }}</title>
|
|
||||||
{% endif %}
|
|
||||||
{% if self.feed_updated() %}
|
|
||||||
<updated>{% block feed_updated %}{% endblock %}</updated>
|
|
||||||
{% else %}
|
|
||||||
<updated>{{ get_time_now() | iso8601 }}</updated>
|
|
||||||
{% endif %}
|
|
||||||
<id>{{ __current_page }}</id>
|
|
||||||
<link rel="self" href="{{ __current_page }}" />
|
|
||||||
<link href="{% block canonical_link %}{% endblock %}" />
|
|
||||||
{% if self.feed_author() %}
|
|
||||||
<author>{% block feed_author %}{% endblock %}</author>
|
|
||||||
{% endif %}
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
</feed>
|
|
||||||
@@ -1,32 +1,31 @@
|
|||||||
{% from 'common/macros.html' import infobox with context %}
|
{%- from 'common/macros.html' import infobox with context -%}
|
||||||
<!DOCTYPE HTML>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="utf-8">
|
||||||
{% if self.title() %}
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{{config.SITE_NAME}} - {% block title %}{% endblock %}</title>
|
|
||||||
{% else %}
|
|
||||||
<title>{{config.SITE_NAME}}</title>
|
|
||||||
{% endif %}
|
|
||||||
<link rel="stylesheet" href="{{ ("/static/css/%s.css" % get_prefers_theme()) | cachebust }}">
|
|
||||||
<link rel="icon" type="image/png" href="/static/favicon.png">
|
<link rel="icon" type="image/png" href="/static/favicon.png">
|
||||||
<script src="{{ '/static/js/vnd/bitty-7.0.0.js' | cachebust }}" type="module"></script>
|
<link rel="stylesheet" href="{{ "/static/css/style.css" | cachebust }}">
|
||||||
{% if __feedlink %}
|
{% if self.title() -%}
|
||||||
<link rel="alternate" type="application/atom+xml" href="{{ __feedlink }}" title="{{ __feedtitle }}">
|
<title>{{ config.SITE_NAME }} - {% block title -%}{%- endblock -%}</title>
|
||||||
{% endif %}
|
{%- else -%}
|
||||||
</head>
|
<title>{{ config.SITE_NAME }}</title>
|
||||||
<body>
|
{%- endif -%}
|
||||||
<bitty-7-0 data-connect="{{ '/static/js/bitties/pyrom-bitty.js' | cachebust }}">
|
</head>
|
||||||
{% include 'common/topnav.html' %}
|
<body>
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
<bitty-8 data-connect="/static/js/bits/progressive-enhancement.js"></bitty-8>
|
||||||
{% if messages %}
|
<bitty-8 data-connect="/static/js/bits/ui.js"></bitty-8>
|
||||||
{% for category, message in messages %}
|
{%- include 'common/topnav.html' -%}
|
||||||
{{ infobox(message, category) }}
|
{%- with messages = get_flashed_messages(with_categories=true) -%}
|
||||||
{% endfor %}
|
{%- if messages -%}
|
||||||
{% endif %}
|
{%- for category, message in messages -%}
|
||||||
{% endwith %}
|
{{- infobox(message, category) -}}
|
||||||
{% block content %}{% endblock %}
|
{%- endfor -%}
|
||||||
{% include 'common/footer.html' %}
|
{%- endif -%}
|
||||||
</bitty-7-0>
|
{%- endwith -%}
|
||||||
<script src="{{ "/static/js/ui.js" | cachebust }}"></script>
|
{%- block content -%}{%- endblock -%}
|
||||||
</body>
|
{%- include 'common/footer.html' -%}
|
||||||
|
<script type="module" src="/static/js/vnd/bitty-8.0.0.js"></script>
|
||||||
|
<script src="{{'/static/js/ui.js' | cachebust}}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
8
app/templates/common/403.html
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{%- from 'common/macros.html' import subheader -%}
|
||||||
|
{%- extends 'base.html' -%}
|
||||||
|
{%- block title -%}Forbidden{%- endblock -%}
|
||||||
|
{%- block content -%}
|
||||||
|
{%- call() subheader('403 Forbidden') -%}
|
||||||
|
<span>You are not allowed to access this page or perform this action.</span>
|
||||||
|
{%- endcall -%}
|
||||||
|
{%- endblock -%}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{% extends 'base.html' %}
|
{%- from 'common/macros.html' import subheader -%}
|
||||||
{% block title %}not found{% endblock %}
|
{%- extends 'base.html' -%}
|
||||||
{% block content %}
|
{%- block title -%}Not found{%- endblock -%}
|
||||||
<div class="darkbg">
|
{%- block content -%}
|
||||||
<h1>404 Not Found</h1>
|
{%- call() subheader('404 Not Found') -%}
|
||||||
<p>The requested URL does not exist.</p>
|
<span>The requested page was not found.</span>
|
||||||
</div>
|
{%- endcall -%}
|
||||||
{% endblock %}
|
{%- endblock -%}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
{% block title %}request entity too large{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="darkbg">
|
|
||||||
<h1>413 Request Entity Too Large</h1>
|
|
||||||
<p>The file(s) you tried to upload are too large.</p>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<footer id="footer">
|
<footer class="plank secondary-bg bottom">
|
||||||
<span>Pyrom commit <a href="{{ "https://git.poto.cafe/yagich/pyrom/commit/" + __commit }}">{{ __commit[:8] }}</a></span>
|
<span>Pyrom commit <a href="{{ "https://git.poto.cafe/yagich/pyrom/commit/" + __commit }}">{{ __commit[:8] }}</a></span>
|
||||||
<ul class="horizontal">
|
<ul class="horizontal">
|
||||||
<li><a href="{{ url_for('guides.contact') }}">Contact</a></li>
|
<li><a href="{{url_for('guides.contact')}}">Contact</a></li>
|
||||||
<li><a href="{{ url_for('guides.guides_index') }}">Guides</a></li>
|
<li><a href="{{url_for('guides.index')}}">Guides</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,67 +1,31 @@
|
|||||||
{# https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license #}
|
{%- macro icn_info(width=48) -%}
|
||||||
|
<img src="/static/icons/info.svg" alt="info" style="width: {{width}}px;">
|
||||||
|
{%- endmacro -%}
|
||||||
|
|
||||||
{% macro icn_bookmark(width=24) -%}
|
{%- macro icn_warn(width=48) -%}
|
||||||
<svg class="icon" width="{{width}}px" height="{{width}}px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<img src="/static/icons/warn.svg" alt="warning" style="width: {{width}}px;">
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M6 6C6 4.89543 6.89543 4 8 4H16C17.1046 4 18 4.89543 18 6V18.7268C18 19.5969 16.9657 20.0519 16.3243 19.4639L12 15.5L7.67573 19.4639C7.03432 20.0519 6 19.5969 6 18.7268V6Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
{%- endmacro -%}
|
||||||
</svg>
|
|
||||||
{%- endmacro %}
|
|
||||||
|
|
||||||
{% macro icn_error(width=60) -%}
|
{%- macro icn_error(width=48) -%}
|
||||||
<svg class="icon" width="{{width}}px" height="{{width}}px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<img src="/static/icons/error.svg" alt="error" style="width: {{width}}px;">
|
||||||
<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="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
{%- endmacro -%}
|
||||||
</svg>
|
|
||||||
{%- endmacro %}
|
|
||||||
|
|
||||||
{% macro icn_info(width=60) -%}
|
{%- macro icn_megaphone(width=48) -%}
|
||||||
<svg class="icon" width="{{width}}px" height="{{width}}px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<img src="/static/icons/megaphone.svg" alt="megaphone" style="width: {{width}}px;">
|
||||||
<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="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
{%- endmacro -%}
|
||||||
</svg>
|
|
||||||
{%- endmacro %}
|
|
||||||
|
|
||||||
{% macro icn_lock(width=60) -%}
|
{%- macro icn_bookmark(width=16) -%}
|
||||||
<svg class="icon" width="{{width}}px" height="{{width}}px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<img src="/static/icons/bookmark.svg" alt="bookmark" style="width: {{width}}px;">
|
||||||
<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="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
{%- endmacro -%}
|
||||||
</svg>
|
|
||||||
{%- endmacro %}
|
|
||||||
|
|
||||||
{% macro icn_warn(width=60) -%}
|
{%- macro icn_locked(width=16) -%}
|
||||||
<svg class="icon" width="{{width}}px" height="{{width}}px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<img src="/static/icons/locked.svg" title="Locked" alt="lock" style="width: {{width}}px;">
|
||||||
<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="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
{%- endmacro -%}
|
||||||
</svg>
|
|
||||||
{%- endmacro %}
|
|
||||||
|
|
||||||
{% macro icn_image(width=24) -%}
|
{%- macro icn_stickied(width=16) -%}
|
||||||
<svg class="icon" width="{{width}}px" height="{{width}}px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<img src="/static/icons/sticky.svg" title="Stickied" alt="paper held by pushpin" style="width: {{width}}px;">
|
||||||
<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="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
{%- endmacro -%}
|
||||||
</svg>
|
|
||||||
{%- endmacro %}
|
|
||||||
|
|
||||||
{% macro icn_spoiler(width=24) -%}
|
{%- macro icn_dragger(width=24) -%}
|
||||||
<svg class="icon" width="{{width}}px" height="{{width}}px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<img src="/static/icons/dragger.svg" style="width: {{width}}px;">
|
||||||
<path d="M4 4L9.87868 9.87868M20 20L14.1213 14.1213M9.87868 9.87868C9.33579 10.4216 9 11.1716 9 12C9 13.6569 10.3431 15 12 15C12.8284 15 13.5784 14.6642 14.1213 14.1213M9.87868 9.87868L14.1213 14.1213M6.76821 6.76821C4.72843 8.09899 2.96378 10.026 2 11.9998C3.74646 15.5764 8.12201 19 11.9998 19C13.7376 19 15.5753 18.3124 17.2317 17.2317M9.76138 5.34717C10.5114 5.12316 11.2649 5 12.0005 5C15.8782 5 20.2531 8.42398 22 12.0002C21.448 13.1302 20.6336 14.2449 19.6554 15.2412" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
{%- endmacro -%}
|
||||||
</svg>
|
|
||||||
{%- endmacro %}
|
|
||||||
|
|
||||||
{% macro icn_sticky(width=24) -%}
|
|
||||||
<svg class="icon" width="{{width}}px" height="{{width}}px" 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="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
{%- endmacro %}
|
|
||||||
|
|
||||||
{% macro icn_megaphone(width=60) -%}
|
|
||||||
<svg width="{{width}}px" height="{{width}}px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M6 18V14M6 14H8L13 17V7L8 10H5C3.89543 10 3 10.8954 3 12V12C3 13.1046 3.89543 14 5 14H6ZM17 7L19 5M17 17L19 19M19 12H21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
{%- endmacro %}
|
|
||||||
|
|
||||||
{% macro icn_rss(width=24) %}
|
|
||||||
<svg width="{{width}}px" height="{{width}}px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M5 11C9.41828 11 13 14.5817 13 19M5 5C12.732 5 19 11.268 19 19M7 18C7 18.5523 6.55228 19 6 19C5.44772 19 5 18.5523 5 18C5 17.4477 5.44772 17 6 17C6.55228 17 7 17.4477 7 18Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% macro icn_drag(width=24) %}
|
|
||||||
<svg width="{{width}}px" height="{{width}}px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M5 10H19M14 19L12 21L10 19M14 5L12 3L10 5M5 14H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
{% endmacro %}
|
|
||||||
|
|||||||
@@ -1,393 +1,300 @@
|
|||||||
{% from 'common/icons.html' import icn_image, icn_spoiler, icn_info, icn_lock, icn_warn, icn_error, icn_bookmark, icn_megaphone, icn_rss, icn_drag %}
|
{%- from 'common/icons.html' import icn_info, icn_warn, icn_error, icn_bookmark, icn_megaphone, icn_dragger -%}
|
||||||
{%- macro dict_to_attr(attrs) -%}
|
|
||||||
|
{% macro dict_to_attr(attrs) -%}
|
||||||
{%- for key, value in attrs.items() if value is not none -%}{{' '}}{{key}}="{{value}}"{%- endfor -%}
|
{%- for key, value in attrs.items() if value is not none -%}{{' '}}{{key}}="{{value}}"{%- endfor -%}
|
||||||
{%- endmacro -%}
|
|
||||||
|
|
||||||
{% macro pager(current_page, page_count) %}
|
|
||||||
{% set left_start = [1, current_page - 5] | max %}
|
|
||||||
{% set right_end = [page_count, current_page + 5] | min %}
|
|
||||||
<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 bookmark_button(type, id, message = "Bookmark…", require_reload=false) %}
|
|
||||||
{% set bid = type[0] + id | string %}
|
|
||||||
<div class="bookmark-dropdown">
|
|
||||||
<button type="button" class="contain-svg inline icon" data-bookmark-type="{{type}}" data-send="showBookmarkMenu" data-concept-id="{{id}}" data-bookmark-id="{{bid}}">{{ icn_bookmark(20) }}{{ message | safe }}</button>
|
|
||||||
<div class="bookmark-dropdown-inner" data-receive="showBookmarkMenu" data-bookmark-id="{{bid}}" data-require-reload={{require_reload | int}}></div>
|
|
||||||
</div>
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% macro infobox(message, kind=InfoboxKind.INFO) %}
|
|
||||||
<div class="{{ "infobox " + InfoboxHTMLClass[kind] }}">
|
|
||||||
<span>
|
|
||||||
<div class="infobox-icon-container">
|
|
||||||
{%- if kind == InfoboxKind.INFO -%}
|
|
||||||
{{- icn_info() -}}
|
|
||||||
{%- elif kind == InfoboxKind.LOCK -%}
|
|
||||||
{{- icn_lock() -}}
|
|
||||||
{%- elif kind == InfoboxKind.WARN -%}
|
|
||||||
{{- icn_warn() -}}
|
|
||||||
{%- elif kind == InfoboxKind.ERROR -%}
|
|
||||||
{{- icn_error() -}}
|
|
||||||
{%- endif -%}
|
|
||||||
</div>
|
|
||||||
<span>
|
|
||||||
{% set m = message.split(';', maxsplit=1) %}
|
|
||||||
<strong>{{ m[0] }}</strong>
|
|
||||||
{%- if m[1] %}
|
|
||||||
{{ m[1] -}}
|
|
||||||
{%- endif -%}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% macro motd(motd_obj) %}
|
|
||||||
<div class="motd">
|
|
||||||
<div class="motd-icon-container contain-svg">
|
|
||||||
{{ icn_megaphone(80) }}
|
|
||||||
<i><abbr title="Message of the Day">MOTD</abbr></i>
|
|
||||||
</div>
|
|
||||||
<div class="motd-content-container">
|
|
||||||
<div class="motd-title">{{ motd_obj.title }}</div>
|
|
||||||
<div class="motd-body">{{ motd_obj.body_rendered | safe}}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% macro timestamp(unix_ts) -%}
|
|
||||||
<span class="timestamp" data-utc="{{ unix_ts }}" data-init="convertTimestamps">{{ 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="", banned_tags=[]) %}
|
{% macro timestamp(unix_ts) -%}
|
||||||
<div class="babycode-editor-container tab-bar" data-receive="toggleTab">
|
<time data-r="localizeTimestamps" datetime="{{ unix_ts | iso8601 }}">{{ unix_ts | ts_datetime('%Y-%m-%d %H:%M')}} <abbr title="Server Time">ST</abbr></time>
|
||||||
<input type="hidden" id="babycode-banned-tags" value="{{banned_tags | unique | list | tojson | forceescape}}">
|
{%- endmacro %}
|
||||||
<div class="tab-buttons">
|
|
||||||
<button data-send="toggleTab" type=button class="tab-button active" data-target-id="tab-edit">Write</button>
|
{% macro subheader(title, desc='') -%}
|
||||||
<button data-send="babycodePreview toggleTab" type=button class="tab-button" data-target-id="tab-preview">Preview</button>
|
<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>
|
||||||
|
{%- 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 -%}
|
||||||
|
|
||||||
|
{%- 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=[], signal_ss=[], signal_rs=[]) -%}
|
||||||
|
<div class="tab-container" data-r="setTab">
|
||||||
|
<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'}}" disabled data-r="enhance" data-s="setTab {{signal_ss[loop.index0] if signal_ss[loop.index0] else ''}}" data-tab-index="{{loop.index0}}">{{tab_label}}</button>
|
||||||
|
{%- endfor -%}
|
||||||
</div>
|
</div>
|
||||||
<div class="tab-content active" id="tab-edit">
|
{%- for tab_label in labels -%}
|
||||||
<span class="babycode-button-container">
|
<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'}}" data-r="{{signal_rs[loop.index0] if signal_rs[loop.index0] else ''}}">
|
||||||
<button data-send="insertBabycodeTag" data-tag="b" class="babycode-button" type=button id="post-editor-bold" title="Insert Bold" {{"disabled" if "b" in banned_tags else ""}}><strong>B</strong></button>
|
{{- caller(loop.index0) -}}
|
||||||
<button data-send="insertBabycodeTag" data-tag="i" class="babycode-button" type=button id="post-editor-italics" title="Insert Italics" {{"disabled" if "i" in banned_tags else ""}}><em>I</em></button>
|
</div>
|
||||||
<button data-send="insertBabycodeTag" data-tag="s" class="babycode-button" type=button id="post-editor-strike" title="Insert Strikethrough" {{"disabled" if "s" in banned_tags else ""}}><del>S</del></button>
|
{%- endfor -%}
|
||||||
<button data-send="insertBabycodeTag" data-tag="u" class="babycode-button" type=button id="post-editor-underline" title="Insert Underline" {{"disabled" if "u" in banned_tags else ""}}><u>U</u></button>
|
</div>
|
||||||
<button data-send="insertBabycodeTag" data-tag="url=" data-prefill="link label" class="babycode-button" type=button id="post-editor-url" title="Insert Link" {{"disabled" if "url" in banned_tags else ""}}><code>://</code></button>
|
{%- endmacro %}
|
||||||
<button data-send="insertBabycodeTag" data-tag="code=" data-break-line="1" class="babycode-button" type=button id="post-editor-code" title="Insert Code block" {{"disabled" if "code" in banned_tags else ""}}><code></></code></button>
|
|
||||||
<button data-send="insertBabycodeTag" data-tag="img=" data-prefill="alt text" class="babycode-button contain-svg" type=button id="post-editor-img" title="Insert Image" {{"disabled" if "img" in banned_tags else ""}}>{{ icn_image() }}</button>
|
{% macro babycode_editor_component(
|
||||||
<button data-send="insertBabycodeTag" data-tag="ol" data-break-line="1" class="babycode-button" type=button id="post-editor-ol" title="Insert Ordered list" {{"disabled" if "ol" in banned_tags else ""}}>1.</button>
|
placeholder='Post content',
|
||||||
<button data-send="insertBabycodeTag" data-tag="ul" data-break-line="1" class="babycode-button" type=button id="post-editor-ul" title="Insert Unordered list" {{"disabled" if "u;" in banned_tags else ""}}>•</button>
|
prefill='',
|
||||||
<button data-send="insertBabycodeTag" data-tag="spoiler=" data-break-line="1" data-prefill="hidden content" class="babycode-button contain-svg" type=button id="post-editor-spoiler" title="Insert spoiler" {{"disabled" if "spoiler" in banned_tags else ""}}>{{ icn_spoiler() }}</button>
|
required=true,
|
||||||
|
id='babycode-content',
|
||||||
|
banned_tags=[]
|
||||||
|
) -%}
|
||||||
|
{%- call(idx) tabs(prefix='babycode', labels=['Write', 'Preview'], signal_ss=[none, 'babycodePreviewInit'], signal_rs=[none, 'babycodePreview']) -%}
|
||||||
|
{%- if idx == 0 -%}
|
||||||
|
<span class="babycode-editor-controls">
|
||||||
|
<span class="button-row js-only" data-r="enhance">
|
||||||
|
<button type="button" title="insert bold" class="minimal" data-babycode-tag="b" data-s="insertBabycode"><b>B</b></button>
|
||||||
|
<button type="button" title="insert italic" class="minimal" data-babycode-tag="i" data-s="insertBabycode"><i>i</i></button>
|
||||||
|
<button type="button" title="insert strikethrough" class="minimal" data-babycode-tag="s" data-s="insertBabycode"><s>S</s></button>
|
||||||
|
<button type="button" title="insert underline" class="minimal" data-babycode-tag="u" data-s="insertBabycode"><u>U</u></button>
|
||||||
|
<button type="button" title="insert link" class="minimal" data-babycode-tag="url" data-prefill="link label" data-s="insertBabycode"><code>://</code></button>
|
||||||
|
<button type="button" title="insert code block" class="minimal" data-babycode-tag="code" data-break-line data-s="insertBabycode"><code></></code></button>
|
||||||
|
<button type="button" title="insert ordered list" class="minimal" data-babycode-tag="ol" data-break-line data-s="insertBabycode">1.</button>
|
||||||
|
<button type="button" title="insert unordered list" class="minimal" data-babycode-tag="ul" data-break-line data-s="insertBabycode">•</button>
|
||||||
|
<button type="button" title="insert spoiler" class="minimal" data-babycode-tag="spoiler=" data-break-line data-prefill="spoiler content" data-s="insertBabycode">s</button>
|
||||||
|
<button type="button" title="insert emoji…" class="minimal"><img src="/static/emoji/angry.png" class="emoji"></button>
|
||||||
</span>
|
</span>
|
||||||
<textarea class="babycode-editor" name="{{ ta_name }}" id="babycode-content" placeholder="{{ ta_placeholder }}" {{ "required" if not optional else "" }} autocomplete="off" data-receive="insertBabycodeTag addQuote">{{ prefill }}</textarea>
|
<span class="flex-last js-only" data-r="enhance babycodeEditorCharCount">0/</span>
|
||||||
<a href="{{ url_for("guides.guide_page", category='user-guides', slug='babycode') }}" target="_blank">babycode guide</a>
|
</span>
|
||||||
{% if banned_tags %}
|
<input type="hidden" name="babycode_banned_tags" id="{{id}}-banned-tags" value="{{banned_tags | unique | list | tojson | forceescape}}">
|
||||||
<div>Forbidden tags:</div>
|
<textarea name="babycode_content" id="{{id}}" class="babycode-editor" placeholder="{{placeholder}}" {{'required' if required else ''}} autocomplete="off" maxlength="5000" data-r="insertBabycode babycodePreviewInit babycodeEditorCharCountInit babycodeEditorQuote" data-listen="input" data-s="babycodeEditorCharCount" data-banned-tags="{{banned_tags | unique | list | tojson | forceescape}}">{{ prefill }}</textarea>
|
||||||
|
{%- if banned_tags -%}
|
||||||
<div>
|
<div>
|
||||||
|
<span>Forbidden tags:</span>
|
||||||
<ul class="horizontal">
|
<ul class="horizontal">
|
||||||
{% for tag in banned_tags | unique %}
|
{%- for tag in banned_tags -%}
|
||||||
<li><code class="inline-code">{{ tag }}</code></li>
|
<li><code class="inline-code">{{tag}}</code></li>
|
||||||
{% endfor %}
|
{%- endfor -%}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{%- endif -%}
|
||||||
</div>
|
<a href="##">babycode help</a>
|
||||||
<div class="tab-content" id="tab-preview" data-receive="babycodePreview">
|
{%- else -%}
|
||||||
<div id="babycode-preview-errors-container">Type something!</div>
|
<div data-r="showBabycodePreview"></div>
|
||||||
<div id="babycode-preview-container"></div>
|
{%- endif -%}
|
||||||
</div>
|
{%- endcall -%}
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro avatar(url) -%}
|
||||||
|
<div class="avatar-container">
|
||||||
|
<img src="{{url}}" class="avatar">
|
||||||
</div>
|
</div>
|
||||||
<script src="{{ "/static/js/babycode-editor.js" | cachebust }}"></script>
|
{%- endmacro %}
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% macro babycode_editor_form(ta_name, prefill = "", cancel_url="", endpoint="") %}
|
{% macro bookmark_button(kind, id, text='Bookmark') -%}
|
||||||
{% set save_button_text = "Post reply" if not cancel_url else "Save" %}
|
<button autocomplete='off' data-r="enhance" data-s="showBookmarkMenu" disabled title="This feature requires JavaScript to be enabled." data-concept-kind="{{kind}}" data-concept-id="{{id}}">{{icn_bookmark(24)}}{{text}}…</button>
|
||||||
<form class="post-edit-form" method="post" {%- if endpoint %}action={{ endpoint }}{% endif %}>
|
{%- endmacro %}
|
||||||
{{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 badge_button(badge) %}
|
|
||||||
{% set img_url = badge.file_path if badge.file_path else badge.get_image_url() %}
|
|
||||||
{% if badge.link %}
|
|
||||||
<a href="{{badge.link}}" rel="noopener noreferrer me" target="_blank">
|
|
||||||
{% endif %}
|
|
||||||
<img class="badge-button" src="{{img_url}}" alt="{{badge.label}}" title="{{badge.label}}"></img>
|
|
||||||
{% if badge.link %}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% macro full_post(
|
{% macro full_post(
|
||||||
post, render_sig = True, is_latest = False,
|
post, render_sig=true, is_latest=false,
|
||||||
editing = False, active_user = None, no_reply = false,
|
show_toolbar=true, is_editing=false, thread=none,
|
||||||
Reactions = none, show_thread_title = false,
|
show_reactions=true, show_thread=false, allow_reacting=true,
|
||||||
show_bookmark = false, memo = None, bookmark_message = "Bookmark…",
|
tb_edit=true, tb_quote=true, tb_delete=true, tb_bookmark=true,
|
||||||
reload_after_bookmark = false
|
bookmark_btn='Bookmark', tb_pretext=''
|
||||||
) %}
|
) -%}
|
||||||
{% set postclass = "post" %}
|
{%- if is_logged_in() -%}
|
||||||
{% if editing %}
|
{%- set can_delete = post.user_id == get_active_user().id or is_mod() -%}
|
||||||
{% set postclass = postclass + " editing" %}
|
{%- else -%}
|
||||||
{% endif %}
|
{%- set show_toolbar = false -%}
|
||||||
{% set post_permalink = url_for("threads.thread", slug = post['thread_slug'], after = post['id'], _anchor = ("post-" + (post['id'] | string))) %}
|
{%- endif -%}
|
||||||
<div class=" {{ postclass }}" id="post-{{ post['id'] }}" data-post-id="{{ post['id'] }}">
|
{%- set owns = is_logged_in() and post.user_id == get_active_user().id -%}
|
||||||
<div class="usercard">
|
{%- 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">
|
<div class="usercard-inner">
|
||||||
<a href="{{ url_for("users.page", username=post['username']) }}" style="display: contents;">
|
{{avatar(post.avatar_path)}}
|
||||||
<img src="{{ post['avatar_path'] }}" class="avatar">
|
<div class="usercard-rest">
|
||||||
</a>
|
<a href="{{url_for('users.user_page', username=post.username)}}" class="usercard-username">{{post.display_name if post.display_name else post.username}}</a>
|
||||||
<a href="{{ url_for("users.page", username=post['username']) }}" class="username-link">{{ post['display_name'] or post['username'] }}</a>
|
<abbr title="mention">@{{post.username}}</abbr>
|
||||||
<em><abbr title="Mention">@{{ post.username }}</abbr></em>
|
<i>{{post.status}}</i>
|
||||||
{% if post['status'] %}
|
{%- set badges=post.badges_json | fromjson | sort(attribute='sort_order') -%}
|
||||||
<em class="user-status">{{ post['status'] }}</em>
|
|
||||||
{% endif %}
|
|
||||||
<div class="badges-container">
|
<div class="badges-container">
|
||||||
{% for badge_data in (post.badges_json | fromjson) %}
|
{%- for badge in badges -%}
|
||||||
{{ badge_button(badge_data) }}
|
{%- if badge.link -%}<a href="{{badge.link}}">{%- endif -%}
|
||||||
{% endfor %}
|
<img src="{{badge.file_path}}" alt="{{badge.label}}" title="{{badge.label}}" class="badge-button">
|
||||||
|
{%- if badge.link -%}</a>{%- endif -%}
|
||||||
|
{%- endfor -%}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="post-content-container" {{ "id=latest-post" if is_latest else "" }}>
|
<div class="post-content">
|
||||||
<div class="post-info">
|
<div class="plank even minimal secondary-bg no-shadow post-info">
|
||||||
<span>
|
<span>
|
||||||
{% if memo -%}
|
{%- if tb_pretext -%}
|
||||||
Memo: <i>{{ memo }}</i> •
|
<span>{{tb_pretext}} • </span>
|
||||||
{%- endif %}
|
{%- endif -%}
|
||||||
{% if show_thread_title %}
|
<a href="{{get_post_url(post.id, _anchor=true)}}">
|
||||||
<a href="{{ url_for('threads.thread', slug=post.thread_slug) }}">Thread: {{ post.thread_title }}</a>
|
{%- if post.edited_at <= post.created_at -%}
|
||||||
•
|
<i>Posted on {{timestamp(post.created_at)}}</i>
|
||||||
{% endif %}
|
{%- else -%}
|
||||||
<a href="{{ post_permalink }}" title="Permalink"><i>
|
<i>Edited on {{timestamp(post.edited_at)}}</i>
|
||||||
{% if (post['edited_at'] | int) > (post['created_at'] | int) %}
|
{%- endif -%}
|
||||||
Edited on {{ timestamp(post['edited_at']) }}
|
</a>
|
||||||
{% else %}
|
{%- if show_thread -%}
|
||||||
Posted on {{ timestamp(post['edited_at']) }}
|
<span> in thread <a href="{{url_for('threads.thread_by_id', thread_id=post.thread_id)}}"> {{post.thread_title}}</a></span>
|
||||||
{% endif %}
|
{%- endif -%}
|
||||||
</i></a>
|
|
||||||
</span>
|
</span>
|
||||||
<span class="thread-actions">
|
{%- if show_toolbar -%}
|
||||||
{% set show_edit = false %}
|
<span class="subheader-actions">
|
||||||
{% if active_user %}
|
{%- if owns and tb_edit -%}
|
||||||
{% 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 %}
|
<a class="linkbutton" href="{{url_for('posts.edit', post_id=post.id, _anchor='babycode-content')}}">Edit</a>
|
||||||
{% endif %}
|
{%- endif -%}
|
||||||
{% if show_edit %}
|
{%- if can_reply and tb_quote -%}
|
||||||
<a class="linkbutton" href="{{ url_for('posts.edit', post_id=post.id, _anchor='babycode-content') }}">Edit</a>
|
<button autocomplete='off' data-r="enhance" data-s="babycodeEditorQuote" disabled title="This feature requires JavaScript to be enabled." data-quote="{{post.original_markup}}" data-poster-name="{{ post.display_name if post.display_name else post.username }}">Quote</button>
|
||||||
{% endif %}
|
{%- endif -%}
|
||||||
|
{%- if can_delete and tb_delete -%}
|
||||||
{% set show_reply = true %}
|
<a class="linkbutton critical" href="{{url_for('posts.delete', post_id=post.id)}}">Delete</a>
|
||||||
|
{%- endif -%}
|
||||||
{% if not active_user %}
|
{%- if tb_bookmark -%}
|
||||||
{% set show_reply = false %}
|
{{ bookmark_button('post', post.id, bookmark_btn) }}
|
||||||
{% elif post['thread_is_locked'] and not active_user.is_mod() %}
|
{%- endif -%}
|
||||||
{% set show_reply = false %}
|
|
||||||
{% elif 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 = "@%s [url=%s]said:[/url]" | format(post['username'], post_permalink) %}
|
|
||||||
{% set reply_text = "%s\n[quote]\n%s\n[/quote]\n" | format(qtext, post['original_markup']) %}
|
|
||||||
<button data-send="addQuote" 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 %}
|
|
||||||
|
|
||||||
{% if show_bookmark %}
|
|
||||||
{{ bookmark_button(type="post", id=post.id, message=bookmark_message, require_reload=reload_after_bookmark)}}
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
</span>
|
||||||
|
{%- endif -%}
|
||||||
</div>
|
</div>
|
||||||
<div class="post-content">
|
<div class="plank even no-shadow post-content-inner minimal">
|
||||||
{% if not editing %}
|
{%- if not is_editing -%}
|
||||||
<div class="post-inner" data-post-permalink="{{ post_permalink }}" data-author-username="{{ post.username }}">{{ post['content'] | safe }}</div>
|
{{post.content | safe}}
|
||||||
{% if render_sig and post['signature_rendered'] %}
|
{%- if render_sig and post.signature_rendered -%}
|
||||||
<div class="signature-container">
|
<aside class="post-signature">{{post.signature_rendered | safe}}</aside>
|
||||||
{{ post['signature_rendered'] | safe }}
|
{%- endif -%}
|
||||||
|
{%- else -%}
|
||||||
|
{{- babycode_editor_component(prefill=post.original_markup) -}}
|
||||||
|
{%- endif -%}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
<div class="plank even secondary-bg minimal no-shadow">
|
||||||
{% else %}
|
{%- if show_reactions -%}
|
||||||
{{ babycode_editor_form(cancel_url = post_permalink, prefill = post['original_markup'], ta_name = "new_content") }}
|
<span class="button-row">
|
||||||
{% endif %}
|
{%- for reaction in Reactions.for_post(post.id) -%}
|
||||||
</div>
|
|
||||||
{% if Reactions -%}
|
|
||||||
{% set can_react = true -%}
|
|
||||||
|
|
||||||
{% if not active_user -%}
|
|
||||||
{% set can_react = false -%}
|
|
||||||
{% elif post['thread_is_locked'] and not active_user.is_mod() -%}
|
|
||||||
{% set can_react = false -%}
|
|
||||||
{% elif active_user.is_guest() -%}
|
|
||||||
{% set can_react = false -%}
|
|
||||||
{% elif editing -%}
|
|
||||||
{% set can_react = false -%}
|
|
||||||
{% endif -%}
|
|
||||||
|
|
||||||
{% set reactions = Reactions.for_post(post.id) -%}
|
|
||||||
<div class="post-reactions">
|
|
||||||
{% for reaction in reactions %}
|
|
||||||
{% set reactors = Reactions.get_users(post.id, reaction.reaction_text) | map(attribute='username') | list %}
|
{% set reactors = Reactions.get_users(post.id, reaction.reaction_text) | map(attribute='username') | list %}
|
||||||
{% set reactors_trimmed = reactors[:10] %}
|
{% set reactors_trimmed = reactors[:10] %}
|
||||||
{% set reactors_str = reactors_trimmed | join (',\n') %}
|
{% set reactors_str = reactors_trimmed | join (',\n') %}
|
||||||
{% if reactors | count > 10 %}
|
{% if reactors | count > 10 %}
|
||||||
{% set reactors_str = reactors_str + '\n...and many others' %}
|
{% set reactors_str = reactors_str + '\n...and many others' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% set has_reacted = active_user is not none and active_user.username in reactors %}
|
{% set has_reacted = get_active_user() is not none and get_active_user().username in reactors %}
|
||||||
<span class="reaction-container" data-emoji="{{ reaction.reaction_text }}" data-post-id="{{ post.id }}"><button type="button" class="reduced reaction-button {{"active" if has_reacted else ""}}" {{ "disabled" if not can_react else ""}} title="{{reactors_str}}"><img class=emoji src="/static/emoji/{{reaction.reaction_text}}.png"> x<span class="reaction-count" data-emoji="{{ reaction.reaction_text }}">{{reaction.c}}</span></button>
|
<button data-r="enhance" type="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>
|
</span>
|
||||||
{% endfor %}
|
{%- if is_logged_in() and allow_reacting -%}<button autocomplete='off' data-r="enhance" disabled title="This feature requires JavaScript to be enabled.">Add reaction</button>{%- endif -%}
|
||||||
{% if can_react %}
|
{%- elif is_editing -%}
|
||||||
<button type="button" class="reduced add-reaction-button" data-post-id="{{ post.id }}">Add reaction</button>
|
<input type="submit" value="Save">
|
||||||
{% endif %}
|
<a href="{{get_post_url(post.id, _anchor=true)}}" class="linkbutton warn">Cancel</a>
|
||||||
|
{%- endif -%}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
</div>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro bookmark_menu() -%}
|
||||||
|
{%- if is_logged_in() -%}
|
||||||
|
<div id="bookmark-popover" data-r="showBookmarkMenu" class="plank even" popover>
|
||||||
|
<div class="bookmark-menu-header">
|
||||||
|
<span>Bookmark collections</span>
|
||||||
|
<a href="{{url_for('users.bookmarks', username=get_active_user().username)}}">View bookmarks</a>
|
||||||
|
</div>
|
||||||
|
<div class="bookmark-menu-inner" data-r="fillBookmarkMenu">Loading…</div>
|
||||||
|
</div>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro infobox(message, kind=InfoboxKind.INFO) -%}
|
||||||
|
<div class="infobox plank top contain-svg horizontal {{InfoboxHTMLClass[kind]}}">
|
||||||
|
{%- if kind == InfoboxKind.INFO -%}
|
||||||
|
{{- icn_info() -}}
|
||||||
|
{%- elif kind == InfoboxKind.WARN -%}
|
||||||
|
{{- icn_warn() -}}
|
||||||
|
{%- elif kind == InfoboxKind.ERROR -%}
|
||||||
|
{{- icn_error() -}}
|
||||||
|
{%- endif -%}
|
||||||
|
{%- set m = message.split(';', maxsplit=1) -%}
|
||||||
|
<strong>{{m[0]}}</strong>
|
||||||
|
{%- if m[1] -%}
|
||||||
|
{{m[1]}}
|
||||||
|
{%- endif -%}
|
||||||
|
</div>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro motd(motd_objs) -%}
|
||||||
|
{%- if motd_objs -%}
|
||||||
|
<div class="motd plank contrast-bg">
|
||||||
|
<div class="contain-svg">
|
||||||
|
{{ icn_megaphone(64) }}
|
||||||
|
<i><abbr title="Message of the Day">MOTD</abbr></i>
|
||||||
|
</div>
|
||||||
|
<div class="motd-content mobile-fill-flex">
|
||||||
|
<h2 class="info">{{ motd_objs[0].title }}</h2>
|
||||||
|
<div class="motd-body">{{ motd_objs[0].body_rendered | safe }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{%- endif -%}
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
{% macro accordion(hidden=false, disabled=false) %}
|
{% macro sortable_list(attr=none) -%}
|
||||||
{% if disabled %}
|
|
||||||
{% set hidden = true %}
|
|
||||||
{% endif %}
|
|
||||||
<div class="accordion {{ "hidden" if hidden else ""}}" data-receive="toggleAccordion">
|
|
||||||
<div class="accordion-header">
|
|
||||||
<button type="button" class="accordion-toggle" {{"disabled" if disabled else ""}} data-send="toggleAccordion">{{ "+" if hidden else "-" }}</button>
|
|
||||||
{{ caller('header') }}
|
|
||||||
</div>
|
|
||||||
<div class="accordion-content {{ "hidden" if hidden else "" }}">
|
|
||||||
{{ caller('content') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% macro guide_sections() %}
|
|
||||||
<div class="guide-container">
|
|
||||||
<div class="guide-topics">
|
|
||||||
{% set sections %}{{ caller() }}{% endset %}
|
|
||||||
{{ sections | safe }}
|
|
||||||
</div>
|
|
||||||
<div class="guide-toc">
|
|
||||||
<h2>Table of contents</h2>
|
|
||||||
<a href="#top">(top)</a>
|
|
||||||
{% set toc = sections | extract_h2 %}
|
|
||||||
<ul>
|
|
||||||
{% for heading in toc %}
|
|
||||||
<li><a href='#{{ heading.id }}'>{{ heading.text }}</a></li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% macro badge_editor_single(options={}, selected=none, fp_hidden=true, badge=none) %}
|
|
||||||
{% set defaults = options | selectattr('user_id', 'none') | list | sort(attribute='file_path') %}
|
|
||||||
{% set uploads = options | selectattr('user_id') | list %}
|
|
||||||
{% if selected is not none %}
|
|
||||||
{% set selected_href = (options | selectattr('id', 'equalto', selected) | list)[0].file_path %}
|
|
||||||
{% else %}
|
|
||||||
{% set selected_href = defaults[0].file_path %}
|
|
||||||
{% endif %}
|
|
||||||
<li class="sortable-item" data-sortable-list-key="" data-receive="deleteBadge"> {# breaking convention on purpose since this one is special #}
|
|
||||||
<span class="dragger" draggable="true">{{ icn_drag(24) }}</span>
|
|
||||||
<bitty-7-0 class="fg" data-connect="{{ '/static/js/bitties/pyrom-bitty.js' | cachebust }} BadgeEditorBadge" data-listeners="click input submit change">
|
|
||||||
<div class="settings-badge-container">
|
|
||||||
<div class="settings-badge-select">
|
|
||||||
<select data-send="badgeUpdatePreview badgeToggleFilePicker" name="badge_choice[]" required>
|
|
||||||
<optgroup label="Default">
|
|
||||||
{% for option in defaults %}
|
|
||||||
<option data-file-path="{{ option.file_path }}" value="{{ option.id }}" {{ "selected" if selected==option.id else "" }}>{{option.file_path | basename_noext}}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Your uploads">
|
|
||||||
{% for option in uploads %}
|
|
||||||
<option data-file-path="{{ option.file_path }}" value="{{ option.id }}" {{ "selected" if selected==option.id else "" }}>{{option.original_filename | basename_noext}}</option>
|
|
||||||
{% endfor %}
|
|
||||||
<option value="custom">Upload new...</option>
|
|
||||||
</optgroup>
|
|
||||||
</select>
|
|
||||||
<img class="badge-button" data-receive="badgeUpdatePreview badgeUpdatePreviewCustom" src="{{ selected_href }}"></img>
|
|
||||||
</div>
|
|
||||||
<div class="settings-badge-file-picker{{ " hidden" if fp_hidden else ""}}" data-receive="badgeToggleFilePicker">
|
|
||||||
<button data-send="openBadgeFilePicker" type=button>Browse…</button>
|
|
||||||
<input data-receive="openBadgeFilePicker badgeErrorSize badgeErrorDim badgeHideErrors" data-send="badgeUpdatePreviewCustom" type="file" accept="image/*" class="hidden" name="badge_file[]">
|
|
||||||
</div>
|
|
||||||
<input type="text" required placeholder="Label" name="badge_label[]" value="{{badge.label}}">
|
|
||||||
<input type="text" placeholder="(Optional) Link" name="badge_link[]" value="{{badge.link}}">
|
|
||||||
<button data-send="deleteBadge" type="button" class="critical" title="Delete">X</button>
|
|
||||||
</div>
|
|
||||||
</bitty-7-0>
|
|
||||||
</li>
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% macro rss_html_content(html) %}
|
|
||||||
<content type="html">{{ html }}</content>
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% macro rss_button(feed) %}
|
|
||||||
<a class="linkbutton contain-svg inline icon rss-button" href="{{feed}}" title="it's actually atom, don't tell anyone ;)">{{ icn_rss(20) }} Subscribe via RSS</a>
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% macro sortable_list(attr=none) %}
|
|
||||||
<ol class="sortable-list" {% if attr %}{{ dict_to_attr(attr) }}{% endif %}>
|
<ol class="sortable-list" {% if attr %}{{ dict_to_attr(attr) }}{% endif %}>
|
||||||
{% if caller %}
|
{%- if caller -%}
|
||||||
{{ caller() }}
|
|
||||||
{% endif %}
|
|
||||||
</ol>
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% macro sortable_list_item(key, immovable=false, attr=none) %}
|
|
||||||
<li class="sortable-item{{' immovable' if immovable else ''}}" data-sortable-list-key="{{key}}" {% if attr %}{{ dict_to_attr(attr) }}{% endif %}>
|
|
||||||
<span class="dragger" draggable="{{ 'true' if not immovable else 'false'}}">{{ icn_drag(24) }}</span>
|
|
||||||
<div class="sortable-item-inner">
|
|
||||||
{{ caller() }}
|
{{ caller() }}
|
||||||
</div>
|
{%- endif -%}
|
||||||
|
</ol>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro sortable_list_item(key, immovable=false, attr=none, full=false) -%}
|
||||||
|
<li class="sortable-item{{ ' immovable' if immovable else '' }} plank even no-shadow {{'secondary-bg' if immovable else ''}}" data-sortable-list-key="{{key}}" {% if attr %}{{ dict_to_attr(attr) }}{% endif %}>
|
||||||
|
<span class="dragger plank minimal even no-shadow tertiary-bg" draggable="{{ 'true' if not immovable else 'false' }}">{{ icn_dragger() }}</span>
|
||||||
|
<div class="sortable-item-inner {{full and 'full' or ''}}">{{ caller() }}</div>
|
||||||
</li>
|
</li>
|
||||||
{% endmacro %}
|
{%- endmacro %}
|
||||||
|
|||||||
@@ -1,31 +1,32 @@
|
|||||||
<nav id="topnav">
|
<nav id="header" class="plank top">
|
||||||
<span>
|
<a class="site-title" href="/">{{config.SITE_NAME}}</a>
|
||||||
<a class="site-title" href="{{url_for('topics.all_topics')}}">{{config.SITE_NAME}}</a>
|
<span>{{config.SITE_TAGLINE or ' ' | safe}}</span>
|
||||||
</span>
|
{%- if is_logged_in() -%}
|
||||||
<span>
|
{%- with user = get_active_user() -%}
|
||||||
{% if not is_logged_in() %}
|
{%- set uc = user.get_unread_count() -%}
|
||||||
{% if not config.DISABLE_SIGNUP %}
|
<ul class="horizontal wrap">
|
||||||
Welcome, guest. Please <a href="{{url_for('users.sign_up')}}">sign up</a> or <a href="{{url_for('users.log_in')}}">log in</a>
|
<li class="mobile-fill-flex">Welcome, <a href="{{url_for('users.user_page', username=user.username)}}">{{ user.get_readable_name() }}</a></li>
|
||||||
{% else %}
|
<li><a class="linkbutton" href="{{url_for('users.settings', username=user.username)}}">Settings</a></li>
|
||||||
Welcome, guest. Please <a href="{{url_for('users.log_in')}}">log in</a>
|
<li><a class="linkbutton" href="{{url_for('users.inbox', username=user.username)}}">Inbox{{' (%s)' % uc if uc else ''}}</a></li>
|
||||||
{% endif %}
|
<li><a class="linkbutton" href="{{url_for('users.bookmarks', username=user.username)}}">Bookmarks</a></li>
|
||||||
{% else %}
|
{%- if user.can_invite() -%}
|
||||||
{% with user = get_active_user() %}
|
<a href="{{url_for('users.settings', username=user.username, _anchor='invite')}}" class="linkbutton alt">Invite</a>
|
||||||
Welcome, <a href="{{ url_for("users.page", username = user.username) }}">{{user.get_readable_name()}}</a>
|
{%- endif %}
|
||||||
<ul class="horizontal">
|
{% if user.is_mod() -%}
|
||||||
<li><a href="{{ url_for("users.settings", username = user.username) }}">Settings</a></li>
|
<li><a class="linkbutton" href="{{url_for('mod.index')}}">Moderation</a></li>
|
||||||
<li><a href="{{ url_for("users.inbox", username = user.username) }}">Inbox</a></li>
|
{%- endif %}
|
||||||
{% if config.DISABLE_SIGNUP and user.can_invite() %}
|
|
||||||
<li><a href="{{ url_for('users.invite_links', username=user.username )}}">Invite to {{ config.SITE_NAME }}</a></li>
|
|
||||||
{% endif %}
|
|
||||||
{% if not user.is_guest() %}
|
|
||||||
<li><a href="{{ url_for('users.bookmarks', username=user.username) }}">Bookmarks</a></li>
|
|
||||||
{% endif %}
|
|
||||||
{% if user.is_mod() %}
|
|
||||||
<li><a href="{{ url_for("mod.panel") }}">Moderation</a></li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
</ul>
|
||||||
{% endwith %}
|
{%- endwith -%}
|
||||||
{% endif %}
|
{%- elif request.path != url_for('users.sign_up') and request.path != url_for('users.log_in') -%}
|
||||||
</span>
|
<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">
|
||||||
|
{%- if not config.DISABLE_SIGNUP -%}
|
||||||
|
<a href="{{url_for('users.sign_up')}}" class="linkbutton alt">Sign up</a>
|
||||||
|
{%- endif -%}
|
||||||
|
</form>
|
||||||
|
{%- endif -%}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
{% from 'common/macros.html' import badge_editor_single %}
|
|
||||||
{% for badge in badges %}
|
|
||||||
{{ badge_editor_single(options=uploads, selected=badge.upload, badge=badge) }}
|
|
||||||
{% endfor %}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
{% from 'common/macros.html' import badge_editor_single %}
|
|
||||||
{{ badge_editor_single(options=uploads) }}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{% set bookmark_url = None %}
|
|
||||||
{% if type == 'post' %}
|
|
||||||
{% set bookmark_url = url_for('api.bookmark_post', post_id=id) %}
|
|
||||||
{% else %}
|
|
||||||
{% set bookmark_url = url_for('api.bookmark_thread', thread_id=id) %}
|
|
||||||
{% endif %}
|
|
||||||
<div class="bookmarks-dropdown" data-bookmark-type="{{type}}" data-receive="saveBookmarks" data-bookmark-endpoint="{{bookmark_url}}" data-originally-contained-in="{{ selected.id if selected else ""}}" data-require-reload={{require_reload | int}} popover=auto>
|
|
||||||
<div class="bookmarks-dropdown-header">
|
|
||||||
<span>Bookmark collections</span>
|
|
||||||
{% if not require_reload %}
|
|
||||||
<a href="{{ url_for('users.bookmarks', username=get_active_user().username) }}">View bookmarks</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="bookmark-dropdown-items-container">
|
|
||||||
{%- for collection in collections -%}
|
|
||||||
{%- set pc = collection.get_posts_count() -%}
|
|
||||||
{%- set tc = collection.get_threads_count() -%}
|
|
||||||
<div class="bookmark-dropdown-item {{ "selected" if selected and (selected.id | int) == (collection.id | int) else ""}}" data-send="selectBookmarkCollection" data-receive="selectBookmarkCollection" data-collection-id="{{collection.id}}">
|
|
||||||
<span class="bookmark-dropdown-item-name">{{collection.name}}</span>
|
|
||||||
<span class="bookmark-dropdown-item-stats"><abbr title="{{ pc }} {{('post' | pluralize(pc))}}">{{ pc }}p</abbr>, <abbr title="{{ tc }} {{('thread' | pluralize(tc))}}">{{ tc }}t</abbr></span>
|
|
||||||
</div>
|
|
||||||
{%- endfor -%}
|
|
||||||
</div>
|
|
||||||
<span>
|
|
||||||
<input type="text" placeholder="Memo" class="bookmark-memo-input" value="{{memo}}"></input>
|
|
||||||
<button type="button" data-send="saveBookmarks">Save</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
{% from 'common/macros.html' import guide_sections with context %}
|
|
||||||
{% block title %}guide - {{ guide.title }}{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="darkbg" id="top">
|
|
||||||
<h1>Guide: {{ guide.title }}</h1>
|
|
||||||
<ul class="horizontal">
|
|
||||||
<li><a href="{{ url_for('guides.category_index', category=category) }}">↑ Back to category</a></li>
|
|
||||||
{% if prev_guide %}
|
|
||||||
<li><a href="{{ prev_guide.url }}">← Previous: {{ prev_guide.title }}</a></li>
|
|
||||||
{% endif %}
|
|
||||||
{% if next_guide %}
|
|
||||||
<li><a href="{{ next_guide.url }}">→ Next: {{ next_guide.title }}</a></li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% call() guide_sections() %}
|
|
||||||
{% block guide_content %}
|
|
||||||
{% endblock %}
|
|
||||||
{% endcall %}
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
{% block title %}guides - {{ category | title }}{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="darkbg">
|
|
||||||
<h1>All guides in category "{{ category | replace('-', ' ') | title }}"</h1>
|
|
||||||
<ul>
|
|
||||||
{% for page in pages %}
|
|
||||||
<li><a href="{{ page.url }}">{{ page.title }}</a></li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
<div>
|
|
||||||
<a href="{{ url_for('guides.guides_index') }}">← All guide categories</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
{% block title %}contact us{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="darkbg">
|
|
||||||
<h1>Contact</h1>
|
|
||||||
{% if config.ADMIN_CONTACT_INFO %}
|
|
||||||
<p>The administrators of {{ config.SITE_NAME }} provide the following contact information:</p>
|
|
||||||
<div>{{ config.ADMIN_CONTACT_INFO | babycode_strict | safe }}</div>
|
|
||||||
{% else %}
|
|
||||||
<p>The administrators of {{ config.SITE_NAME }} did not provide any contact information.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
{% block title %}guides{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="darkbg">
|
|
||||||
<h1>Guides index</h1>
|
|
||||||
<ul>
|
|
||||||
{% for category in categories %}
|
|
||||||
<li>
|
|
||||||
<h2><a href="{{ url_for('guides.category_index', category=category )}}">{{ category | replace('-', ' ') | title }}</a></h2>
|
|
||||||
<ul>
|
|
||||||
{% for page in categories[category] %}
|
|
||||||
<li><a href="{{ page.url }}">{{ page.title }}</a></li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
# Introduction
|
|
||||||
{% extends 'guides/_layout.html' %}
|
|
||||||
{% block guide_content %}
|
|
||||||
<section class="guide-section">
|
|
||||||
<h2 id="pyrom">What is {{config.SITE_NAME}}?</h2>
|
|
||||||
{% if config.GUIDE_DESCRIPTION %}
|
|
||||||
<div>{{ config.GUIDE_DESCRIPTION | babycode_strict | safe }}</div>
|
|
||||||
{% endif %}
|
|
||||||
<p>{{ config.SITE_NAME }} is powered by <a href="https://git.poto.cafe/yagich/pyrom/" target="_blank">Pyrom</a>, a home-grown forum software for the indie web. This guide will help you get started, from creating an account to understanding how discussions are organized.</p>
|
|
||||||
</section>
|
|
||||||
<section class="guide-section">
|
|
||||||
<h2 id="signing-up">Signing up</h2>
|
|
||||||
<p>Most interactive features on {{ config.SITE_NAME }} such as creating threads or posting replies require a user account. While browsing is open to all, you must be signed in to participate.</p>
|
|
||||||
{% if config.DISABLE_SIGNUP %}
|
|
||||||
<p>{{ config.SITE_NAME }} uses an invite-only sign-up system. You cannot sign up directly. Instead, {% if config.USERS_CAN_INVITE %}an existing user{% elif config.MODS_CAN_INVITE %}a moderator{% endif %} can generate an invite link containing an invite key. Using that link will take you to the sign up page.</p>
|
|
||||||
{% else %}
|
|
||||||
<p>You can create an account on the <a href="{{url_for('users.sign_up')}}">sign-up page.</a> After you create your account, a moderator will need to manually approve your account before you can post. This may take some time.</p>
|
|
||||||
{% endif %}
|
|
||||||
<p>To create an account, you will need to provide:</p>
|
|
||||||
<ul>
|
|
||||||
<li>A username:
|
|
||||||
<ul>
|
|
||||||
<li>Your username must be 3-20 characters long and only include lowercase letters (a-z), numbers (0-9), hyphens (-) and underscores (_).</li>
|
|
||||||
<li>If your username includes uppercase characters, the system will automatically convert them to lowercase. Your original capitalization will be preserved as part of your display name.</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li>A strong password:
|
|
||||||
<ul>
|
|
||||||
<li>Your password must be at least 10 characters long and include at least one uppercase letter, at least one lowercase letter, at least one number, at least one special character, and no spaces.</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p>Once your account is {% if not config.DISABLE_SIGNUP %}confirmed and{% endif %} active, you will have full access to create threads, post replies, and use other features.</p>
|
|
||||||
</section>
|
|
||||||
<section class="guide-section">
|
|
||||||
<h2 id="topics">Topics</h2>
|
|
||||||
<p>The front page of {{ config.SITE_NAME }} is an overview of topics that you can create threads in. A topic is a broad category created by moderators to organize discussions.</p>
|
|
||||||
<p>Topics usually have a description that is shown under its name. Use it as guidance for where to post a thread. The order topics show up in is chosen by moderators.</p>
|
|
||||||
<p>A topic may be locked by a moderator. This will prevent threads from being created or moved into it by non-moderators. These topics are usually used for forum-wide information, but it is of course up to the moderators.</p>
|
|
||||||
<p>By default, threads in a topic are sorted by activity, but this can be changed in your user settings.</p>
|
|
||||||
</section>
|
|
||||||
<section class="guide-section">
|
|
||||||
<h2 id="threads">Threads</h2>
|
|
||||||
<p>Threads are where discussion happens. A thread is started by a user with a name and an initial post, and other users may add their own posts to it (known as replying to the thread).</p>
|
|
||||||
<p>When drafting a thread, you have to choose which topic to post it under. Be sure to follow the posting rules. If you created a thread under the wrong topic, you may move it when viewing replies. Moderators have the option to move any thread.</p>
|
|
||||||
<p>A thread may be locked by the original poster (OP) or a moderator. When a thread is locked, nobody except moderators can reply to it.</p>
|
|
||||||
<p>A thread may also be stickied by a moderator. Stickied threads show above all other threads in the topic it's under. If a thread is stickied, it is likely important.</p>
|
|
||||||
<p>You can subscribe to any thread. When subscribed, you will receive an overview of posts you missed for that thread in your inbox.</p>
|
|
||||||
</section>
|
|
||||||
<section class="guide-section">
|
|
||||||
<h2 id="posts">Posts</h2>
|
|
||||||
<p>Posts are the individual messages that make up a thread. The first post starts the thread, and every subsequent message is a post in reply.</p>
|
|
||||||
<p>A post is split up into five sections:</p>
|
|
||||||
<ol>
|
|
||||||
<li>Usercard
|
|
||||||
<ul><li>The post author's information shows up to the left of the post. This includes their avatar, display name, mention, status, and badges.</li></ul>
|
|
||||||
</li>
|
|
||||||
<li>Post actions
|
|
||||||
<ul><li>This shows the time and date when the post has been made (or edited), and buttons for actions you can perform on the post, such as quoting or editing.</li></ul>
|
|
||||||
</li>
|
|
||||||
<li>Post content
|
|
||||||
<ul><li>The actual message of the post.</li></ul>
|
|
||||||
</li>
|
|
||||||
<li>Signature (sig)
|
|
||||||
<ul><li>If the user has a signature set, it will show under each of their posts.</li></ul>
|
|
||||||
</li>
|
|
||||||
<li>Reactions
|
|
||||||
<ul><li>This shows the emoji reactions users have added to the post, and you can add your own.</li></ul>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
<p>You may edit or delete your posts after creating them. Edited posts will show when they were edited instead of when they were posted.</p>
|
|
||||||
<p>You can quote another user's post by pressing the <button>Quote</button> button on that post. This will copy the contents of it into the reply box and mention the user.</p>
|
|
||||||
<p>Posts are written in a markup language called Babycode. <a href="{{ url_for("guides.guide_page", category='user-guides', slug='babycode') }}">More information on it can be found in a separate guide.</a></p>
|
|
||||||
</section>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# User profiles
|
|
||||||
{% extends 'guides/_layout.html' %}
|
|
||||||
{% block guide_content %}
|
|
||||||
<section class="guide-section">
|
|
||||||
<h2 id="profile">User profiles</h2>
|
|
||||||
<p>Each user on {{ config.SITE_NAME }} has a profile.</p>
|
|
||||||
<p>A user's profile shows:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Their avatar;</li>
|
|
||||||
<li>Their username and display name;</li>
|
|
||||||
<li>Their status;</li>
|
|
||||||
<li>Their signature;</li>
|
|
||||||
<li>Their badges;</li>
|
|
||||||
<li>Their stats:
|
|
||||||
<ul>
|
|
||||||
<li>Their permission level (regular user, moderator, etc);</li>
|
|
||||||
<li>The number of posts they've created;</li>
|
|
||||||
<li>The number of threads they've started;</li>
|
|
||||||
<li>A link to their latest started thread;</li>
|
|
||||||
<li>A few of their latest posts.</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
# User settings
|
|
||||||
{% extends 'guides/_layout.html' %}
|
|
||||||
{% block guide_content %}
|
|
||||||
<section class="guide-section">
|
|
||||||
<h2 id="settings">Settings</h2>
|
|
||||||
<p>You can access your settings by following the "Settings" link in the top navigation bar or from the <button>Settings</button> button in your profile.</p>
|
|
||||||
<p>The settings page lets you set your avatar, change your personalization options, and change your password.</p>
|
|
||||||
</section>
|
|
||||||
<section class="guide-section">
|
|
||||||
<h2 id="avatar">Avatar</h2>
|
|
||||||
<p>When you upload an avatar, keep in mind that it will be cropped to a square and resized to fit 256 by 256 pixels. It's best to upload an image that is already square. Avatars must be no more than 1 MB in size. You can also clear your avatar, resetting it to the default one.</p>
|
|
||||||
<p>To change your avatar, first press the <button>Browse…</button> button and select a file from your device, then press the <button>Save avatar</button> button to upload it.</p>
|
|
||||||
</section>
|
|
||||||
<section class="guide-section">
|
|
||||||
<h2 id="personalization">Personalization</h2>
|
|
||||||
<p>The personalization section of the settings lets you change the following settings:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Theme
|
|
||||||
<ul>
|
|
||||||
<li>Set the appearance of the forum. This feature is still in beta and themes may change unexpectedly.</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li>Thread sorting
|
|
||||||
<ul>
|
|
||||||
<li>Set the sorting of threads when viewing a single topic. "Latest activity" will put threads which had a response more recently closer to the top. "Thread creation date" will put threads which were created more recently closer to the top.</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li>Display name
|
|
||||||
<ul>
|
|
||||||
<li>If set, your display name will show up instead of your username in most places. In a post's usercard, the display name will show up above your username/mention. In posts that have mentioned you, your display name will be shown instead of your @username.</li>
|
|
||||||
<li>Display names have fewer restrictions than usernames, and most characters are allowed.</li>
|
|
||||||
<li>If you do not wish to use a display name, you can leave the field blank.</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li>Status
|
|
||||||
<ul>
|
|
||||||
<li>If set, your status will show up below your @mention in a post's usercard. 100 characters limit, no <a href="{{ url_for("guides.guide_page", category='user-guides', slug='babycode') }}">Babycodes.</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li>Subscribe by default
|
|
||||||
<ul>
|
|
||||||
<li>If checked, you will automatically add the thread to your subscriptions when replying to it. You can override this setting by checking or unchecking "Subscribe to thread" before posting your reply in the reply box.</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li>Signature
|
|
||||||
<ul>
|
|
||||||
<li>If set, this signature will appear under all of your posts. <a href="{{ url_for("guides.guide_page", category='user-guides', slug='babycode') }}">Babycode</a> is allowed (except @mentions).</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
<section class="guide-section">
|
|
||||||
<h2 id="password">Changing your password</h2>
|
|
||||||
<p>You can change your password by typing your desired new password in the "New password" field and again in the "Confirm new password" field, then pressing the <button class="warn">Change password</button> button. The passwords in the two fields must match.</p>
|
|
||||||
</section>
|
|
||||||
<section class="guide-section">
|
|
||||||
<h2 id="badges">Badges</h2>
|
|
||||||
<p>Badges, also known as buttons, are 88x31 images that you can use to add more flair to your profile or link to other websites.</p>
|
|
||||||
<p>Badges you set will be shown on the usercard in threads and on your profile page. You can have up to 10 badges.</p>
|
|
||||||
<p>To add a badge, press the "Add badge" button. A badge editor will be added. A badge consists of three parts:</p>
|
|
||||||
<ol>
|
|
||||||
<li>Image
|
|
||||||
<ul>
|
|
||||||
<li>{{ config.SITE_NAME }} provides a selection of default badges that you can use. You may select one by using the dropdown, under the "Default" category.</li>
|
|
||||||
<li>Alternatively, you may upload your own image. To do so, select the "Upload…" option in the dropdown, under the "Your uploads" category. A <button>Browse…</button> button will appear, letting you select a file. The image must be exactly 88x31 pixels and may not be over 500KB in size.<br>
|
|
||||||
Custom badge images that you have uploaded before can be reused and will appear under "Your uploads". If a badge image you've uploaded before is not used by any of your badges, it will be deleted and you will have to upload it again if you wish to reuse it.<br>
|
|
||||||
Other users can not use images you've uploaded as part of their badges unless they download it manually.</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li>Label
|
|
||||||
<ul>
|
|
||||||
<li>The label will be shown when the badge is hovered over. It will also be the badge image's alt text.</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li>Link
|
|
||||||
<ul>
|
|
||||||
<li>Optionally, you may turn the badge into a clickable button by providing a link. The link will open in a new tab when pressed, and will use <code class="inline-code">rel="me"</code> so you can use your profile as verification on platforms like Mastodon.</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
<p>If a badge is not valid, its editor and any invalid fields will have a dashed border.</p>
|
|
||||||
<p>You can delete a badge by pressing the <button class="critical">X</button> button. Your changes are not saved until you press the <button>Save badges</button> button.</p>
|
|
||||||
</section>
|
|
||||||
<section class="guide-section">
|
|
||||||
<h2 id="deleting">Deleting your account</h2>
|
|
||||||
<p>If you no longer wish to participate in {{ config.SITE_NAME }}, you may delete your account. To do so, press the <button class="critical">Delete account</button> button in your settings, which will take you to a confirmation page.</p>
|
|
||||||
<p>Deleting your account is an irreversible action. Once you delete your account, <strong>you will not be able to log back in to it.</strong></p>
|
|
||||||
<p>Deleting your account <strong>does not delete your threads and posts.</strong> They will be kept intact to preserve history. They will be altered to show up as if a system user authored them.</p>
|
|
||||||
<p>If you want your posts and threads to be deleted as well, you may either delete them yourself or <a href="{{url_for("guides.contact")}}" target="_blank">contact the administrators</a> for help.</p>
|
|
||||||
<p>If you're sure you want to delete your account, you will need to type your password on the confirmation page before pressing the <button class="critical">Delete account</button> button.</p>
|
|
||||||
</section>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# Inbox/subscriptions
|
|
||||||
{% extends 'guides/_layout.html' %}
|
|
||||||
{% block guide_content %}
|
|
||||||
<section class="guide-section">
|
|
||||||
<h2 id="overview">Subscriptions</h2>
|
|
||||||
<p>You can subscribe to any thread. When subscribed, you will receive an overview of posts you missed for that thread in your inbox.</p>
|
|
||||||
<p>Additionally, when browsing a topic, threads you've subscribed to will show the number of posts you haven't read in it next to the thread's name.</p>
|
|
||||||
</section>
|
|
||||||
<section class="guide-section">
|
|
||||||
<h2 id="inbox">Inbox</h2>
|
|
||||||
<p>You can access your Inbox by following the "Inbox" link in the top navigation bar.</p>
|
|
||||||
<p>The Inbox is split into two parts:</p>
|
|
||||||
<ol>
|
|
||||||
<li>Subscriptions
|
|
||||||
<ul>
|
|
||||||
<li>A table of all threads that you've subscribed to. You can unsubscribe from each thread by pressing the <button class="warn">Unsubscribe</button> button for that thread's row.</li>
|
|
||||||
<li>This section is collapsible, like a Babycode spoiler - press the "-" button to collapse it.</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li>Unread posts
|
|
||||||
<ul>
|
|
||||||
<li>These are the posts you missed in each thread, if any. They are sorted in reverse chronological order and are grouped by thread. Each thread is collapsible.</li>
|
|
||||||
<li>You can mark an entire thread as read by pressing the <button>Mark thread as Read</button> button, or unsubscribe from it by pressing the <button class="warn">Unsubscribe</button> button.</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
<p>Your inbox is updated automatically when you browse a subscribed thread. Once you've seen the posts you missed, they will disappear from your Inbox.</p>
|
|
||||||
</section>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# Bookmarks
|
|
||||||
{% from 'common/icons.html' import icn_bookmark %}
|
|
||||||
{% extends 'guides/_layout.html' %}
|
|
||||||
{% block guide_content %}
|
|
||||||
<section class="guide-section">
|
|
||||||
<h2 id="bookmarks">Bookmarks</h2>
|
|
||||||
<p>You can bookmark whole threads or individual posts to save them for later reading. Bookmarks are split into collections.</p>
|
|
||||||
<p>Each post or thread you bookmark (collectively bookmarks) can only belong to one collection.</p>
|
|
||||||
<p>Each bookmark can have a short memo that you can add to it, for example to help you remember why you saved it.</p>
|
|
||||||
</section>
|
|
||||||
<section class="guide-section">
|
|
||||||
<h2 id="collections">Bookmark collections</h2>
|
|
||||||
<p>Bookmarks are organized into collections. There is always at least one collection you have access to, called "Bookmarks" by default.</p>
|
|
||||||
<p>You can add any number of collections by pressing the <button>Manage collections</button> button on the your bookmarks page.</p>
|
|
||||||
<p>On the "Manage bookmark collections" page, you can add, delete, rename, and reorder your collections.</p>
|
|
||||||
<p>To add a new collection, press the <button>Add new collection</button> button. A new item will be added.</p>
|
|
||||||
<p>You can name any collection by using the input box.</p>
|
|
||||||
<p>You can reorder collections by dragging and dropping it above or below others. The default collection is always at the top and can not be moved.</p>
|
|
||||||
<p>You can delete a collection by pressing the <button class="critical">Delete</button> button on that collection. Deleting a collection will remove all bookmarks from it. If you delete a collection by accident, simply refresh the page. Your changes are not saved until you press the <button>Save</button> button.</p>
|
|
||||||
</section>
|
|
||||||
<section class="guide-section">
|
|
||||||
<h2 id="bookmarking">Bookmarking posts and threads</h2>
|
|
||||||
<ul>
|
|
||||||
<li>To bookmark a thread, press the <button class="contain-svg inline icon">{{ icn_bookmark(20) }}Bookmark…</button> button at the top of the thread.</li>
|
|
||||||
<li>To bookmark a post, press the <button class="contain-svg inline icon">{{ icn_bookmark(20) }}Bookmark…</button> button on that post.</li>
|
|
||||||
</ul>
|
|
||||||
<p>Both will bring up a popup with your bookmark collections. You can select the collection to save the bookmark into by pressing its name and add an optional memo. To save the bookmark, press the <button>Save</button> button. Each bookmark can only belong to one collection.</p>
|
|
||||||
<p>If the post or thread is already bookmarked, the name of the collection will be highlighed and the box to its name will be filled. If you wish to remove the bookmark, press the name of the collection it's in and press the <button>Save</button> button.</p>
|
|
||||||
</section>
|
|
||||||
<section class="guide-section">
|
|
||||||
<h2 id="browsing-bookmarks">Browsing and managing bookmarks</h2>
|
|
||||||
<p>You can access your bookmarks by following the "Bookmarks" link in the top navigation bar.</p>
|
|
||||||
<p>On the Bookmarks page, each collection will show up as a collapsible section. If a collection is empty, it will be collapsed.</p>
|
|
||||||
<p>If a collection has any bookmarks, it will have two collapsibe sections nested inside it: one for threads and one for posts.</p>
|
|
||||||
<p>Bookmarked threads are shown as a table with their title, the memo, and a <button class="contain-svg inline icon">{{ icn_bookmark(20) }}Manage…</button> button. Pressing this button will show the same popup, letting you move or remove the bookmark.</p>
|
|
||||||
<p>Bookmarked posts are shown in the same way they're shown in a thread. The memo will be shown before the post's time stamp. To the right of the post is the same <button class="contain-svg inline icon">{{ icn_bookmark(20) }}Manage…</button> button.</p>
|
|
||||||
<p>If you made changes to bookmarked threads or posts on this page, you will have to refresh to save those changes.</p>
|
|
||||||
</section>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
# Babycode
|
|
||||||
{% extends 'guides/_layout.html' %}
|
|
||||||
{% block guide_content %}
|
|
||||||
<section class="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>
|
|
||||||
<p>A <b>tag</b> is a short name enclosed in square brackets. Tags can be opening tags, like <code class="inline-code">[b]</code> or closing tags, like <code class="inline-code">[/b]</code>. Anything inserted between matching opening and closing tags is known as the tag's content.</p>
|
|
||||||
<p>Some tags can provide more specific instructions using an <b>attribute</b>. An attribute is added to the opening tag with an equals sign (<code class="inline-code">=</code>). This allows you to specify details like a particular color or a link's address.</p>
|
|
||||||
<p>Babycode is used in posts, user signatures, and MOTDs.</p>
|
|
||||||
</section>
|
|
||||||
<section class="guide-section">
|
|
||||||
<h2 id="text-formatting-tags">Text formatting tags</h2>
|
|
||||||
<ul class='guide-list'>
|
|
||||||
<li>To make some text <strong>bold</strong>, enclose it in <code class="inline-code">[b][/b]</code>:<br>
|
|
||||||
[b]Hello World[/b]<br>
|
|
||||||
Will become<br>
|
|
||||||
<strong>Hello World</strong>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<ul class='guide-list'>
|
|
||||||
<li>To <em>italicize</em> text, enclose it in <code class="inline-code">[i][/i]</code>:<br>
|
|
||||||
[i]Hello World[/i]<br>
|
|
||||||
Will become<br>
|
|
||||||
<em>Hello World</em>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<ul class='guide-list'>
|
|
||||||
<li>To make some text <del>strikethrough</del>, enclose it in <code class="inline-code">[s][/s]</code>:<br>
|
|
||||||
[s]Hello World[/s]<br>
|
|
||||||
Will become<br>
|
|
||||||
<del>Hello World</del>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<ul class='guide-list'>
|
|
||||||
<li>To <u>underline</u> some text, enclose it in <code class="inline-code">[u][/u]</code>:<br>
|
|
||||||
[u]Hello World[/u]<br>
|
|
||||||
Will become<br>
|
|
||||||
<u>Hello World</u>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<ul class='guide-list'>
|
|
||||||
<li>To make some text {{ "[big]big[/big]" | babycode | safe }}, enclose it in <code class="inline-code">[big][/big]</code>:<br>
|
|
||||||
[big]Hello World[/big]<br>
|
|
||||||
Will become<br>
|
|
||||||
{{ "[big]Hello World[/big]" | babycode | safe }}
|
|
||||||
<li>Similarly, you can make text {{ "[small]small[/small]" | babycode | safe }} with <code class="inline-code">[small][/small]</code>:<br>
|
|
||||||
[small]Hello World[/small]<br>
|
|
||||||
Will become<br>
|
|
||||||
{{ "[small]Hello World[/small]" | babycode | safe }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<ul class='guide-list'>
|
|
||||||
<li>You can change the text color by using <code class="inline-code">[color][/color]</code>:<br>
|
|
||||||
[color=red]Red text[/color]<br>
|
|
||||||
[color=white]White text[/color]<br>
|
|
||||||
[color=#3b08f0]Blueish text[/color]<br>
|
|
||||||
Will become<br>
|
|
||||||
{{ "[color=red]Red text[/color]" | babycode | safe }}<br>
|
|
||||||
{{ "[color=white]White text[/color]" | babycode | safe }}<br>
|
|
||||||
{{ "[color=#3b08f0]Blueish text[/color]" | babycode | safe }}<br>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<ul class='guide-list'>
|
|
||||||
<li>You can center text by enclosing it in <code class="inline-code">[center][/center]</code>:<br>
|
|
||||||
[center]Hello World[/center]<br>
|
|
||||||
Will become<br>
|
|
||||||
{{ "[center]Hello World[/center]" | babycode | safe }}
|
|
||||||
</li>
|
|
||||||
<li>You can right-align text by enclosing it in <code class="inline-code">[right][/right]</code>:<br>
|
|
||||||
[right]Hello World[/right]<br>
|
|
||||||
Will become<br>
|
|
||||||
{{ "[right]Hello World[/right]" | babycode | safe }}
|
|
||||||
</li>
|
|
||||||
Note: the center and right tags will break the paragraph. See <a href="#paragraph-rules">Paragraph rules</a> for more details.
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
<section class="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="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:
|
|
||||||
{{ 'paragraph 1\n\nparagraph 2' | babycode(true) | safe }}
|
|
||||||
<p>To break a line without starting a new paragraph, end a line with two spaces:</p>
|
|
||||||
{{ '[code]paragraph 1 \nstill paragraph 1[/code]' | babycode | safe }}
|
|
||||||
That will produce:<br>
|
|
||||||
{{ 'paragraph 1 \nstill paragraph 1' | babycode(true) | safe }}
|
|
||||||
<p>Additionally, the following tags will break into a new paragraph:</p>
|
|
||||||
<ul>
|
|
||||||
<li><code class="inline-code">[code]</code> (code block, not inline);</li>
|
|
||||||
<li><code class="inline-code">[img]</code>;</li>
|
|
||||||
<li><code class="inline-code">[center]</code>;</li>
|
|
||||||
<li><code class="inline-code">[right]</code>;</li>
|
|
||||||
<li><code class="inline-code">[ul]</code> and <code class="inline-code">[ol]</code>;</li>
|
|
||||||
<li><code class="inline-code">[quote]</code>.</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
<section class="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 <code class="inline-code">[url=https://example.com]Link label[/url]</code>:<br>
|
|
||||||
<a href="https://example.com">Link label</a></p>
|
|
||||||
</section>
|
|
||||||
<section class="guide-section">
|
|
||||||
<h2 id="attaching-an-image">Attaching an image</h2>
|
|
||||||
<p>To add an image to your post, use the <code class="inline-code">[img]</code> tag:<br>
|
|
||||||
<code class="inline-code">[img=https://forum.poto.cafe/avatars/default.webp]the Python logo with a cowboy hat[/img]</code></p>
|
|
||||||
{{ '[img=/static/avatars/default.webp]the Python logo with a cowboy hat[/img]' | babycode | safe }}
|
|
||||||
<p>The attribute is the image URL. The text inside the tag will become the image's alt text.</p>
|
|
||||||
<p>Images will always break up a paragraph and will get scaled down to a maximum of 400px. However, consecutive image tags will try to stay in one line, wrapping if necessary. Break the paragraph if you wish to keep images on their own paragraph.</p>
|
|
||||||
<p>Multiple images attached to a post can be clicked to open a dialog to view them.</p>
|
|
||||||
</section>
|
|
||||||
<section class="guide-section">
|
|
||||||
<h2 id="adding-code-blocks">Adding code blocūs</h2>
|
|
||||||
{% set code = 'func _ready() -> void:\n\tprint("hello world!")' %}
|
|
||||||
<p>There are two kinds of code blocks recognized by babycode: inline and block. Inline code blocks do not break a paragraph. They can be added with <code class="inline-code">[code]your code here[/code]</code>. As long as there are no line breaks inside the code block, it is considered inline. If there are any, it will produce this:</p>
|
|
||||||
{{ ('[code]%s[/code]' % code) | babycode | safe }}
|
|
||||||
<p>Optionally, you can enable syntax highlighting by specifying the language in the attribute like this: <code class="inline-code">[code=gdscript]</code></p>
|
|
||||||
{{ ('[code=gdscript]%s[/code]' % code) | babycode | safe}}
|
|
||||||
<p>A full list of languages that can be highlighted is available <a href="https://pygments.org/languages/" target=_blank>here</a> (the short names column).</p>
|
|
||||||
<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="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="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>
|
|
||||||
<section class="guide-section">
|
|
||||||
<h2 id="spoilers">Spoilers</h2>
|
|
||||||
{% set spoiler = "[spoiler=Major Metal Gear Spoilers]Snake dies[/spoiler]" %}
|
|
||||||
<p>You can make a section collapsible by using the <code class="inline-code">[spoiler]</code> tag:</p>
|
|
||||||
{{ ("[code]\n%s[/code]" % spoiler) | babycode | safe }}
|
|
||||||
Will produce:
|
|
||||||
{{ spoiler | babycode | safe }}
|
|
||||||
All other tags are supported inside spoilers.
|
|
||||||
</section>
|
|
||||||
<section class="guide-section">
|
|
||||||
<h2 id="mentions">Mentioning users</h2>
|
|
||||||
<p>You can mention users by their username (<em>not</em> their display name) by using <code class="inline-code">@username</code>. A user's username is always shown below their avatar and display name on their posts and their user page.</p>
|
|
||||||
<p>A mention will show up on your post as a clickable box with the user's display name if they have one set or their username with an <code class="inline-code">@</code> symbol if they don't:</p>
|
|
||||||
<a class="mention" href="#mentions" title="@user-without-display-name">@user-without-display-name</a>
|
|
||||||
<a class="mention display" href="#mentions" title="@user-with-display-name">User with display name</a>
|
|
||||||
<a class="mention display me" href="#mentions" title="@your-username">Your display name</a>
|
|
||||||
<p>Mentioning a user does not notify them. It is simply a way to link to their profile in your posts.</p>
|
|
||||||
</section>
|
|
||||||
<section class="guide-section">
|
|
||||||
{% set hr_example = "some section\n---\nanother section" %}
|
|
||||||
<h2 id="rule">Horizontal rules</h2>
|
|
||||||
<p>The special <code class="inline-code">---</code> markup inserts a horizontal separator, also known as a horizontal rule:</p>
|
|
||||||
{{ ("[code]%s[/code]" % hr_example) | babycode | safe }}
|
|
||||||
Will become
|
|
||||||
{{ hr_example | babycode(true) | safe}}
|
|
||||||
<p>Horizontal rules will always break the current paragraph.</p>
|
|
||||||
</section>
|
|
||||||
<section class="guide-section">
|
|
||||||
<h2 id="void-tags">Void tags</h2>
|
|
||||||
<p>The special void tags <code class="inline-code">[lb]</code>, <code class="inline-code">[rb]</code>, <code class="inline-code">[d]</code> and <code class="inline-code">[at]</code> will appear as the literal characters <code class="inline-code">[</code>, <code class="inline-code">]</code>, <code class="inline-code">-</code>, and <code class="inline-code">@</code> respectively. Unlike other tags, they are self-contained and have no closing equivalent.</p>
|
|
||||||
<ul class="guide-list">
|
|
||||||
{% set lbrb = "[color=red]This text will be red[/color]\n\n[lb]color=red[rb]This text won't be red[lb]/color[rb]" %}
|
|
||||||
<li><code class="inline-code">[lb]</code> and <code class="inline-code">[rb]</code> allow you to use square brackets without them being interpreted as Babycode:
|
|
||||||
{{ ("[code]" + lbrb + "[/code]") | babycode | safe }}
|
|
||||||
Will result in:<br>
|
|
||||||
{{ lbrb | babycode | safe }}
|
|
||||||
</li>
|
|
||||||
<li>The <code class="inline-code">[at]</code> tag allows you to use the @ symbol without it being turned into a mention.</li>
|
|
||||||
<li>The <code class="inline-code">[d]</code> tag allows you to use the - (dash) symbol without it being turned into a rule.</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
{% endblock %}
|
|
||||||
51
app/templates/hyper/badge_editor.html
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{%- macro badge_input(uploads, label='', link='', selected=none, id=none) -%}
|
||||||
|
{%- set defaults = uploads | selectattr('user_id', 'none') | list | sort(attribute='file_path') -%}
|
||||||
|
{%- set user = uploads | selectattr('user_id') | list -%}
|
||||||
|
{%- if selected is not none -%}
|
||||||
|
{%- set selected_href = (uploads | selectattr('id', 'equalto', selected) | list)[0].file_path -%}
|
||||||
|
{%- else -%}
|
||||||
|
{% set selected_href = defaults[0].file_path %}
|
||||||
|
{%- endif -%}
|
||||||
|
<input type="hidden" name="id[]" value="{{id and id or '-1'}}">
|
||||||
|
<div class="badge-editor-badge-container">
|
||||||
|
<div class="badge-editor-badge-select">
|
||||||
|
<select name="badge_choice[]" required data-s="badgeEditorSetPreview badgeEditorToggleFilePicker" data-listen="change">
|
||||||
|
<optgroup label="Default">
|
||||||
|
{%- for upload in defaults -%}
|
||||||
|
<option data-file-path="{{upload.file_path}}" value="{{upload.id}}" {{selected==upload.id and 'selected' or ''}}>{{upload.file_path | basename_noext}}</option>
|
||||||
|
{%- endfor -%}
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Your uploads">
|
||||||
|
{%- for upload in user -%}
|
||||||
|
<option data-file-path="{{upload.file_path}}" value="{{upload.id}}" {{selected==upload.id and 'selected' or ''}}>{{upload.original_filename | basename_noext}}</option>
|
||||||
|
{%- endfor -%}
|
||||||
|
<option value="custom">Upload new…</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
<img class="badge-button" src="{{selected_href}}" data-r="badgeEditorSetPreview badgeEditorSetPreviewCustom">
|
||||||
|
</div>
|
||||||
|
<div class="badge-editor-file-picker hidden" data-r="badgeEditorToggleFilePicker">
|
||||||
|
<button data-s="badgeEditorShowFilePicker" type="button" class="alt">Upload…</button>
|
||||||
|
<input data-s="badgeEditorFileSelected" data-r="badgeEditorShowFilePicker" type="file" accept="image/png, image/jpeg, image/jpg, image/webp" name="badge_file[]">
|
||||||
|
</div>
|
||||||
|
<input type="text" required placeholder="Label" value="{{label}}" autocomplete="off" name="label[]">
|
||||||
|
<input type="url" placeholder="(Optional) Link" value="{{link}}" autocomplete="off" name="link[]" pattern="https://.*">
|
||||||
|
<button type="button" class="critical" data-s="badgeEditorDelete">Delete</button>
|
||||||
|
</div>
|
||||||
|
{%- endmacro -%}
|
||||||
|
{%- from 'common/macros.html' import sortable_list, sortable_list_item -%}
|
||||||
|
<button type="button" data-s="badgeEditorAddBadge" data-r="setBadgeCount">Add badge</button>
|
||||||
|
<input type="submit" value="Save badges">
|
||||||
|
<span data-r="setBadgeCount">0/10</span>
|
||||||
|
{%- call() sortable_list(attr={'data-r': 'badgeEditorAddBadge'}) -%}
|
||||||
|
{%- for badge in badges -%}
|
||||||
|
{%- call() sortable_list_item('badge', full=true, attr={'data-r': 'badgeEditorDelete badgeEditorAssignImgId'}) -%}
|
||||||
|
{{badge_input(badge_uploads, badge.label, badge.link, badge.upload, badge.id)}}
|
||||||
|
{%- endcall -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
{%- endcall -%}
|
||||||
|
<script type="text/html" id="badge-template">
|
||||||
|
{%- call() sortable_list_item('badge', full=true, attr={'data-r': 'badgeEditorDelete'}) -%}
|
||||||
|
{{- badge_input(badge_uploads) -}}
|
||||||
|
{%- endcall -%}
|
||||||
|
</script>
|
||||||
24
app/templates/hyper/bookmark_dropdown.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<form class="full-width" method="POST" action="{{submit_url}}" data-listen="submit" data-s="bookmarkMenuSubmit" data-r="bookmarkMenuSubmit">
|
||||||
|
<input type="hidden" name="concept_id" value="{{concept_id}}">
|
||||||
|
<div class="inline-group bookmark-menu-item">
|
||||||
|
<input data-s="bookmarkMenuResetSavedButton" autocomplete="off" type="radio" name="target_collection" id="collection-none" {{'checked' if in_collection==none else ''}} value="-1">
|
||||||
|
<label class="bookmark-menu-label" for="collection-none">
|
||||||
|
No collection
|
||||||
|
<small>Choose this option to remove this {{'thread' if is_thread else 'post'}} from your bookmarks.</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{%- for collection in collections -%}
|
||||||
|
<div class="inline-group bookmark-menu-item">
|
||||||
|
<input data-s="bookmarkMenuResetSavedButton" autocomplete="off" type="radio" name="target_collection" id="collection-{{collection.id}}" {{'checked' if in_collection==collection.id else ''}} value="{{collection.id}}">
|
||||||
|
{%- set tc = collection.get_threads_count() -%}
|
||||||
|
{%- set pc = collection.get_posts_count() -%}
|
||||||
|
<label class="bookmark-menu-label" for="collection-{{collection.id}}">
|
||||||
|
{{collection.name}}
|
||||||
|
<small>{{tc}} {{'thread' | pluralize(num=tc)}}, {{pc}} {{'post' | pluralize(num=pc)}}</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{%- endfor -%}
|
||||||
|
<input type="text" placeholder="Optional memo" maxlength=100 name="note" autocomplete="off" value="{{note}}">
|
||||||
|
<input type="submit" value="{{'Saved!' if request.args.saved else 'Save'}}" data-r="bookmarkMenuShowSavedButton bookmarkMenuResetSavedButton">
|
||||||
|
<span class="errors hidden" data-r="bookmarkMenuShowError bookmarkMenuHideError">Something went wrong. Try again later.</span>
|
||||||
|
</form>
|
||||||
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)}}
|
||||||
|
<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 -%}
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{% from 'common/macros.html' import babycode_editor_component %}
|
|
||||||
{% extends 'base.html' %}
|
|
||||||
{% block title %}editing MOTD{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="darkbg">
|
|
||||||
<h1>Edit Message of the Day</h1>
|
|
||||||
<p>The Message of the Day will show up on the main page and in every topic.</p>
|
|
||||||
<form method="POST">
|
|
||||||
<label for="title">Title</label>
|
|
||||||
<input name="title" id="title" type="text" required autocomplete="off" placeholder="Required" value="{{ current.title }}"><br>
|
|
||||||
<label for="body">Body</label>
|
|
||||||
{{ babycode_editor_component('body', ta_placeholder='MOTD body (required)', banned_tags=MOTD_BANNED_TAGS, prefill=current.body_original_markup) }}
|
|
||||||
<input type="submit" value="Save">
|
|
||||||
<input class="critical" type="submit" formaction="{{ url_for('mod.motd_delete') }}" value="Delete MOTD" formnovalidate {{"disabled" if not current else ""}}>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% 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,12 +1,36 @@
|
|||||||
{% extends "base.html" %}
|
{%- from 'common/macros.html' import subheader, babycode_editor_component, sortable_list, sortable_list_item -%}
|
||||||
{% block title %}moderation{% endblock %}
|
{%- extends 'base.html' -%}
|
||||||
{% block content %}
|
{%- block title -%}moderation panel{%- endblock -%}
|
||||||
<div class="darkbg">
|
{%- block content -%}
|
||||||
<h1>Moderation actions</h1>
|
{{- subheader('Moderation panel') -}}
|
||||||
<ul>
|
<fieldset class="plank">
|
||||||
<li><a href="{{ url_for('mod.user_list') }}">User list</a></li>
|
<legend>Message of the Day</legend>
|
||||||
<li><a href="{{ url_for('mod.sort_topics') }}">Sort topics</a></li>
|
<p>The Message of the Day is shown on the front page and on each topic's thread list.</p>
|
||||||
<li><a href="{{ url_for('mod.motd_editor') }}">Message of the Day</a></li>
|
<form class="full-width" method="POST" action="{{url_for('mod.set_motd')}}" id="motd-form">
|
||||||
</ul>
|
<label for="motd-title">Title</label>
|
||||||
</div>
|
<input type="text" id="motd-title" name="motd_title" autocomplete="off" value="{{motd.title}}" required>
|
||||||
{% endblock %}
|
<label for="motd-content">Body</label>
|
||||||
|
{{ babycode_editor_component(placeholder='MOTD content', prefill=motd.body_original_markup, id='motd-content', banned_tags=MOTD_BANNED_TAGS) }}
|
||||||
|
</form>
|
||||||
|
<input type="submit" value="Save MOTD" form="motd-form">
|
||||||
|
<form method="POST" action="{{url_for('mod.clear_motd')}}">
|
||||||
|
<input type="submit" value="Clear MOTD" class="warn">
|
||||||
|
</form>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="plank" id="sort-topics">
|
||||||
|
<legend>Sort topics</legend>
|
||||||
|
<p>Drag topics around to reorder them. Press "Save order" when done.</p>
|
||||||
|
<form method="POST" action="{{url_for('mod.sort_topics')}}">
|
||||||
|
<input type="submit" value="Save order">
|
||||||
|
{%- call() sortable_list() -%}
|
||||||
|
{%- for topic in topics -%}
|
||||||
|
{%- call() sortable_list_item(key="topics") -%}
|
||||||
|
<h2 class="info">{{ topic.name }}</h2>
|
||||||
|
<div>{{topic.description}}</div>
|
||||||
|
<input type="hidden" name="topics[]" value="{{ topic.id }}">
|
||||||
|
{%- endcall -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
{%- endcall -%}
|
||||||
|
</form>
|
||||||
|
</fieldset>
|
||||||
|
{%- endblock -%}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% from 'common/macros.html' import sortable_list, sortable_list_item %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="darkbg">
|
|
||||||
<h1>Change topics order</h1>
|
|
||||||
<p>Drag topic titles to reoder them. Press "Save order" when done. The topics will appear to users in the order set here.</p>
|
|
||||||
<form method="post">
|
|
||||||
<input type=submit value="Save order">
|
|
||||||
{% call() sortable_list() %}
|
|
||||||
{% for topic in topics %}
|
|
||||||
{% call() sortable_list_item(key="topics") %}
|
|
||||||
<div class="thread-title">{{ topic.name }}</div>
|
|
||||||
<div>{{ topic.description }}</div>
|
|
||||||
<input type="hidden" name="topics[]" value="{{ topic.id }}" class="topic-input">
|
|
||||||
{% endcall %}
|
|
||||||
{% endfor %}
|
|
||||||
{% endcall %}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,69 +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>
|
|
||||||
{% if active_user.is_admin() %}
|
|
||||||
<th class="small">Create password reset link</th>
|
|
||||||
{% endif %}
|
|
||||||
</thead>
|
|
||||||
{% for user in not_guests %}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<a href="{{url_for("users.page", username=user['username'])}}">{{user['username']}}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{{ user.permission | permission_string }}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{{ timestamp(user.created_at) }}
|
|
||||||
</td>
|
|
||||||
{% if active_user.is_admin() %}
|
|
||||||
<td>
|
|
||||||
<form method="post" action="{{url_for('mod.create_reset_pass', user_id=user.id)}}">
|
|
||||||
<input type="submit" class="warn" value="Create password reset link">
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
{% endif %}
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</table>
|
|
||||||
{% endif %}
|
|
||||||
{% endcall %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
16
app/templates/posts/delete.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{%- from 'common/macros.html' import subheader -%}
|
||||||
|
{%- from 'common/macros.html' import full_post with context -%}
|
||||||
|
{%- extends 'base.html' -%}
|
||||||
|
{%- block title -%}deleting a post{%- endblock -%}
|
||||||
|
{%- block content -%}
|
||||||
|
{%- call() subheader("Delete post", "Are you sure you want to delete this post? This action can not be undone.") -%}
|
||||||
|
<form method="POST">
|
||||||
|
<fieldset class="plank minimal even no-shadow subheader-actions">
|
||||||
|
<legend>Please confirm</legend>
|
||||||
|
<a href="{{get_post_url(post.id, _anchor=true)}}" class="linkbutton">Cancel</a>
|
||||||
|
<input type="submit" value="Delete" class="critical">
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
{%- endcall -%}
|
||||||
|
<div class="post plank">{{- full_post(post=post, show_toolbar=false, show_reactions=false) -}}</div>
|
||||||
|
{%- endblock -%}
|
||||||
@@ -1,18 +1,29 @@
|
|||||||
{% from 'common/macros.html' import full_post %}
|
{%- from 'common/macros.html' import subheader -%}
|
||||||
{% extends 'base.html' %}
|
{%- from 'common/macros.html' import full_post with context -%}
|
||||||
{% block title %}editing a post{% endblock %}
|
{%- extends 'base.html' -%}
|
||||||
{% block content %}
|
{%- block title -%}editing a post{%- endblock -%}
|
||||||
{% for post in prev_context | reverse %}
|
{%- block content -%}
|
||||||
{{ full_post(post=post, no_reply=true, active_user=active_user) }}
|
{%- set nav -%}
|
||||||
{% endfor %}
|
<a href="{{get_post_url(post.id, _anchor=true)}}">← Back to thread</a>
|
||||||
<span class="context-explain">
|
{%- endset -%}
|
||||||
<span>↑↑↑</span><i>Context</i><span>↑↑↑</span>
|
{{ subheader("Editing your post", nav)}}
|
||||||
</span>
|
{%- for post in context_prev -%}
|
||||||
{{ full_post(post=editing_post, editing=true, no_reply=true, active_user=active_user) }}
|
<div class="post plank">{{- full_post(post=post, show_toolbar=false, show_reactions=false) -}}</div>
|
||||||
<span class="context-explain">
|
{%- endfor -%}
|
||||||
<span>↓↓↓</span><i>Context</i><span>↓↓↓</span>
|
<div class="plank secondary-bg context-explain">
|
||||||
</span>
|
<span>↑↑↑</span>
|
||||||
{% for post in next_context %}
|
<i>Context</i>
|
||||||
{{ full_post(post=post, no_reply=true, active_user=active_user) }}
|
<span>↑↑↑</span>
|
||||||
{% endfor %}
|
</div>
|
||||||
{% endblock %}
|
<form class="post plank" method="POST">
|
||||||
|
{{- full_post(post=post, is_editing=true, show_toolbar=false, show_reactions=false) -}}
|
||||||
|
</form>
|
||||||
|
<div class="plank secondary-bg context-explain">
|
||||||
|
<span>↓↓↓</span>
|
||||||
|
<i>Context</i>
|
||||||
|
<span>↓↓↓</span>
|
||||||
|
</div>
|
||||||
|
{%- for post in context_next -%}
|
||||||
|
<div class="post plank">{{- full_post(post=post, show_toolbar=false, show_reactions=false) -}}</div>
|
||||||
|
{%- 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">
|
|
||||||
<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 %}
|
|
||||||
11
app/templates/threads/edit.html
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{%- from 'common/macros.html' import subheader, babycode_editor_component -%}
|
||||||
|
{%- extends 'base.html' -%}
|
||||||
|
{%- block title -%}editing thread "{{thread.title}}"{%- endblock -%}
|
||||||
|
{%- block content -%}
|
||||||
|
{{subheader('Rename thread "%s"' % thread.title, 'You can change the thread title here. To edit the OP of this thread, press the Edit button on the post instead.')}}
|
||||||
|
<form class="plank primary-bg full-width" method="POST">
|
||||||
|
<label for="title">New title</label>
|
||||||
|
<input type="text" id="title" name="title" required value="{{thread.title}}">
|
||||||
|
<input type="submit" value="Save">
|
||||||
|
</form>
|
||||||
|
{%- 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 | sort(attribute='sort_order') -%}
|
||||||
|
<option value="{{topic.id}}" {{'selected' if selected_topic == topic.id else ''}} {{'disabled' if not get_active_user().can_post_to_thread_or_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,19 +0,0 @@
|
|||||||
{% extends 'base.atom' %}
|
|
||||||
{% from 'common/macros.html' import rss_html_content %}
|
|
||||||
{% block title %}replies to {{thread.title}}{% endblock %}
|
|
||||||
{% block canonical_link %}{{url_for('threads.thread', slug=thread.slug, _external=true)}}{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
{% for post in posts %}
|
|
||||||
{% set post_url = get_post_url(post.id, _anchor=true, external=true) %}
|
|
||||||
<entry>
|
|
||||||
<title>Re: {{ thread.title }}</title>
|
|
||||||
<link href="{{ post_url }}"/>
|
|
||||||
<id>{{ post_url }}</id>
|
|
||||||
<updated>{{ post.edited_at | iso8601 }}</updated>
|
|
||||||
{{rss_html_content(post.content_rss)}}
|
|
||||||
<author>
|
|
||||||
<name>{{ post.display_name }} @{{ post.username }}</name>
|
|
||||||
</author>
|
|
||||||
</entry>
|
|
||||||
{% endfor %}
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,95 +1,98 @@
|
|||||||
{% from 'common/macros.html' import pager, babycode_editor_form, full_post, bookmark_button, rss_button %}
|
{%- from 'common/macros.html' import subheader, timestamp, pager, babycode_editor_component -%}
|
||||||
{% from 'common/icons.html' import icn_bookmark %}
|
{%- from 'common/icons.html' import icn_bookmark -%}
|
||||||
{% extends "base.html" %}
|
{%- from 'common/macros.html' import full_post, bookmark_menu with context -%}
|
||||||
{% block title %}{{ thread.title }}{% endblock %}
|
{%- extends 'base.html' -%}
|
||||||
{% block content %}
|
{%- block title -%}{{thread.title}}{%- endblock -%}
|
||||||
{% set can_post = false %}
|
{%- block content -%}
|
||||||
{% set can_lock = false %}
|
<bitty-8 data-connect="/static/js/bits/bookmark-menu.js"></bitty-8>
|
||||||
{% set can_subscribe = false %}
|
{%- set td -%}
|
||||||
{% set can_bookmark = false %}
|
<ul class="horizontal">
|
||||||
{% if active_user %}
|
<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>
|
||||||
{% set can_subscribe = true %}
|
{%- if thread.locked() or thread.stickied() -%}
|
||||||
{% set can_bookmark = not active_user.is_guest() %}
|
{%- if thread.locked() -%}
|
||||||
{% set can_post = (not thread.is_locked and not active_user.is_guest()) or active_user.is_mod() %}
|
<li class="visible">Locked</li>
|
||||||
{% set can_lock = ((active_user.id | int) == (thread.user_id | int)) or active_user.is_mod() %}
|
{%- endif -%}
|
||||||
{% endif %}
|
{%- if thread.stickied() -%}
|
||||||
<main>
|
<li class="visible">Stickied</li>
|
||||||
<nav class="darkbg">
|
{%- endif -%}
|
||||||
<h1 class="thread-title">{{ thread.title }}{% if unread_count is not none %} ({{ unread_count }} unread){% endif %}</h1>
|
{%- endif -%}
|
||||||
<span>Posted in <a href="{{ url_for("topics.topic", slug=topic.slug) }}">{{ topic.name }}</a>
|
</ul>
|
||||||
{% if thread.is_stickied %}
|
{%- endset -%}
|
||||||
• <i>stickied, so it's probably important</i>
|
{%- call() subheader(thread.title, td) -%}
|
||||||
{% endif %}
|
<fieldset class="plank even no-shadow minimal subheader-actions">
|
||||||
</span>
|
<legend>Actions</legend>
|
||||||
<div class="thread-actions">
|
{%- if is_logged_in() -%}
|
||||||
{% if can_subscribe %}
|
{%- if thread.user_id == get_active_user().id -%}
|
||||||
<form class="modform" action="{{ url_for('threads.subscribe', slug=thread.slug) }}" method="post">
|
<a class="linkbutton" href="{{url_for('threads.edit', thread_id=thread.id)}}">Rename</a>
|
||||||
<input type='hidden' name='last_visible_post' value='{{posts[-1].id}}'>
|
{%- endif -%}
|
||||||
<input type='hidden' name='subscribe' value='{{ 'unsubscribe' if is_subscribed else 'subscribe' }}'>
|
<form action="{{url_for('threads.subscribe' if not get_active_user().is_subscribed(thread.id) else 'threads.unsubscribe', thread_id=thread.id)}}" method="POST">
|
||||||
<input type='submit' value='{{ 'Unsubscribe' if is_subscribed else 'Subscribe' }}'>
|
<input type="hidden" name="last_post_timestamp" value="{{last_post.created_at}}">
|
||||||
|
<input type="hidden" name="last_post_id" value="{{last_post.id}}">
|
||||||
|
<input type="submit" value="{{'Subscribe' if not get_active_user().is_subscribed(thread.id) else 'Unsubscribe'}}">
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
<button disabled autocomplete='off' data-r="enhance" data-s="showBookmarkMenu" title="This feature requires JavaScript to be enabled." data-concept-kind="thread" data-concept-id="{{thread.id}}">{{icn_bookmark(24)}}Bookmark…</button>
|
||||||
{% if can_bookmark %}
|
{%- endif -%}
|
||||||
{{ bookmark_button(type="thread", id=thread.id) }}
|
<a href="{{url_for('threads.feed', thread_id=thread.id)}}" class="linkbutton rss">Subscribe via RSS</a>
|
||||||
{% endif %}
|
</fieldset>
|
||||||
{% if can_lock %}
|
{%- if is_mod() -%}
|
||||||
<form class="modform" action="{{ url_for("threads.lock", slug=thread.slug) }}" method="post">
|
<fieldset class="plank even no-shadow minimal subheader-actions">
|
||||||
<input type=hidden name='target_op' value="{{ (not thread.is_locked) | int }}">
|
<legend>Moderation actions</legend>
|
||||||
<input class="warn" type="submit" value="{{"Unlock thread" if thread.is_locked else "Lock thread"}}">
|
{%- if thread.user_id != get_active_user().id -%}
|
||||||
|
<a class="linkbutton warn" href="{{url_for('threads.edit', thread_id=thread.id)}}">Rename</a>
|
||||||
|
{%- endif -%}
|
||||||
|
<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>
|
</form>
|
||||||
{% endif %}
|
<form class="horizontal wrap" method="POST" action="{{url_for('mod.move_thread', thread_id=thread.id)}}">
|
||||||
{% if active_user and active_user.is_mod() %}
|
<select name="new_topic_id" id="new-topic-id" autocomplete="off" required>
|
||||||
<form class="modform" action="{{ url_for("threads.sticky", slug=thread.slug) }}" method="post">
|
<option selected disabled value="">Move to topic:</option>
|
||||||
<input type=hidden name='target_op' value="{{ (not thread.is_stickied) | int }}">
|
{%- for t in topics -%}
|
||||||
<input class="warn" type="submit" value="{{"Unsticky thread" if thread.is_stickied else "Sticky thread"}}">
|
<option value="{{t.id}}" {{'disabled' if t.id==topic.id else ''}}>{{t.name}}</option>
|
||||||
</form>
|
{%- endfor -%}
|
||||||
<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 %}
|
|
||||||
</select>
|
</select>
|
||||||
<input class="warn" type="submit" value="Move thread">
|
<input type="submit" value="Move" class="warn">
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
</fieldset>
|
||||||
{{ rss_button(url_for('threads.thread_atom', slug=thread.slug)) }}
|
<fieldset class="plank even no-shadow minimal subheader-actions">
|
||||||
</div>
|
<legend>Page</legend>
|
||||||
</nav>
|
{{- pager(page, page_count) -}}
|
||||||
{% for post in posts %}
|
</fieldset>
|
||||||
{{ full_post(post = post, active_user = active_user, is_latest = loop.index == (posts | length), Reactions = Reactions, show_bookmark = can_bookmark) }}
|
{%- endif -%}
|
||||||
{% endfor %}
|
{%- endcall -%}
|
||||||
|
<main>
|
||||||
|
{%- for post in posts -%}
|
||||||
|
<article id="post-{{post.id}}" class="post plank">
|
||||||
|
{{full_post(post)}}
|
||||||
|
</article>
|
||||||
|
{%- endfor -%}
|
||||||
</main>
|
</main>
|
||||||
|
<div class="plank secondary-bg">
|
||||||
<nav id="bottomnav">
|
<fieldset class="plank even no-shadow minimal subheader-actions">
|
||||||
{{ pager(current_page = current_page, page_count = page_count) }}
|
<legend>Page</legend>
|
||||||
</nav>
|
{{- pager(page, page_count) -}}
|
||||||
|
</fieldset>
|
||||||
{% 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?
|
|
||||||
<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>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</dialog>
|
|
||||||
<input type='hidden' id='allowed-reaction-emoji' value='{{ REACTION_EMOJI | join(' ') }}'>
|
|
||||||
<input type='hidden' id='thread-subscribe-endpoint' value='{{ url_for('api.thread_updates', thread_id=thread.id) }}'>
|
|
||||||
<div id="new-post-notification" class="new-concept-notification hidden">
|
|
||||||
<div class="new-notification-content">
|
|
||||||
<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>
|
</div>
|
||||||
<script src="{{ "/static/js/thread.js" | cachebust }}"></script>
|
<div id="new-post-toast" class="plank even contrast-bg hidden">
|
||||||
{% endblock %}
|
<span>New post in thread!</span>
|
||||||
|
<span class="notification-buttons">
|
||||||
|
<button>Dismiss</button>
|
||||||
|
<button>View post</button>
|
||||||
|
<button>Stop updates</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{{ bookmark_menu() }}
|
||||||
|
{%- if is_logged_in() and get_active_user().can_post_to_thread_or_topic(thread) -%}
|
||||||
|
<form action="{{url_for('threads.reply', thread_id=thread.id)}}" method="POST" class="plank post-edit-form" data-listen="submit" data-r="clearThreadDraft" data-s="clearThreadDraft">
|
||||||
|
<h2 class="info">Reply to "{{thread.title}}"</h2>
|
||||||
|
{{- babycode_editor_component() -}}
|
||||||
|
<span>
|
||||||
|
<input type="checkbox" {{'' if session['dont_subscribe_by_default'] else 'checked'}} name="subscribe" id="subscribe" autocomplete=off>
|
||||||
|
<label for="subscribe">Subscribe to thread</label>
|
||||||
|
</span>
|
||||||
|
<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">
|
|
||||||
<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">
|
|
||||||
<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,20 +0,0 @@
|
|||||||
{% extends 'base.atom' %}
|
|
||||||
{% from 'common/macros.html' import rss_html_content %}
|
|
||||||
{% block title %}latest threads in {{target_topic.name}}{% endblock %}
|
|
||||||
{% block canonical_link %}{{url_for('topics.topic', slug=target_topic.slug, _external=true)}}{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<subtitle>{{ target_topic.description }}</subtitle>
|
|
||||||
{% for thread in threads_list %}
|
|
||||||
<entry>
|
|
||||||
<title>[new thread] {{ thread.title | escape }}</title>
|
|
||||||
<link href="{{ url_for('threads.thread', slug=thread.slug, _external=true)}}" />
|
|
||||||
<link rel="replies" type="application/atom+xml" href="{{ url_for('threads.thread_atom', slug=thread.slug, _external=true)}}" />
|
|
||||||
<id>{{ url_for('threads.thread', slug=thread.slug, _external=true)}}</id>
|
|
||||||
<updated>{{ thread.created_at | iso8601 }}</updated>
|
|
||||||
{{rss_html_content(thread.original_post_content)}}
|
|
||||||
<author>
|
|
||||||
<name>{{thread.started_by_display_name}} @{{ thread.started_by }}</name>
|
|
||||||
</author>
|
|
||||||
</entry>
|
|
||||||
{% endfor %}
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,97 +1,86 @@
|
|||||||
{% from 'common/macros.html' import pager, timestamp, motd, rss_button %}
|
{% from 'common/macros.html' import timestamp, subheader, pager, motd %}
|
||||||
{% from 'common/icons.html' import icn_lock, icn_sticky %}
|
{% from 'common/icons.html' import icn_locked, icn_stickied %}
|
||||||
{% extends "base.html" %}
|
{%- extends 'base.html' -%}
|
||||||
{% block title %}browsing topic {{ topic['name'] }}{% endblock %}
|
{%- block title -%}browsing topic {{topic.name}}{%- endblock -%}
|
||||||
{% block content %}
|
{%- block content -%}
|
||||||
<nav class="darkbg">
|
{%- set td -%}
|
||||||
<h1 class="thread-title">All threads in "{{topic['name']}}"</h1>
|
<ul class="horizontal">
|
||||||
<span>{{topic['description']}}</span>
|
<li>{{topic.description}}</li>
|
||||||
<div class="thread-actions">
|
{%- if topic.locked() -%}
|
||||||
{% if active_user %}
|
<li class="visible">Locked</li>
|
||||||
{% if not (topic['is_locked']) | int or active_user.is_mod() %}
|
{%- endif -%}
|
||||||
<a class="linkbutton" href="{{ url_for("threads.create", topic_id=topic['id']) }}">New thread</a>
|
</ul>
|
||||||
{% endif %}
|
{%- endset -%}
|
||||||
{% if active_user.is_mod() %}
|
{%- call() subheader(('Threads in "%s"' % topic.name), td) -%}
|
||||||
<a class="linkbutton" href="{{url_for("topics.edit", slug=topic['slug'])}}">Edit topic</a>
|
<fieldset class="plank even no-shadow minimal subheader-actions">
|
||||||
<form class="modform" method="post" action="{{url_for("topics.edit", slug=topic['slug']) }}">
|
<legend>Actions</legend>
|
||||||
<input type="hidden" name="is_locked" value="{{ (not topic.is_locked) | int }}">
|
{%- if is_logged_in() and get_active_user().can_post_to_thread_or_topic(topic) -%}
|
||||||
<input class="warn" type="submit" id="lock" value="{{"Unlock topic" if topic['is_locked'] else "Lock topic"}}">
|
<a href="{{url_for('threads.new', topic_id=topic.id)}}" class="linkbutton">New thread</a>
|
||||||
</form>
|
{%- endif -%}
|
||||||
<button type="button" class="critical" id="topic-delete-dialog-open">Delete</button>
|
<a href="{{url_for('topics.feed', topic_id=topic.id)}}" class="linkbutton rss">Subscribe via RSS</a>
|
||||||
{{ rss_button(url_for('topics.topic_atom', slug=topic.slug)) }}
|
<form method="GET">
|
||||||
{% endif %}
|
<select name="sort_by">
|
||||||
{% endif %}
|
<option value="activity"{% if sort_by == 'activity' %}selected{% endif %}>Sorted by activity</option>
|
||||||
</div>
|
<option value="thread" {% if sort_by == 'thread' %}selected{% endif %}>Sorted by newest</option>
|
||||||
</nav>
|
</select>
|
||||||
|
<input type="submit" value="Sort">
|
||||||
{% if topic['is_locked'] %}
|
</form>
|
||||||
{{ infobox("This topic is locked.;Only moderators can create new threads.", InfoboxKind.INFO) }}
|
</fieldset>
|
||||||
{% endif %}
|
{%- if is_mod() -%}
|
||||||
|
<fieldset class="plank even no-shadow minimal subheader-actions">
|
||||||
{%- with motds = get_motds() -%}
|
<legend>Moderation actions</legend>
|
||||||
{%- if motds -%}
|
<a href="{{url_for('mod.edit_topic', topic_id=topic.id)}}" class="linkbutton">Edit</a>
|
||||||
{%- for motd_obj in motds -%}
|
<form action="{{url_for('mod.lock_topic', topic_id=topic.id)}}" method="POST">
|
||||||
{{- motd(motd_obj) -}}
|
<input type="hidden" value="{{(not topic.locked()) | int}}" name="lock">
|
||||||
{%- endfor -%}
|
<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 subheader-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_thread_or_topic(topic) %} Be the first to start a discussion!{%- endif -%}</p></div>
|
||||||
|
{%- endif -%}
|
||||||
|
{{ motd(get_motds()) }}
|
||||||
|
{%- for thread in threads -%}
|
||||||
|
<div class="topic-info plank">
|
||||||
|
<div class="title-container">
|
||||||
|
<span class="info thread-title-counter">
|
||||||
|
<ul class="horizontal">
|
||||||
|
<li><a href="{{url_for('threads.thread_by_id', thread_id=thread.id)}}">{{thread.title}}</a></li>
|
||||||
|
{%- if subscriptions[thread.id] -%}
|
||||||
|
<li>({{subscriptions[thread.id]}} unread)</li>
|
||||||
|
{%- endif -%}
|
||||||
|
</ul>
|
||||||
|
</span>
|
||||||
|
<ul class="horizontal">
|
||||||
|
{%- if thread.is_locked -%}
|
||||||
|
<li>{{icn_locked(24)}}</li>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- if thread.is_stickied -%}
|
||||||
|
<li>{{icn_stickied(24)}}</li>
|
||||||
|
{%- endif -%}
|
||||||
|
</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 -%}
|
{%- endif -%}
|
||||||
{%- endwith -%}
|
|
||||||
|
|
||||||
{% 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'] %}
|
|
||||||
{{ icn_sticky(48) }}
|
|
||||||
<i>Stickied</i>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="thread-info-container">
|
<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 class="thread-info-header">
|
<span>{{thread.posts_count}} {{'repl' | pluralize(thread.posts_count, 'y', 'ies')}}</span>
|
||||||
<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>
|
||||||
<span class="thread-title"><a href="{{ url_for("threads.thread", slug=thread['slug']) }}">{{thread['title']}}</a>
|
</div>
|
||||||
{% if thread['id'] in subscriptions %}
|
{%- endfor -%}
|
||||||
({{ subscriptions[thread['id']] }} unread)
|
{%- if threads | length > 0 -%}
|
||||||
{% endif %}
|
<div class="plank secondary-bg">
|
||||||
</span>
|
<fieldset class="plank even no-shadow minimal subheader-actions">
|
||||||
•
|
<legend>Page</legend>
|
||||||
<span>
|
{{- pager(page, page_count, args=request.args) -}}
|
||||||
Started by <a href="{{ url_for("users.page", username=thread['started_by']) }}">{{ thread['started_by_display_name'] or thread['started_by'] }}</a> on {{ timestamp(thread['created_at']) }}
|
</fieldset>
|
||||||
</span>
|
</div>
|
||||||
</span>
|
{%- endif -%}
|
||||||
</span>
|
{%- endblock -%}
|
||||||
<span>
|
|
||||||
Latest post by <a href="{{ url_for("users.page", username=thread['latest_post_username']) }}">{{ thread['latest_post_display_name'] or 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>
|
|
||||||
</div>
|
|
||||||
<div class="thread-locked-container contain-svg">
|
|
||||||
{% if thread['is_locked'] %}
|
|
||||||
{{ icn_lock(48) }}
|
|
||||||
<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 %}
|
|
||||||
|
|||||||
@@ -1,51 +1,37 @@
|
|||||||
{% from 'common/icons.html' import icn_lock %}
|
{% from 'common/macros.html' import timestamp, subheader, motd %}
|
||||||
{% from 'common/macros.html' import timestamp, motd %}
|
{% from 'common/icons.html' import icn_locked %}
|
||||||
{% extends "base.html" %}
|
{%- extends 'base.html' -%}
|
||||||
{% block content %}
|
{%- block content -%}
|
||||||
<nav class="darkbg">
|
{%- call() subheader('All topics') -%}
|
||||||
<h1 class="thread-title">All topics</h1>
|
{%- if is_mod() -%}
|
||||||
{% if active_user and active_user.is_mod() %}
|
<fieldset class="plank even no-shadow minimal subheader-actions">
|
||||||
<a class="linkbutton" href={{ url_for("topics.create") }}>Create new topic</a>
|
<legend>Moderation actions</legend>
|
||||||
<a class="linkbutton" href={{ url_for("mod.sort_topics") }}>Sort topics</a>
|
<a href="{{url_for('mod.new_topic')}}" class="linkbutton">New topic</a>
|
||||||
{% endif %}
|
<a href="{{url_for('mod.index', _anchor='sort-topics')}}" class="linkbutton">Sort topics</a>
|
||||||
</nav>
|
</fieldset>
|
||||||
{%- with motds = get_motds() -%}
|
{%- endif -%}
|
||||||
{%- if motds -%}
|
{%- endcall -%}
|
||||||
{%- for motd_obj in motds -%}
|
{{ motd(get_motds()) }}
|
||||||
{{- motd(motd_obj) -}}
|
{%- for topic in topics -%}
|
||||||
{%- endfor -%}
|
<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>
|
||||||
|
{%- if topic.is_locked -%}
|
||||||
|
{{icn_locked(24)}}
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
{%- endwith -%}
|
|
||||||
{% 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_display_name'] or 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['display_name'] or thread['username'] }}</a> at <a href="{{ get_post_url(thread.post_id, _anchor=true) }}">{{ timestamp(thread['post_created_at']) }}</a>
|
|
||||||
</span>
|
|
||||||
{% endwith %}
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
<i>No threads yet.</i>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="topic-locked-container contain-svg">
|
<div>{{topic.description}}</div>
|
||||||
{% if topic['is_locked'] %}
|
<ul class="horizontal">
|
||||||
{{ icn_lock(48) }}
|
<li>{{topic.threads_count}} {{"thread" | pluralize(topic.threads_count)}}</li>
|
||||||
<i>Locked</i>
|
<li>{{topic.posts_count}} {{"post" | pluralize(topic.posts_count)}}</li>
|
||||||
{% endif %}
|
</ul>
|
||||||
|
<div>
|
||||||
|
{%- if topic.latest_post_timestamp -%}
|
||||||
|
Latest post at: {{timestamp(topic.latest_post_timestamp)}}
|
||||||
|
{%- else -%}
|
||||||
|
No posts yet
|
||||||
|
{%- endif -%}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{%- endfor -%}
|
||||||
{% endif %}
|
{%- endblock -%}
|
||||||
{% endblock %}
|
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% from 'common/macros.html' import sortable_list, sortable_list_item %}
|
|
||||||
{% block title %}managing bookmark collections{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="darkbg">
|
|
||||||
<h1>Manage bookmark collections</h1>
|
|
||||||
<p>Drag collections to reoder them. You cannot move or remove the default collection, but you can rename it.</p>
|
|
||||||
<bitty-7-0 data-connect="{{ '/static/js/bitties/pyrom-bitty.js' | cachebust }} CollectionsEditor">
|
|
||||||
<button type="button" data-send="addCollection">Add new collection</button>
|
|
||||||
{% set sorted_collections = collections | sort(attribute='sort_order') %}
|
|
||||||
{% macro collection_inner(collection) %}
|
|
||||||
<input type="text" class="collection-name" value="{{collection.name}}" placeholder="Collection name" required autocomplete="off" maxlength="60">
|
|
||||||
<div>{{ collection.get_threads_count() }} {{ "thread" | pluralize(num=collection.get_threads_count()) }}, {{ collection.get_posts_count() }} {{ "post" | pluralize(num=collection.get_posts_count()) }}</div>
|
|
||||||
{% if collection.is_default %}
|
|
||||||
<i>Default collection</i>
|
|
||||||
{% else %}
|
|
||||||
<button type="button" class="delete-button critical" data-send="deleteCollection">Delete</button>
|
|
||||||
{% endif %}
|
|
||||||
{% endmacro %}
|
|
||||||
{% call() sortable_list(attr={'data-receive': 'addCollection' }) %}
|
|
||||||
{% call() sortable_list_item(key='collections', immovable=true, attr={'data-collection-id': sorted_collections[0].id, 'data-receive': 'deleteCollection getCollectionData testValidity'}) %}
|
|
||||||
{{ collection_inner(sorted_collections[0]) }}
|
|
||||||
{% endcall %}
|
|
||||||
{% for collection in sorted_collections[1:] %}
|
|
||||||
{% call() sortable_list_item(key='collections', attr={'data-collection-id': collection.id, 'data-receive': 'deleteCollection getCollectionData testValidity'}) %}
|
|
||||||
{{ collection_inner(collection) }}
|
|
||||||
{% endcall %}
|
|
||||||
{% endfor %}
|
|
||||||
{% endcall %}
|
|
||||||
<button data-use="saveCollections" type="button" id="save-button" data-submit-href="{{ url_for('api.manage_bookmark_collections', user_id=active_user.id) }}">Save</button>
|
|
||||||
</div>
|
|
||||||
<template id="new-collection-template">
|
|
||||||
{% call() sortable_list_item(key='collections', attr={'data-receive': 'deleteCollection getCollectionData testValidity'}) %}
|
|
||||||
<input type="text" class="collection-name" value="" placeholder="Collection name" required autocomplete="off" maxlength="60">
|
|
||||||
<div>0 threads, 0 posts</div>
|
|
||||||
<button type="button" class="delete-button critical" data-send="deleteCollection">Delete</button>
|
|
||||||
{% endcall %}
|
|
||||||
</template>
|
|
||||||
</bitty-7-0>
|
|
||||||
{#<script src="{{ "/static/js/manage-bookmark-collections.js" | cachebust }}"></script>#}
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,52 +1,56 @@
|
|||||||
{% from "common/macros.html" import accordion, full_post, bookmark_button %}
|
{%- from 'common/macros.html' import full_post, bookmark_menu with context -%}
|
||||||
{% from "common/icons.html" import icn_bookmark %}
|
{%- from 'common/macros.html' import subheader, bookmark_button -%}
|
||||||
{% extends "base.html" %}
|
{%- extends 'base.html' -%}
|
||||||
{% block title %}bookmarks{% endblock %}
|
{%- block title -%}bookmarks"{%- endblock -%}
|
||||||
{% block content %}
|
{%- block content -%}
|
||||||
<div class="darkbg inbox-container">
|
<bitty-8 data-connect="/static/js/bits/bookmark-menu.js"></bitty-8>
|
||||||
<a class="linkbutton" href="{{ url_for('users.bookmark_collections', username=get_active_user().username) }}">Manage collections</a>
|
<bitty-8 data-connect="/static/js/bits/bookmarks.js"></bitty-8>
|
||||||
{% for collection in collections | sort(attribute='sort_order') %}
|
{%- call() subheader('Your bookmarks') -%}
|
||||||
{% call(section) accordion(disabled=collection.is_empty()) %}
|
<fieldset class="plank even no-shadow minimal subheader-actions js-only" data-r="enhance">
|
||||||
{% if section == 'header' %}
|
<legend>Actions</legend>
|
||||||
<h1 class="thread-title">{{ collection.name }}</h1>{{" (no bookmarks)" if collection.is_empty() else ""}}
|
<a href="{{url_for('users.bookmark_collections', username=get_active_user().username)}}" class="linkbutton">Manage collections</a>
|
||||||
{% else %}
|
</fieldset>
|
||||||
{% call(inner_section) accordion(disabled=not collection.has_threads()) %}
|
{%- endcall -%}
|
||||||
{% if inner_section == 'header' %}
|
<div class="plank">
|
||||||
Threads{{" (no bookmarks)" if not collection.has_threads() else ""}}
|
{%- for collection in collections -%}
|
||||||
{% else %}
|
{%- set thread_count = collection.get_threads_count() -%}
|
||||||
<table class="colorful-table">
|
{%- set post_count = collection.get_posts_count() -%}
|
||||||
|
<details class="separated" data-id="{{collection.id}}" data-r="restoreCollectionDetails setCollectionDetails" data-s="setCollectionDetails">
|
||||||
|
<summary class="plank secondary-bg no-shadow even">{{collection.name}} ({{thread_count}} {{'thread' | pluralize(num=thread_count)}}, {{post_count}} {{'post' | pluralize(num=post_count)}})</summary>
|
||||||
|
{%- if thread_count > 0 -%}
|
||||||
|
<details class="inner" data-id="{{collection.id}}" data-r="restoreThreadDetails setThreadDetails" data-s="setThreadDetails">
|
||||||
|
<summary class="plank no-shadow even">Threads</summary>
|
||||||
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<th>Title</th>
|
|
||||||
<th>Memo</th>
|
|
||||||
<th class="small">Manage</th>
|
|
||||||
</thead>
|
|
||||||
{% for thread in collection.get_threads() %}
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<th class="plank even no-shadow contrast-bg" style="--w:65%">Title</th>
|
||||||
<a href="{{ url_for('threads.thread', slug=thread.get_thread().slug) }}">{{ thread.get_thread().title }}</a>
|
<th class="plank even no-shadow contrast-bg" style="--w:25%">Memo</th>
|
||||||
</td>
|
<th class="plank even no-shadow contrast-bg" style="--w:10%">Manage</th>
|
||||||
<td>
|
|
||||||
<i>{{ thread.note }}</i>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{{ bookmark_button(type='thread', id=thread.thread_id, message='Manage…', require_reload=true) }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{%- for bt in collection.get_threads() -%}
|
||||||
|
{%- set thread = bt.get_thread() -%}
|
||||||
|
<tr>
|
||||||
|
<td class="center plank even no-shadow minimal secondary-bg"><a href="{{url_for('threads.thread_by_id', thread_id=thread.id)}}">{{thread.title}}</a></td>
|
||||||
|
<td class="center plank even no-shadow minimal secondary-bg">{{bt.note}}</td>
|
||||||
|
<td class="center plank even no-shadow minimal secondary-bg">{{bookmark_button('thread', id=thread.id, text='Manage')}}</td>
|
||||||
|
</tr>
|
||||||
|
{%- endfor -%}
|
||||||
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% endif %}
|
</details>
|
||||||
{% endcall %}
|
{%- endif -%}
|
||||||
{% call(inner_section) accordion(disabled=not collection.has_posts()) %}
|
{%- if post_count > 0 -%}
|
||||||
{% if inner_section == 'header' %}
|
<details class="inner" data-id="{{collection.id}}" data-r="restorePostDetails setPostDetails" data-s="setPostDetails">
|
||||||
Posts{{" (no bookmarks)" if not collection.has_posts() else ""}}
|
<summary class="plank no-shadow even">Posts</summary>
|
||||||
{% else %}
|
{%- for bp in collection.get_posts() -%}
|
||||||
{% for post in collection.get_posts() %}
|
<div class="post plank no-shadow even">{{ full_post(bp.get_post().get_full_post_view(), render_sig=false, show_thread=true, show_reactions=false, tb_edit=false, tb_quote=false, tb_delete=false, bookmark_btn='Manage', tb_pretext=('memo: ' + bp.note) if bp.note else '') }}</div>
|
||||||
{{ full_post(post.get_post().get_full_post_view(), no_reply=false, render_sig=false, show_thread_title=true, show_bookmark=true, memo=post.note, bookmark_message="Manage…", reload_after_bookmark=true) }}
|
{%- endfor -%}
|
||||||
{% endfor %}
|
</details>
|
||||||
{% endif %}
|
{%- endif -%}
|
||||||
{% endcall %}
|
</details>
|
||||||
{% endif %}
|
{%- endfor -%}
|
||||||
{% endcall %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{{ bookmark_menu() }}
|
||||||
|
{%- endblock -%}
|
||||||
|
|||||||
20
app/templates/users/delete_confirm.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{%- from 'common/macros.html' import subheader -%}
|
||||||
|
{%- extends 'base.html' -%}
|
||||||
|
{%- block title -%}account deletion confirmation{%- endblock -%}
|
||||||
|
{%- block content -%}
|
||||||
|
{%- set sub -%}
|
||||||
|
<a href="{{url_for('users.settings', username=get_active_user().username)}}">← Back to settings</a>
|
||||||
|
{%- endset -%}
|
||||||
|
{{- subheader('Confirm account deletion', sub) -}}
|
||||||
|
<div class="plank">
|
||||||
|
<form class="full-width" method="POST">
|
||||||
|
<p>Are you sure you want to delete your account on {{ config.SITE_NAME }}? <strong>This action is irreversible.</strong> Your posts and threads will remain accessible to preserve history but will be de-personalized, showing up as authored by a system user. Posts that @mention you will also mention the system user instead.</p>
|
||||||
|
<p>If you wish for any and all content relating to you to be removed, you will have to <a href="{{url_for('guides.contact')}}" target="_blank">contact {{ config.SITE_NAME }}'s administrators separately.</a></p>
|
||||||
|
<p>If you are sure, please confirm your current password below.</p>
|
||||||
|
<label for="password">Confirm password</label>
|
||||||
|
{{csrf_input() | safe}}
|
||||||
|
<input type="password" id="password" name="password" required autocomplete="current-password">
|
||||||
|
<input type="submit" class="critical" value="Permanently delete account">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{%- endblock -%}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
{% block title %}delete confirmation{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="darkbg">
|
|
||||||
<h1>Confirm account deletion</h1>
|
|
||||||
<p>Are you sure you want to delete your account on {{ config.SITE_NAME }}? <strong>This action is irreversible.</strong> Your posts and threads will remain accessible to preserve history but will be de-personalized, showing up as authored by a system user. Posts that @mention you will also mention the system user instead.</p>
|
|
||||||
<p>If you wish for any and all content relating to you to be removed, you will have to <a href="{{url_for("guides.contact")}}" target="_blank">contact {{ config.SITE_NAME }}'s administrators separately.</a></p>
|
|
||||||
<p>If you are sure, please confirm your current password below.</p>
|
|
||||||
<form method="post">
|
|
||||||
<label for="password">Confirm password</label>
|
|
||||||
<input type="password" id="password" name="password" required autocomplete="current-password">
|
|
||||||
<input class="critical" type="submit" value="Delete account">
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,65 +1,38 @@
|
|||||||
{% from "common/macros.html" import timestamp, full_post, accordion %}
|
{% from 'common/macros.html' import subheader %}
|
||||||
{% extends "base.html" %}
|
{% from 'common/macros.html' import full_post with context %}
|
||||||
{% block title %}inbox{% endblock %}
|
{%- extends 'base.html' -%}
|
||||||
{% block content %}
|
{%- block title -%}inbox{%- endblock -%}
|
||||||
<div class="darkbg inbox-container">
|
{%- block content -%}
|
||||||
{% set has_subscriptions = all_subscriptions is not none %}
|
{%- set topline -%}
|
||||||
{% call(section) accordion(disabled=not has_subscriptions) %}
|
{%- if unread_count -%}
|
||||||
{% if section == "header" %}
|
You have {{unread_count}} unread posts across {{subscriptions | length}} threads.
|
||||||
{% if not has_subscriptions %}
|
{%- elif subscriptions | length > 0 -%}
|
||||||
(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.
|
You have no unread posts.
|
||||||
{% else %}
|
{%- else -%}
|
||||||
You have {{ total_unreads_count }} total unread {{("post" | pluralize(num=total_unreads_count))}}:
|
You do not have any subscriptions.
|
||||||
{% for thread in new_posts %}
|
{%- endif -%}
|
||||||
{% call(section) accordion() %}
|
{%- endset -%}
|
||||||
{% if section == "header" %}
|
{{ subheader('Your inbox', topline) }}
|
||||||
{% set latest_post_id = thread.posts[-1].id %}
|
{%- if subscriptions | length > 0 -%}
|
||||||
{% set unread_posts_text = " (" + (thread.unread_count | string) + (" unread post" | pluralize(num=thread.unread_count)) %}
|
<div class="plank">
|
||||||
<a class="accordion-title" href="{{ url_for("threads.thread", slug=thread.thread_slug, after=latest_post_id, _anchor="post-" + (latest_post_id | string)) }}" title="Jump to latest post">{{thread.thread_title + unread_posts_text}}, latest at {{ timestamp(thread.newest_post_time) }})</a>
|
{%- for sub in subscriptions -%}
|
||||||
<form class="modform" method="post" action="{{ url_for("threads.subscribe", slug = thread.thread_slug) }}">
|
<details class="separated">
|
||||||
<input type="hidden" name="subscribe" value="read">
|
{%- set thread = sub.get_thread() -%}
|
||||||
<input type="submit" value="Mark thread as Read">
|
<summary class="plank secondary-bg no-shadow even">
|
||||||
|
{{thread.title}} ({{sub.get_unread_count()}} unread)
|
||||||
|
<form method="POST" action="{{url_for('threads.unsubscribe', thread_id=thread.id)}}">
|
||||||
|
<input type="hidden" name="return_to" value="{{url_for('users.inbox', username=get_active_user().username)}}">
|
||||||
|
<a href="{{url_for('threads.thread_by_id', thread_id=thread.id)}}" class="linkbutton">Go to thread</a>
|
||||||
|
<input type="submit" value="Unsubscribe" class="warn">
|
||||||
</form>
|
</form>
|
||||||
<form class="modform" method="post" action="{{ url_for("threads.subscribe", slug = thread.thread_slug) }}">
|
</summary>
|
||||||
<input type="hidden" name="subscribe" value="unsubscribe">
|
{%- set posts = sub.get_full_posts_view() -%}
|
||||||
<input class="warn" type="submit" value="Unsubscribe">
|
|
||||||
</form>
|
{%- for post in posts -%}
|
||||||
{% elif section == "content" %}
|
<div class="plank post no-shadow even">{{full_post(post, show_toolbar=false, show_reactions=false)}}</div>
|
||||||
{% for post in thread.posts %}
|
{%- endfor -%}
|
||||||
{{ full_post(post, no_reply = true) }}
|
</details>
|
||||||
{% endfor %}
|
{%- endfor -%}
|
||||||
{% endif %}
|
|
||||||
{% endcall %}
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{%- endif -%}
|
||||||
|
{%- endblock -%}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
{% from 'common/macros.html' import accordion %}
|
|
||||||
{% extends 'base.html' %}
|
|
||||||
{% block title %}invites{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="darkbg inbox-container">
|
|
||||||
<p>To manage growth, {{ config.SITE_NAME }} disallows direct sign ups. Instead, users already with an account may invite people they know. You can create invite links here. Once an invite link is used to sign up, it can no longer be used.</p>
|
|
||||||
{% call(section) accordion(disabled=invites | length == 0) %}
|
|
||||||
{% if section == 'header' %}
|
|
||||||
Your invites
|
|
||||||
{% else %}
|
|
||||||
{% if invites %}
|
|
||||||
<table class="colorful-table">
|
|
||||||
<thead>
|
|
||||||
<th class='small'>Link</th>
|
|
||||||
<th class='small'>Revoke</th>
|
|
||||||
</thead>
|
|
||||||
{% for invite in invites %}
|
|
||||||
<tr>
|
|
||||||
<td><a href="{{url_for('users.sign_up', key=invite.key)}}">Link</a></td>
|
|
||||||
<td>
|
|
||||||
<form method="post" action="{{ url_for('users.revoke_invite_link', username=active_user.username) }}">
|
|
||||||
<input type=hidden value="{{ invite.key }}" name="key">
|
|
||||||
<input type=submit class=warn value="Revoke">
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</table>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
{% endcall %}
|
|
||||||
<form method="post" action="{{ url_for('users.create_invite_link', username=active_user.username) }}">
|
|
||||||
<input type=submit value="Create new invite">
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,14 +1,21 @@
|
|||||||
{% extends 'base.html' %}
|
{% from 'common/macros.html' import infobox with context %}
|
||||||
{% block title %}Log in{% endblock %}
|
{% from 'common/macros.html' import subheader %}
|
||||||
{% block content %}
|
{%- extends 'base.html' -%}
|
||||||
<div class="darkbg">
|
{%- block title -%}log in{%- endblock -%}
|
||||||
<h1>Log in</h1>
|
{%- block content -%}
|
||||||
<form method="post">
|
{%- set welcome -%}
|
||||||
<label for="username">Username</label><br>
|
Welcome back! No account yet? <a href="{{url_for('users.sign_up')}}">Sign up</a>
|
||||||
<input type="text" id="username" name="username" required autocomplete="username"><br>
|
{%- endset -%}
|
||||||
<label for="password">Password</label><br>
|
{{ subheader('Log in', welcome)}}
|
||||||
<input type="password" id="password" name="password" required autocomplete="current-password"><br>
|
{%- if request.args.get('error') -%}
|
||||||
|
{{infobox(request.args.error, InfoboxKind.ERROR)}}
|
||||||
|
{%- 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>
|
||||||
|
<div class="inline-group"><input type="checkbox" name="remember" id="remember"> <label for="remember">Remember me</label></div>
|
||||||
<input type="submit" value="Log in">
|
<input type="submit" value="Log in">
|
||||||
</form>
|
</form>
|
||||||
</div>
|
{%- endblock -%}
|
||||||
{% endblock %}
|
|
||||||
|
|||||||
44
app/templates/users/manage_collections.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{%- from 'common/macros.html' import subheader, sortable_list, sortable_list_item -%}
|
||||||
|
{%- macro collection_item(name='', can_delete=true, id=-1, thread_count=0, post_count=0) -%}
|
||||||
|
<input name="name[]" type="text" autocomplete="off" value="{{name}}" required maxlength=60 placeholder="Collection name">
|
||||||
|
<input type="hidden" name="id[]" value="{{ 'new' if id == -1 else id}}" autocomplete="off">
|
||||||
|
<span>{{thread_count}} {{'thread' | pluralize(num=thread_count)}}, {{post_count}} {{'post' | pluralize(num=post_count)}}</span>
|
||||||
|
{%- if not can_delete -%}
|
||||||
|
<i>Default collection</i>
|
||||||
|
{%- else -%}
|
||||||
|
<button type="button" class="critical" data-s="deleteCollection">Delete</button>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endmacro -%}
|
||||||
|
{%- extends 'base.html' -%}
|
||||||
|
{%- block title -%}managing bookmark collections{%- endblock -%}
|
||||||
|
{%- block content -%}
|
||||||
|
<bitty-8 data-connect="/static/js/bits/collections-editor.js"></bitty-8>
|
||||||
|
{%- set sh -%}
|
||||||
|
<span class="js-only" data-r="enhance">
|
||||||
|
Drag collections to reoder them. You cannot move or remove the default collection, but you can rename it.
|
||||||
|
</span>
|
||||||
|
<div data-r="enhanceHide">This page requires JS enabled to work correctly.</div>
|
||||||
|
{%- endset -%}
|
||||||
|
{%- call() subheader('Manage bookmark collections', sh) -%}
|
||||||
|
<fieldset class="plank even no-shadow minimal subheader-actions js-only" data-r="enhance">
|
||||||
|
<legend>Actions</legend>
|
||||||
|
<button data-s="addCollection">Add new collection</button>
|
||||||
|
<input type="submit" class="alt" value="Save collections" form="collections-form">
|
||||||
|
</fieldset>
|
||||||
|
{%- endcall -%}
|
||||||
|
<form class="plank" method="POST" id="collections-form">
|
||||||
|
<input type="hidden" autocomplete="off" name="deleted_ids" value="" data-r="countDeletedCollection">
|
||||||
|
{%- call() sortable_list(attr={'data-r': 'addCollection'}) -%}
|
||||||
|
{%- for collection in collections -%}
|
||||||
|
{%- call() sortable_list_item(key='bc', immovable=collection.is_default == 1, attr={'data-r': 'deleteCollection', 'data-id': collection.id}) -%}
|
||||||
|
{{ collection_item(name=collection.name, can_delete=collection.is_default != 1, thread_count=collection.get_threads_count(), post_count=collection.get_posts_count(), id=collection.id) }}
|
||||||
|
{%- endcall -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
{%- endcall -%}
|
||||||
|
</form>
|
||||||
|
<script type="text/html" data-template="collectionItem">
|
||||||
|
{%- call() sortable_list_item(key='bc', attr={'data-r': 'deleteCollection', 'data-id': 'new'}) -%}
|
||||||
|
{{- collection_item() -}}
|
||||||
|
{%- endcall -%}
|
||||||
|
</script>
|
||||||
|
{%- endblock -%}
|
||||||
30
app/templates/users/posts.html
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{%- from 'common/macros.html' import full_post with context -%}
|
||||||
|
{%- from 'common/macros.html' import subheader, pager -%}
|
||||||
|
{%- extends 'base.html' -%}
|
||||||
|
{%- block title -%}{{ target_user.get_readable_name() }}'s posts{%- endblock -%}
|
||||||
|
{%- block content -%}
|
||||||
|
{%- set td -%}
|
||||||
|
<a href="{{url_for('users.user_page', username=target_user.username)}}">← Back to profile</a>
|
||||||
|
{%- endset -%}
|
||||||
|
{%- call() subheader("%s's posts" % target_user.get_readable_name(), td) -%}
|
||||||
|
{%- if posts -%}
|
||||||
|
<fieldset class="plank even no-shadow minimal subheader-actions">
|
||||||
|
<legend>Page</legend>
|
||||||
|
{{- pager(page, page_count) -}}
|
||||||
|
</fieldset>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endcall -%}
|
||||||
|
{%- if posts -%}
|
||||||
|
{%- for post in posts -%}
|
||||||
|
<div class="post plank">{{full_post(post, show_toolbar=false, show_thread=true, allow_reacting=false)}}</div>
|
||||||
|
{%- endfor -%}
|
||||||
|
<div class="plank">
|
||||||
|
<fieldset class="plank even no-shadow minimal subheader-actions">
|
||||||
|
<legend>Page</legend>
|
||||||
|
{{- pager(page, page_count) -}}
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
{%- else -%}
|
||||||
|
<div class="plank">{{target_user.get_readable_name()}} has no posts.</div>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endblock -%}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
{% block title %}Reset password{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="darkbg">
|
|
||||||
<h1>Reset password for {{username}}</h1>
|
|
||||||
<p>Send this link to {{username}} to allow them to reset their password.</p>
|
|
||||||
<form method="post">
|
|
||||||
<label for="password">New password</label><br>
|
|
||||||
<input type="password" id="password" name="password" autocomplete="new-password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required><br>
|
|
||||||
<label for="password2">Confirm password</label><br>
|
|
||||||
<input type="password" id="password2" name="password2" autocomplete="new-password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required><br>
|
|
||||||
<input type="submit" value="Reset password">
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,79 +1,126 @@
|
|||||||
{% from 'common/macros.html' import babycode_editor_component, badge_editor_single, sortable_list %}
|
{%- from 'common/macros.html' import babycode_editor_component -%}
|
||||||
{% extends 'base.html' %}
|
{%- from 'common/macros.html' import subheader, avatar, timestamp -%}
|
||||||
{% block title %}settings{% endblock %}
|
{%- extends 'base.html' -%}
|
||||||
{% block content %}
|
{%- block title -%}settings{%- endblock -%}
|
||||||
{% set disable_avatar = not is_logged_in() %}
|
{%- block content -%}
|
||||||
<div class='darkbg'>
|
{%- set sub -%}
|
||||||
<h1>User settings</h1>
|
{%- if user.is_guest() -%}You are a guest. Your customization options are limited until a moderator confirms your account.{%- endif -%}
|
||||||
<div class="settings-grid">
|
{%- endset -%}
|
||||||
<fieldset class="hfc">
|
{{- subheader('User settings', sub) -}}
|
||||||
<legend>Set avatar</legend>
|
{%- if not user.is_guest() -%}
|
||||||
<form class='avatar-form' method='post' action='{{ url_for('users.set_avatar', username=active_user.username) }}' enctype='multipart/form-data'>
|
<fieldset class="plank">
|
||||||
<img src='{{ active_user.get_avatar_url() }}'>
|
<legend>Avatar</legend>
|
||||||
<input id='file' type='file' name='avatar' accept='image/*' required>
|
<form method="POST" class="avatar-form" action="{{url_for('users.set_avatar', username=user.username)}}" enctype="multipart/form-data">
|
||||||
<div>
|
{{- avatar(user.get_avatar_url()) -}}
|
||||||
<input type='submit' value='Save avatar' {{ 'disabled' if disable_avatar else '' }}>
|
<span class="avatar-form-controls">
|
||||||
<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 '' }}>
|
<label for="avatar" class="linkbutton alt">Upload…</label>
|
||||||
</div>
|
<span class="avatar-form-size-label">1MB max. Will be cropped to square.</span>
|
||||||
<span>1MB maximum size. Avatar will be cropped to square.</span>
|
<input type="file" style="display: none;" id="avatar" name="avatar" accept="image/*" required>
|
||||||
|
<input type="submit" value="Save">
|
||||||
|
<input type="submit" class="warn" value="Clear" formaction="{{url_for('users.clear_avatar', username=user.username)}}">
|
||||||
|
</span>
|
||||||
</form>
|
</form>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset class="hfc">
|
{%- endif -%}
|
||||||
|
<fieldset class="plank">
|
||||||
<legend>Change password</legend>
|
<legend>Change password</legend>
|
||||||
<form method='post' action='{{ url_for('users.change_password', username=active_user.username) }}'>
|
<p>After you change your password, you will be logged out of all sessions and will need to log in again.</p>
|
||||||
<label for="new_password">New password</label><br>
|
<form class="full-width" method="POST" action="{{ url_for('users.change_password', username=user.username) }}">
|
||||||
<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="current-password">Current password</label>
|
||||||
<label for="new_password2">Confirm new password</label><br>
|
<input type="password" name="current_password" id="current-password" autocomplete="current-password" required>
|
||||||
<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>
|
<label for="new-password">New password</label>
|
||||||
<input class="warn" type="submit" value="Change password">
|
<input type="password" name="new_password" id="new-password" autocomplete="new-password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,255}" title="10+ chars with at least: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required>
|
||||||
|
<label for="new-password2">Confirm new password</label>
|
||||||
|
<input type="password" name="new_password2" id="new-password2" autocomplete="new-password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,255}" title="10+ chars with at least: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required>
|
||||||
|
<input type="submit" value="Change password" class="warn">
|
||||||
</form>
|
</form>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset class="hfc">
|
{%- if not user.is_guest() -%}
|
||||||
|
<fieldset class="plank">
|
||||||
<legend>Personalization</legend>
|
<legend>Personalization</legend>
|
||||||
<form method='post'>
|
<form class="full-width" method="POST" action="{{ url_for('users.set_personalization', username=user.username)}}">
|
||||||
<label for='theme'>Theme (beta)</label>
|
<label for="sort-by">Sort threads by:</label>
|
||||||
<select autocomplete='off' id='theme' name='theme'>
|
<select name="sort_by" id="sort-by" autocomplete=off>
|
||||||
{% for theme in config.allowed_themes %}
|
<option value="activity" {{ 'selected' if sort_by == 'activity' else '' }}>Activity</option>
|
||||||
<option value="{{ theme }}" {{ 'selected' if get_prefers_theme() == theme }}>{{ theme | theme_name }}</option>
|
<option value="thread" {{ 'selected' if sort_by == 'thread' else '' }}>Newest</option>
|
||||||
{% endfor %}
|
|
||||||
</select>
|
</select>
|
||||||
<label for='topic_sort_by'>Sort threads by:</label>
|
<label for="display-name">Display name</label>
|
||||||
<select id='topic_sort_by' name='topic_sort_by'>
|
<input type="text" name="display_name" id="display-name" value="{{user.display_name}}" placeholder="Same as username" pattern="(?:[\w!#$%^*\(\)\-_=+\[\]\{\}\|;:,.?\s]{3,50})?" title="Optional. 3-50 characters, no @, no <>, no &." maxlength="50" autocomplete=off>
|
||||||
<option value='activity' {{ 'selected' if session['sort_by'] == 'activity' else '' }}>Latest activity</option>
|
<label for="status">Status</label>
|
||||||
<option value='thread' {{ 'selected' if session['sort_by'] == 'thread' else '' }}>Thread creation date</option>
|
<input type="text" name="status" id="status" maxlength="100" value="{{user.status}}" placeholder="Will be shown under your username on posts. Max. 100 characters." autocomplete="off">
|
||||||
</select>
|
<div class="inline-group">
|
||||||
<label for='display_name'>Display name</label>
|
<input type="checkbox" id="subscribe-by-default" name="subscribe_by_default" {{'' if session['dont_subscribe_by_default'] else 'checked'}} autocomplete="off">
|
||||||
<input type='text' id='display_name' name='display_name' value='{{ active_user.display_name }}' pattern="(?:[\w!#$%^*\(\)\-_=+\[\]\{\}\|;:,.?\s]{3,50})?" title='3-50 characters, no @, no <>, no &' placeholder='Optional. Will be shown in place of username.' autocomplete='off'></input>
|
<label for="subscribe-by-default">Automatically subscribe to thread when responding</label>
|
||||||
<label for='status'>Status</label>
|
</div>
|
||||||
<input type='text' id='status' name='status' value='{{ active_user.status }}' maxlength=100 placeholder='Will be shown under your name. Max 100 characters.'>
|
<input type="submit" value="Save">
|
||||||
<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>
|
|
||||||
<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, banned_tags=SIG_BANNED_TAGS) }}
|
|
||||||
<input type='submit' value='Save settings'>
|
|
||||||
</form>
|
</form>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset class="hfc">
|
<fieldset class="plank">
|
||||||
|
<legend>Signature</legend>
|
||||||
|
<form method="POST" class="full-width" action="{{url_for('users.set_sig', username=user.username)}}">
|
||||||
|
<p>The signature will appear under each of your posts.</p>
|
||||||
|
{{babycode_editor_component(id='signature-content', placeholder='Signature content', prefill=user.signature_original_markup, required=false, banned_tags=['@mention'])}}
|
||||||
|
<input type="submit" value="Save signature">
|
||||||
|
</form>
|
||||||
|
</fieldset>
|
||||||
|
{#<fieldset class="plank">
|
||||||
|
<legend>About me/Bio</legend>
|
||||||
|
<form method="POST" class="full-width">
|
||||||
|
<span>Your bio will appear on your profile.</span>
|
||||||
|
{{babycode_editor_component(id='bio-content', placeholder='Bio content', prefill=user.signature_original_markup, required=false, banned_tags=['@mention'])}}
|
||||||
|
<input type="submit" value="Save bio">
|
||||||
|
</form>
|
||||||
|
</fieldset>#}
|
||||||
|
<fieldset class="plank">
|
||||||
|
<bitty-8 data-connect="/static/js/bits/badge-editor.js"></bitty-8>
|
||||||
<legend>Badges</legend>
|
<legend>Badges</legend>
|
||||||
<a href="{{ url_for('guides.guide_page', category='user-guides', slug='settings', _anchor='badges')}}">Badges help</a>
|
<form method="POST" action="{{url_for('users.save_badges', username=get_active_user().username)}}" data-listen="submit" data-r="badgeEditorInit" enctype="multipart/form-data">
|
||||||
<bitty-7-0 data-connect="{{ '/static/js/bitties/pyrom-bitty.js' | cachebust }} BadgeEditorForm" data-listeners="click input submit change">
|
<p>Loading badges…</p>
|
||||||
<form data-use="badgeEditorPrepareSubmit" data-init='loadBadgeEditor' method='post' enctype='multipart/form-data' action='{{ url_for('users.save_badges', username=active_user.username) }}'>
|
<p>If badges fail to load, make sure JS is enabled.</p>
|
||||||
<div>Loading badges…</div>
|
|
||||||
<div>If badges fail to load, JS may be disabled.</div>
|
|
||||||
</form>
|
</form>
|
||||||
</bitty-7-0>
|
</fieldset>
|
||||||
</fieldset>
|
{%- if user.can_invite() -%}
|
||||||
</div>
|
<fieldset class="plank" id="invite">
|
||||||
<div>
|
<legend>Invite keys</legend>
|
||||||
<a class="linkbutton critical" href="{{ url_for('users.delete_page', username=active_user.username) }}">Delete account</a>
|
<p>To manage growth, {{ config.SITE_NAME }} disallows direct sign ups. Instead, users already with an account may invite people they know. You can create invite links here.</p>
|
||||||
</div>
|
<p>Invite links are valid for 48 hours. Once an invite link is used to sign up, it can no longer be used.</p>
|
||||||
</div>
|
<form method="POST" action="{{url_for('users.create_invite_key', username=user.username)}}">
|
||||||
|
{{ csrf_input() | safe }}
|
||||||
<template id='badge-editor-template'>
|
<input type="submit" value="Create new invite">
|
||||||
{{ badge_editor_single(options=uploads) }}
|
</form>
|
||||||
</template>
|
{%- if invites -%}
|
||||||
|
<table>
|
||||||
<template id="badges-list-template">
|
<thead>
|
||||||
{{ sortable_list(attr={'data-receive': 'addBadge'}) }}
|
<tr>
|
||||||
</template>
|
<th class="plank even no-shadow contrast-bg" style="--w: 50%;">Link</th>
|
||||||
{% endblock %}
|
<th class="plank even no-shadow contrast-bg" style="--w: 30%;">Expires</th>
|
||||||
|
<th class="plank even no-shadow contrast-bg" style="--w: 20%;">Revoke</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{%- for invite in invites -%}
|
||||||
|
<tr>
|
||||||
|
<td class="plank even no-shadow minimal"><a href="{{url_for('users.sign_up', key=invite.key)}}">Copy this</a></td>
|
||||||
|
<td class="plank even no-shadow minimal">{{timestamp(invite.expires_at)}}</td>
|
||||||
|
<td class="plank even no-shadow minimal center">
|
||||||
|
<form method="POST" action="{{url_for('users.revoke_invite_key', username=user.username)}}">
|
||||||
|
{{ csrf_input() | safe }}
|
||||||
|
<input type="hidden" name="key" value="{{invite.key}}">
|
||||||
|
<input type="submit" class="warn" value="Revoke">
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{%- endfor -%}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{%- else -%}
|
||||||
|
<p>You do not have any invites pending activation.</p>
|
||||||
|
{%- endif -%}
|
||||||
|
</fieldset>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endif -%}
|
||||||
|
<fieldset class="plank">
|
||||||
|
<legend>Disown & Delete account</legend>
|
||||||
|
<a class="linkbutton critical" href="{{url_for('users.delete_confirm', username=user.username)}}">Delete account</a>
|
||||||
|
</fieldset>
|
||||||
|
{%- endblock -%}
|
||||||
|
|||||||
@@ -1,25 +1,32 @@
|
|||||||
{% extends 'base.html' %}
|
{% from 'common/macros.html' import infobox with context %}
|
||||||
{% block title %}Sign up{% endblock %}
|
{% from 'common/macros.html' import subheader %}
|
||||||
{% block content %}
|
{%- extends 'base.html' -%}
|
||||||
<div class="darkbg">
|
{%- block title -%}sign up{%- endblock -%}
|
||||||
<h1>Sign up</h1>
|
{%- block content -%}
|
||||||
{% if inviter %}
|
{%- set welcome -%}
|
||||||
<p>You have been invited by <a href="{{ url_for('users.page', username=inviter.username) }}">{{ inviter.get_readable_name() }}</a> to join {{ config.SITE_NAME }}. Create an identity below.</p>
|
<p>Please read the rules etc. stub</p>
|
||||||
{% endif %}
|
{%- if not inviter -%}
|
||||||
<form method="post">
|
<p>After you sign up, a moderator will need to confirm your account before you will be allowed to post.
|
||||||
{% if key %}
|
{%- else -%}
|
||||||
<input type="hidden" value={{key}} name="key">
|
You have been invited by <a href="{{url_for('users.user_page', username=inviter.username)}}">{{inviter.get_readable_name()}}</a> to join {{config.SITE_NAME}}. Create an identity below.
|
||||||
{% endif %}
|
{%- endif -%}
|
||||||
<label for="username">Username</label><br>
|
</p>
|
||||||
<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>
|
{%- endset -%}
|
||||||
<label for="password">Password</label>
|
{{ subheader('Sign up', welcome)}}
|
||||||
<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>
|
{%- if request.args.get('error') -%}
|
||||||
<label for="password-confirm">Confirm Password</label>
|
{{infobox(request.args.error, InfoboxKind.ERROR)}}
|
||||||
<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>
|
{%- endif -%}
|
||||||
<input type="submit" value="Sign up">
|
<form class="plank primary-bg full-width" method="POST">
|
||||||
</form>
|
{%- if invite -%}
|
||||||
{% if not inviter %}
|
<input type="hidden" name="key" value="{{invite.key}}">
|
||||||
<span>After you sign up, a moderator will need to confirm your account before you will be allowed to post.</span>
|
{%- endif -%}
|
||||||
{% endif %}
|
<label for="username">Username</label>
|
||||||
</div>
|
<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>
|
||||||
{% endblock %}
|
<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 at least: 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 at least: 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" class="alt">
|
||||||
|
</form>
|
||||||
|
{%- endblock -%}
|
||||||
|
|||||||
33
app/templates/users/threads.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{%- from 'common/macros.html' import full_post with context -%}
|
||||||
|
{%- from 'common/macros.html' import subheader, pager -%}
|
||||||
|
{%- extends 'base.html' -%}
|
||||||
|
{%- block title -%}threads by {{ target_user.get_readable_name() }}{%- endblock -%}
|
||||||
|
{%- block content -%}
|
||||||
|
{%- set td -%}
|
||||||
|
<a href="{{url_for('users.user_page', username=target_user.username)}}">← Back to profile</a>
|
||||||
|
{%- endset -%}
|
||||||
|
{%- call() subheader("%s's started threads" % target_user.get_readable_name(), td) -%}
|
||||||
|
{%- if threads -%}
|
||||||
|
<fieldset class="plank even no-shadow minimal subheader-actions">
|
||||||
|
<legend>Page</legend>
|
||||||
|
{{- pager(page, page_count) -}}
|
||||||
|
</fieldset>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endcall -%}
|
||||||
|
{%- if threads -%}
|
||||||
|
{%- for post in threads -%}
|
||||||
|
<div class="plank">
|
||||||
|
<h2 class="info"><a href="{{url_for('threads.thread_by_id', thread_id=post.thread_id)}}">"{{post.thread_title}}"</a> in topic <a href="{{url_for('topics.topic_by_id', topic_id=post.topic_id)}}">{{post.topic_name}}</a></h2>
|
||||||
|
<div class="post">{{full_post(post, show_toolbar=false, allow_reacting=false)}}</div>
|
||||||
|
</div>
|
||||||
|
{%- endfor -%}
|
||||||
|
<div class="plank">
|
||||||
|
<fieldset class="plank even no-shadow minimal subheader-actions">
|
||||||
|
<legend>Page</legend>
|
||||||
|
{{- pager(page, page_count) -}}
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
{%- else -%}
|
||||||
|
<div class="plank">{{target_user.get_readable_name()}} has not started any threads.</div>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endblock -%}
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
{% from 'common/macros.html' import timestamp, badge_button %}
|
|
||||||
{% extends 'base.html' %}
|
|
||||||
{% block title %}{{ target_user.get_readable_name() }}'s profile{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="darkbg">
|
|
||||||
<h1 class="thread-title"><i>{{ target_user.get_readable_name() }}</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.get_readable_name() }}</strong>
|
|
||||||
<em><abbr title="Mention">@{{ target_user.username }}</abbr></em>
|
|
||||||
{% 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 class="badges-container">
|
|
||||||
{% for badge in target_user.get_badges() %}
|
|
||||||
{{ badge_button(badge) }}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</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 %}
|
|
||||||
{% if stats.inviter_username %}
|
|
||||||
<li>Invited by <a href="{{ url_for('users.page', username=stats.inviter_username) }}">{{ stats.inviter_display_name or stats.inviter_username }}</a></li>
|
|
||||||
{% 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 user-page-post-preview">
|
|
||||||
<div class="post-inner wider">{{ post.content | safe }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endwith %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
101
app/templates/users/user_page.html
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
{%- from 'common/macros.html' import subheader, timestamp, pager, avatar -%}
|
||||||
|
{%- extends 'base.html' -%}
|
||||||
|
{%- block title -%}@{{ target_user.username }}{%- 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 subheader-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 and target_user.permission < get_active_user().permission -%}
|
||||||
|
<fieldset class="plank even no-shadow minimal subheader-actions">
|
||||||
|
<legend>Moderation actions</legend>
|
||||||
|
<form class="subheader-actions" 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">
|
||||||
|
{{- avatar(target_user.get_avatar_url()) -}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="plank even minimal no-shadow user-stats">
|
||||||
|
<h3 class="info">{{target_user.get_readable_name()}} (@{{target_user.username}})</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>
|
||||||
|
{%- if target_user.confirmed_on -%}
|
||||||
|
<span>Joined: {{timestamp(target_user.confirmed_on)}}</span>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- if invited_by -%}
|
||||||
|
<span>Invited by: <a href="{{url_for('users.user_page', username=invited_by.username)}}">{{invited_by.get_readable_name()}}</a></span>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- 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 -%}
|
||||||
33
app/util.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from flask import url_for, session, request
|
||||||
|
from .models import Posts, Threads
|
||||||
|
from .auth import is_logged_in
|
||||||
|
import time
|
||||||
|
|
||||||
|
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()}">'
|
||||||
|
|
||||||
|
def get_form_checkbox(name: str) -> bool:
|
||||||
|
return request.form.get(name, None) == 'on'
|
||||||
|
|
||||||
|
def time_now() -> int:
|
||||||
|
return int(time.time())
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
sass_dir="sass"
|
|
||||||
css_dir="data/static/css"
|
|
||||||
|
|
||||||
if [[ "$1" == "--watch" && -n "$2" ]]; then
|
|
||||||
file="$2"
|
|
||||||
[[ $(basename "$file") = _* ]] && exit 1
|
|
||||||
sass --no-source-map --watch "$file" "$css_dir/theme-$(basename "$file" .scss).css"
|
|
||||||
else
|
|
||||||
set -u
|
|
||||||
rm -r "$css_dir/"
|
|
||||||
#build default first
|
|
||||||
sass --no-source-map "$sass_dir/_default.scss" "$css_dir/style.css"
|
|
||||||
for file in "$sass_dir"/*.scss; do
|
|
||||||
[[ $(basename "$file") = _* ]] && continue
|
|
||||||
sass --no-source-map "$file" "$css_dir/theme-$(basename "$file" .scss).css"
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
@@ -15,6 +15,9 @@ SERVER_NAME = "forum.your.domain"
|
|||||||
# your forum's name, shown on the header.
|
# your forum's name, shown on the header.
|
||||||
SITE_NAME = "Pyrom"
|
SITE_NAME = "Pyrom"
|
||||||
|
|
||||||
|
# the forum's tagline, shown below the title.
|
||||||
|
SITE_TAGLINE = "anti-social media"
|
||||||
|
|
||||||
# if true, users can not sign up manually. see the following two settings.
|
# if true, users can not sign up manually. see the following two settings.
|
||||||
DISABLE_SIGNUP = false
|
DISABLE_SIGNUP = false
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1000 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 256 B After Width: | Height: | Size: 396 B |
|
Before Width: | Height: | Size: 366 B After Width: | Height: | Size: 410 B |
|
Before Width: | Height: | Size: 682 B After Width: | Height: | Size: 666 B |
|
Before Width: | Height: | Size: 394 B After Width: | Height: | Size: 458 B |
|
Before Width: | Height: | Size: 274 B After Width: | Height: | Size: 386 B |
BIN
data/static/badges/pride-pansexual.webp
Normal file
|
After Width: | Height: | Size: 442 B |
|
Before Width: | Height: | Size: 756 B After Width: | Height: | Size: 784 B |
|
Before Width: | Height: | Size: 478 B After Width: | Height: | Size: 502 B |
|
Before Width: | Height: | Size: 402 B After Width: | Height: | Size: 384 B |
|
Before Width: | Height: | Size: 676 B After Width: | Height: | Size: 694 B |
|
Before Width: | Height: | Size: 772 B After Width: | Height: | Size: 772 B |
|
Before Width: | Height: | Size: 616 B After Width: | Height: | Size: 638 B |
|
Before Width: | Height: | Size: 582 B After Width: | Height: | Size: 586 B |
|
Before Width: | Height: | Size: 850 B After Width: | Height: | Size: 850 B |
|
Before Width: | Height: | Size: 690 B After Width: | Height: | Size: 674 B |