Compare commits

...

14 Commits

31 changed files with 848 additions and 474 deletions

View File

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

View File

@@ -85,3 +85,18 @@ URL: https://bitty-js.com/
License: CC0 1.0 License: CC0 1.0
Author: alan w smith https://www.alanwsmith.com/ Author: alan w smith https://www.alanwsmith.com/
Repo: https://github.com/alanwsmith/bitty Repo: https://github.com/alanwsmith/bitty
## Flask-Caching
URL: https://flask-caching.readthedocs.io/
Copyright:
```
Copyright (c) 2010 by Thadeus Burgess.
Copyright (c) 2016 by Peter Justin.
Some rights reserved.
```
License: BSD-3-Clause ([see more](https://github.com/pallets-eco/flask-caching/blob/e59bc040cd47cd2b43e501d636d43d442c50b3ff/LICENSE))
Repo: https://github.com/pallets-eco/flask-caching

View File

@@ -1,6 +1,6 @@
from flask import Flask, session, request, render_template from flask import Flask, session, request, render_template
from dotenv import load_dotenv from dotenv import load_dotenv
from .models import Avatars, Users, PostHistory, Posts, MOTD, BadgeUploads from .models import Avatars, Users, PostHistory, Posts, MOTD, BadgeUploads, Sessions
from .auth import digest from .auth import digest
from .routes.users import is_logged_in, get_active_user, get_prefers_theme from .routes.users import is_logged_in, get_active_user, get_prefers_theme
from .constants import ( from .constants import (
@@ -10,6 +10,7 @@ from .constants import (
SIG_BANNED_TAGS, STRICT_BANNED_TAGS, SIG_BANNED_TAGS, STRICT_BANNED_TAGS,
) )
from .lib.babycode import babycode_to_html, babycode_to_rssxml, EMOJI, BABYCODE_VERSION from .lib.babycode import babycode_to_html, babycode_to_rssxml, EMOJI, BABYCODE_VERSION
from .lib.exceptions import SiteNameMissingException
from datetime import datetime, timezone from datetime import datetime, timezone
from flask_caching import Cache from flask_caching import Cache
import os import os
@@ -137,6 +138,16 @@ def bind_default_badges(path):
'uploaded_at': int(os.path.getmtime(real_path)), 'uploaded_at': int(os.path.getmtime(real_path)),
}) })
def clear_stale_sessions():
from .db import db
with db.transaction():
now = int(time.time())
stale_sessions = Sessions.findall([
('expires_at', '<', now)
])
for sess in stale_sessions:
sess.delete()
cache = Cache() cache = Cache()
@@ -165,6 +176,8 @@ def create_app():
load_dotenv() load_dotenv()
else: else:
app.config["DB_PATH"] = "data/db/db.prod.sqlite" app.config["DB_PATH"] = "data/db/db.prod.sqlite"
if not app.config["SERVER_NAME"]:
raise SiteNameMissingException()
app.config["SECRET_KEY"] = os.getenv("FLASK_SECRET_KEY") app.config["SECRET_KEY"] = os.getenv("FLASK_SECRET_KEY")
@@ -223,6 +236,8 @@ def create_app():
create_admin() create_admin()
create_deleted_user() create_deleted_user()
clear_stale_sessions()
reparse_babycode() reparse_babycode()
bind_default_badges(app.config['BADGES_PATH']) bind_default_badges(app.config['BADGES_PATH'])

View File

@@ -196,13 +196,12 @@ class RSSXMLRenderer(BabycodeRenderer):
def make_mention(self, e): def make_mention(self, e):
from ..models import Users from ..models import Users
from flask import url_for, current_app from flask import url_for
with current_app.test_request_context('/'):
target_user = Users.find({'username': e['name'].lower()}) target_user = Users.find({'username': e['name'].lower()})
if not target_user: if not target_user:
return f"@{e['name']}" return f"@{e['name']}"
return f'<a href="{url_for('users.page', username=target_user.username, _external=True)}" title="@{target_user.username}">{target_user.get_readable_name()}</a>' return f'<a href="{url_for('users.page', username=target_user.username, _external=True)}" title="@{target_user.username}">{'@' if not target_user.has_display_name() else ''}{target_user.get_readable_name()}</a>'
NAMED_COLORS = [ NAMED_COLORS = [
@@ -238,7 +237,7 @@ NAMED_COLORS = [
def make_emoji(name, code): def make_emoji(name, code):
return f' <img class=emoji src="/static/emoji/{name}.png" alt="{name}" title=":{code}:">' return f'<img class=emoji src="/static/emoji/{name}.png" alt="{name}" title=":{code}:">'
EMOJI = { EMOJI = {

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

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

View File

@@ -1,5 +1,4 @@
from flask import Blueprint, redirect, url_for, render_template from flask import Blueprint, redirect, url_for, render_template
from app import cache
from datetime import datetime from datetime import datetime
bp = Blueprint("app", __name__, url_prefix = "/") bp = Blueprint("app", __name__, url_prefix = "/")
@@ -7,12 +6,3 @@ bp = Blueprint("app", __name__, url_prefix = "/")
@bp.route("/") @bp.route("/")
def index(): def index():
return redirect(url_for("topics.all_topics")) return redirect(url_for("topics.all_topics"))
@bp.route("/cache-test")
def cache_test():
test_value = cache.get('test')
if test_value is None:
test_value = 'cached_value_' + str(datetime.now())
cache.set('test', test_value, timeout=10)
return f"set cache: {test_value}"
return f"cached: {test_value}"

View File

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

View File

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

View File

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

View File

@@ -59,3 +59,9 @@
<path d="M5 11C9.41828 11 13 14.5817 13 19M5 5C12.732 5 19 11.268 19 19M7 18C7 18.5523 6.55228 19 6 19C5.44772 19 5 18.5523 5 18C5 17.4477 5.44772 17 6 17C6.55228 17 7 17.4477 7 18Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M5 11C9.41828 11 13 14.5817 13 19M5 5C12.732 5 19 11.268 19 19M7 18C7 18.5523 6.55228 19 6 19C5.44772 19 5 18.5523 5 18C5 17.4477 5.44772 17 6 17C6.55228 17 7 17.4477 7 18Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro icn_drag(width=24) %}
<svg width="{{width}}px" height="{{width}}px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 10H19M14 19L12 21L10 19M14 5L12 3L10 5M5 14H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{% endmacro %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
{% from 'common/macros.html' import babycode_editor_component %} {% from 'common/macros.html' import babycode_editor_component, badge_editor_single, sortable_list %}
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}settings{% endblock %} {% block title %}settings{% endblock %}
{% block content %} {% block content %}
@@ -57,7 +57,7 @@
<legend>Badges</legend> <legend>Badges</legend>
<a href="{{ url_for('guides.guide_page', category='user-guides', slug='settings', _anchor='badges')}}">Badges help</a> <a href="{{ url_for('guides.guide_page', category='user-guides', slug='settings', _anchor='badges')}}">Badges help</a>
<bitty-7-0 data-connect="{{ '/static/js/bitties/pyrom-bitty.js' | cachebust }} BadgeEditorForm" data-listeners="click input submit change"> <bitty-7-0 data-connect="{{ '/static/js/bitties/pyrom-bitty.js' | cachebust }} BadgeEditorForm" data-listeners="click input submit change">
<form data-use="badgeEditorPrepareSubmit" data-init='loadBadgeEditor' data-receive='addBadge' method='post' enctype='multipart/form-data' action='{{ url_for('users.save_badges', username=active_user.username) }}'> <form data-use="badgeEditorPrepareSubmit" data-init='loadBadgeEditor' method='post' enctype='multipart/form-data' action='{{ url_for('users.save_badges', username=active_user.username) }}'>
<div>Loading badges&hellip;</div> <div>Loading badges&hellip;</div>
<div>If badges fail to load, JS may be disabled.</div> <div>If badges fail to load, JS may be disabled.</div>
</form> </form>
@@ -68,4 +68,12 @@
<a class="linkbutton critical" href="{{ url_for('users.delete_page', username=active_user.username) }}">Delete account</a> <a class="linkbutton critical" href="{{ url_for('users.delete_page', username=active_user.username) }}">Delete account</a>
</div> </div>
</div> </div>
<template id='badge-editor-template'>
{{ badge_editor_single(options=uploads) }}
</template>
<template id="badges-list-template">
{{ sortable_list(attr={'data-receive': 'addBadge'}) }}
</template>
{% endblock %} {% endblock %}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 B

View File

@@ -60,11 +60,6 @@ body {
color: black; color: black;
} }
@media (orientation: portrait) {
body {
margin: 20px 0;
}
}
:where(a:link) { :where(a:link) {
color: #c11c1c; color: #c11c1c;
} }
@@ -872,6 +867,10 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
background-color: rgb(230.2, 235.4, 223.8); background-color: rgb(230.2, 235.4, 223.8);
} }
input:not(form input):invalid {
border: 2px dashed red;
}
textarea { textarea {
font-family: "Atkinson Hyperlegible Mono", monospace; font-family: "Atkinson Hyperlegible Mono", monospace;
} }
@@ -1074,35 +1073,6 @@ textarea {
background-color: none; background-color: none;
} }
.draggable-topic {
cursor: pointer;
user-select: none;
background-color: #c1ceb1;
padding: 20px;
margin: 15px 0;
border-top: 5px outset rgb(217.26, 220.38, 213.42);
border-bottom: 5px outset rgb(135.1928346457, 145.0974015748, 123.0025984252);
}
.draggable-topic.dragged {
background-color: rgb(177, 206, 204.5);
}
.draggable-collection {
cursor: pointer;
user-select: none;
background-color: #c1ceb1;
padding: 20px;
margin: 15px 0;
border-top: 5px outset rgb(217.26, 220.38, 213.42);
border-bottom: 5px outset rgb(135.1928346457, 145.0974015748, 123.0025984252);
}
.draggable-collection.dragged {
background-color: rgb(177, 206, 204.5);
}
.draggable-collection.default {
background-color: #beb1ce;
}
.editing { .editing {
background-color: rgb(217.26, 220.38, 213.42); background-color: rgb(217.26, 220.38, 213.42);
} }
@@ -1539,3 +1509,70 @@ img.badge-button {
background-color: rgb(186.8501612903, 155.5098387097, 132.6498387097); background-color: rgb(186.8501612903, 155.5098387097, 132.6498387097);
color: black; color: black;
} }
@media (orientation: portrait) {
body {
margin: 20px 0;
}
.guide-container {
grid-template-areas: "guide-toc" "guide-topics";
grid-template-columns: unset;
}
.guide-toc {
position: unset;
}
.guide-section {
padding-right: 20px;
}
}
ol.sortable-list {
list-style: none;
flex-grow: 1;
margin: 0;
}
ol.sortable-list li {
display: flex;
gap: 10px;
background-color: #c1ceb1;
padding: 20px;
margin: 15px 0;
border-top: 5px outset rgb(217.26, 220.38, 213.42);
border-bottom: 5px outset rgb(135.1928346457, 145.0974015748, 123.0025984252);
}
ol.sortable-list li.dragged {
background-color: rgb(177, 206, 204.5);
}
ol.sortable-list li.immovable {
background-color: #beb1ce;
}
ol.sortable-list li.immovable .dragger {
cursor: not-allowed;
}
.dragger {
display: flex;
align-items: center;
background-color: rgb(135.1928346457, 145.0974015748, 123.0025984252);
padding: 5px 10px;
cursor: move;
}
.sortable-item-inner {
display: flex;
gap: 10px;
flex-grow: 1;
flex-direction: column;
}
.sortable-item-inner > * {
flex-grow: 1;
}
.sortable-item-inner.row {
flex-direction: row;
}
.sortable-item-inner:not(.row) > * {
margin-right: auto;
}
.fg {
flex-grow: 1;
}

View File

@@ -60,11 +60,6 @@ body {
color: #e6e6e6; color: #e6e6e6;
} }
@media (orientation: portrait) {
body {
margin: 20px 0;
}
}
:where(a:link) { :where(a:link) {
color: #e87fe1; color: #e87fe1;
} }
@@ -872,6 +867,10 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
background-color: #514151; background-color: #514151;
} }
input:not(form input):invalid {
border: 2px dashed #d53232;
}
textarea { textarea {
font-family: "Atkinson Hyperlegible Mono", monospace; font-family: "Atkinson Hyperlegible Mono", monospace;
} }
@@ -1074,35 +1073,6 @@ textarea {
background-color: #503250; background-color: #503250;
} }
.draggable-topic {
cursor: pointer;
user-select: none;
background-color: #9b649b;
padding: 20px;
margin: 15px 0;
border-top: 5px outset #503250;
border-bottom: 5px outset rgb(96.95, 81.55, 96.95);
}
.draggable-topic.dragged {
background-color: #3c283c;
}
.draggable-collection {
cursor: pointer;
user-select: none;
background-color: #9b649b;
padding: 20px;
margin: 15px 0;
border-top: 5px outset #503250;
border-bottom: 5px outset rgb(96.95, 81.55, 96.95);
}
.draggable-collection.dragged {
background-color: #3c283c;
}
.draggable-collection.default {
background-color: #8a5584;
}
.editing { .editing {
background-color: #503250; background-color: #503250;
} }
@@ -1463,7 +1433,7 @@ a.mention:hover, a.mention:visited:hover {
.settings-grid fieldset { .settings-grid fieldset {
border: 1px solid black; border: 1px solid black;
border-radius: 8px; border-radius: 8px;
background-color: rgb(141.6, 79.65, 141.6); background-color: #503250;
} }
.hfc { .hfc {
@@ -1484,10 +1454,10 @@ h1 {
margin: 10px 0; margin: 10px 0;
} }
.settings-badge-container:has(input:invalid) { .settings-badge-container:has(input:invalid) {
border: 2px dashed red; border: 2px dashed #d53232;
} }
.settings-badge-container input:invalid { .settings-badge-container input:invalid {
border: 2px dashed red; border: 2px dashed #d53232;
} }
.settings-badge-file-picker { .settings-badge-file-picker {
@@ -1540,6 +1510,73 @@ img.badge-button {
color: black; color: black;
} }
@media (orientation: portrait) {
body {
margin: 20px 0;
}
.guide-container {
grid-template-areas: "guide-toc" "guide-topics";
grid-template-columns: unset;
}
.guide-toc {
position: unset;
}
.guide-section {
padding-right: 20px;
}
}
ol.sortable-list {
list-style: none;
flex-grow: 1;
margin: 0;
}
ol.sortable-list li {
display: flex;
gap: 10px;
background-color: #9b649b;
padding: 20px;
margin: 15px 0;
border-top: 5px outset #503250;
border-bottom: 5px outset rgb(96.95, 81.55, 96.95);
}
ol.sortable-list li.dragged {
background-color: #3c283c;
}
ol.sortable-list li.immovable {
background-color: #8a5584;
}
ol.sortable-list li.immovable .dragger {
cursor: not-allowed;
}
.dragger {
display: flex;
align-items: center;
background-color: rgb(96.95, 81.55, 96.95);
padding: 5px 10px;
cursor: move;
}
.sortable-item-inner {
display: flex;
gap: 10px;
flex-grow: 1;
flex-direction: column;
}
.sortable-item-inner > * {
flex-grow: 1;
}
.sortable-item-inner.row {
flex-direction: row;
}
.sortable-item-inner:not(.row) > * {
margin-right: auto;
}
.fg {
flex-grow: 1;
}
#topnav { #topnav {
margin-bottom: 10px; margin-bottom: 10px;
border: 10px solid rgb(40, 40, 40); border: 10px solid rgb(40, 40, 40);

View File

@@ -60,11 +60,6 @@ body {
color: black; color: black;
} }
@media (orientation: portrait) {
body {
margin: 12px 0;
}
}
:where(a:link) { :where(a:link) {
color: black; color: black;
} }
@@ -872,6 +867,10 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
background-color: rgb(249.8, 201.8, 189); background-color: rgb(249.8, 201.8, 189);
} }
input:not(form input):invalid {
border: 2px dashed #f73030;
}
textarea { textarea {
font-family: "Atkinson Hyperlegible Mono", monospace; font-family: "Atkinson Hyperlegible Mono", monospace;
} }
@@ -1074,35 +1073,6 @@ textarea {
background-color: #f27a5a; background-color: #f27a5a;
} }
.draggable-topic {
cursor: pointer;
user-select: none;
background-color: #f27a5a;
padding: 12px;
margin: 8px 0;
border-top: 5px outset rgb(219.84, 191.04, 183.36);
border-bottom: 5px outset rgb(155.8907865169, 93.2211235955, 76.5092134831);
}
.draggable-topic.dragged {
background-color: #f27a5a;
}
.draggable-collection {
cursor: pointer;
user-select: none;
background-color: #f27a5a;
padding: 12px;
margin: 8px 0;
border-top: 5px outset rgb(219.84, 191.04, 183.36);
border-bottom: 5px outset rgb(155.8907865169, 93.2211235955, 76.5092134831);
}
.draggable-collection.dragged {
background-color: #f27a5a;
}
.draggable-collection.default {
background-color: #b54444;
}
.editing { .editing {
background-color: rgb(219.84, 191.04, 183.36); background-color: rgb(219.84, 191.04, 183.36);
} }
@@ -1484,10 +1454,10 @@ h1 {
margin: 6px 0; margin: 6px 0;
} }
.settings-badge-container:has(input:invalid) { .settings-badge-container:has(input:invalid) {
border: 2px dashed red; border: 2px dashed #f73030;
} }
.settings-badge-container input:invalid { .settings-badge-container input:invalid {
border: 2px dashed red; border: 2px dashed #f73030;
} }
.settings-badge-file-picker { .settings-badge-file-picker {
@@ -1540,6 +1510,73 @@ img.badge-button {
color: black; color: black;
} }
@media (orientation: portrait) {
body {
margin: 12px 0;
}
.guide-container {
grid-template-areas: "guide-toc" "guide-topics";
grid-template-columns: unset;
}
.guide-toc {
position: unset;
}
.guide-section {
padding-right: 12px;
}
}
ol.sortable-list {
list-style: none;
flex-grow: 1;
margin: 0;
}
ol.sortable-list li {
display: flex;
gap: 6px;
background-color: #f27a5a;
padding: 12px;
margin: 8px 0;
border-top: 5px outset rgb(219.84, 191.04, 183.36);
border-bottom: 5px outset rgb(155.8907865169, 93.2211235955, 76.5092134831);
}
ol.sortable-list li.dragged {
background-color: #f27a5a;
}
ol.sortable-list li.immovable {
background-color: #b54444;
}
ol.sortable-list li.immovable .dragger {
cursor: not-allowed;
}
.dragger {
display: flex;
align-items: center;
background-color: rgb(155.8907865169, 93.2211235955, 76.5092134831);
padding: 3px 6px;
cursor: move;
}
.sortable-item-inner {
display: flex;
gap: 6px;
flex-grow: 1;
flex-direction: column;
}
.sortable-item-inner > * {
flex-grow: 1;
}
.sortable-item-inner.row {
flex-direction: row;
}
.sortable-item-inner:not(.row) > * {
margin-right: auto;
}
.fg {
flex-grow: 1;
}
#topnav { #topnav {
border-top-left-radius: 16px; border-top-left-radius: 16px;
border-top-right-radius: 16px; border-top-right-radius: 16px;

View File

@@ -60,11 +60,6 @@ body {
color: black; color: black;
} }
@media (orientation: portrait) {
body {
margin: 20px 0;
}
}
:where(a:link) { :where(a:link) {
color: #711579; color: #711579;
} }
@@ -872,6 +867,10 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
background-color: rgb(235.4, 239.8, 248.2); background-color: rgb(235.4, 239.8, 248.2);
} }
input:not(form input):invalid {
border: 2px dashed red;
}
textarea { textarea {
font-family: "Atkinson Hyperlegible Mono", monospace; font-family: "Atkinson Hyperlegible Mono", monospace;
} }
@@ -1074,35 +1073,6 @@ textarea {
background-color: none; background-color: none;
} }
.draggable-topic {
cursor: pointer;
user-select: none;
background-color: #ced9ee;
padding: 20px;
margin: 15px 0;
border-top: 5px outset rgb(231.36, 234, 239.04);
border-bottom: 5px outset rgb(136.0836363636, 149.3636363636, 174.7163636364);
}
.draggable-topic.dragged {
background-color: #eecee9;
}
.draggable-collection {
cursor: pointer;
user-select: none;
background-color: #ced9ee;
padding: 20px;
margin: 15px 0;
border-top: 5px outset rgb(231.36, 234, 239.04);
border-bottom: 5px outset rgb(136.0836363636, 149.3636363636, 174.7163636364);
}
.draggable-collection.dragged {
background-color: #eecee9;
}
.draggable-collection.default {
background-color: #eee3ce;
}
.editing { .editing {
background-color: rgb(231.36, 234, 239.04); background-color: rgb(231.36, 234, 239.04);
} }
@@ -1539,3 +1509,70 @@ img.badge-button {
background-color: rgb(186.8501612903, 155.5098387097, 132.6498387097); background-color: rgb(186.8501612903, 155.5098387097, 132.6498387097);
color: black; color: black;
} }
@media (orientation: portrait) {
body {
margin: 20px 0;
}
.guide-container {
grid-template-areas: "guide-toc" "guide-topics";
grid-template-columns: unset;
}
.guide-toc {
position: unset;
}
.guide-section {
padding-right: 20px;
}
}
ol.sortable-list {
list-style: none;
flex-grow: 1;
margin: 0;
}
ol.sortable-list li {
display: flex;
gap: 10px;
background-color: #ced9ee;
padding: 20px;
margin: 15px 0;
border-top: 5px outset rgb(231.36, 234, 239.04);
border-bottom: 5px outset rgb(136.0836363636, 149.3636363636, 174.7163636364);
}
ol.sortable-list li.dragged {
background-color: #eecee9;
}
ol.sortable-list li.immovable {
background-color: #eee3ce;
}
ol.sortable-list li.immovable .dragger {
cursor: not-allowed;
}
.dragger {
display: flex;
align-items: center;
background-color: rgb(136.0836363636, 149.3636363636, 174.7163636364);
padding: 5px 10px;
cursor: move;
}
.sortable-item-inner {
display: flex;
gap: 10px;
flex-grow: 1;
flex-direction: column;
}
.sortable-item-inner > * {
flex-grow: 1;
}
.sortable-item-inner.row {
flex-direction: row;
}
.sortable-item-inner:not(.row) > * {
margin-right: auto;
}
.fg {
flex-grow: 1;
}

View File

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

View File

@@ -287,11 +287,7 @@ export class BadgeEditorForm {
return; return;
} }
if (this.#badgeTemplate === undefined){ if (this.#badgeTemplate === undefined){
const badge = await this.api.getHTML(`${badgeEditorEndpoint}/template`) this.#badgeTemplate = document.getElementById('badge-editor-template').content.firstElementChild.outerHTML;
if (!badge.value){
return;
}
this.#badgeTemplate= badge.value;
} }
el.replaceChildren(); el.replaceChildren();
const addButton = `<button data-disable-if-max="1" data-receive="updateBadgeCount" DISABLE_IF_MAX type="button" data-send="addBadge">Add badge</button>`; const addButton = `<button data-disable-if-max="1" data-receive="updateBadgeCount" DISABLE_IF_MAX type="button" data-send="addBadge">Add badge</button>`;
@@ -303,14 +299,18 @@ export class BadgeEditorForm {
['DISABLE_IF_MAX', badgeCount === 10 ? 'disabled' : ''], ['DISABLE_IF_MAX', badgeCount === 10 ? 'disabled' : ''],
]; ];
el.appendChild(this.api.makeHTML(controls, subs)); el.appendChild(this.api.makeHTML(controls, subs));
el.appendChild(badges.value);
const listTemplate = document.getElementById('badges-list-template').content.firstElementChild.outerHTML;
const list = this.api.makeHTML(listTemplate).firstElementChild;
list.appendChild(badges.value);
el.appendChild(list);
} }
addBadge(ev, el) { addBadge(ev, el) {
if (this.#badgeTemplate === undefined) { if (this.#badgeTemplate === undefined) {
return; return;
} }
const badge = this.#badgeTemplate.cloneNode(true); const badge = this.api.makeHTML(this.#badgeTemplate).firstElementChild;
el.appendChild(badge); el.appendChild(badge);
this.api.localTrigger('updateBadgeCount'); this.api.localTrigger('updateBadgeCount');
} }
@@ -344,9 +344,7 @@ export class BadgeEditorForm {
noUploads.forEach(e => { noUploads.forEach(e => {
e.value = null; e.value = null;
}) })
// console.log(noUploads);
el.submit(); el.submit();
// console.log('would submit now');
} }
} }
@@ -466,3 +464,80 @@ export class BadgeEditorBadge {
el.setCustomValidity(''); el.setCustomValidity('');
} }
} }
const getCollectionDataForEl = el => {
const nameInput = el.querySelector(".collection-name");
const collectionId = el.dataset.collectionId;
return {
id: collectionId,
name: nameInput.value,
is_new: !('collectionId' in el.dataset),
};
}
export class CollectionsEditor {
#collectionTemplate = undefined;
#collectionsData = [];
#removedCollections = [];
#valid = true;
addCollection(ev, el) {
if (this.#collectionTemplate === undefined) {
this.#collectionTemplate = document.getElementById('new-collection-template').content;
}
// interesting
const newCollection = this.api.makeHTML(this.#collectionTemplate.firstElementChild.outerHTML);
el.appendChild(newCollection);
}
deleteCollection(ev, el) {
if (!el.contains(ev.sender)) {
return;
}
if ('collectionId' in el.dataset) {
this.#removedCollections.push(el.dataset.collectionId);
}
el.remove();
}
async saveCollections(ev, el) {
this.#valid = true;
this.api.localTrigger('testValidity');
if (!this.#valid) {
return;
}
this.#collectionsData = [];
this.api.localTrigger('getCollectionData');
const data = {
collections: this.#collectionsData,
removed_collections: this.#removedCollections,
};
const res = await this.api.getJSON(el.prop('submitHref'), [], {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (res.error) {
return;
}
window.location.reload();
}
getCollectionData(ev, el) {
this.#collectionsData.push(getCollectionDataForEl(el));
}
testValidity(ev, el) {
const input = el.querySelector('input');
if (!input.validity.valid) {
input.reportValidity();
this.#valid = false;
}
}
}

View File

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

View File

@@ -1,45 +0,0 @@
// https://codepen.io/crouchingtigerhiddenadam/pen/qKXgap
let selected = null;
let container = document.getElementById("topics-container")
function isBefore(el1, el2) {
let cur
if (el2.parentNode === el1.parentNode) {
for (cur = el1.previousSibling; cur; cur = cur.previousSibling) {
if (cur === el2) return true
}
}
return false;
}
function dragOver(e) {
let target = e.target.closest(".draggable-topic")
if (!target || target === selected) {
return;
}
if (isBefore(selected, target)) {
container.insertBefore(selected, target)
} else {
container.insertBefore(selected, target.nextSibling)
}
}
function dragEnd() {
if (!selected) return;
selected.classList.remove("dragged")
selected = null;
for (let i = 0; i < container.childElementCount - 1; i++) {
let input = container.children[i].querySelector(".topic-input");
input.value = i + 1;
}
}
function dragStart(e) {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', null)
selected = e.target
selected.classList.add("dragged")
}

View File

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

View File

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

View File

@@ -56,6 +56,7 @@ $PAGE_SIDE_MARGIN: 100px !default;
// BORDERS // BORDERS
// ************** // **************
$DEFAULT_BORDER: 1px solid black !default; $DEFAULT_BORDER: 1px solid black !default;
$DEFAULT_BORDER_INVALID: 2px dashed $BUTTON_COLOR_CRITICAL !default;
$DEFAULT_BORDER_RADIUS: 4px !default; $DEFAULT_BORDER_RADIUS: 4px !default;
// other variables can be found before the rule that uses them. they are usually constructed from these basic variables. // other variables can be found before the rule that uses them. they are usually constructed from these basic variables.
@@ -178,13 +179,6 @@ body {
color: $DEFAULT_FONT_COLOR; color: $DEFAULT_FONT_COLOR;
} }
$body_portrait_margin: $BIG_PADDING $ZERO_PADDING !default;
@media (orientation: portrait) {
body {
margin: $body_portrait_margin;
}
}
$link_color: #c11c1c !default; $link_color: #c11c1c !default;
$link_color_visited: #730c0c !default; $link_color_visited: #730c0c !default;
:where(a:link){ :where(a:link){
@@ -716,6 +710,11 @@ input[type="text"], input[type="password"], textarea, select {
} }
} }
// lone required inputs managed by js
input:not(form input):invalid {
border: $DEFAULT_BORDER_INVALID;
}
textarea { textarea {
font-family: "Atkinson Hyperlegible Mono", monospace; font-family: "Atkinson Hyperlegible Mono", monospace;
} }
@@ -973,54 +972,6 @@ $topic_locked_background: none !default;
background-color: $topic_locked_background; background-color: $topic_locked_background;
} }
$draggable_topic_background: $ACCENT_COLOR !default;
$draggable_topic_dragged_color: $BUTTON_COLOR !default;
$draggable_topic_padding: $BIG_PADDING !default;
$draggable_topic_margin: $MEDIUM_BIG_PADDING 0 !default;
$draggable_topic_border: 5px outset !default;
$draggable_topic_border_top: $draggable_topic_border $LIGHT !default;
$draggable_topic_border_bottom: $draggable_topic_border $DARK_2 !default;
.draggable-topic {
cursor: pointer;
user-select: none;
background-color: $draggable_topic_background;
padding: $draggable_topic_padding;
margin: $draggable_topic_margin;
border-top: $draggable_topic_border_top;
border-bottom: $draggable_topic_border_bottom;
&.dragged {
background-color: $draggable_topic_dragged_color;
}
}
$draggable_collection_background: $ACCENT_COLOR !default;
$draggable_collection_dragged_color: $BUTTON_COLOR !default;
$draggable_collection_default_color: $BUTTON_COLOR_2 !default;
$draggable_collection_padding: $BIG_PADDING !default;
$draggable_collection_margin: $MEDIUM_BIG_PADDING 0 !default;
$draggable_collection_border: 5px outset !default;
$draggable_collection_border_top: $draggable_collection_border $LIGHT !default;
$draggable_collection_border_bottom: $draggable_collection_border $DARK_2 !default;
.draggable-collection {
cursor: pointer;
user-select: none;
background-color: $draggable_collection_background;
padding: $draggable_collection_padding;
margin: $draggable_collection_margin;
border-top: $draggable_collection_border_top;
border-bottom: $draggable_collection_border_bottom;
&.dragged {
background-color: $draggable_collection_dragged_color;
}
&.default {
background-color: $draggable_collection_default_color;
}
}
$post_editing_header_color: $LIGHT !default; $post_editing_header_color: $LIGHT !default;
.editing { .editing {
background-color: $post_editing_header_color; background-color: $post_editing_header_color;
@@ -1422,6 +1373,7 @@ $settings_grid_gap: $MEDIUM_PADDING !default;
$settings_grid_item_min_width: 600px !default; $settings_grid_item_min_width: 600px !default;
$settings_grid_fieldset_border: 1px solid $DEFAULT_FONT_COLOR_INVERSE !default; $settings_grid_fieldset_border: 1px solid $DEFAULT_FONT_COLOR_INVERSE !default;
$settings_grid_fieldset_border_radius: $DEFAULT_BORDER_RADIUS !default; $settings_grid_fieldset_border_radius: $DEFAULT_BORDER_RADIUS !default;
$settings_grid_fieldset_background_color: $DARK_1_LIGHTER !default;
.settings-grid { .settings-grid {
display: grid; display: grid;
gap: $settings_grid_gap; gap: $settings_grid_gap;
@@ -1432,7 +1384,7 @@ $settings_grid_fieldset_border_radius: $DEFAULT_BORDER_RADIUS !default;
& fieldset { & fieldset {
border: $settings_grid_fieldset_border; border: $settings_grid_fieldset_border;
border-radius: $settings_grid_fieldset_border_radius; border-radius: $settings_grid_fieldset_border_radius;
background-color: $DARK_1_LIGHTER; background-color: $settings_grid_fieldset_background_color;
} }
} }
@@ -1447,7 +1399,7 @@ h1 {
$settings_badge_container_gap: $SMALL_PADDING !default; $settings_badge_container_gap: $SMALL_PADDING !default;
$settings_badge_container_border: $DEFAULT_BORDER !default; $settings_badge_container_border: $DEFAULT_BORDER !default;
$settings_badge_container_border_invalid: 2px dashed red !default; $settings_badge_container_border_invalid: $DEFAULT_BORDER_INVALID !default;
$settings_badge_container_border_radius: $DEFAULT_BORDER_RADIUS !default; $settings_badge_container_border_radius: $DEFAULT_BORDER_RADIUS !default;
$settings_badge_container_padding: $SMALL_PADDING $MEDIUM_PADDING !default; $settings_badge_container_padding: $SMALL_PADDING $MEDIUM_PADDING !default;
$settings_badge_container_margin: $MEDIUM_PADDING $ZERO_PADDING !default; $settings_badge_container_margin: $MEDIUM_PADDING $ZERO_PADDING !default;
@@ -1536,3 +1488,96 @@ $rss_button_font_color_active: black !default;
color: $rss_button_font_color_active; color: $rss_button_font_color_active;
} }
} }
@media (orientation: portrait) {
$body_portrait_margin: $BIG_PADDING $ZERO_PADDING !default;
body {
margin: $body_portrait_margin;
}
.guide-container {
grid-template-areas:
"guide-toc"
"guide-topics";
grid-template-columns: unset;
}
.guide-toc {
position: unset;
}
$guide_section_padding_right_portrait: $BIG_PADDING !default;
.guide-section {
padding-right: $guide_section_padding_right_portrait;
}
}
$sortable_item_background: $ACCENT_COLOR !default;
$sortable_item_dragged_color: $BUTTON_COLOR !default;
$sortable_item_immovable_color: $BUTTON_COLOR_2 !default;
$sortable_item_padding: $BIG_PADDING !default;
$sortable_item_margin: $MEDIUM_BIG_PADDING 0 !default;
$sortable_item_border: 5px outset !default;
$sortable_item_border_top: $sortable_item_border $LIGHT !default;
$sortable_item_border_bottom: $sortable_item_border $DARK_2 !default;
$sortable_item_gap: $MEDIUM_PADDING !default;
ol.sortable-list {
list-style: none;
flex-grow: 1;
margin: 0;
li {
display: flex;
gap: $sortable_item_gap;
background-color: $sortable_item_background;
padding: $sortable_item_padding;
margin: $sortable_item_margin;
border-top: $sortable_item_border_top;
border-bottom: $sortable_item_border_bottom;
}
li.dragged {
background-color: $sortable_item_dragged_color;
}
li.immovable {
background-color: $sortable_item_immovable_color;
}
li.immovable .dragger {
cursor: not-allowed;
}
}
$sortable_item_grabber_padding: $SMALL_PADDING $MEDIUM_PADDING !default;
.dragger {
display: flex;
align-items: center;
background-color: $DARK_2;
padding: $sortable_item_grabber_padding;
cursor: move;
}
$sortable_item_inner_gap: $MEDIUM_PADDING !default;
.sortable-item-inner {
display: flex;
gap: $sortable_item_inner_gap;
flex-grow: 1;
flex-direction: column;
& > * {
flex-grow: 1;
}
&.row {
flex-direction: row;
}
&:not(.row) > * {
margin-right: auto;
}
}
.fg {
flex-grow: 1;
}

View File

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

View File

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