Compare commits
51 Commits
6b7a0e7a17
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
13c5c5cf69
|
|||
|
bf3028e7d6
|
|||
|
50c61da8b6
|
|||
|
812f322141
|
|||
|
6e73186127
|
|||
|
8a7eb91a34
|
|||
|
b63b6a1682
|
|||
|
5dfe477607
|
|||
|
b6450a29fd
|
|||
|
7b16ac91ed
|
|||
|
84dbaa2cd8
|
|||
|
200bd37a28
|
|||
|
d01bbaca54
|
|||
|
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
|
1
.gitignore
vendored
@@ -6,6 +6,7 @@ data/static/avatars/*
|
||||
!data/static/avatars/default.webp
|
||||
data/static/badges/user
|
||||
data/_cached
|
||||
data/static/js/vnd/*.source.*
|
||||
|
||||
config/secrets.prod.env
|
||||
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
|
||||
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:
|
||||
|
||||
@@ -32,13 +32,10 @@ below is an explanation of the folder structure:
|
||||
- `static/` - static files
|
||||
- `avatars/` - user avatar uploads
|
||||
- `badges/` - user badge uploads
|
||||
- `css/` - CSS files generated from Sass sources
|
||||
- `css/` - stylesheets
|
||||
- `emoji/` - emoji images used on the forum
|
||||
- `fonts/`
|
||||
- `js/`
|
||||
- `sass/`
|
||||
- `_default.scss` - the default theme. Sass variables that other themes modify are defined here, along with the default styles. other files define the available themes.
|
||||
- `build-themes.sh` - script for building Sass files into CSS
|
||||
- `nginx.conf` - nginx config (production only)
|
||||
- `uwsgi.ini` - uwsgi config (production only)
|
||||
|
||||
@@ -92,10 +89,10 @@ $ pip install -r requirements.txt
|
||||
4. run dev server:
|
||||
|
||||
```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.
|
||||
|
||||
@@ -110,4 +107,3 @@ when you want to run the server again, make sure to activate the venv first:
|
||||
$ source .venv/bin/activate
|
||||
$ python -m app.run
|
||||
```
|
||||
|
||||
|
||||
@@ -73,8 +73,8 @@ Repo: https://github.com/emcconville/wand
|
||||
|
||||
## Bitty
|
||||
|
||||
Affected files: [`data/static/js/vnd/bitty-7.0.0.js`](./data/static/js/vnd/bitty-7.0.0.js)
|
||||
URL: https://bitty-js.com/
|
||||
Affected files: [`data/static/js/vnd/bitty-8.0.0.js`](./data/static/js/vnd/bitty-8.0.0.js)
|
||||
URL: https://bittyjs.com/
|
||||
License: CC0 1.0
|
||||
Author: alan w smith https://www.alanwsmith.com/
|
||||
Repo: https://github.com/alanwsmith/bitty
|
||||
|
||||
@@ -139,6 +139,11 @@ def bind_default_badges(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():
|
||||
from .db import db
|
||||
with db.transaction():
|
||||
@@ -149,12 +154,27 @@ def clear_stale_sessions():
|
||||
for sess in stale_sessions:
|
||||
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()
|
||||
|
||||
def ensure_default_collection():
|
||||
from .db import db
|
||||
from .models import BookmarkCollections
|
||||
with db.transaction():
|
||||
for missing_user in BookmarkCollections.get_users_without_default():
|
||||
BookmarkCollections.create_default(missing_user)
|
||||
|
||||
cache = Cache()
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
app.config['SITE_NAME'] = 'Pyrom'
|
||||
app.config['SITE_TAGLINE'] = 'anti-social media'
|
||||
app.config['DISABLE_SIGNUP'] = False
|
||||
app.config['MODS_CAN_INVITE'] = True
|
||||
app.config['USERS_CAN_INVITE'] = False
|
||||
@@ -203,12 +223,16 @@ def create_app():
|
||||
from app.routes.guides import bp as guides_bp
|
||||
from app.routes.mod import bp as mod_bp
|
||||
from app.routes.posts import bp as posts_bp
|
||||
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(threads_bp)
|
||||
app.register_blueprint(users_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():
|
||||
from .schema import create as create_tables
|
||||
@@ -221,9 +245,13 @@ def create_app():
|
||||
create_deleted_user()
|
||||
|
||||
clear_stale_sessions()
|
||||
clear_api_limits()
|
||||
clear_stale_invites()
|
||||
|
||||
reparse_babycode()
|
||||
|
||||
ensure_default_collection()
|
||||
|
||||
bind_default_badges(app.config['BADGES_PATH'])
|
||||
|
||||
app.config['SESSION_COOKIE_SECURE'] = True
|
||||
@@ -318,6 +346,15 @@ def create_app():
|
||||
else:
|
||||
return render_template('common/404.html'), e.code
|
||||
|
||||
@app.errorhandler(405)
|
||||
def _handle_405(e):
|
||||
if request.path.startswith('/hyperapi/'):
|
||||
return '<h1>method not allowed</h1>', e.code
|
||||
elif request.path.startswith('/api/'):
|
||||
return {'error': 'method not allowed'}, e.code
|
||||
else:
|
||||
return render_template('common/404.html'), e.code
|
||||
|
||||
@app.errorhandler(403)
|
||||
def _handle_403(e):
|
||||
if request.path.startswith('/hyperapi/'):
|
||||
@@ -348,13 +385,6 @@ def create_app():
|
||||
def cachebust(subject):
|
||||
return f'{subject}?v={str(int(time.time()))}'
|
||||
|
||||
@app.template_filter('theme_name')
|
||||
def get_theme_name(subject: str):
|
||||
if subject == 'style':
|
||||
return 'Default'
|
||||
|
||||
return f'{subject.removeprefix('theme-').replace('-', ' ').capitalize()} (beta)'
|
||||
|
||||
@app.template_filter('fromjson')
|
||||
def fromjson(subject: str):
|
||||
return json.loads(subject)
|
||||
|
||||
@@ -105,6 +105,14 @@ def login_required(view_func):
|
||||
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):
|
||||
|
||||
14
app/db.py
@@ -49,6 +49,12 @@ class DB:
|
||||
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):
|
||||
"""Executes a query and returns a list of dictionaries."""
|
||||
with self.connection() as conn:
|
||||
@@ -104,8 +110,12 @@ class DB:
|
||||
conditions = []
|
||||
params = []
|
||||
for col, op, val in self._where:
|
||||
conditions.append(f"{col} {op} ?")
|
||||
params.append(val)
|
||||
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} ?")
|
||||
params.append(val)
|
||||
|
||||
return " WHERE " + " AND ".join(conditions), params
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from pygments.lexers import get_lexer_by_name
|
||||
from pygments.util import ClassNotFound as PygmentsClassNotFound
|
||||
import re
|
||||
|
||||
BABYCODE_VERSION = 10
|
||||
BABYCODE_VERSION = 13
|
||||
|
||||
|
||||
class BabycodeError(Exception):
|
||||
@@ -183,7 +183,7 @@ class HTMLRenderer(BabycodeRenderer):
|
||||
if mention_data not in self.mentions:
|
||||
self.mentions.append(mention_data)
|
||||
|
||||
return f"<a class='mention{' display' if target_user.has_display_name() else ''}' href='{url_for('users.user_page', username=target_user.username)}' title='@{target_user.username}' data-init='highlightMentions' data-username='{target_user.username}'>{'@' if not target_user.has_display_name() else ''}{target_user.get_readable_name()}</a>"
|
||||
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-r='highlightMentions' data-username='{target_user.username}'>{'@' if not target_user.has_display_name() else ''}{target_user.get_readable_name()}</a>"
|
||||
|
||||
def render(self, ast):
|
||||
out = super().render(ast)
|
||||
@@ -357,8 +357,8 @@ def tag_code(children, attr):
|
||||
else:
|
||||
code = input_code
|
||||
|
||||
button = f'<button type=button class="copy-code" data-s="copyCode">Copy</button>'
|
||||
block = f'<fieldset data-r="copyCode" value="{input_code}" class="code-block-container plank minimal no-shadow secondary-bg"><legend>{language}</legend>{button}<pre><code>{code}</code></pre></fieldset>'
|
||||
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
|
||||
|
||||
|
||||
@@ -406,6 +406,13 @@ def tag_quote(children, attr):
|
||||
|
||||
return f'<fieldset class="plank minimal no-shadow secondary-bg"><legend>{quotee}</legend><blockquote>{children}</blockquote></fieldset>'
|
||||
|
||||
def tag_quote_rss(children, attr):
|
||||
if attr:
|
||||
quotee = f'Quoting: {attr.strip()}'
|
||||
return f'<figure><blockquote>{children}</blockquote><figcaption>{quotee}</figcaption></figure>'
|
||||
else:
|
||||
return f'<blockquote>{children}</blockquote>'
|
||||
|
||||
TAGS = {
|
||||
"b": lambda children, attr: f"<strong>{children}</strong>",
|
||||
"i": lambda children, attr: f"<em>{children}</em>",
|
||||
@@ -462,6 +469,7 @@ RSS_TAGS = {
|
||||
'url': tag_url_rss,
|
||||
'spoiler': lambda children, attr: f'<details><summary>{attr or "Spoiler"} (click to reveal)</summary>{children}</details>',
|
||||
'code': tag_code_rss,
|
||||
'quote': tag_quote_rss,
|
||||
|
||||
'big': lambda children, attr: f'<span style="font-size: 1.2em">{children}</span>',
|
||||
'small': lambda children, attr: f'<small>{children}</small>'
|
||||
|
||||
@@ -43,7 +43,8 @@ MIGRATIONS = [
|
||||
add_signature_format,
|
||||
create_default_bookmark_collections,
|
||||
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():
|
||||
|
||||
@@ -172,7 +172,7 @@ class Topics(Model):
|
||||
name = name.strip()
|
||||
description = description.strip()
|
||||
now = int(time.time())
|
||||
slug = f'{slugify(name)}-{now}'
|
||||
slug = slugify(name, max_length=50)
|
||||
|
||||
topic_count = Topics.count()
|
||||
return Topics.create({
|
||||
@@ -287,7 +287,7 @@ class Threads(Model):
|
||||
def new(cls, user_id: int, topic_id: int, title: str, content: str, language: str = 'babycode') -> Threads:
|
||||
from slugify import slugify
|
||||
now = int(time.time())
|
||||
slug = f'{slugify(title)}-{now}'
|
||||
slug = slugify(title, max_length=50)
|
||||
thread = Threads.create({
|
||||
'topic_id': topic_id,
|
||||
'user_id': user_id,
|
||||
@@ -506,10 +506,30 @@ class BookmarkCollections(Model):
|
||||
|
||||
@classmethod
|
||||
def create_default(cls, user_id):
|
||||
q = """INSERT INTO bookmark_collections (user_id, name, is_default, sort_order)
|
||||
VALUES (?, "Bookmarks", 1, 0) RETURNING id
|
||||
"""
|
||||
res = db.fetch_one(q, user_id)
|
||||
return cls.create({
|
||||
'user_id': user_id,
|
||||
'name': 'Bookmarks',
|
||||
'is_default': True,
|
||||
'sort_order': 0,
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def get_users_without_default():
|
||||
q = """
|
||||
SELECT users.id FROM users
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM bookmark_collections bc
|
||||
WHERE bc.user_id = users.id
|
||||
AND bc.is_default = 1
|
||||
)
|
||||
"""
|
||||
return [row['id'] for row in db.query(q)]
|
||||
|
||||
@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):
|
||||
q = 'SELECT EXISTS(SELECT 1 FROM bookmarked_posts WHERE collection_id = ?) as e'
|
||||
@@ -558,6 +578,21 @@ class BookmarkCollections(Model):
|
||||
class BookmarkedPosts(Model):
|
||||
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):
|
||||
return Posts.find({'id': self.post_id})
|
||||
|
||||
@@ -565,6 +600,21 @@ class BookmarkedPosts(Model):
|
||||
class BookmarkedThreads(Model):
|
||||
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):
|
||||
return Threads.find({'id': self.thread_id})
|
||||
|
||||
@@ -612,6 +662,12 @@ class BadgeUploads(Model):
|
||||
class Badges(Model):
|
||||
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):
|
||||
bu = BadgeUploads.find({'id': int(self.upload)})
|
||||
return bu.file_path
|
||||
|
||||
89
app/routes/api.py
Normal file
@@ -0,0 +1,89 @@
|
||||
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 ..models import APIRateLimits, Posts, Threads, Reactions
|
||||
from ..constants import REACTION_EMOJI
|
||||
|
||||
bp = Blueprint('api', __name__, url_prefix='/api/')
|
||||
|
||||
@bp.before_request
|
||||
def ensure_json():
|
||||
if request.method == 'POST':
|
||||
if not request.is_json:
|
||||
return {'error': 'unsupported media type'}, 415
|
||||
elif not request.content_length:
|
||||
return {'error': 'body expected'}, 400
|
||||
elif not isinstance(request.json, dict):
|
||||
return {'error': 'body must be an object'}, 400
|
||||
|
||||
@bp.post('/babycode-preview/')
|
||||
@hard_login_required
|
||||
def babycode_preview():
|
||||
user = get_active_user()
|
||||
if not APIRateLimits.is_allowed(user.id, 'babycode_preview', 5):
|
||||
return {'error': 'too many requests'}, 429
|
||||
markup = str(request.json.get('markup', ''))
|
||||
if not markup:
|
||||
return {'error': 'markup field missing or invalid type'}, 400
|
||||
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
|
||||
return {'html': rendered}
|
||||
|
||||
@bp.get('/whoami/')
|
||||
def whoami():
|
||||
user = get_active_user()
|
||||
if not user:
|
||||
return {}
|
||||
|
||||
return {
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
'display_name': user.display_name,
|
||||
}
|
||||
|
||||
@bp.post('/toggle-reaction/')
|
||||
@hard_login_required
|
||||
def toggle_reaction():
|
||||
user = get_active_user()
|
||||
emoji = request.json.get('reaction')
|
||||
if emoji not in REACTION_EMOJI:
|
||||
return {'error': f'invalid reaction string, given: {emoji}'}, 400
|
||||
|
||||
post_id = request.json.get('post', -1)
|
||||
post = Posts.find({'id': post_id})
|
||||
if not post:
|
||||
return {'error': 'post not found'}, 404
|
||||
|
||||
thread = Threads.find({'id': post.thread_id})
|
||||
|
||||
if not user.can_post_to_thread_or_topic(thread):
|
||||
return {'error': 'thread is locked'}, 403
|
||||
|
||||
reaction_obj = {
|
||||
'user_id': int(user.id),
|
||||
'post_id': int(post_id),
|
||||
'reaction_text': emoji,
|
||||
}
|
||||
r = Reactions.find(reaction_obj)
|
||||
if r:
|
||||
# remove
|
||||
r.delete()
|
||||
return {'status': 'ok', 'added': False}
|
||||
else:
|
||||
# add
|
||||
r = Reactions.create(reaction_obj)
|
||||
return {'status': 'ok', 'added': True}
|
||||
|
||||
@bp.get('/thread-permission/<int:thread_id>')
|
||||
def thread_permission(thread_id):
|
||||
user = get_active_user()
|
||||
if not user:
|
||||
return {'can_post': False}
|
||||
|
||||
thread = Threads.find({'id': thread_id})
|
||||
if not thread:
|
||||
return {'can_post': False}
|
||||
|
||||
return {'can_post': user.can_post_to_thread_or_topic(thread)}
|
||||
139
app/routes/hyperapi.py
Normal file
@@ -0,0 +1,139 @@
|
||||
from flask import Blueprint, render_template, request, url_for
|
||||
from ..auth import get_active_user, is_logged_in, hard_login_required
|
||||
from ..models import BookmarkCollections, BookmarkedPosts, BookmarkedThreads, Threads, Posts, Badges, BadgeUploads, Reactions
|
||||
from functools import wraps
|
||||
|
||||
bp = Blueprint('hyperapi', __name__, url_prefix='/hyperapi/')
|
||||
|
||||
def user_required(view_func):
|
||||
@wraps(view_func)
|
||||
def wrapper(*args, **kwargs):
|
||||
if get_active_user().is_guest():
|
||||
return '<span>Your account must be approved by a moderator before you may perform this action.</span>', 403
|
||||
return view_func(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
@bp.get('/bookmarks/dropdown/')
|
||||
@hard_login_required
|
||||
@user_required
|
||||
def get_bookmark_dropdown():
|
||||
user = get_active_user()
|
||||
concept_kind = request.args.get('concept_kind', 'thread')
|
||||
try:
|
||||
concept_id = int(request.args.get('concept_id', 0))
|
||||
except ValueError:
|
||||
return 'error', 400
|
||||
is_thread = concept_kind == 'thread'
|
||||
if is_thread:
|
||||
target_thread = Threads.find({'id': concept_id})
|
||||
if not target_thread:
|
||||
return 'This thread no longer exists. Please refresh the page.', 404
|
||||
else:
|
||||
target_post = Posts.find({'id': concept_id})
|
||||
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)
|
||||
|
||||
@bp.post('/bookmarks/thread/')
|
||||
@hard_login_required
|
||||
@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:
|
||||
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:
|
||||
BookmarkedPosts.create({
|
||||
'collection_id': target_collection_id,
|
||||
'post_id': post_id,
|
||||
'note': note,
|
||||
})
|
||||
|
||||
return '', 204
|
||||
|
||||
@bp.get('/badges/editor/')
|
||||
@hard_login_required
|
||||
@user_required
|
||||
def badge_editor():
|
||||
user = get_active_user()
|
||||
badges = Badges.get_for_user(user.id)
|
||||
badge_uploads = BadgeUploads.get_for_user(user.id)
|
||||
return render_template('hyper/badge_editor.html', badges=badges, badge_uploads=badge_uploads)
|
||||
|
||||
@bp.get('/reactions/<int:post_id>')
|
||||
def get_reaction_buttons(post_id):
|
||||
return render_template('hyper/reaction_buttons.html', Reactions=Reactions, post_id=post_id)
|
||||
@@ -106,7 +106,7 @@ def edit_topic_post(topic_id):
|
||||
topic.update({
|
||||
'name': target_name,
|
||||
'description': request.form.get('description').strip(),
|
||||
'slug': slugify(target_name[:50]),
|
||||
'slug': slugify(target_name, max_length=50),
|
||||
})
|
||||
return redirect(url_for('topics.topic_by_id', topic_id=topic.id))
|
||||
|
||||
|
||||
@@ -35,6 +35,13 @@ def ownership_or_mod_required(view_func):
|
||||
return view_func(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
@bp.get('/<int:post_id>/')
|
||||
def post_by_id(post_id):
|
||||
post = get_post_url(post_id, _anchor=True)
|
||||
if not post:
|
||||
abort(404)
|
||||
return redirect(post)
|
||||
|
||||
@bp.get('/<int:post_id>/edit/')
|
||||
@login_required
|
||||
@ownership_required
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
from flask import Blueprint, redirect, url_for, render_template, request, abort
|
||||
from flask import Blueprint, redirect, url_for, render_template, request, abort, current_app
|
||||
from functools import wraps
|
||||
from app import cache
|
||||
from ..auth import login_required, get_active_user, is_logged_in
|
||||
from ..db import db
|
||||
from ..models import Threads, Posts, Topics, Users, Reactions, Subscriptions
|
||||
from ..lib.render_atom import render_atom_template
|
||||
from ..util import get_form_checkbox, time_now
|
||||
import math
|
||||
|
||||
@@ -33,7 +36,7 @@ def thread(thread_id, slug):
|
||||
if not thread:
|
||||
abort(404)
|
||||
if thread.slug != slug:
|
||||
return redirect(url_for('.thread', thread_id=thread_id, slug=thread.slug, **request.kwargs))
|
||||
return redirect(url_for('.thread', thread_id=thread_id, slug=thread.slug, **request.args))
|
||||
|
||||
topic = Topics.find({'id': thread.topic_id})
|
||||
started_by = Users.find({'id': thread.user_id})
|
||||
@@ -62,16 +65,30 @@ def thread(thread_id, slug):
|
||||
user = get_active_user()
|
||||
if user:
|
||||
subscription = Subscriptions.find({'user_id': user.id, 'thread_id': thread.id})
|
||||
if subscription:
|
||||
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
|
||||
Reactions=Reactions, last_post=last_post,
|
||||
__feedlink=url_for('.feed', thread_id=thread_id, _external=True),
|
||||
__feedtitle=f'replies to {thread.title}',
|
||||
)
|
||||
|
||||
@bp.get('/<int:thread_id>/feed.atom/')
|
||||
@cache.cached(timeout=5 * 60, unless=lambda: current_app.config['DEBUG'])
|
||||
def feed(thread_id):
|
||||
thread = Threads.find({'id': thread_id})
|
||||
if not thread:
|
||||
abort(404)
|
||||
|
||||
topic = Topics.find({'id': thread.topic_id})
|
||||
posts = thread.get_posts_rss()
|
||||
|
||||
return render_atom_template('threads/thread.atom', thread=thread, topic=topic, posts=posts)
|
||||
|
||||
@bp.post('/<int:thread_id>/')
|
||||
@login_required
|
||||
def reply(thread_id):
|
||||
@@ -82,13 +99,16 @@ def reply(thread_id):
|
||||
if not user.can_post_to_thread_or_topic(thread):
|
||||
return redirect(url_for('.thread_by_id', thread_id=thread_id))
|
||||
post = Posts.new(user.id, thread.id, request.form.get('babycode_content'))
|
||||
subscription = Subscriptions.find({'user_id': user.id, 'thread_id': thread.id})
|
||||
if get_form_checkbox('subscribe'):
|
||||
if not Subscriptions.find({'user_id': user.id, 'thread_id': thread.id}):
|
||||
Subscriptions.create({
|
||||
if not subscription:
|
||||
subscription = Subscriptions.create({
|
||||
'user_id': user.id,
|
||||
'thread_id': thread.id,
|
||||
'last_seen': time_now(),
|
||||
})
|
||||
if subscription and subscription.last_seen < time_now():
|
||||
subscription.update({'last_seen': time_now()})
|
||||
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/')
|
||||
@@ -112,7 +132,8 @@ def edit_post(thread_id):
|
||||
abort(400)
|
||||
|
||||
if new_title != thread.title:
|
||||
thread.update({'title': new_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))
|
||||
|
||||
@@ -162,9 +183,33 @@ def unsubscribe(thread_id):
|
||||
subscription.delete()
|
||||
return redirect(return_to)
|
||||
|
||||
@bp.get('/<int:thread_id>/feed.atom/')
|
||||
def feed(thread_id):
|
||||
return 'stub'
|
||||
@bp.post('/subscriptions/unsubscribe-all/')
|
||||
@login_required
|
||||
def unsubscribe_all():
|
||||
user = get_active_user()
|
||||
subs = Subscriptions.findall({'user_id': user.id})
|
||||
if not subs:
|
||||
return redirect(url_for('users.inbox', username=user.username))
|
||||
with db.transaction():
|
||||
for sub in subs:
|
||||
sub.delete()
|
||||
|
||||
return redirect(url_for('users.inbox', username=user.username))
|
||||
|
||||
@bp.post('/subscriptions/mark-read/')
|
||||
@login_required
|
||||
def mark_read():
|
||||
# TODO: make a return_to param
|
||||
user = get_active_user()
|
||||
sub_ids = request.form.getlist('id[]', type=int)
|
||||
now = time_now()
|
||||
for sub_id in sub_ids:
|
||||
sub = Subscriptions.find({'id': sub_id, 'user_id': user.id})
|
||||
if not sub:
|
||||
continue
|
||||
sub.update({'last_seen': now})
|
||||
|
||||
return redirect(url_for('users.inbox', username=user.username))
|
||||
|
||||
@bp.get('/new/')
|
||||
@login_required
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from flask import Blueprint, redirect, url_for, render_template, request, session, abort
|
||||
|
||||
from flask import Blueprint, redirect, url_for, render_template, request, session, abort, current_app
|
||||
from app import cache
|
||||
from ..lib.render_atom import render_atom_template
|
||||
from ..models import Topics, Threads, Subscriptions
|
||||
from ..auth import get_active_user
|
||||
import math
|
||||
@@ -42,8 +43,20 @@ def topic(topic_id, slug):
|
||||
subscription = Subscriptions.find({'user_id': user.id, 'thread_id': thread['id']})
|
||||
if subscription:
|
||||
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(
|
||||
'topics/topic.html', topic=topic,
|
||||
threads=threads, sort_by=sort_by,
|
||||
page=page, page_count=page_count, subscriptions=subscriptions,
|
||||
__feedlink=url_for('.feed', topic_id=topic_id, _external=True),
|
||||
__feedtitle=f'latest threads in {topic.name}',
|
||||
)
|
||||
|
||||
@bp.get('/<int:topic_id>/feed.atom/')
|
||||
@cache.cached(timeout=5 * 60, unless=lambda: current_app.config['DEBUG'])
|
||||
def feed(topic_id):
|
||||
return 'stub'
|
||||
topic = Topics.find({'id': topic_id})
|
||||
if not topic:
|
||||
abort(404)
|
||||
|
||||
threads_list = topic.get_threads_with_op_rss()
|
||||
return render_atom_template('topics/topic.atom', topic=topic, threads_list=threads_list)
|
||||
|
||||
@@ -4,7 +4,7 @@ from flask import (
|
||||
abort, flash, current_app
|
||||
)
|
||||
from functools import wraps
|
||||
from secrets import compare_digest as compare_timesafe
|
||||
from secrets import compare_digest as compare_timesafe, token_urlsafe
|
||||
from wand.image import Image
|
||||
from wand.color import Color
|
||||
from wand.exceptions import WandException
|
||||
@@ -14,14 +14,17 @@ from ..auth import (
|
||||
login_required, revoke_session, get_active_user,
|
||||
parse_display_name, revoke_all_sessions, csrf_verified
|
||||
)
|
||||
from ..models import Users, Posts, Reactions, Threads, Avatars, PostHistory, Mentions
|
||||
from ..models import Users, Posts, Reactions, Threads, Avatars, PostHistory, Mentions, BookmarkCollections, InviteKeys, Badges, BadgeUploads
|
||||
from ..constants import PermissionLevel, InfoboxKind
|
||||
from ..util import get_form_checkbox
|
||||
from ..util import get_form_checkbox, time_now
|
||||
from ..lib.babycode import babycode_to_html
|
||||
from ..db import db
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
|
||||
AVATAR_MAX_SIZE = 1000 * 1000 # 1MB
|
||||
BADGE_MAX_SIZE = 1000 * 500 # 500K
|
||||
|
||||
bp = Blueprint('users', __name__, url_prefix='/users/')
|
||||
|
||||
@@ -58,6 +61,22 @@ def validate_and_create_avatar(input_image, filename):
|
||||
except WandException:
|
||||
return False
|
||||
|
||||
def validate_and_create_badge(input_image, filename):
|
||||
try:
|
||||
with Image(blob=input_image) as img:
|
||||
if img.width != 88 or img.height != 31:
|
||||
return False
|
||||
if hasattr(img, 'sequence') and len(img.sequence) > 1:
|
||||
img = Image(image=img.sequence[0])
|
||||
img.strip()
|
||||
|
||||
img.format = 'webp'
|
||||
img.compression_quality = 90
|
||||
img.save(filename=filename)
|
||||
return True
|
||||
except WandException:
|
||||
return False
|
||||
|
||||
def anonymize_user(user_id):
|
||||
deleted_user = Users.find({'username': 'deleteduser'})
|
||||
|
||||
@@ -167,15 +186,37 @@ def log_out():
|
||||
@bp.get('/sign-up/')
|
||||
@redirect_if_logged_in()
|
||||
def sign_up():
|
||||
return render_template('users/sign_up.html')
|
||||
key = request.args.get('key', '')
|
||||
invite = None
|
||||
inviter = None
|
||||
if not key and current_app.config['DISABLE_SIGNUP']:
|
||||
return redirect(url_for('topics.all_topics'))
|
||||
elif key and current_app.config['DISABLE_SIGNUP']:
|
||||
invite = InviteKeys.find({'key': key})
|
||||
if not invite:
|
||||
return redirect(url_for('topics.all_topics'))
|
||||
inviter = Users.find({'id': invite.created_by})
|
||||
return render_template('users/sign_up.html', invite=invite, inviter=inviter)
|
||||
|
||||
@bp.post('/sign-up/')
|
||||
@redirect_if_logged_in()
|
||||
def sign_up_post():
|
||||
generic_error_page = redirect(url_for('.sign_up', error='The username or password you entered is invalid.'))
|
||||
invalid_username_error_page = redirect(url_for('.sign_up', error='This username cannot be used. Please pick another.'))
|
||||
passwords_error_page = redirect(url_for('.sign_up', error='The passwords do not match.'))
|
||||
args_sans_error = dict(request.args)
|
||||
args_sans_error.pop('error', '')
|
||||
generic_error_page = redirect(url_for('.sign_up', error='The username or password you entered is invalid.', **args_sans_error))
|
||||
invalid_username_error_page = redirect(url_for('.sign_up', error='This username cannot be used. Please pick another.', **args_sans_error))
|
||||
passwords_error_page = redirect(url_for('.sign_up', error='The passwords do not match.', **args_sans_error))
|
||||
username = request.form.get('username', default='')
|
||||
invite = None
|
||||
if current_app.config['DISABLE_SIGNUP']:
|
||||
key = request.form.get('key', '')
|
||||
if not key:
|
||||
return generic_error_page
|
||||
invite = InviteKeys.find({'key': key})
|
||||
if not invite:
|
||||
return generic_error_page
|
||||
if invite.expires_at < time_now():
|
||||
return generic_error_page
|
||||
if not username:
|
||||
return generic_error_page
|
||||
if request.form.get('password') is None:
|
||||
@@ -195,12 +236,21 @@ def sign_up_post():
|
||||
|
||||
password_hash = digest(request.form.get('password'))
|
||||
|
||||
user = Users.create({
|
||||
user_data = {
|
||||
'username': username_pair[0],
|
||||
'password_hash': password_hash,
|
||||
'permission': PermissionLevel.GUEST.value,
|
||||
'created_at': int(time.time()),
|
||||
})
|
||||
'created_at': time_now(),
|
||||
}
|
||||
if invite:
|
||||
user_data['invited_by'] = invite.created_by
|
||||
user_data['permission'] = PermissionLevel.USER.value
|
||||
user_data['confirmed_on'] = time_now()
|
||||
invite.delete()
|
||||
|
||||
user = Users.create(user_data)
|
||||
|
||||
BookmarkCollections.create_default(user.id)
|
||||
|
||||
if username_pair[0] != username_pair[1]:
|
||||
user.update({
|
||||
@@ -213,6 +263,7 @@ def sign_up_post():
|
||||
if session['remember']:
|
||||
session.permanent = True
|
||||
|
||||
flash(f'Welcome to {current_app.config['SITE_NAME']}!', InfoboxKind.INFO)
|
||||
return redirect(url_for('topics.all_topics'))
|
||||
|
||||
@bp.get('/<username>/')
|
||||
@@ -221,7 +272,11 @@ def user_page(username):
|
||||
target_user = Users.find({'username': username})
|
||||
if not target_user:
|
||||
abort(404)
|
||||
return render_template('users/user_page.html', target_user=target_user)
|
||||
if current_app.config['DISABLE_SIGNUP'] and target_user.invited_by:
|
||||
invited_by = Users.find({'id': target_user.invited_by})
|
||||
else:
|
||||
invited_by = None
|
||||
return render_template('users/user_page.html', target_user=target_user, invited_by=invited_by)
|
||||
|
||||
@bp.get('/<username>/posts/')
|
||||
def posts(username):
|
||||
@@ -243,7 +298,8 @@ def posts(username):
|
||||
return render_template(
|
||||
'users/posts.html', posts=posts,
|
||||
page=page, page_count=page_count,
|
||||
target_user=target_user, Reactions=Reactions
|
||||
target_user=target_user,
|
||||
Reactions=Reactions,
|
||||
)
|
||||
|
||||
@bp.get('/<username>/threads/')
|
||||
@@ -266,7 +322,8 @@ def threads(username):
|
||||
return render_template(
|
||||
'users/threads.html', threads=threads,
|
||||
page=page, page_count=page_count,
|
||||
target_user=target_user, Reactions=Reactions
|
||||
target_user=target_user,
|
||||
Reactions=Reactions,
|
||||
)
|
||||
|
||||
@bp.get('/<username>/comments/')
|
||||
@@ -282,9 +339,11 @@ def comments(username):
|
||||
def settings(username):
|
||||
user = get_active_user()
|
||||
sort_by = session.get('sort_by', 'activity')
|
||||
invites = InviteKeys.findall({'created_by': user.id})
|
||||
return render_template(
|
||||
'users/settings.html', user=user,
|
||||
sort_by=sort_by
|
||||
sort_by=sort_by,
|
||||
invites=invites,
|
||||
)
|
||||
|
||||
@bp.post('/<username>/settings/set-avatar')
|
||||
@@ -386,11 +445,27 @@ def set_personalization(username):
|
||||
session['sort_by'] = request.form.get('sort_by', 'activity')
|
||||
session['dont_subscribe_by_default'] = not get_form_checkbox('subscribe_by_default')
|
||||
|
||||
old_display_name = user.display_name
|
||||
new_display_name = parse_display_name(request.form.get('display_name', ''))
|
||||
|
||||
user.update({
|
||||
'status': request.form.get('status', '')[:100],
|
||||
'display_name': parse_display_name(request.form.get('display_name', ''))
|
||||
'display_name': new_display_name
|
||||
})
|
||||
|
||||
if old_display_name != new_display_name:
|
||||
# re-parse posts with mentions
|
||||
q = """SELECT DISTINCT m.revision_id FROM mentions m
|
||||
JOIN post_history ph ON m.revision_id = ph.id
|
||||
JOIN posts p ON p.current_revision_id = ph.id
|
||||
WHERE m.mentioned_user_id = ?"""
|
||||
mentions = db.query(q, int(user.id))
|
||||
with db.transaction():
|
||||
for mention in mentions:
|
||||
rev = PostHistory.find({'id': int(mention['revision_id'])})
|
||||
parsed_content = babycode_to_html(rev.original_markup).result
|
||||
rev.update({'content': parsed_content})
|
||||
|
||||
flash('Personalization settings updated.', InfoboxKind.INFO)
|
||||
return redirect(url_for('.settings', username=username))
|
||||
|
||||
@@ -424,8 +499,72 @@ def inbox(username):
|
||||
@login_required
|
||||
@redirect_to_own
|
||||
def bookmarks(username):
|
||||
username = username.lower()
|
||||
return 'stub'
|
||||
user = get_active_user()
|
||||
collections = BookmarkCollections.get_for_user(user.id)
|
||||
return render_template('users/bookmarks.html', collections=collections)
|
||||
|
||||
@bp.get('/<username>/bookmarks/collections/')
|
||||
@login_required
|
||||
@user_required
|
||||
@redirect_to_own
|
||||
def bookmark_collections(username):
|
||||
user = get_active_user()
|
||||
collections = BookmarkCollections.get_for_user(user.id)
|
||||
return render_template('users/manage_collections.html', collections=collections)
|
||||
|
||||
@bp.post('/<username>/bookmarks/collections/')
|
||||
@login_required
|
||||
@user_required
|
||||
@redirect_to_own
|
||||
def edit_bookmark_collections(username):
|
||||
user = get_active_user()
|
||||
ids = request.form.getlist('id[]')
|
||||
names = request.form.getlist('name[]')
|
||||
if len(ids) == 0 or len(ids) != len(names):
|
||||
abort(400)
|
||||
deleted_ids = filter(lambda x: x.strip(), request.form.get('deleted_ids', '').split(';'))
|
||||
try:
|
||||
deleted_ids = map(lambda x: int(x), deleted_ids)
|
||||
except ValueError:
|
||||
abort(400)
|
||||
|
||||
with db.transaction():
|
||||
for new_order, id in enumerate(ids):
|
||||
new_name = names[new_order]
|
||||
if id == 'new':
|
||||
bc = BookmarkCollections.create({
|
||||
'user_id': user.id,
|
||||
'is_default': False,
|
||||
'name': new_name,
|
||||
'sort_order': new_order,
|
||||
})
|
||||
continue
|
||||
id = int(id)
|
||||
bc = BookmarkCollections.find({'id': id})
|
||||
if not bc:
|
||||
continue
|
||||
if bc.user_id != user.id:
|
||||
continue
|
||||
if bc.is_default:
|
||||
new_order = 0
|
||||
elif new_order == 0:
|
||||
new_order = 1
|
||||
bc.update({
|
||||
'name': new_name,
|
||||
'sort_order': new_order,
|
||||
})
|
||||
|
||||
for deleted_id in deleted_ids:
|
||||
bc = BookmarkCollections.find({'id': deleted_id})
|
||||
if not bc:
|
||||
continue
|
||||
if bc.user_id != user.id:
|
||||
continue
|
||||
if bc.is_default:
|
||||
continue
|
||||
bc.delete()
|
||||
|
||||
return redirect(url_for('.bookmark_collections', username=username))
|
||||
|
||||
@bp.get('/<username>/delete-confirm/')
|
||||
@login_required
|
||||
@@ -454,3 +593,143 @@ def delete_confirm_post(username):
|
||||
user.delete()
|
||||
|
||||
return redirect(url_for('topics.all_topics'))
|
||||
|
||||
@bp.post('/<username>/invite-keys/create/')
|
||||
@login_required
|
||||
@redirect_to_own
|
||||
@csrf_verified
|
||||
def create_invite_key(username):
|
||||
user = get_active_user()
|
||||
if not user.can_invite():
|
||||
abort(404)
|
||||
|
||||
key = token_urlsafe(16)
|
||||
expires_at = time_now() + 48 * 60 * 60
|
||||
|
||||
invite = InviteKeys.create({
|
||||
'created_by': user.id,
|
||||
'expires_at': expires_at,
|
||||
'key': key,
|
||||
})
|
||||
|
||||
return redirect(url_for('.settings', username=username, _anchor='invite'))
|
||||
|
||||
@bp.post('/<username>/invite-keys/revoke/')
|
||||
@login_required
|
||||
@redirect_to_own
|
||||
@csrf_verified
|
||||
def revoke_invite_key(username):
|
||||
user = get_active_user()
|
||||
if not user.can_invite():
|
||||
abort(404)
|
||||
|
||||
key = request.form.get('key', '')
|
||||
invite = InviteKeys.find({'created_by': user.id, 'key': key})
|
||||
if not invite:
|
||||
abort(404)
|
||||
|
||||
invite.delete()
|
||||
return redirect(url_for('.settings', username=username, _anchor='invite'))
|
||||
|
||||
@bp.post('/<username>/settings/badges/')
|
||||
@login_required
|
||||
@redirect_to_own
|
||||
def save_badges(username):
|
||||
user = get_active_user()
|
||||
if user.is_guest():
|
||||
abort(403)
|
||||
|
||||
ids = request.form.getlist('id[]', type=int)
|
||||
badge_choices = request.form.getlist('badge_choice[]')
|
||||
files = request.files.getlist('badge_file[]')
|
||||
labels = request.form.getlist('label[]')
|
||||
links = request.form.getlist('link[]')
|
||||
|
||||
existing_badges = {badge.id: badge for badge in Badges.findall({'user_id': user.id})}
|
||||
|
||||
if not (len(ids) == len(badge_choices) == len(files) == len(labels) == len(links)):
|
||||
abort(400)
|
||||
|
||||
rejected_badges = []
|
||||
# print(ids)
|
||||
|
||||
# print(db.query(f'SELECT id FROM badges WHERE id NOT IN {db.binding_list(len(ids))}', *ids))
|
||||
deleted_badges = Badges.findall([
|
||||
('id', 'NOT IN', ids),
|
||||
('user_id', '=', user.id),
|
||||
])
|
||||
|
||||
with db.transaction():
|
||||
for b in deleted_badges:
|
||||
b.delete()
|
||||
|
||||
for i, id in enumerate(ids):
|
||||
badge_upload_id = badge_choices[i]
|
||||
label = labels[i]
|
||||
link = links[i]
|
||||
pending_badge = {
|
||||
'label': label,
|
||||
'link': link,
|
||||
'sort_order': i,
|
||||
}
|
||||
if badge_upload_id == 'custom':
|
||||
file = files[i]
|
||||
if not file:
|
||||
rejected_badges.append(file.filename)
|
||||
continue
|
||||
file.seek(0, os.SEEK_END)
|
||||
file_size = file.tell()
|
||||
file.seek(0, os.SEEK_SET)
|
||||
|
||||
if file_size > BADGE_MAX_SIZE:
|
||||
rejected_badges.append(file.filename)
|
||||
continue
|
||||
|
||||
file_bytes = file.read()
|
||||
now = time_now()
|
||||
filename = f'u{user.id}d{now}s{i}.webp'
|
||||
output_path = os.path.join(current_app.config['BADGES_UPLOAD_PATH'], filename)
|
||||
proxied_filename = f'/static/badges/user/{filename}'
|
||||
res = validate_and_create_badge(file_bytes, output_path)
|
||||
if not res:
|
||||
rejected_badges.append(file.filename)
|
||||
continue
|
||||
|
||||
bu = BadgeUploads.create({
|
||||
'user_id': user.id,
|
||||
'uploaded_at': now,
|
||||
'file_path': proxied_filename,
|
||||
'original_filename': file.filename
|
||||
})
|
||||
else:
|
||||
bu = BadgeUploads.find({'id': badge_upload_id})
|
||||
if not bu:
|
||||
continue
|
||||
|
||||
pending_badge['upload'] = bu.id
|
||||
|
||||
if id == -1:
|
||||
pending_badge['user_id'] = user.id
|
||||
badge = Badges.create(pending_badge)
|
||||
else:
|
||||
badge = Badges.find({'id': id})
|
||||
if badge.user_id != user.id:
|
||||
continue
|
||||
if not badge:
|
||||
continue
|
||||
badge.update(pending_badge)
|
||||
|
||||
for stale_upload in BadgeUploads.get_unused_for_user(user.id):
|
||||
filename = os.path.join(current_app.config['BADGES_UPLOAD_PATH'], os.path.basename(stale_upload.file_path))
|
||||
os.remove(filename)
|
||||
stale_upload.delete()
|
||||
|
||||
message = 'Badges updated.'
|
||||
icon = InfoboxKind.INFO
|
||||
if rejected_badges:
|
||||
message += f';Some of your badges were incorrect and were not uploaded: {", ".join(rejected_badges)}.'
|
||||
icon = InfoboxKind.WARN
|
||||
|
||||
flash(message, icon)
|
||||
|
||||
return redirect(url_for('.settings', username=username))
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
from app import create_app
|
||||
import os
|
||||
import sys
|
||||
|
||||
app = create_app()
|
||||
|
||||
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(
|
||||
host = "127.0.0.1",
|
||||
host = hostname,
|
||||
port = 8080
|
||||
)
|
||||
|
||||
@@ -187,6 +187,8 @@ SCHEMA = [
|
||||
|
||||
'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_invite_key_user ON invite_keys(created_by, key)'
|
||||
]
|
||||
|
||||
def create():
|
||||
|
||||
20
app/templates/base.atom
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
{%- if self.title() -%}
|
||||
<title>{%- block title -%}{%- endblock -%}</title>
|
||||
{%- else -%}
|
||||
<title>{{- config.SITE_NAME -}}</title>
|
||||
{%- endif -%}
|
||||
{%- if self.feed_updated() -%}
|
||||
<updated>{%- block feed_updated -%}{%- endblock -%}</updated>
|
||||
{%- else -%}
|
||||
<updated>{{- get_time_now() | iso8601 -}}</updated>
|
||||
{%- endif -%}
|
||||
<id>{{- __current_page -}}</id>
|
||||
<link rel="self" href="{{ __current_page }}" />
|
||||
<link href="{%- block canonical_link -%}{%- endblock -%}" />
|
||||
{%- if self.feed_author() -%}
|
||||
<author>{%- block feed_author -%}{%- endblock -%}</author>
|
||||
{%- endif -%}
|
||||
{%- block content -%}{%- endblock -%}
|
||||
</feed>
|
||||
@@ -11,8 +11,13 @@
|
||||
{%- else -%}
|
||||
<title>{{ config.SITE_NAME }}</title>
|
||||
{%- endif -%}
|
||||
{%- if __feedlink -%}
|
||||
<link rel="alternate" type="application/atom+xml" href="{{ __feedlink }}" title="{{ __feedtitle }}">
|
||||
{%- endif -%}
|
||||
</head>
|
||||
<body>
|
||||
<bitty-8 data-connect="/static/js/bits/progressive-enhancement.js"></bitty-8>
|
||||
<bitty-8 data-connect="/static/js/bits/ui.js"></bitty-8>
|
||||
{%- include 'common/topnav.html' -%}
|
||||
{%- with messages = get_flashed_messages(with_categories=true) -%}
|
||||
{%- if messages -%}
|
||||
@@ -23,6 +28,7 @@
|
||||
{%- endwith -%}
|
||||
{%- block content -%}{%- endblock -%}
|
||||
{%- 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>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro timestamp(unix_ts) -%}
|
||||
<time datetime="{{ unix_ts | iso8601 }}">{{ unix_ts | ts_datetime('%Y-%m-%d %H:%M')}} <abbr title="Server Time">ST</abbr></time>
|
||||
<time data-r="localizeTimestamps" datetime="{{ unix_ts | iso8601 }}">{{ unix_ts | ts_datetime('%Y-%m-%d %H:%M')}} <abbr title="Server Time">ST</abbr></time>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro subheader(title, desc='') -%}
|
||||
@@ -72,15 +72,15 @@
|
||||
</span>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro tabs(prefix='', labels = []) -%}
|
||||
<div class="tab-container">
|
||||
{% 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>{{tab_label}}</button>
|
||||
<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>
|
||||
{%- for tab_label in labels -%}
|
||||
<div class="plank secondary-bg even no-shadow tab-content {{'hidden' if loop.index0!=0 else ''}}" role="tabpanel" aria-labelledby="{{prefix+'-'+(tab_label | lower)+'-tab'}}" id="{{prefix+'-'+(tab_label | lower)+'-content'}}">
|
||||
<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 ''}}">
|
||||
{{- caller(loop.index0) -}}
|
||||
</div>
|
||||
{%- endfor -%}
|
||||
@@ -94,24 +94,25 @@
|
||||
id='babycode-content',
|
||||
banned_tags=[]
|
||||
) -%}
|
||||
{%- call(idx) tabs(prefix='babycode', labels=['Write', 'Preview']) -%}
|
||||
{%- 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">
|
||||
<button type="button" class="minimal" disabled><b>B</b></button>
|
||||
<button type="button" class="minimal" disabled><i>i</i></button>
|
||||
<button type="button" class="minimal" disabled><s>S</s></button>
|
||||
<button type="button" class="minimal" disabled><u>U</u></button>
|
||||
<button type="button" class="minimal" disabled><code>://</code></button>
|
||||
<button type="button" class="minimal" disabled><code></></code></button>
|
||||
<button type="button" class="minimal" disabled>1.</button>
|
||||
<button type="button" class="minimal" disabled>•</button>
|
||||
<button type="button" class="minimal" disabled><img src="/static/emoji/angry.png" class="emoji"></button>
|
||||
<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 class="flex-last">{# stub: char count #}</span>
|
||||
<span class="flex-last js-only" data-r="enhance babycodeEditorCharCount">0/</span>
|
||||
</span>
|
||||
<input type="hidden" name="babycode_banned_tags" id="{{id}}-banned-tags" value="{{banned_tags | unique | list | tojson | forceescape}}">
|
||||
<textarea name="babycode_content" id="{{id}}" class="babycode-editor" placeholder="{{placeholder}}" {{'required' if required else ''}} autocomplete="off" maxlength="5000">{{ prefill }}</textarea>
|
||||
<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>
|
||||
<span>Forbidden tags:</span>
|
||||
@@ -123,6 +124,8 @@
|
||||
</div>
|
||||
{%- endif -%}
|
||||
<a href="##">babycode help</a>
|
||||
{%- else -%}
|
||||
<div data-r="showBabycodePreview"></div>
|
||||
{%- endif -%}
|
||||
{%- endcall -%}
|
||||
{%- endmacro %}
|
||||
@@ -133,10 +136,29 @@
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro bookmark_button(kind, id, text='Bookmark') -%}
|
||||
<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>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro reaction_buttons(post_id) -%}
|
||||
{%- for reaction in Reactions.for_post(post_id) -%}
|
||||
{% set reactors = Reactions.get_users(post_id, reaction.reaction_text) | map(attribute='username') | list %}
|
||||
{% set reactors_trimmed = reactors[:10] %}
|
||||
{% set reactors_str = reactors_trimmed | join (',\n') %}
|
||||
{% if reactors | count > 10 %}
|
||||
{% set reactors_str = reactors_str + '\n...and many others' %}
|
||||
{% endif %}
|
||||
{% set has_reacted = get_active_user() is not none and get_active_user().username in reactors %}
|
||||
<button autocomplete="off" type="button" title="{{reactors_str}}" class="minimal {{'alt' if has_reacted else ''}}" data-emoji="{{reaction.reaction_text}}" data-s="toggleReaction" data-r="enableReactionButtons" disabled><img src="/static/emoji/{{reaction.reaction_text}}.png">{{reaction.c}}</button>
|
||||
{%- endfor -%}
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro full_post(
|
||||
post, render_sig=true, is_latest=false,
|
||||
show_toolbar=true, is_editing=false, thread=none,
|
||||
show_reactions=true, show_thread=false, allow_reacting=true
|
||||
show_reactions=true, show_thread=false, allow_reacting=true,
|
||||
tb_edit=true, tb_quote=true, tb_delete=true, tb_bookmark=true,
|
||||
bookmark_btn='Bookmark', tb_pretext=''
|
||||
) -%}
|
||||
{%- if is_logged_in() -%}
|
||||
{%- set can_delete = post.user_id == get_active_user().id or is_mod() -%}
|
||||
@@ -152,7 +174,7 @@
|
||||
<a href="{{url_for('users.user_page', username=post.username)}}" class="usercard-username">{{post.display_name if post.display_name else post.username}}</a>
|
||||
<abbr title="mention">@{{post.username}}</abbr>
|
||||
<i>{{post.status}}</i>
|
||||
{%- set badges=post.badges_json | fromjson -%}
|
||||
{%- set badges=post.badges_json | fromjson | sort(attribute='sort_order') -%}
|
||||
<div class="badges-container">
|
||||
{%- for badge in badges -%}
|
||||
{%- if badge.link -%}<a href="{{badge.link}}">{%- endif -%}
|
||||
@@ -163,9 +185,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="post-content">
|
||||
<div class="post-content" data-r="collectImages">
|
||||
<div class="plank even minimal secondary-bg no-shadow post-info">
|
||||
<span>
|
||||
{%- if tb_pretext -%}
|
||||
<span>{{tb_pretext}} • </span>
|
||||
{%- endif -%}
|
||||
<a href="{{get_post_url(post.id, _anchor=true)}}">
|
||||
{%- if post.edited_at <= post.created_at -%}
|
||||
<i>Posted on {{timestamp(post.created_at)}}</i>
|
||||
@@ -178,17 +203,19 @@
|
||||
{%- endif -%}
|
||||
</span>
|
||||
{%- if show_toolbar -%}
|
||||
<span class="thread-actions">
|
||||
{%- if owns -%}
|
||||
<span class="subheader-actions">
|
||||
{%- if owns and tb_edit -%}
|
||||
<a class="linkbutton" href="{{url_for('posts.edit', post_id=post.id, _anchor='babycode-content')}}">Edit</a>
|
||||
{%- endif -%}
|
||||
{%- if can_reply -%}
|
||||
<button disabled title="This feature requires JavaScript to be enabled.">Quote</button>
|
||||
{%- if can_reply and tb_quote -%}
|
||||
<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 -%}
|
||||
{%- if can_delete -%}
|
||||
{%- if can_delete and tb_delete -%}
|
||||
<a class="linkbutton critical" href="{{url_for('posts.delete', post_id=post.id)}}">Delete</a>
|
||||
{%- endif -%}
|
||||
<button disabled title="This feature requires JavaScript to be enabled.">{{icn_bookmark(24)}}Bookmark…</button>
|
||||
{%- if tb_bookmark -%}
|
||||
{{ bookmark_button('post', post.id, bookmark_btn) }}
|
||||
{%- endif -%}
|
||||
</span>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
@@ -204,19 +231,10 @@
|
||||
</div>
|
||||
<div class="plank even secondary-bg minimal no-shadow">
|
||||
{%- if show_reactions -%}
|
||||
<span class="button-row">
|
||||
{%- for reaction in Reactions.for_post(post.id) -%}
|
||||
{% set reactors = Reactions.get_users(post.id, reaction.reaction_text) | map(attribute='username') | list %}
|
||||
{% set reactors_trimmed = reactors[:10] %}
|
||||
{% set reactors_str = reactors_trimmed | join (',\n') %}
|
||||
{% if reactors | count > 10 %}
|
||||
{% set reactors_str = reactors_str + '\n...and many others' %}
|
||||
{% endif %}
|
||||
{% set has_reacted = get_active_user() is not none and get_active_user().username in reactors %}
|
||||
<button 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 class="button-row" data-r="replaceReactionButtons">
|
||||
{{- reaction_buttons(post.id) -}}
|
||||
</span>
|
||||
{%- if is_logged_in() and allow_reacting -%}<button disabled title="This feature requires JavaScript to be enabled.">Add reaction</button>{%- endif -%}
|
||||
{%- if is_logged_in() and allow_reacting -%}<button autocomplete='off' data-r="disableReactionMenuButton enableReactionMenuButton" disabled title="This feature requires JavaScript to be enabled." data-s="openReactionMenu">Add reaction</button>{%- endif -%}
|
||||
{%- elif is_editing -%}
|
||||
<input type="submit" value="Save">
|
||||
<a href="{{get_post_url(post.id, _anchor=true)}}" class="linkbutton warn">Cancel</a>
|
||||
@@ -226,6 +244,18 @@
|
||||
</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 -%}
|
||||
@@ -259,16 +289,20 @@
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro sortable_list(attr=none) -%}
|
||||
<ol class="sortable-list plank even no-shadow minimal tertiary-bg" {% if attr %}{{ dict_to_attr(attr) }}{% endif %}>
|
||||
<ol class="sortable-list" {% if attr %}{{ dict_to_attr(attr) }}{% endif %}>
|
||||
{%- if caller -%}
|
||||
{{ caller() }}
|
||||
{%- endif -%}
|
||||
</ol>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro sortable_list_item(key, immovable=false, attr=none) -%}
|
||||
<li class="sortable-item{{ ' immovable' if immovable else '' }} plank even no-shadow {{'tertiary-bg' if immovable else ''}}" data-sortable-list-key="{{key}}" {% if attr %}{{ dict_to_attr(attr) }}{% endif %}>
|
||||
{% 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">{{ caller() }}</div>
|
||||
<div class="sortable-item-inner {{full and 'full' or ''}}">{{ caller() }}</div>
|
||||
</li>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro rss_html_content(html) -%}
|
||||
<content type="html">{{ html }}</content>
|
||||
{%- endmacro %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<nav id="header" class="plank top">
|
||||
<a class="site-title" href="/">Porom</a>
|
||||
<span>anti-social media</span>
|
||||
<a class="site-title" href="/">{{config.SITE_NAME}}</a>
|
||||
<span>{{config.SITE_TAGLINE or ' ' | safe}}</span>
|
||||
{%- if is_logged_in() -%}
|
||||
{%- with user = get_active_user() -%}
|
||||
{%- set uc = user.get_unread_count() -%}
|
||||
@@ -9,6 +9,9 @@
|
||||
<li><a class="linkbutton" href="{{url_for('users.settings', username=user.username)}}">Settings</a></li>
|
||||
<li><a class="linkbutton" href="{{url_for('users.inbox', username=user.username)}}">Inbox{{' (%s)' % uc if uc else ''}}</a></li>
|
||||
<li><a class="linkbutton" href="{{url_for('users.bookmarks', username=user.username)}}">Bookmarks</a></li>
|
||||
{%- if user.can_invite() -%}
|
||||
<a href="{{url_for('users.settings', username=user.username, _anchor='invite')}}" class="linkbutton alt">Invite</a>
|
||||
{%- endif %}
|
||||
{% if user.is_mod() -%}
|
||||
<li><a class="linkbutton" href="{{url_for('mod.index')}}">Moderation</a></li>
|
||||
{%- endif %}
|
||||
@@ -21,7 +24,9 @@
|
||||
<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>
|
||||
|
||||
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>
|
||||
2
app/templates/hyper/reaction_buttons.html
Normal file
@@ -0,0 +1,2 @@
|
||||
{%- from 'common/macros.html' import reaction_buttons with context -%}
|
||||
{{- reaction_buttons(post_id) -}}
|
||||
@@ -1,6 +1,6 @@
|
||||
{%- from 'common/macros.html' import subheader, babycode_editor_component, sortable_list, sortable_list_item -%}
|
||||
{%- extends 'base.html' -%}
|
||||
{%- block title -%}settings{%- endblock -%}
|
||||
{%- block title -%}moderation panel{%- endblock -%}
|
||||
{%- block content -%}
|
||||
{{- subheader('Moderation panel') -}}
|
||||
<fieldset class="plank">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{%- 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 thread-actions">
|
||||
<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">
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<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 -%}
|
||||
{%- 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>
|
||||
|
||||
20
app/templates/threads/thread.atom
Normal file
@@ -0,0 +1,20 @@
|
||||
{% from 'common/macros.html' import rss_html_content %}
|
||||
{%- extends 'base.atom' -%}
|
||||
{%- block title -%}replies to {{thread.title}}{%- endblock -%}
|
||||
{%- block canonical_link -%}{{ url_for('threads.thread_by_id', thread_id=thread.id, _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 | escape }}</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 | escape }} @{{ post.username }}</name>
|
||||
<uri>{{ url_for('users.user_page', username=post.username, _external=true) }}</uri>
|
||||
</author>
|
||||
</entry>
|
||||
{%- endfor -%}
|
||||
{%- endblock -%}
|
||||
@@ -1,9 +1,11 @@
|
||||
{%- from 'common/macros.html' import subheader, timestamp, pager, babycode_editor_component -%}
|
||||
{%- from 'common/macros.html' import subheader, timestamp, pager, babycode_editor_component, bookmark_button -%}
|
||||
{%- from 'common/icons.html' import icn_bookmark -%}
|
||||
{%- from 'common/macros.html' import full_post with context -%}
|
||||
{%- from 'common/macros.html' import full_post, bookmark_menu with context -%}
|
||||
{%- extends 'base.html' -%}
|
||||
{%- block title -%}{{thread.title}}{%- endblock -%}
|
||||
{%- block content -%}
|
||||
<bitty-8 data-connect="/static/js/bits/bookmark-menu.js"></bitty-8>
|
||||
<bitty-8 data-connect="/static/js/bits/thread.js"></bitty-8>
|
||||
{%- set td -%}
|
||||
<ul class="horizontal">
|
||||
<li>Started by <a href="{{url_for('users.user_page', username=started_by.username)}}">{{started_by.get_readable_name()}}</a> in topic <a href="{{url_for('topics.topic_by_id', topic_id=topic.id)}}">{{topic.name}}</a></li>
|
||||
@@ -18,7 +20,7 @@
|
||||
</ul>
|
||||
{%- endset -%}
|
||||
{%- call() subheader(thread.title, td) -%}
|
||||
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||
<fieldset class="plank even no-shadow minimal subheader-actions">
|
||||
<legend>Actions</legend>
|
||||
{%- if is_logged_in() -%}
|
||||
{%- if thread.user_id == get_active_user().id -%}
|
||||
@@ -29,12 +31,12 @@
|
||||
<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>
|
||||
<button disabled title="This feature requires JavaScript to be enabled.">{{icn_bookmark(24)}}Bookmark…</button>
|
||||
{{- bookmark_button('thread', thread.id) -}}
|
||||
{%- endif -%}
|
||||
<a href="{{url_for('threads.feed', thread_id=thread.id)}}" class="linkbutton rss">Subscribe via RSS</a>
|
||||
</fieldset>
|
||||
{%- if is_mod() -%}
|
||||
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||
<fieldset class="plank even no-shadow minimal subheader-actions">
|
||||
<legend>Moderation actions</legend>
|
||||
{%- if thread.user_id != get_active_user().id -%}
|
||||
<a class="linkbutton warn" href="{{url_for('threads.edit', thread_id=thread.id)}}">Rename</a>
|
||||
@@ -55,7 +57,7 @@
|
||||
<input type="submit" value="Move" class="warn">
|
||||
</form>
|
||||
</fieldset>
|
||||
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||
<fieldset class="plank even no-shadow minimal subheader-actions">
|
||||
<legend>Page</legend>
|
||||
{{- pager(page, page_count) -}}
|
||||
</fieldset>
|
||||
@@ -63,13 +65,13 @@
|
||||
{%- endcall -%}
|
||||
<main>
|
||||
{%- for post in posts -%}
|
||||
<article id="post-{{post.id}}" class="post plank">
|
||||
<article id="post-{{post.id}}" class="post plank" data-postid="{{post.id}}">
|
||||
{{full_post(post)}}
|
||||
</article>
|
||||
{%- endfor -%}
|
||||
</main>
|
||||
<div class="plank secondary-bg">
|
||||
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||
<fieldset class="plank even no-shadow minimal subheader-actions">
|
||||
<legend>Page</legend>
|
||||
{{- pager(page, page_count) -}}
|
||||
</fieldset>
|
||||
@@ -82,8 +84,28 @@
|
||||
<button>Stop updates</button>
|
||||
</span>
|
||||
</div>
|
||||
{{ bookmark_menu() }}
|
||||
<dialog closedby="any" class="plank thread-lighbox" data-r="showLightbox closeLightbox">
|
||||
<div class="menu">
|
||||
<button data-s="closeLightbox">Close</button>
|
||||
<a href="" target="_blank" rel="noreferrer noopener" class="linkbutton alt">Open original</a>
|
||||
</div>
|
||||
<img class="lightbox-image" src="https://placehold.co/900x710">
|
||||
<div class="menu">
|
||||
<button data-s="lightboxPrevious">Previous</button>
|
||||
<span data-r="lightboxSetCounter">0/0</span>
|
||||
<button data-s="lightboxNext">Next</button>
|
||||
</div>
|
||||
</dialog>
|
||||
{%- 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">
|
||||
<div class="plank even" id="reaction-popover" popover data-r="openReactionMenu closeReactionMenu">
|
||||
{%- for emoji in REACTION_EMOJI -%}
|
||||
<button class="minimal emoji-button" title=":{{emoji}}:" data-emoji="{{emoji}}" data-s="toggleReaction"><img src="/static/emoji/{{emoji}}.png"></button>
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
{%- endif -%}
|
||||
{%- 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>
|
||||
|
||||
21
app/templates/topics/topic.atom
Normal file
@@ -0,0 +1,21 @@
|
||||
{% from 'common/macros.html' import rss_html_content %}
|
||||
{%- extends 'base.atom' -%}
|
||||
{%- block title -%}latest threads in {{topic.name | escape}}{%- endblock -%}
|
||||
{%- block canonical_link -%}{{ url_for('topics.topic_by_id', topic_id=topic.id, _external=true) }}{%- endblock -%}
|
||||
{%- block content -%}
|
||||
<subtitle>{{ topic.description | escape }}</subtitle>
|
||||
{%- for thread in threads_list -%}
|
||||
<entry>
|
||||
<title>{{ thread.title | escape }}</title>
|
||||
<link href="{{ url_for('threads.thread_by_id', thread_id=thread.id, _external=true) }}"/>
|
||||
<link rel="replies" type="application/atom+xml" href="{{ url_for('threads.feed', thread_id=thread.id, _external=true) }}"/>
|
||||
<id>{{ url_for('threads.thread_by_id', thread_id=thread.id, _external=true) }}</id>
|
||||
{{ rss_html_content(thread.original_post_content) }}
|
||||
<updated>{{ thread.created_at | iso8601 }}</updated>
|
||||
<author>
|
||||
<name>{{ thread.started_by_display_name | escape }} @{{ thread.started_by }}</name>
|
||||
<uri>{{ url_for('users.user_page', username=thread.started_by, _external=true) }}</uri>
|
||||
</author>
|
||||
</entry>
|
||||
{%- endfor -%}
|
||||
{%- endblock -%}
|
||||
@@ -12,7 +12,7 @@
|
||||
</ul>
|
||||
{%- endset -%}
|
||||
{%- call() subheader(('Threads in "%s"' % topic.name), td) -%}
|
||||
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||
<fieldset class="plank even no-shadow minimal subheader-actions">
|
||||
<legend>Actions</legend>
|
||||
{%- if is_logged_in() and get_active_user().can_post_to_thread_or_topic(topic) -%}
|
||||
<a href="{{url_for('threads.new', topic_id=topic.id)}}" class="linkbutton">New thread</a>
|
||||
@@ -20,14 +20,14 @@
|
||||
<a href="{{url_for('topics.feed', topic_id=topic.id)}}" class="linkbutton rss">Subscribe via RSS</a>
|
||||
<form method="GET">
|
||||
<select name="sort_by">
|
||||
<option value="activity"{% if sort_by == 'activity' %}selected{% endif %}>Sorted by activity</option>
|
||||
<option value="activity" {% if sort_by == 'activity' %}selected{% endif %}>Sorted by activity</option>
|
||||
<option value="thread" {% if sort_by == 'thread' %}selected{% endif %}>Sorted by newest</option>
|
||||
</select>
|
||||
<input type="submit" value="Sort">
|
||||
</form>
|
||||
</fieldset>
|
||||
{%- if is_mod() -%}
|
||||
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||
<fieldset class="plank even no-shadow minimal subheader-actions">
|
||||
<legend>Moderation actions</legend>
|
||||
<a href="{{url_for('mod.edit_topic', topic_id=topic.id)}}" class="linkbutton">Edit</a>
|
||||
<form action="{{url_for('mod.lock_topic', topic_id=topic.id)}}" method="POST">
|
||||
@@ -37,16 +37,16 @@
|
||||
</fieldset>
|
||||
{%- endif -%}
|
||||
{%- if threads | length > 0 -%}
|
||||
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||
<fieldset class="plank even no-shadow minimal subheader-actions">
|
||||
<legend>Page</legend>
|
||||
{{- pager(page, page_count, args=request.args) -}}
|
||||
</fieldset>
|
||||
{%- endif -%}
|
||||
{%- endcall -%}
|
||||
{{ motd(get_motds()) }}
|
||||
{%- 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">
|
||||
@@ -77,7 +77,7 @@
|
||||
{%- endfor -%}
|
||||
{%- if threads | length > 0 -%}
|
||||
<div class="plank secondary-bg">
|
||||
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||
<fieldset class="plank even no-shadow minimal subheader-actions">
|
||||
<legend>Page</legend>
|
||||
{{- pager(page, page_count, args=request.args) -}}
|
||||
</fieldset>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{%- block content -%}
|
||||
{%- call() subheader('All topics') -%}
|
||||
{%- if is_mod() -%}
|
||||
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||
<fieldset class="plank even no-shadow minimal subheader-actions">
|
||||
<legend>Moderation actions</legend>
|
||||
<a href="{{url_for('mod.new_topic')}}" class="linkbutton">New topic</a>
|
||||
<a href="{{url_for('mod.index', _anchor='sort-topics')}}" class="linkbutton">Sort topics</a>
|
||||
|
||||
56
app/templates/users/bookmarks.html
Normal file
@@ -0,0 +1,56 @@
|
||||
{%- from 'common/macros.html' import full_post, bookmark_menu with context -%}
|
||||
{%- from 'common/macros.html' import subheader, bookmark_button -%}
|
||||
{%- extends 'base.html' -%}
|
||||
{%- block title -%}bookmarks"{%- endblock -%}
|
||||
{%- block content -%}
|
||||
<bitty-8 data-connect="/static/js/bits/bookmark-menu.js"></bitty-8>
|
||||
<bitty-8 data-connect="/static/js/bits/bookmarks.js"></bitty-8>
|
||||
{%- call() subheader('Your bookmarks') -%}
|
||||
<fieldset class="plank even no-shadow minimal subheader-actions js-only" data-r="enhance">
|
||||
<legend>Actions</legend>
|
||||
<a href="{{url_for('users.bookmark_collections', username=get_active_user().username)}}" class="linkbutton">Manage collections</a>
|
||||
</fieldset>
|
||||
{%- endcall -%}
|
||||
<div class="plank">
|
||||
{%- for collection in collections -%}
|
||||
{%- set thread_count = collection.get_threads_count() -%}
|
||||
{%- 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>
|
||||
<tr>
|
||||
<th class="plank even no-shadow contrast-bg" style="--w:65%">Title</th>
|
||||
<th class="plank even no-shadow contrast-bg" style="--w:25%">Memo</th>
|
||||
<th class="plank even no-shadow contrast-bg" style="--w:10%">Manage</th>
|
||||
</tr>
|
||||
</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>
|
||||
</details>
|
||||
{%- endif -%}
|
||||
{%- if post_count > 0 -%}
|
||||
<details class="inner" data-id="{{collection.id}}" data-r="restorePostDetails setPostDetails" data-s="setPostDetails">
|
||||
<summary class="plank no-shadow even">Posts</summary>
|
||||
{%- for bp 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>
|
||||
{%- endfor -%}
|
||||
</details>
|
||||
{%- endif -%}
|
||||
</details>
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
{{ bookmark_menu() }}
|
||||
{%- endblock -%}
|
||||
@@ -12,7 +12,22 @@
|
||||
You do not have any subscriptions.
|
||||
{%- endif -%}
|
||||
{%- endset -%}
|
||||
{{ subheader('Your inbox', topline) }}
|
||||
{%- call() subheader('Your inbox', topline) -%}
|
||||
{%- if subscriptions -%}
|
||||
<fieldset class="plank even no-shadow minimal subheader-actions">
|
||||
<legend>Actions</legend>
|
||||
<form method="POST" action="{{url_for('threads.mark_read')}}">
|
||||
{%- for sub in subscriptions -%}
|
||||
<input type="hidden" name="id[]" value="{{sub.id}}">
|
||||
{%- endfor -%}
|
||||
<button>Mark all as read</button>
|
||||
</form>
|
||||
<form method="POST" action="{{url_for('threads.unsubscribe_all')}}">
|
||||
<button class="warn">Unsubscribe from all</button>
|
||||
</form>
|
||||
</fieldset>
|
||||
{%- endif -%}
|
||||
{%- endcall -%}
|
||||
{%- if subscriptions | length > 0 -%}
|
||||
<div class="plank">
|
||||
{%- for sub in subscriptions -%}
|
||||
@@ -20,11 +35,17 @@
|
||||
{%- set thread = sub.get_thread() -%}
|
||||
<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)}}">
|
||||
<div>
|
||||
<form class="inline horizontal" 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 class="inline horizontal" method="POST" action="{{url_for('threads.mark_read')}}">
|
||||
<input type="hidden" name="id[]" value="{{sub.id}}">
|
||||
<button>Mark as read</button>
|
||||
</form>
|
||||
</div>
|
||||
</summary>
|
||||
{%- set posts = sub.get_full_posts_view() -%}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ Welcome back! No account yet? <a href="{{url_for('users.sign_up')}}">Sign up</a>
|
||||
<input type="text" id="username" name="username" autocomplete="username" required>
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" autocomplete="current-password" required>
|
||||
<span><input type="checkbox" name="remember" id="remember"> <label for="remember">Remember me</label></span>
|
||||
<div class="inline-group"><input type="checkbox" name="remember" id="remember"> <label for="remember">Remember me</label></div>
|
||||
<input type="submit" value="Log in">
|
||||
</form>
|
||||
{%- 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 -%}
|
||||
@@ -8,7 +8,7 @@
|
||||
{%- endset -%}
|
||||
{%- call() subheader("%s's posts" % target_user.get_readable_name(), td) -%}
|
||||
{%- if posts -%}
|
||||
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||
<fieldset class="plank even no-shadow minimal subheader-actions">
|
||||
<legend>Page</legend>
|
||||
{{- pager(page, page_count) -}}
|
||||
</fieldset>
|
||||
@@ -19,7 +19,7 @@
|
||||
<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 thread-actions">
|
||||
<fieldset class="plank even no-shadow minimal subheader-actions">
|
||||
<legend>Page</legend>
|
||||
{{- pager(page, page_count) -}}
|
||||
</fieldset>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{%- from 'common/macros.html' import babycode_editor_component -%}
|
||||
{%- from 'common/macros.html' import subheader, avatar -%}
|
||||
{%- from 'common/macros.html' import subheader, avatar, timestamp -%}
|
||||
{%- extends 'base.html' -%}
|
||||
{%- block title -%}settings{%- endblock -%}
|
||||
{%- block content -%}
|
||||
@@ -15,7 +15,7 @@
|
||||
<span class="avatar-form-controls">
|
||||
<label for="avatar" class="linkbutton alt">Upload…</label>
|
||||
<span class="avatar-form-size-label">1MB max. Will be cropped to square.</span>
|
||||
<input type="file" style="display: none;" id="avatar" name="avatar" accept="image/*">
|
||||
<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>
|
||||
@@ -48,10 +48,10 @@
|
||||
<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>
|
||||
<label for="status">Status</label>
|
||||
<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">
|
||||
<span>
|
||||
<div class="inline-group">
|
||||
<input type="checkbox" id="subscribe-by-default" name="subscribe_by_default" {{'' if session['dont_subscribe_by_default'] else 'checked'}} autocomplete="off">
|
||||
<label for="subscribe-by-default">Automatically subscribe to thread when responding</label>
|
||||
</span>
|
||||
</div>
|
||||
<input type="submit" value="Save">
|
||||
</form>
|
||||
</fieldset>
|
||||
@@ -72,10 +72,52 @@
|
||||
</form>
|
||||
</fieldset>#}
|
||||
<fieldset class="plank">
|
||||
<bitty-8 data-connect="/static/js/bits/badge-editor.js"></bitty-8>
|
||||
<legend>Badges</legend>
|
||||
<div>Loading badges…</div>
|
||||
<div>If badges fail to load, make sure JS is enabled.</div>
|
||||
<form method="POST" action="{{url_for('users.save_badges', username=get_active_user().username)}}" data-listen="submit" data-r="badgeEditorInit" enctype="multipart/form-data">
|
||||
<p>Loading badges…</p>
|
||||
<p>If badges fail to load, make sure JS is enabled.</p>
|
||||
</form>
|
||||
</fieldset>
|
||||
{%- if user.can_invite() -%}
|
||||
<fieldset class="plank" id="invite">
|
||||
<legend>Invite keys</legend>
|
||||
<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>
|
||||
<p>Invite links are valid for 48 hours. Once an invite link is used to sign up, it can no longer be used.</p>
|
||||
<form method="POST" action="{{url_for('users.create_invite_key', username=user.username)}}">
|
||||
{{ csrf_input() | safe }}
|
||||
<input type="submit" value="Create new invite">
|
||||
</form>
|
||||
{%- if invites -%}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="plank even no-shadow contrast-bg" style="--w: 50%;">Link</th>
|
||||
<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>
|
||||
|
||||
@@ -4,13 +4,22 @@
|
||||
{%- block title -%}sign up{%- endblock -%}
|
||||
{%- block content -%}
|
||||
{%- set welcome -%}
|
||||
Please read the rules etc. stub
|
||||
<p>Please read the rules etc. stub</p>
|
||||
{%- if not inviter -%}
|
||||
<p>After you sign up, a moderator will need to confirm your account before you will be allowed to post.
|
||||
{%- else -%}
|
||||
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 -%}
|
||||
</p>
|
||||
{%- endset -%}
|
||||
{{ subheader('Sign up', welcome)}}
|
||||
{%- if request.args.get('error') -%}
|
||||
{{infobox(request.args.error, InfoboxKind.ERROR)}}
|
||||
{%- endif -%}
|
||||
<form class="plank primary-bg full-width" method="POST">
|
||||
{%- if invite -%}
|
||||
<input type="hidden" name="key" value="{{invite.key}}">
|
||||
{%- endif -%}
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" pattern="[a-zA-Z0-9_\-]{3,24}" title="3-24 characters. Only upper and lowercase letters, digits, hyphens, and underscores" autocomplete="username" required>
|
||||
<label for="password">Create password</label>
|
||||
@@ -18,6 +27,6 @@ Please read the rules etc. stub
|
||||
<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">
|
||||
<input type="submit" value="Sign up" class="alt">
|
||||
</form>
|
||||
{%- endblock -%}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
{%- endset -%}
|
||||
{%- call() subheader("%s's started threads" % target_user.get_readable_name(), td) -%}
|
||||
{%- if threads -%}
|
||||
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||
<fieldset class="plank even no-shadow minimal subheader-actions">
|
||||
<legend>Page</legend>
|
||||
{{- pager(page, page_count) -}}
|
||||
</fieldset>
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
{%- endfor -%}
|
||||
<div class="plank">
|
||||
<fieldset class="plank even no-shadow minimal thread-actions">
|
||||
<fieldset class="plank even no-shadow minimal subheader-actions">
|
||||
<legend>Page</legend>
|
||||
{{- pager(page, page_count) -}}
|
||||
</fieldset>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{%- from 'common/macros.html' import subheader, timestamp, pager, avatar -%}
|
||||
{%- extends 'base.html' -%}
|
||||
{%- block title -%}{{ target_user.get_readable_name() }}'s profile{%- endblock -%}
|
||||
{%- 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 thread-actions">
|
||||
<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">
|
||||
@@ -16,9 +16,9 @@
|
||||
{%- 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 thread-actions">
|
||||
<fieldset class="plank even no-shadow minimal subheader-actions">
|
||||
<legend>Moderation actions</legend>
|
||||
<form class="thread-actions" method="POST">
|
||||
<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)}}">
|
||||
@@ -45,16 +45,17 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="plank even minimal no-shadow user-stats">
|
||||
<h3 class="info">{{target_user.get_readable_name()}}</h3>
|
||||
<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>
|
||||
{%- set time = target_user.created_at -%}
|
||||
{%- if target_user.approved_at -%}
|
||||
{%- set time = target_user.approved_at -%}
|
||||
{%- 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 -%}
|
||||
<span>Joined: {{timestamp(target_user.created_at)}}</span>
|
||||
{%- if not target_user.is_guest() -%}
|
||||
<span>Posts: <a href="{{url_for('users.posts', username=target_user.username)}}">{{stats.post_count}}</a></span>
|
||||
<span>Threads started: <a href="{{url_for('users.threads', username=target_user.username)}}">{{stats.thread_count}}</a></span>
|
||||
|
||||
@@ -15,6 +15,9 @@ SERVER_NAME = "forum.your.domain"
|
||||
# your forum's name, shown on the header.
|
||||
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.
|
||||
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 |
|
Before Width: | Height: | Size: 842 B After Width: | Height: | Size: 848 B |
|
Before Width: | Height: | Size: 658 B After Width: | Height: | Size: 676 B |
|
Before Width: | Height: | Size: 620 B After Width: | Height: | Size: 646 B |
@@ -92,17 +92,18 @@ button, .linkbutton, input[type="submit"], input[type="file"]::file-selector-but
|
||||
--hover-color: hsl(from var(--main-color) h s calc(l * 1.05));
|
||||
--active-color: hsl(from var(--main-color) h s calc(l * 0.8));
|
||||
--disabled-color: hsl(from var(--main-color) h calc(s * 0.5) l);
|
||||
--bottom-color: hsl(from var(--main-color) h s calc(l * 0.7));
|
||||
--top-color: hsl(from var(--main-color) h s calc(l * 1.2));
|
||||
--bottom-color: hsl(from var(--main-color) h s calc(l * 0.8));
|
||||
--top-color: hsl(from var(--main-color) h s 90);
|
||||
--top-color2: hsl(from var(--main-color) h s calc(l * 1.1));
|
||||
--inset-color: #fff7;
|
||||
--current-color: var(--main-color);
|
||||
/* position: relative; */
|
||||
/* display: inline-block; */
|
||||
padding: var(--small-padding) var(--medium-padding);
|
||||
margin: var(--base-padding) 0px;
|
||||
border-radius: var(--border-radius);
|
||||
border: solid var(--border-thickness) var(--border-color);
|
||||
background: linear-gradient(var(--top-color) 0%, var(--top-color2) 25%, var(--main-color) 26%, var(--main-color) 50%, var(--bottom-color) 100%);
|
||||
background: linear-gradient(to bottom, var(--top-color), var(--main-color) 50%, var(--bottom-color) 75%);
|
||||
/* box-shadow: inset 0px 2px 5px 3px var(--inset-color); */
|
||||
/* color: var(--font-color); */
|
||||
/* HACK: better than contrast-color on critical */
|
||||
@@ -141,7 +142,8 @@ button, .linkbutton, input[type="submit"], input[type="file"]::file-selector-but
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(var(--top-color) 0%, var(--top-color2) 25%, var(--hover-color) 26%, var(--hover-color) 80%, var(--bottom-color) 100%);
|
||||
/*background: linear-gradient(var(--top-color) 0%, var(--top-color2) 10%, var(--hover-color) 12%, var(--hover-color) 80%, var(--bottom-color) 100%);*/
|
||||
background: linear-gradient(to bottom, var(--top-color), var(--hover-color) 50%, var(--hover-color) 80%, var(--bottom-color) 100%);
|
||||
}
|
||||
|
||||
&:is(:active, .active, [aria-selected='true']) {
|
||||
@@ -199,14 +201,13 @@ button, .linkbutton, input[type="submit"], input[type="file"]::file-selector-but
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
input[type="text"], input[type="password"], textarea, select {
|
||||
input[type="text"], input[type="password"], input[type="url"], textarea, select {
|
||||
--main-color: hsl(from var(--bg-color-primary) h s calc(l + 10));
|
||||
--active-color: hsl(from var(--main-color) h s calc(l + 5));
|
||||
--border-color: hsl(from var(--main-color) h calc(s * 1.3) 25);
|
||||
background-color: var(--main-color);
|
||||
border-radius: var(--border-radius);
|
||||
border: solid var(--border-thickness) var(--border-color);
|
||||
resize: vertical;
|
||||
|
||||
padding: var(--small-padding) var(--medium-padding);
|
||||
margin: var(--base-padding) 0px;
|
||||
@@ -217,7 +218,8 @@ input[type="text"], input[type="password"], textarea, select {
|
||||
}
|
||||
|
||||
textarea {
|
||||
font-family: 'Atkinson Hyperlegible Mono'
|
||||
font-family: 'Atkinson Hyperlegible Mono';
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@@ -263,7 +265,7 @@ a.site-title {
|
||||
--rotation: 180deg;
|
||||
padding: var(--medium-padding) var(--huge-padding);
|
||||
|
||||
background: linear-gradient(var(--rotation), var(--lighter-color) 0%, var(--main-color) 30%, var(--main-color) 70%, var(--darker-color) 100%);
|
||||
background: linear-gradient(var(--rotation), var(--lighter-color) 0%, var(--main-color) 40px, var(--main-color) calc(100% - 40px), var(--darker-color) 100%);
|
||||
background-color: var(--main-color);
|
||||
|
||||
border: 2px groove var(--border-color);
|
||||
@@ -297,6 +299,25 @@ a.site-title {
|
||||
margin-top: var(--medium-padding);
|
||||
}
|
||||
}
|
||||
|
||||
&.primary-bg {
|
||||
--main-color: var(--bg-color-primary);
|
||||
background-color: var(--bg-color-primary);
|
||||
}
|
||||
|
||||
&.secondary-bg {
|
||||
--main-color: var(--bg-color-secondary);
|
||||
--rotation: 0deg;
|
||||
}
|
||||
|
||||
&.tertiary-bg {
|
||||
--main-color: var(--bg-color-tertiary);
|
||||
--rotation: 0deg;
|
||||
}
|
||||
|
||||
&.contrast-bg {
|
||||
--main-color: var(--bg-color-contrast);
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
@@ -314,6 +335,10 @@ form.horizontal {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&.inline {
|
||||
display: inline flex;
|
||||
}
|
||||
|
||||
&> fieldset {
|
||||
display: flex;
|
||||
gap: var(--base-padding);
|
||||
@@ -368,25 +393,6 @@ ul.horizontal, ol.horizontal {
|
||||
}
|
||||
}
|
||||
|
||||
.primary-bg {
|
||||
--main-color: var(--bg-color-primary);
|
||||
background-color: var(--bg-color-primary);
|
||||
}
|
||||
|
||||
.secondary-bg {
|
||||
--main-color: var(--bg-color-secondary);
|
||||
--rotation: 0deg;
|
||||
}
|
||||
|
||||
.tertiary-bg {
|
||||
--main-color: var(--bg-color-tertiary);
|
||||
--rotation: 0deg;
|
||||
}
|
||||
|
||||
.contrast-bg {
|
||||
--main-color: var(--bg-color-contrast);
|
||||
}
|
||||
|
||||
.motd {
|
||||
display: flex;
|
||||
gap: var(--big-padding);
|
||||
@@ -465,7 +471,7 @@ footer {
|
||||
gap: var(--base-padding);
|
||||
}
|
||||
|
||||
.thread-actions {
|
||||
.subheader-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--base-padding);
|
||||
@@ -520,6 +526,7 @@ footer {
|
||||
border-radius: var(--base-padding);
|
||||
border: var(--base-padding) outset gray;
|
||||
box-shadow: 0px 0px 12px 2px #0006;
|
||||
align-self: center;
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -629,9 +636,20 @@ form.full-width {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
&> textarea, &> select, &> input[type="text"], &> input[type="password"] {
|
||||
gap: var(--small-padding);
|
||||
&> textarea, &> select, &> input[type="text"], &> input[type="password"], &> .inline-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&> .inline-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--base-padding);
|
||||
|
||||
&> label {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.context-explain {
|
||||
@@ -651,12 +669,12 @@ details {
|
||||
}
|
||||
}
|
||||
|
||||
&:not([open]) summary::before {
|
||||
&:not([open]) > summary::before {
|
||||
content: '▶';
|
||||
padding-inline: var(--base-padding);
|
||||
}
|
||||
|
||||
&[open] summary::before {
|
||||
&[open] > summary::before {
|
||||
content: '▼';
|
||||
padding-inline: var(--base-padding);
|
||||
}
|
||||
@@ -666,6 +684,10 @@ details.separated {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
details.inner {
|
||||
margin-inline: var(--base-padding);
|
||||
}
|
||||
|
||||
.avatar-form {
|
||||
display: flex;
|
||||
gap: var(--huge-padding);
|
||||
@@ -678,6 +700,205 @@ details.separated {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#reaction-popover {
|
||||
position: absolute;
|
||||
margin-block: var(--small-padding);
|
||||
margin-inline: 0;
|
||||
width: 300px;
|
||||
--button-size: calc(var(--huge-padding) * 2);
|
||||
|
||||
.emoji-button {
|
||||
min-width: var(--button-size);
|
||||
min-height: var(--button-size);
|
||||
|
||||
img {
|
||||
image-rendering: crisp-edges;
|
||||
width: 30px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#reaction-popover:popover-open {
|
||||
--gap: var(--base-padding);
|
||||
--max-columns: 4;
|
||||
display: grid;
|
||||
gap: var(--gap);
|
||||
justify-items: center;
|
||||
|
||||
--grid-item-size: calc((100% - var(--gap) * var(--max-columns)) / var(--max-columns));
|
||||
|
||||
grid-template-columns: repeat(
|
||||
auto-fit,
|
||||
minmax(max(var(--button-size), var(--grid-item-size)), 1fr)
|
||||
);
|
||||
}
|
||||
|
||||
#bookmark-popover {
|
||||
position: absolute;
|
||||
min-width: 400px;
|
||||
max-width: 400px;
|
||||
max-height: 500px;
|
||||
margin-block: var(--small-padding);
|
||||
margin-inline: 0;
|
||||
padding-inline: var(--medium-padding);
|
||||
|
||||
overflow: scroll;
|
||||
|
||||
.bookmark-menu-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.bookmark-menu-inner .errors.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.bookmark-menu-item {
|
||||
padding-block: var(--medium-padding);
|
||||
padding-inline: var(--base-padding);
|
||||
|
||||
&:has(.bookmark-menu-label:hover, input:hover) {
|
||||
background-color: #0001;
|
||||
}
|
||||
.bookmark-menu-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
th {
|
||||
width: var(--w, 50%);
|
||||
}
|
||||
|
||||
td.center {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
ol.sortable-list {
|
||||
display: flex;
|
||||
gap: var(--base-padding);
|
||||
flex-direction: column;
|
||||
list-style: none;
|
||||
flex-grow: 1;
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
gap: var(--big-padding);
|
||||
}
|
||||
|
||||
li.immovable .dragger {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.plank.dragger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/*background-color: var(--bg-color-tertiary);*/
|
||||
padding: var(--base-padding);
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.sortable-item-inner {
|
||||
display: flex;
|
||||
gap: var(--base-padding);
|
||||
flex-grow: 1;
|
||||
flex-direction: column;
|
||||
|
||||
& > * {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
&.row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
&:not(.row):not(.full) > * {
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-editor-badge-container {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--base-padding);
|
||||
|
||||
& > input[type=text], & > input[type=url] {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-editor-file-picker {
|
||||
display: flex;
|
||||
gap: var(--base-padding);
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 150px;
|
||||
|
||||
& > input[type=file] {
|
||||
width: 100%;
|
||||
|
||||
&::file-selector-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-editor-badge-select {
|
||||
display: flex;
|
||||
gap: var(--base-padding);
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 200px;
|
||||
& > select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.js-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
dialog.plank.thread-lighbox {
|
||||
margin: auto;
|
||||
min-width: 80vw;
|
||||
min-height: 70vh;
|
||||
max-width: 80vw;
|
||||
max-height: 70vh;
|
||||
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
& > .menu {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&:open {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.lightbox-image {
|
||||
max-width: 50vw;
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
/* babycode tags */
|
||||
.inline-code {
|
||||
background-color: var(--code-bg-color);
|
||||
@@ -848,7 +1069,7 @@ a.mention {
|
||||
padding: var(--base-padding);
|
||||
background-color: var(--mention-color);
|
||||
color: black;
|
||||
border: 1px dashed;
|
||||
border: 1px solid hsl(from var(--mention-color) h s 55%);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--hover-color);
|
||||
@@ -859,53 +1080,6 @@ a.mention {
|
||||
}
|
||||
}
|
||||
|
||||
ol.sortable-list {
|
||||
display: flex;
|
||||
gap: var(--base-padding);
|
||||
flex-direction: column;
|
||||
list-style: none;
|
||||
flex-grow: 1;
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
gap: var(--big-padding);
|
||||
}
|
||||
|
||||
li.immovable .dragger {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.plank.dragger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/*background-color: var(--bg-color-tertiary);*/
|
||||
padding: var(--base-padding);
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.sortable-item-inner {
|
||||
display: flex;
|
||||
gap: var(--base-padding);
|
||||
flex-grow: 1;
|
||||
flex-direction: column;
|
||||
|
||||
& > * {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
&.row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
&:not(.row) > * {
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
margin-left: 0;
|
||||
@@ -928,9 +1102,9 @@ ol.sortable-list {
|
||||
min-height: 140px;
|
||||
}
|
||||
|
||||
.usercard-inner {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
.usercard-inner:has(.usercard-rest) {
|
||||
display: grid;
|
||||
grid-template-columns: min-content 1fr;
|
||||
}
|
||||
|
||||
.thread-title-counter {
|
||||
@@ -971,4 +1145,22 @@ ol.sortable-list {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.badge-editor-badge-container {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
dialog.plank.thread-lighbox {
|
||||
margin: 0;
|
||||
min-width: 100vw;
|
||||
min-height: 100vh;
|
||||
max-width: unset;
|
||||
max-height: unset;
|
||||
}
|
||||
|
||||
.lightbox-image {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
164
data/static/js/bits/badge-editor.js
Normal file
@@ -0,0 +1,164 @@
|
||||
async function getHTML(endpoint, options = {}) {
|
||||
let query = {};
|
||||
if (options._query !== undefined) {
|
||||
query = options._query;
|
||||
delete options._query;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(query);
|
||||
const res = await fetch(`${endpoint}?${params}`, options);
|
||||
|
||||
return { body: await res.text(), status: res.status };
|
||||
}
|
||||
|
||||
const validateBase64Img = dataURL => new Promise(resolve => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
resolve(img.width === 88 && img.height === 31);
|
||||
};
|
||||
img.src = dataURL;
|
||||
});
|
||||
|
||||
export const b = {
|
||||
init: 'badgeEditorInit',
|
||||
}
|
||||
|
||||
const badgeEditorEndpoint = '/hyperapi/badges/editor/'
|
||||
const MAX_BADGES = 10;
|
||||
let badgesCount = 0;
|
||||
let customImageDatas = {};
|
||||
|
||||
export async function badgeEditorInit(_, __, el) {
|
||||
const res = await getHTML(badgeEditorEndpoint);
|
||||
if (res.status != 200) {
|
||||
return;
|
||||
}
|
||||
el.innerHTML = res.body;
|
||||
badgesCount = el.querySelectorAll('.sortable-item').length;
|
||||
b.trigger('badgeEditorAssignImgId');
|
||||
b.trigger('setBadgeCount');
|
||||
}
|
||||
|
||||
export function badgeEditorAssignImgId(_, __, el) {
|
||||
if (el.dataset.imgId) return;
|
||||
|
||||
const id = b.uuid();
|
||||
const filePicker = el.querySelector('input[type=file]');
|
||||
const img = el.querySelector('img.badge-button');
|
||||
console.log(img);
|
||||
el.dataset.imgId = id;
|
||||
filePicker.dataset.imgId = id;
|
||||
img.dataset.imgId = id;
|
||||
}
|
||||
|
||||
export function badgeEditorSetPreview(ev, sender, el) {
|
||||
if (!sender.parentNode.contains(el)) return;
|
||||
|
||||
const selectedItem = sender.selectedOptions[0];
|
||||
if (selectedItem.value !== 'custom') {
|
||||
el.src = selectedItem.dataset.filePath;
|
||||
} else if (customImageDatas[el.dataset.imgId]) {
|
||||
el.src = customImageDatas[el.dataset.imgId];
|
||||
} else {
|
||||
el.removeAttribute('src');
|
||||
}
|
||||
}
|
||||
|
||||
export function badgeEditorSetPreviewCustom(payload, _, el) {
|
||||
if (!payload.badge.contains(el)) return;
|
||||
if (!customImageDatas[el.dataset.imgId]) {
|
||||
el.removeAttribute('src');
|
||||
} else {
|
||||
el.src = customImageDatas[el.dataset.imgId];
|
||||
}
|
||||
}
|
||||
|
||||
export function badgeEditorToggleFilePicker(ev, sender, el) {
|
||||
if (!sender.parentNode.parentNode.contains(el)) return;
|
||||
|
||||
const selectedItem = sender.selectedOptions[0];
|
||||
const picker = el.querySelector('input[type=file]');
|
||||
if (selectedItem.value !== 'custom') {
|
||||
el.classList.add('hidden');
|
||||
picker.required = false;
|
||||
picker.setCustomValidity('');
|
||||
} else {
|
||||
el.classList.remove('hidden');
|
||||
picker.required = true;
|
||||
picker.setCustomValidity(picker.dataset.validity || '');
|
||||
}
|
||||
}
|
||||
|
||||
export function badgeEditorAddBadge(ev, sender, el) {
|
||||
// TODO: page templates do not get updated on mutation
|
||||
const badgeTemplate = document.getElementById('badge-template').innerText;
|
||||
const parser = new DOMParser();
|
||||
const e = parser.parseFromString(badgeTemplate, 'text/html').body.firstElementChild;
|
||||
el.appendChild(e);
|
||||
b.trigger('badgeEditorAssignImgId');
|
||||
badgesCount++;
|
||||
b.trigger('setBadgeCount');
|
||||
}
|
||||
|
||||
export function badgeEditorDelete(ev, sender, el) {
|
||||
if (!el.contains(sender)) return;
|
||||
el.remove();
|
||||
badgesCount--;
|
||||
b.trigger('setBadgeCount');
|
||||
}
|
||||
|
||||
export function badgeEditorShowFilePicker(ev, sender, el) {
|
||||
if (sender.nextElementSibling !== el) return;
|
||||
el.showPicker();
|
||||
}
|
||||
|
||||
export async function badgeEditorFileSelected(ev, sender, el) {
|
||||
const file = sender.files[0];
|
||||
const badge = sender.parentNode.parentNode;
|
||||
|
||||
if (
|
||||
!['image/png', 'image/jpeg', 'image/jpg', 'image/webp'].includes(file.type)
|
||||
) {
|
||||
sender.dataset.validity = 'The badge file must be an image.';
|
||||
sender.setCustomValidity(sender.dataset.validity);
|
||||
sender.reportValidity();
|
||||
customImageDatas[sender.dataset.imgId] = null;
|
||||
b.send({ badge: badge }, 'badgeEditorSetPreviewCustom');
|
||||
return;
|
||||
}
|
||||
if (file.size >= 1000 * 500) {
|
||||
sender.dataset.validity = 'The badge image must be smaller than 500KB.';
|
||||
sender.setCustomValidity(sender.dataset.validity);
|
||||
sender.reportValidity();
|
||||
customImageDatas[sender.dataset.imgId] = null;
|
||||
b.send({ badge: badge }, 'badgeEditorSetPreviewCustom');
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async e => {
|
||||
const dimsValid = await validateBase64Img(e.target.result);
|
||||
if (!dimsValid) {
|
||||
sender.setCustomValidity('The badge image must be exactly 88x31 pixels.');
|
||||
sender.reportValidity();
|
||||
customImageDatas[sender.dataset.imgId] = null;
|
||||
b.send({ badge: badge }, 'badgeEditorSetPreviewCustom');
|
||||
return;
|
||||
}
|
||||
customImageDatas[sender.dataset.imgId] = e.target.result;
|
||||
|
||||
sender.dataset.validity = '';
|
||||
sender.setCustomValidity('');
|
||||
b.send({ badge: badge }, 'badgeEditorSetPreviewCustom');
|
||||
}
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
export function setBadgeCount(_, __, el) {
|
||||
if (el instanceof HTMLButtonElement) {
|
||||
el.disabled = badgesCount === MAX_BADGES;
|
||||
} else {
|
||||
el.innerText = `${badgesCount}/${MAX_BADGES}`;
|
||||
}
|
||||
}
|
||||
127
data/static/js/bits/bookmark-menu.js
Normal file
@@ -0,0 +1,127 @@
|
||||
async function getHTML(endpoint, options = {}) {
|
||||
let query = {};
|
||||
if (options._query !== undefined) {
|
||||
query = options._query;
|
||||
delete options._query;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(query);
|
||||
const res = await fetch(`${endpoint}?${params}`, options);
|
||||
// if (!res.ok) {
|
||||
// console.error(res);
|
||||
// }
|
||||
|
||||
return { body: await res.text(), status: res.status };
|
||||
}
|
||||
|
||||
export const b = {};
|
||||
|
||||
const BOOKMARKS_COLLECTION_ENDPOINT = '/hyperapi/bookmarks/dropdown/';
|
||||
let bookmarkMenuState = {};
|
||||
|
||||
export async function showBookmarkMenu(ev, sender, el) {
|
||||
if (bookmarkMenuState.state === undefined) {
|
||||
el.addEventListener('toggle', e => {
|
||||
if (e.newState === 'closed') {
|
||||
bookmarkMenuState.state = 'closed';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// dismiss if open and last invoker is the same button that opened it
|
||||
if (bookmarkMenuState.state === 'open' && bookmarkMenuState.invoker === sender) {
|
||||
el.hidePopover();
|
||||
return;
|
||||
}
|
||||
|
||||
bookmarkMenuState.invoker = sender;
|
||||
bookmarkMenuState.state = 'open';
|
||||
b.send({ 'plain': 'Loading…' }, 'fillBookmarkMenu');
|
||||
el.showPopover();
|
||||
const bRect = sender.getBoundingClientRect();
|
||||
const menuRect = el.getBoundingClientRect();
|
||||
const preferredLeft = bRect.right - menuRect.width;
|
||||
const enoughSpace = preferredLeft >= 0;
|
||||
|
||||
const scrollY = window.scrollY;
|
||||
|
||||
if (enoughSpace) {
|
||||
el.style.left = `${preferredLeft}px`;
|
||||
} else {
|
||||
el.style.left = `${bRect.left}px`;
|
||||
}
|
||||
el.style.top = `${bRect.bottom + scrollY}px`;
|
||||
|
||||
bookmarkMenuState.kind = sender.dataset.conceptKind;
|
||||
bookmarkMenuState.id = sender.dataset.conceptId;
|
||||
|
||||
const bookmarkCollections = await getHTML(BOOKMARKS_COLLECTION_ENDPOINT, {
|
||||
_query: {
|
||||
concept_kind: bookmarkMenuState.kind,
|
||||
concept_id: bookmarkMenuState.id,
|
||||
}
|
||||
});
|
||||
b.send({ 'html': bookmarkCollections.body }, 'fillBookmarkMenu');
|
||||
}
|
||||
|
||||
export function fillBookmarkMenu(payload, __, el) {
|
||||
if (payload.plain) {
|
||||
el.innerText = payload.plain;
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = payload.html;
|
||||
}
|
||||
|
||||
export async function bookmarkMenuSubmit(ev, _, el) {
|
||||
ev.preventDefault();
|
||||
const url = el.action;
|
||||
const body = new URLSearchParams(new FormData(el));
|
||||
const options = { body: body, method: 'POST' };
|
||||
const status = (await getHTML(url, options)).status;
|
||||
|
||||
if (status !== 204) {
|
||||
b.send({ status: status }, 'bookmarkMenuShowError');
|
||||
return;
|
||||
}
|
||||
|
||||
const newCollections = await getHTML(BOOKMARKS_COLLECTION_ENDPOINT, {
|
||||
_query: {
|
||||
concept_kind: bookmarkMenuState.kind,
|
||||
concept_id: bookmarkMenuState.id,
|
||||
saved: true,
|
||||
}
|
||||
});
|
||||
b.send({ 'html': newCollections.body }, 'fillBookmarkMenu');
|
||||
}
|
||||
|
||||
export function bookmarkMenuResetSavedButton(_, __, el) {
|
||||
el.value = 'Save';
|
||||
}
|
||||
|
||||
export function bookmarkMenuShowError(payload, _, el) {
|
||||
if (el === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.status === 404) {
|
||||
el.innerText = 'This thread or post no longer exists. Please refresh the page.';
|
||||
} else {
|
||||
el.innerText = 'Something went wrong. Try again later.';
|
||||
}
|
||||
|
||||
if (el.classList.contains('hidden')) {
|
||||
el.classList.remove('hidden');
|
||||
setTimeout(() => { b.trigger('bookmarkMenuHideError') }, 4000);
|
||||
}
|
||||
}
|
||||
|
||||
export function bookmarkMenuHideError(_, __, el) {
|
||||
if (el === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!el.classList.contains('hidden')) {
|
||||
el.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
62
data/static/js/bits/bookmarks.js
Normal file
@@ -0,0 +1,62 @@
|
||||
export const b = {
|
||||
init: 'restoreCollectionDetails restoreThreadDetails restorePostDetails',
|
||||
}
|
||||
|
||||
const COLLECTION_DETAILS_KEY = 'collectionsOpen';
|
||||
const THREAD_DETAILS_KEY = 'threadsOpen';
|
||||
const POST_DETAILS_KEY = 'postsOpen';
|
||||
|
||||
let collectionDetailsData = {};
|
||||
let collectionThreadDetailsData = {};
|
||||
let collectionPostDetailsData = {};
|
||||
|
||||
async function setDetailsData(obj, key, id, isOpen) {
|
||||
obj[id] = isOpen;
|
||||
await b.savePageData(obj, key);
|
||||
}
|
||||
|
||||
export async function restoreCollectionDetails(_, __, el) {
|
||||
collectionDetailsData = await b.loadPageData(COLLECTION_DETAILS_KEY, {});
|
||||
el.open = collectionDetailsData[el.dataset.id] === true;
|
||||
}
|
||||
|
||||
export async function setCollectionDetails(ev, sender, el) {
|
||||
if (el !== sender) {
|
||||
return;
|
||||
}
|
||||
if (ev.target !== el.querySelector('summary')) {
|
||||
return;
|
||||
}
|
||||
console.log(!el.open);
|
||||
await setDetailsData(collectionDetailsData, COLLECTION_DETAILS_KEY, el.dataset.id, !el.open);
|
||||
}
|
||||
|
||||
export async function restoreThreadDetails(_, __, el) {
|
||||
collectionThreadDetailsData = await b.loadPageData(THREAD_DETAILS_KEY, {});
|
||||
el.open = collectionThreadDetailsData[el.dataset.id] === true;
|
||||
}
|
||||
|
||||
export async function setThreadDetails(ev, sender, el) {
|
||||
if (el !== sender) {
|
||||
return;
|
||||
}
|
||||
if (ev.target !== el.querySelector('summary')) {
|
||||
return;
|
||||
}
|
||||
await setDetailsData(collectionThreadDetailsData, THREAD_DETAILS_KEY, el.dataset.id, !el.open);
|
||||
}
|
||||
|
||||
export async function restorePostDetails(_, __, el) {
|
||||
collectionPostDetailsData = await b.loadPageData(POST_DETAILS_KEY, {});
|
||||
el.open = collectionPostDetailsData[el.dataset.id] === true;
|
||||
}
|
||||
|
||||
export async function setPostDetails(ev, sender, el) {
|
||||
if (el !== sender) {
|
||||
return;
|
||||
}
|
||||
if (ev.target !== el.querySelector('summary')) {
|
||||
return;
|
||||
}
|
||||
await setDetailsData(collectionPostDetailsData, POST_DETAILS_KEY, el.dataset.id, !el.open);
|
||||
}
|
||||
20
data/static/js/bits/collections-editor.js
Normal file
@@ -0,0 +1,20 @@
|
||||
export const b = {}
|
||||
|
||||
export function addCollection(ev, sender, el) {
|
||||
const parser = new DOMParser();
|
||||
const e = parser.parseFromString(b.templates.collectionItem, 'text/html').body.firstElementChild;
|
||||
el.appendChild(e);
|
||||
}
|
||||
|
||||
export function deleteCollection(ev, sender, el) {
|
||||
if (!el.contains(sender)) return;
|
||||
b.send({ 'id': el.prop('id') }, 'countDeletedCollection');
|
||||
el.remove();
|
||||
}
|
||||
|
||||
export function countDeletedCollection(payload, _, el) {
|
||||
if (payload.id === 'new') {
|
||||
return;
|
||||
}
|
||||
el.value += `${payload.id};`
|
||||
}
|
||||
28
data/static/js/bits/progressive-enhancement.js
Normal file
@@ -0,0 +1,28 @@
|
||||
export const b = {
|
||||
init: 'enhance enhanceHide',
|
||||
}
|
||||
|
||||
export function enhance(_, __, el) {
|
||||
if (el === undefined) { // nothing to enhance but init still runs
|
||||
return;
|
||||
}
|
||||
|
||||
if (el.classList.contains('js-only')) {
|
||||
el.classList.remove('js-only');
|
||||
}
|
||||
|
||||
if (el.disabled) {
|
||||
el.disabled = false;
|
||||
if (el.title.search('JavaScript') !== -1) {
|
||||
el.removeAttribute('title');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function enhanceHide(_, __, el) {
|
||||
if (el === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
el.style.display = 'none';
|
||||
}
|
||||
180
data/static/js/bits/thread.js
Normal file
@@ -0,0 +1,180 @@
|
||||
export const b = {
|
||||
init: 'activatePostImages getUserData',
|
||||
}
|
||||
|
||||
const POST_IMAGES_SELECTOR = 'img.post-image:not(aside img.post-image)'
|
||||
const WHOAMI_ENDPOINT = '/api/whoami/'
|
||||
const THREAD_PERM_ENDPOINT = '/api/thread-permission/'
|
||||
const TOGGLE_REACTION_ENDPOINT = '/api/toggle-reaction/'
|
||||
const REPLACE_REACTIONS_ENDPOINT = '/hyperapi/reactions/'
|
||||
|
||||
const getThreadId = () => {
|
||||
const scheme = window.location.pathname.split("/");
|
||||
if (scheme[1] !== 'threads' || scheme[2] === 'new') {
|
||||
return -1;
|
||||
}
|
||||
return parseInt(scheme[2]);
|
||||
}
|
||||
|
||||
let images = [];
|
||||
let currentIndex = 0;
|
||||
let currentUser = null;
|
||||
|
||||
let reactionMenuState = {};
|
||||
|
||||
export function activatePostImages(_, __, ___) {
|
||||
const images = document.querySelectorAll(POST_IMAGES_SELECTOR);
|
||||
images.forEach(image => {
|
||||
image.style.cursor = 'pointer';
|
||||
image.dataset.s = 'collectImages';
|
||||
});
|
||||
}
|
||||
|
||||
export function collectImages(_, sender, el) {
|
||||
if (!el.contains(sender)) return;
|
||||
images = Array.from(el.querySelectorAll(POST_IMAGES_SELECTOR));
|
||||
currentIndex = images.indexOf(sender);
|
||||
b.trigger('showLightbox');
|
||||
}
|
||||
|
||||
export function showLightbox(_, __, el) {
|
||||
const originalImg = images[currentIndex];
|
||||
const lightboxImg = el.querySelector('img');
|
||||
const anchor = el.querySelector('a');
|
||||
anchor.href = originalImg.src;
|
||||
lightboxImg.src = originalImg.src;
|
||||
lightboxImg.alt = originalImg.alt;
|
||||
|
||||
if (!el.open) {
|
||||
el.showModal();
|
||||
}
|
||||
|
||||
b.trigger('lightboxSetCounter');
|
||||
}
|
||||
|
||||
export function closeLightbox(_, __, el) {
|
||||
el.close();
|
||||
}
|
||||
|
||||
export function lightboxSetCounter(_, __, el) {
|
||||
el.innerText = `${currentIndex + 1}/${images.length}`;
|
||||
}
|
||||
|
||||
export function lightboxNext(_, __, ___) {
|
||||
if (images.length == 1) return;
|
||||
currentIndex++;
|
||||
if (currentIndex >= images.length) {
|
||||
currentIndex = 0;
|
||||
}
|
||||
b.trigger('showLightbox');
|
||||
}
|
||||
|
||||
export function lightboxPrevious(_, __, ___) {
|
||||
if (images.length == 1) return;
|
||||
currentIndex--;
|
||||
if (currentIndex < 0) {
|
||||
currentIndex = images.length - 1;
|
||||
}
|
||||
b.trigger('showLightbox');
|
||||
}
|
||||
|
||||
export async function getUserData(_, __, ___) {
|
||||
currentUser = await b.getData(WHOAMI_ENDPOINT);
|
||||
b.trigger('highlightMentions');
|
||||
const d = (await b.getData(`${THREAD_PERM_ENDPOINT}${getThreadId()}`)).can_post;
|
||||
if (d) {
|
||||
b.trigger('enableReactionMenuButton');
|
||||
b.trigger('enableReactionButtons');
|
||||
} else {
|
||||
b.trigger('disableReactionMenuButton');
|
||||
}
|
||||
}
|
||||
|
||||
export function highlightMentions(_, __, el) {
|
||||
if (!el) return;
|
||||
|
||||
if (el.dataset.username === currentUser.username) {
|
||||
el.classList.add('me');
|
||||
}
|
||||
}
|
||||
|
||||
export function openReactionMenu(ev, sender, el) {
|
||||
if (!el) return;
|
||||
if (reactionMenuState.state === undefined) {
|
||||
el.addEventListener('toggle', e => {
|
||||
if (e.newState === 'closed') {
|
||||
reactionMenuState.state = 'closed';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (reactionMenuState.state === 'open' && reactionMenuState.invoker === sender) {
|
||||
el.hidePopover();
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: [el, sender].prop(key) searches for ancestors with attr [data-${key}] if current element does not have `dataset[key]` but dataset transforms key names whereas css does not
|
||||
reactionMenuState.post = sender.prop('postid');
|
||||
|
||||
reactionMenuState.invoker = sender;
|
||||
reactionMenuState.state = 'open';
|
||||
el.showPopover();
|
||||
|
||||
const bRect = sender.getBoundingClientRect();
|
||||
const scrollY = window.scrollY;
|
||||
|
||||
el.style.left = `${bRect.left}px`;
|
||||
el.style.top = `${bRect.bottom + scrollY}px`;
|
||||
}
|
||||
|
||||
export function closeReactionMenu(_, __, el) {
|
||||
el.hidePopover();
|
||||
}
|
||||
|
||||
export async function toggleReaction(_, sender, __) {
|
||||
const emoji = sender.dataset.emoji;
|
||||
const post = sender.prop('postid') ? sender.prop('postid') : reactionMenuState.post;
|
||||
|
||||
const res = await fetch(TOGGLE_REACTION_ENDPOINT, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reaction: emoji, post: post }),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
return;
|
||||
}
|
||||
|
||||
b.send({ postId: post }, 'replaceReactionButtons');
|
||||
}
|
||||
|
||||
export async function replaceReactionButtons(payload, __, el) {
|
||||
if (payload.postId !== el.prop('postid')) return;
|
||||
const res = await fetch(`${REPLACE_REACTIONS_ENDPOINT}${payload.postId}`);
|
||||
if (res.status !== 200) {
|
||||
return;
|
||||
}
|
||||
const body = await res.text();
|
||||
const p = new DOMParser();
|
||||
const e = p.parseFromString(body, 'text/html').body;
|
||||
el.replaceChildren(...e.children);
|
||||
el.childNodes.forEach(b => {
|
||||
if (!b instanceof HTMLButtonElement) return;
|
||||
b.disabled = false;
|
||||
})
|
||||
}
|
||||
|
||||
export function disableReactionMenuButton(_, __, el) {
|
||||
el.title = 'You do not have permission to add reactions to this post.';
|
||||
}
|
||||
|
||||
export function enableReactionMenuButton(_, __, el) {
|
||||
el.disabled = false;
|
||||
el.title = '';
|
||||
}
|
||||
|
||||
export function enableReactionButtons(_, __, el) {
|
||||
if (!el) return;
|
||||
el.disabled = false;
|
||||
}
|
||||
227
data/static/js/bits/ui.js
Normal file
@@ -0,0 +1,227 @@
|
||||
export const b = {
|
||||
init: 'babycodeEditorCharCountInit localizeTimestamps',
|
||||
}
|
||||
|
||||
const BABYCODE_PREVIEW_ENDPOINT = '/api/babycode-preview/';
|
||||
|
||||
const getThreadId = () => {
|
||||
const scheme = window.location.pathname.split("/");
|
||||
if (scheme[1] !== 'threads' || scheme[2] === 'new') {
|
||||
return -1;
|
||||
}
|
||||
return parseInt(scheme[2]);
|
||||
}
|
||||
|
||||
export function setTab(_, sender, el) {
|
||||
if (sender.ariaSelected === 'true') {
|
||||
return;
|
||||
}
|
||||
if (!el.contains(sender)) {
|
||||
return;
|
||||
}
|
||||
const tabIndex = parseInt(sender.dataset.tabIndex);
|
||||
const tabPanels = el.querySelectorAll('.tab-content');
|
||||
const tabButtons = el.querySelectorAll('.tab-bar button');
|
||||
|
||||
for (let i = 0; i < tabPanels.length; i++) {
|
||||
const tabPanel = tabPanels[i];
|
||||
const tabButton = tabButtons[i];
|
||||
if (i === tabIndex) {
|
||||
tabPanel.classList.remove('hidden');
|
||||
tabButton.ariaSelected = 'true';
|
||||
} else if (!tabPanel.classList.contains('hidden')) {
|
||||
tabPanel.classList.add('hidden');
|
||||
tabButton.ariaSelected = 'false';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function insertBabycode(_, sender, el) {
|
||||
if (!el.parentNode.contains(sender)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagStart = sender.dataset.babycodeTag;
|
||||
const breakLine = 'breakLine' in sender.dataset;
|
||||
const prefill = 'prefill' in sender.dataset ? sender.dataset.prefill : '';
|
||||
|
||||
const hasAttr = tagStart[tagStart.length - 1] === '=';
|
||||
|
||||
let tagEnd = tagStart;
|
||||
let tagInsertStart = `[${tagStart}]${breakLine ? '\n' : ''}`;
|
||||
if (hasAttr) {
|
||||
tagEnd = tagEnd.slice(0, -1);
|
||||
}
|
||||
const tagInsertEnd = `${breakLine ? '\n' : ''}[/${tagEnd}]`;
|
||||
const hasSelection = el.selectionStart !== el.selectionEnd;
|
||||
const text = el.value;
|
||||
|
||||
if (hasSelection) {
|
||||
const realStart = Math.min(el.selectionStart, el.selectionEnd);
|
||||
const realEnd = Math.max(el.selectionStart, el.selectionEnd);
|
||||
const selectionLength = realEnd - realStart;
|
||||
|
||||
const strStart = text.slice(0, realStart);
|
||||
const strEnd = text.substring(realEnd);
|
||||
const frag = `${tagInsertStart}${text.slice(realStart, realEnd)}${tagInsertEnd}`;
|
||||
const reconst = `${strStart}${frag}${strEnd}`;
|
||||
el.value = reconst;
|
||||
if (!hasAttr) {
|
||||
el.setSelectionRange(realStart + tagInsertStart.length, realStart + tagInsertEnd.length + selectionLength - 1);
|
||||
} else {
|
||||
const attrCursor = realStart + tagInsertEnd.length - (1 + (breakLine ? 1 : 0))
|
||||
el.setSelectionRange(attrCursor, attrCursor); // cursor on attr
|
||||
}
|
||||
} else {
|
||||
if (hasAttr) {
|
||||
tagInsertStart += prefill;
|
||||
}
|
||||
const cursor = el.selectionStart;
|
||||
const strStart = text.slice(0, cursor);
|
||||
const strEnd = text.substring(cursor);
|
||||
|
||||
let newCursor = strStart.length + tagInsertStart.length;
|
||||
if (hasAttr) {
|
||||
newCursor = cursor + tagInsertStart.length - prefill.length - (1 + (breakLine ? 1 : 0)); //cursor on attr
|
||||
}
|
||||
const reconst = `${strStart}${tagInsertStart}${tagInsertEnd}${strEnd}`;
|
||||
el.value = reconst;
|
||||
el.setSelectionRange(newCursor, newCursor);
|
||||
}
|
||||
el.focus();
|
||||
b.send({ sender: el }, 'babycodeEditorCharCount');
|
||||
}
|
||||
|
||||
export function babycodeEditorCharCount(evOrPayload, sender, el) {
|
||||
if (!sender) { // sent from bitty, not input
|
||||
sender = evOrPayload.sender;
|
||||
}
|
||||
|
||||
if (!sender.parentNode.contains(el)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxLength = sender.maxLength;
|
||||
const currentLength = sender.value.length;
|
||||
|
||||
el.innerText = `${currentLength}/${maxLength}`;
|
||||
|
||||
const threadId = getThreadId();
|
||||
|
||||
if (threadId !== -1) {
|
||||
localStorage.setItem(`thread-${threadId}`, sender.value);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearThreadDraft(_, __, ___) {
|
||||
const threadId = getThreadId();
|
||||
if (threadId === -1) return;
|
||||
localStorage.removeItem(`thread-${threadId}`);
|
||||
}
|
||||
|
||||
export function babycodeEditorCharCountInit(_, __, el) {
|
||||
if (el === undefined) { // no editors on page
|
||||
return;
|
||||
}
|
||||
|
||||
const threadId = getThreadId();
|
||||
if (threadId !== -1) {
|
||||
el.value = localStorage.getItem(`thread-${threadId}`) || '';
|
||||
}
|
||||
b.send({ sender: el }, 'babycodeEditorCharCount');
|
||||
}
|
||||
|
||||
export function babycodePreviewInit(ev, sender, el) {
|
||||
if (!sender.parentNode.parentNode.contains(el)) { // tab container > tab bar > button
|
||||
return;
|
||||
}
|
||||
|
||||
b.send({ text: el.value, sender: sender, bannedTags: JSON.parse(el.dataset.bannedTags) }, 'babycodePreview');
|
||||
}
|
||||
|
||||
export async function babycodePreview(payload, _, el) {
|
||||
if (!payload.sender.parentNode.parentNode.contains(el)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload.text.trim()) {
|
||||
b.send({ plain: 'Type something to get a preview.', sender: el }, 'showBabycodePreview');
|
||||
return;
|
||||
}
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
markup: payload.text,
|
||||
banned_tags: payload.bannedTags,
|
||||
}),
|
||||
}
|
||||
|
||||
const f = await fetch(BABYCODE_PREVIEW_ENDPOINT, options);
|
||||
try {
|
||||
if (!f.ok) {
|
||||
console.error(f);
|
||||
let msg = '';
|
||||
switch (f.status) {
|
||||
case 429:
|
||||
return;
|
||||
default:
|
||||
msg = '(Something went wrong. Try again later.)'
|
||||
}
|
||||
b.send({ plain: msg, sender: el }, 'showBabycodePreview');
|
||||
return;
|
||||
}
|
||||
b.send({ ...(await f.json()), sender: el }, 'showBabycodePreview');
|
||||
} catch (error) {
|
||||
b.send({ plain: '(Something went wrong. Try again later.)', sender: el }, 'showBabycodePreview');
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export function showBabycodePreview(payload, _, el) {
|
||||
if (!payload.sender.parentNode.contains(el)) {
|
||||
return;
|
||||
}
|
||||
if (payload.plain) {
|
||||
el.innerHTML = `<p>${payload.plain}</p>`;
|
||||
} else {
|
||||
el.innerHTML = payload.html;
|
||||
}
|
||||
}
|
||||
|
||||
export function babycodeEditorQuote(ev, sender, el) {
|
||||
console.log(sender.dataset.quote);
|
||||
const newline = el.value.length === 0 ? '' : '\n'
|
||||
el.value += `${newline}[quote=${sender.dataset.posterName}]\n${sender.dataset.quote}\n[/quote]\n\n`
|
||||
b.send({ sender: el }, 'babycodeEditorCharCount');
|
||||
el.focus();
|
||||
}
|
||||
|
||||
export async function copyCode(ev, sender, el) {
|
||||
if (!el.contains(sender)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalText = sender.textContent;
|
||||
const doneText = 'Copied!';
|
||||
const code = el.dataset.code;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
sender.textContent = doneText;
|
||||
setTimeout(() => { sender.textContent = originalText }, 2000);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function localizeTimestamps(_, __, el) {
|
||||
if (el === undefined) {
|
||||
return;
|
||||
}
|
||||
const d = new Date(el.dateTime);
|
||||
el.innerText = d.toLocaleString();
|
||||
}
|
||||
@@ -32,6 +32,9 @@
|
||||
if (!target || target === draggedItem) {
|
||||
return;
|
||||
}
|
||||
if (draggedItem === null) {
|
||||
return;
|
||||
}
|
||||
const inSameList = draggedItem.dataset.sortableListKey === target.dataset.sortableListKey;
|
||||
if (!inSameList) {
|
||||
return;
|
||||
@@ -70,8 +73,8 @@
|
||||
if (listItems.has(node)) return;
|
||||
|
||||
const dragger = node.querySelector('.dragger');
|
||||
dragger.addEventListener('dragstart', e => { sortableItemDragStart(e, item) });
|
||||
dragger.addEventListener('dragend', e => { sortableItemDragEnd(e, item) });
|
||||
dragger.addEventListener('dragstart', e => { sortableItemDragStart(e, node) });
|
||||
dragger.addEventListener('dragend', e => { sortableItemDragEnd(e, node) });
|
||||
node.addEventListener('dragover', e => { sortableItemDragOver(e, node) });
|
||||
listItems.add(node);
|
||||
listItemsHandled.set(list, listItems);
|
||||
@@ -95,3 +98,18 @@
|
||||
});
|
||||
listsObs.observe(document.body, { childList: true, subtree: true })
|
||||
}
|
||||
|
||||
{
|
||||
// babycode editor: press ctrl+enter to submit
|
||||
document.querySelectorAll('.babycode-editor').forEach(ta => {
|
||||
if (ta.form instanceof HTMLFormElement) {
|
||||
ta.addEventListener('keydown', e => {
|
||||
if (e.ctrlKey && e.key === 'Enter') {
|
||||
if (ta.form.reportValidity()) {
|
||||
ta.form.requestSubmit();
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||