Compare commits
9 Commits
21ace9299f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
40219f2b54
|
|||
|
4a45b62521
|
|||
|
fc55aaf87a
|
|||
|
db68ef2c3d
|
|||
|
a808137e5b
|
|||
|
a93a89f0df
|
|||
|
7aa3a9382e
|
|||
|
46704df7d9
|
|||
|
98bf430604
|
73
README.md
73
README.md
@@ -1,18 +1,70 @@
|
||||
# Pyrom
|
||||
python/flask port of [porom](https://git.poto.cafe/yagich/porom)
|
||||
pyrom is a playful home-grown forum software for the indie web borne out of frustration with social media and modern forums imitating it.
|
||||
|
||||
this is now the canonical implementation of porom. it's compatible with the database of porom.
|
||||
the aim is not to recreate the feeling of forums from any time period. rather, it aims to serve as a lightweight alternative to other forum software packages. pyrom is lean and "fire-and-forget"; there is little necessary configuration, making it a great fit for smaller communities (though nothing prevents it from being used in larger ones.)
|
||||
|
||||
# License
|
||||
Released under [CNPLv7+](https://thufie.lain.haus/NPL.html).
|
||||
Please read the [full terms](./LICENSE.md) for proper wording.
|
||||
a live example can be seen in action over at [Porom](https://forum.poto.cafe/).
|
||||
|
||||
## stack & structure
|
||||
on the server side, pyrom is built in Python using the Flask framework. content is rendered mostly server-side with Jinja templates. the database used is SQLite.
|
||||
|
||||
on the client side, JS with only one library ([Bitty](https://bitty-js.com)) is used. for CSS, pyrom uses Sass.
|
||||
|
||||
below is an explanation of the folder structure:
|
||||
|
||||
- `/`
|
||||
- `app/`
|
||||
- `lib/` - utility libraries
|
||||
- `routes/` - each `.py` file represents a "sub-app", usually the first part of the URL
|
||||
- `templates/` - Jinja templates used by the routes. each subfolder corresponds to the "sub-app" that uses that template.
|
||||
- `__init__.py` - creates the app
|
||||
- `auth.py` - authentication helper
|
||||
- `constants.py` - constant values used throughout the forum
|
||||
- `db.py` - database abstraction layer and ORM library
|
||||
- `migrations.py` - database migrations
|
||||
- `models.py` - ORM model definitions
|
||||
- `run.py` - runner script for development
|
||||
- `schema.py` - database schema definition
|
||||
- `config/` - configuration for the forum
|
||||
- `data/`
|
||||
- `_cached/` - cached versions of certain endpoints are stored here
|
||||
- `db/` - the SQLite database is stored here
|
||||
- `static/` - static files
|
||||
- `avatars/` - user avatar uploads
|
||||
- `badges/` - user badge uploads
|
||||
- `css/` - CSS files generated from Sass sources
|
||||
- `emoji/` - emoji images used on the forum
|
||||
- `fonts/`
|
||||
- `js/`
|
||||
- `sass/`
|
||||
- `_default.scss` - the default theme. Sass variables that other themes modify are defined here, along with the default styles. other files define the available themes.
|
||||
- `build-themes.sh` - script for building Sass files into CSS
|
||||
- `nginx.conf` - nginx config (production only)
|
||||
- `uwsgi.ini` - uwsgi config (production only)
|
||||
|
||||
# license
|
||||
released under [CNPLv7+](https://thufie.lain.haus/NPL.html).
|
||||
please read the [full terms](./LICENSE.md) for proper wording.
|
||||
|
||||
# acknowledgments
|
||||
|
||||
pyrom uses many open-source and otherwise free-culture components. see the [THIRDPARTY](./THIRDPARTY.md) file for full credit.
|
||||
|
||||
# installing & first time setup
|
||||
## docker (production)
|
||||
create `config/secrets.prod.env` according to `config/secrets.prod.env.example`
|
||||
1. clone the repo
|
||||
2. create `config/secrets.prod.env` according to `config/secrets.prod.env.example`
|
||||
3. create `config/pyrom_config.toml` according to `config/pyrom_config.toml.example` and modify as needed
|
||||
4. make sure the `data/` folder is writable by the app:
|
||||
|
||||
```bash
|
||||
$ docker compose up
|
||||
$ chmod -R 777 data/
|
||||
```
|
||||
|
||||
5. bring up the container:
|
||||
|
||||
```bash
|
||||
$ docker compose up --build
|
||||
```
|
||||
|
||||
- opens port 8080
|
||||
@@ -20,10 +72,10 @@ $ docker compose up
|
||||
|
||||
make sure to run it in an interactive session the first time, because it will spit out the password to the auto-created admin account.
|
||||
|
||||
alternatively, if you already had porom running before, put the db file (`db.prod.sqlite`) in `data/db` and it will Just Work.
|
||||
6. point your favorite proxy at `localhost:8080`
|
||||
|
||||
## manual (development)
|
||||
1. install python >= 3.11, sqlite3, libargon2, and imagemagick & clone repo
|
||||
1. install python >= 3.13, sqlite3, libargon2, and imagemagick & clone repo
|
||||
2. create a venv:
|
||||
|
||||
```bash
|
||||
@@ -59,6 +111,3 @@ $ source .venv/bin/activate
|
||||
$ python -m app.run
|
||||
```
|
||||
|
||||
# acknowledgments
|
||||
|
||||
pyrom uses many open-source and otherwise free-culture components. see the [THIRDPARTY](./THIRDPARTY.md) file for full credit.
|
||||
|
||||
@@ -85,3 +85,18 @@ URL: https://bitty-js.com/
|
||||
License: CC0 1.0
|
||||
Author: alan w smith https://www.alanwsmith.com/
|
||||
Repo: https://github.com/alanwsmith/bitty
|
||||
|
||||
## Flask-Caching
|
||||
|
||||
URL: https://flask-caching.readthedocs.io/
|
||||
Copyright:
|
||||
|
||||
```
|
||||
Copyright (c) 2010 by Thadeus Burgess.
|
||||
Copyright (c) 2016 by Peter Justin.
|
||||
|
||||
Some rights reserved.
|
||||
```
|
||||
|
||||
License: BSD-3-Clause ([see more](https://github.com/pallets-eco/flask-caching/blob/e59bc040cd47cd2b43e501d636d43d442c50b3ff/LICENSE))
|
||||
Repo: https://github.com/pallets-eco/flask-caching
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from flask import Flask, session, request, render_template
|
||||
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 .routes.users import is_logged_in, get_active_user, get_prefers_theme
|
||||
from .constants import (
|
||||
@@ -10,6 +10,7 @@ from .constants import (
|
||||
SIG_BANNED_TAGS, STRICT_BANNED_TAGS,
|
||||
)
|
||||
from .lib.babycode import babycode_to_html, babycode_to_rssxml, EMOJI, BABYCODE_VERSION
|
||||
from .lib.exceptions import SiteNameMissingException
|
||||
from datetime import datetime, timezone
|
||||
from flask_caching import Cache
|
||||
import os
|
||||
@@ -137,6 +138,16 @@ def bind_default_badges(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()
|
||||
|
||||
@@ -165,6 +176,8 @@ def create_app():
|
||||
load_dotenv()
|
||||
else:
|
||||
app.config["DB_PATH"] = "data/db/db.prod.sqlite"
|
||||
if not app.config["SERVER_NAME"]:
|
||||
raise SiteNameMissingException()
|
||||
|
||||
app.config["SECRET_KEY"] = os.getenv("FLASK_SECRET_KEY")
|
||||
|
||||
@@ -223,6 +236,8 @@ def create_app():
|
||||
create_admin()
|
||||
create_deleted_user()
|
||||
|
||||
clear_stale_sessions()
|
||||
|
||||
reparse_babycode()
|
||||
|
||||
bind_default_badges(app.config['BADGES_PATH'])
|
||||
|
||||
@@ -196,13 +196,12 @@ class RSSXMLRenderer(BabycodeRenderer):
|
||||
|
||||
def make_mention(self, e):
|
||||
from ..models import Users
|
||||
from flask import url_for, current_app
|
||||
with current_app.test_request_context('/'):
|
||||
from flask import url_for
|
||||
target_user = Users.find({'username': e['name'].lower()})
|
||||
if not target_user:
|
||||
return f"@{e['name']}"
|
||||
|
||||
return f'<a href="{url_for('users.page', username=target_user.username, _external=True)}" title="@{target_user.username}">{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 = [
|
||||
|
||||
9
app/lib/exceptions.py
Normal file
9
app/lib/exceptions.py
Normal file
@@ -0,0 +1,9 @@
|
||||
class MissingConfigurationException(Exception):
|
||||
def __init__(self, configuration_field: str):
|
||||
message = f"Missing configuration field '{configuration_field}'"
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class SiteNameMissingException(MissingConfigurationException):
|
||||
def __init__(self):
|
||||
super().__init__('SITE_NAME')
|
||||
@@ -1,5 +1,4 @@
|
||||
from flask import Blueprint, redirect, url_for, render_template
|
||||
from app import cache
|
||||
from datetime import datetime
|
||||
|
||||
bp = Blueprint("app", __name__, url_prefix = "/")
|
||||
@@ -7,12 +6,3 @@ bp = Blueprint("app", __name__, url_prefix = "/")
|
||||
@bp.route("/")
|
||||
def index():
|
||||
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}"
|
||||
|
||||
@@ -29,8 +29,10 @@ def sort_topics():
|
||||
|
||||
@bp.post("/sort-topics")
|
||||
def sort_topics_post():
|
||||
topics_list = request.form.getlist('topics[]')
|
||||
print(topics_list)
|
||||
with db.transaction():
|
||||
for topic_id, new_order in request.form.items():
|
||||
for new_order, topic_id in enumerate(topics_list):
|
||||
db.execute("UPDATE topics SET sort_order = ? WHERE id = ?", new_order, topic_id)
|
||||
|
||||
return redirect(url_for(".sort_topics"))
|
||||
|
||||
@@ -74,7 +74,17 @@ def validate_and_create_badge(input_image, filename):
|
||||
return False
|
||||
|
||||
def is_logged_in():
|
||||
return "pyrom_session_key" in session
|
||||
if "pyrom_session_key" not in session:
|
||||
return False
|
||||
sess = Sessions.find({"key": session["pyrom_session_key"]})
|
||||
if not sess:
|
||||
return False
|
||||
if sess.expires_at < int(time.time()):
|
||||
session.clear()
|
||||
sess.delete()
|
||||
flash('Your session expired.;Please log in again.', InfoboxKind.INFO)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_active_user():
|
||||
@@ -83,6 +93,8 @@ def get_active_user():
|
||||
sess = Sessions.find({"key": session["pyrom_session_key"]})
|
||||
if not sess:
|
||||
return None
|
||||
if sess.expires_at < int(time.time()):
|
||||
return None
|
||||
return Users.find({"id": sess.user_id})
|
||||
|
||||
|
||||
@@ -645,12 +657,29 @@ def inbox(username):
|
||||
subscriptions ON subscriptions.thread_id = posts.thread_id
|
||||
WHERE subscriptions.user_id = ? AND posts.created_at > subscriptions.last_seen
|
||||
GROUP BY posts.thread_id
|
||||
),
|
||||
user_badges AS (
|
||||
SELECT
|
||||
b.user_id,
|
||||
json_group_array(
|
||||
json_object(
|
||||
'label', b.label,
|
||||
'link', b.link,
|
||||
'sort_order', b.sort_order,
|
||||
'file_path', bu.file_path
|
||||
)
|
||||
) AS badges_json
|
||||
FROM badges b
|
||||
LEFT JOIN badge_uploads bu ON b.upload = bu.id
|
||||
GROUP BY b.user_id
|
||||
ORDER BY b.sort_order
|
||||
)
|
||||
|
||||
SELECT
|
||||
tm.thread_id, tm.thread_slug, tm.thread_title, tm.unread_count, tm.newest_post_time,
|
||||
|
||||
posts.id, posts.created_at, post_history.content, post_history.edited_at, users.username, users.status, avatars.file_path AS avatar_path, posts.thread_id, users.id AS user_id, post_history.original_markup, users.signature_rendered
|
||||
posts.id, posts.created_at, post_history.content, post_history.edited_at, users.username, users.status, avatars.file_path AS avatar_path, posts.thread_id, users.id AS user_id, post_history.original_markup, users.signature_rendered,
|
||||
COALESCE(user_badges.badges_json, '[]') AS badges_json
|
||||
FROM
|
||||
thread_metadata tm
|
||||
JOIN
|
||||
@@ -665,6 +694,8 @@ def inbox(username):
|
||||
avatars ON users.avatar_id = avatars.id
|
||||
LEFT JOIN
|
||||
subscriptions ON subscriptions.thread_id = posts.thread_id
|
||||
LEFT JOIN
|
||||
user_badges ON users.id = user_badges.user_id
|
||||
WHERE
|
||||
subscriptions.user_id = ? AND posts.created_at > subscriptions.last_seen
|
||||
ORDER BY
|
||||
@@ -698,6 +729,7 @@ def inbox(username):
|
||||
'user_id': row['user_id'],
|
||||
'original_markup': row['original_markup'],
|
||||
'signature_rendered': row['signature_rendered'],
|
||||
'badges_json': row['badges_json'],
|
||||
|
||||
'thread_slug': row['thread_slug'],
|
||||
})
|
||||
@@ -864,6 +896,10 @@ def delete_page_confirm(username):
|
||||
flash('Incorrect password.', InfoboxKind.ERROR)
|
||||
return redirect(url_for('.delete_page', username=username))
|
||||
|
||||
if target_user.is_admin():
|
||||
flash('You cannot delete the admin account.', InfoboxKind.ERROR)
|
||||
return redirect(url_for('.delete_page', username=username))
|
||||
|
||||
anonymize_user(target_user.id)
|
||||
sessions = Sessions.findall({'user_id': int(target_user.id)})
|
||||
for session_obj in sessions:
|
||||
|
||||
@@ -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"/>
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro icn_drag(width=24) %}
|
||||
<svg width="{{width}}px" height="{{width}}px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 10H19M14 19L12 21L10 19M14 5L12 3L10 5M5 14H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{% from 'common/icons.html' import icn_image, icn_spoiler, icn_info, icn_lock, icn_warn, icn_error, icn_bookmark, icn_megaphone, 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) %}
|
||||
{% set left_start = [1, current_page - 5] | max %}
|
||||
{% set right_end = [page_count, current_page + 5] | min %}
|
||||
@@ -331,7 +335,9 @@
|
||||
{% else %}
|
||||
{% set selected_href = defaults[0].file_path %}
|
||||
{% endif %}
|
||||
<bitty-7-0 data-connect="{{ '/static/js/bitties/pyrom-bitty.js' | cachebust }} BadgeEditorBadge" data-listeners="click input submit change" data-receive="deleteBadge">
|
||||
<li class="sortable-item" data-sortable-list-key="" data-receive="deleteBadge"> {# breaking convention on purpose since this one is special #}
|
||||
<span class="dragger" draggable="true">{{ icn_drag(24) }}</span>
|
||||
<bitty-7-0 class="fg" data-connect="{{ '/static/js/bitties/pyrom-bitty.js' | cachebust }} BadgeEditorBadge" data-listeners="click input submit change">
|
||||
<div class="settings-badge-container">
|
||||
<div class="settings-badge-select">
|
||||
<select data-send="badgeUpdatePreview badgeToggleFilePicker" name="badge_choice[]" required>
|
||||
@@ -358,6 +364,7 @@
|
||||
<button data-send="deleteBadge" type="button" class="critical" title="Delete">X</button>
|
||||
</div>
|
||||
</bitty-7-0>
|
||||
</li>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro rss_html_content(html) %}
|
||||
@@ -367,3 +374,20 @@
|
||||
{% macro rss_button(feed) %}
|
||||
<a class="linkbutton contain-svg inline icon rss-button" href="{{feed}}" title="it's actually atom, don't tell anyone ;)">{{ icn_rss(20) }} Subscribe via RSS</a>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro sortable_list(attr=none) %}
|
||||
<ol class="sortable-list" {% if attr %}{{ dict_to_attr(attr) }}{% endif %}>
|
||||
{% if caller %}
|
||||
{{ caller() }}
|
||||
{% endif %}
|
||||
</ol>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro sortable_list_item(key, immovable=false, attr=none) %}
|
||||
<li class="sortable-item{{' immovable' if immovable else ''}}" data-sortable-list-key="{{key}}" {% if attr %}{{ dict_to_attr(attr) }}{% endif %}>
|
||||
<span class="dragger" draggable="{{ 'true' if not immovable else 'false'}}">{{ icn_drag(24) }}</span>
|
||||
<div class="sortable-item-inner">
|
||||
{{ caller() }}
|
||||
</div>
|
||||
</li>
|
||||
{% endmacro %}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
{% from 'common/macros.html' import sortable_list, sortable_list_item %}
|
||||
{% block content %}
|
||||
<div class="darkbg">
|
||||
<h1>Change topics order</h1>
|
||||
<p>Drag topic titles to reoder them. Press submit when done. The topics will appear to users in the order set here.</p>
|
||||
<form method="post" id=topics-container>
|
||||
{% for topic in topics %}
|
||||
<div draggable="true" class="draggable-topic" ondragover="dragOver(event)" ondragstart="dragStart(event)" ondragend="dragEnd()">
|
||||
<div class="thread-title">{{ topic['name'] }}</div>
|
||||
<div>{{ topic.description }}</div>
|
||||
<input type="hidden" name="{{ topic['id'] }}" value="{{ topic['sort-order'] }}" class="topic-input">
|
||||
</div>
|
||||
{% endfor %}
|
||||
<p>Drag topic titles to reoder them. Press "Save order" when done. The topics will appear to users in the order set here.</p>
|
||||
<form method="post">
|
||||
<input type=submit value="Save order">
|
||||
{% call() sortable_list() %}
|
||||
{% for topic in topics %}
|
||||
{% call() sortable_list_item(key="topics") %}
|
||||
<div class="thread-title">{{ topic.name }}</div>
|
||||
<div>{{ topic.description }}</div>
|
||||
<input type="hidden" name="topics[]" value="{{ topic.id }}" class="topic-input">
|
||||
{% endcall %}
|
||||
{% endfor %}
|
||||
{% endcall %}
|
||||
</form>
|
||||
</div>
|
||||
<script src="{{ "/static/js/sort-topics.js" | cachebust }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,35 +1,41 @@
|
||||
{% extends "base.html" %}
|
||||
{% from 'common/macros.html' import sortable_list, sortable_list_item %}
|
||||
{% block title %}managing bookmark collections{% endblock %}
|
||||
{% block content %}
|
||||
<div class="darkbg">
|
||||
<h1>Manage bookmark collections</h1>
|
||||
<p>Drag collections to reoder them. You cannot move or remove the default collection, but you can rename it.</p>
|
||||
<div>
|
||||
<button type="button" id="add-collection-button">Add new collection</button>
|
||||
<div id="collections-container">
|
||||
{% for collection in collections | sort(attribute='sort_order') %}
|
||||
<div class="draggable-collection {{ "default" if collection.is_default else ""}}"
|
||||
{% if not collection.is_default %}
|
||||
draggable="true"
|
||||
ondragover="dragOver(event)"
|
||||
ondragstart="dragStart(event)"
|
||||
ondragend="dragEnd()"
|
||||
{% else %}
|
||||
id="default-collection"
|
||||
{% endif %}
|
||||
data-collection-id="{{ collection.id }}">
|
||||
<input type="text" class="collection-name" value="{{ collection.name }}" placeholder="Collection name" required autocomplete="off" maxlength="60"><br>
|
||||
<bitty-7-0 data-connect="{{ '/static/js/bitties/pyrom-bitty.js' | cachebust }} CollectionsEditor">
|
||||
<button type="button" data-send="addCollection">Add new collection</button>
|
||||
{% set sorted_collections = collections | sort(attribute='sort_order') %}
|
||||
{% macro collection_inner(collection) %}
|
||||
<input type="text" class="collection-name" value="{{collection.name}}" placeholder="Collection name" required autocomplete="off" maxlength="60">
|
||||
<div>{{ collection.get_threads_count() }} {{ "thread" | pluralize(num=collection.get_threads_count()) }}, {{ collection.get_posts_count() }} {{ "post" | pluralize(num=collection.get_posts_count()) }}</div>
|
||||
{% if collection.is_default %}
|
||||
<i>Default collection</i>
|
||||
{% else %}
|
||||
<button type="button" class="delete-button critical">Delete</button>
|
||||
<button type="button" class="delete-button critical" data-send="deleteCollection">Delete</button>
|
||||
{% 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 %}
|
||||
{% 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>
|
||||
<button type="button" id="save-button" data-submit-href="{{ url_for('api.manage_bookmark_collections', user_id=active_user.id) }}">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<script src="{{ "/static/js/manage-bookmark-collections.js" | cachebust }}"></script>
|
||||
<template id="new-collection-template">
|
||||
{% call() sortable_list_item(key='collections', attr={'data-receive': 'deleteCollection getCollectionData testValidity'}) %}
|
||||
<input type="text" class="collection-name" value="" placeholder="Collection name" required autocomplete="off" maxlength="60">
|
||||
<div>0 threads, 0 posts</div>
|
||||
<button type="button" class="delete-button critical" data-send="deleteCollection">Delete</button>
|
||||
{% endcall %}
|
||||
</template>
|
||||
</bitty-7-0>
|
||||
{#<script src="{{ "/static/js/manage-bookmark-collections.js" | cachebust }}"></script>#}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{% from 'common/macros.html' import babycode_editor_component, badge_editor_single %}
|
||||
{% from 'common/macros.html' import babycode_editor_component, badge_editor_single, sortable_list %}
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}settings{% endblock %}
|
||||
{% block content %}
|
||||
@@ -57,7 +57,7 @@
|
||||
<legend>Badges</legend>
|
||||
<a href="{{ url_for('guides.guide_page', category='user-guides', slug='settings', _anchor='badges')}}">Badges help</a>
|
||||
<bitty-7-0 data-connect="{{ '/static/js/bitties/pyrom-bitty.js' | cachebust }} BadgeEditorForm" data-listeners="click input submit change">
|
||||
<form data-use="badgeEditorPrepareSubmit" data-init='loadBadgeEditor' 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…</div>
|
||||
<div>If badges fail to load, JS may be disabled.</div>
|
||||
</form>
|
||||
@@ -72,4 +72,8 @@
|
||||
<template id='badge-editor-template'>
|
||||
{{ badge_editor_single(options=uploads) }}
|
||||
</template>
|
||||
|
||||
<template id="badges-list-template">
|
||||
{{ sortable_list(attr={'data-receive': 'addBadge'}) }}
|
||||
</template>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,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"
|
||||
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,
|
||||
# no one can sign up. this may be useful later when/if LDAP is implemented.
|
||||
|
||||
MODS_CAN_INVITE = true # if true, allows moderators to create invite links. useless unless DISABLE_SIGNUP to be true.
|
||||
USERS_CAN_INVITE = false # if true, allows users to create invite links. useless unless DISABLE_SIGNUP to be true.
|
||||
# if true, allows moderators to create invite links. useless unless DISABLE_SIGNUP is true.
|
||||
MODS_CAN_INVITE = true
|
||||
|
||||
# if true, allows users to create invite links. useless unless DISABLE_SIGNUP is true.
|
||||
USERS_CAN_INVITE = false
|
||||
|
||||
# contact information, will be shown in /guides/contact
|
||||
# some babycodes allowed
|
||||
|
||||
@@ -867,6 +867,10 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
|
||||
background-color: rgb(230.2, 235.4, 223.8);
|
||||
}
|
||||
|
||||
input:not(form input):invalid {
|
||||
border: 2px dashed red;
|
||||
}
|
||||
|
||||
textarea {
|
||||
font-family: "Atkinson Hyperlegible Mono", monospace;
|
||||
}
|
||||
@@ -1069,35 +1073,6 @@ textarea {
|
||||
background-color: none;
|
||||
}
|
||||
|
||||
.draggable-topic {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background-color: #c1ceb1;
|
||||
padding: 20px;
|
||||
margin: 15px 0;
|
||||
border-top: 5px outset rgb(217.26, 220.38, 213.42);
|
||||
border-bottom: 5px outset rgb(135.1928346457, 145.0974015748, 123.0025984252);
|
||||
}
|
||||
.draggable-topic.dragged {
|
||||
background-color: rgb(177, 206, 204.5);
|
||||
}
|
||||
|
||||
.draggable-collection {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background-color: #c1ceb1;
|
||||
padding: 20px;
|
||||
margin: 15px 0;
|
||||
border-top: 5px outset rgb(217.26, 220.38, 213.42);
|
||||
border-bottom: 5px outset rgb(135.1928346457, 145.0974015748, 123.0025984252);
|
||||
}
|
||||
.draggable-collection.dragged {
|
||||
background-color: rgb(177, 206, 204.5);
|
||||
}
|
||||
.draggable-collection.default {
|
||||
background-color: #beb1ce;
|
||||
}
|
||||
|
||||
.editing {
|
||||
background-color: rgb(217.26, 220.38, 213.42);
|
||||
}
|
||||
@@ -1550,3 +1525,54 @@ img.badge-button {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -867,6 +867,10 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
|
||||
background-color: #514151;
|
||||
}
|
||||
|
||||
input:not(form input):invalid {
|
||||
border: 2px dashed #d53232;
|
||||
}
|
||||
|
||||
textarea {
|
||||
font-family: "Atkinson Hyperlegible Mono", monospace;
|
||||
}
|
||||
@@ -1069,35 +1073,6 @@ textarea {
|
||||
background-color: #503250;
|
||||
}
|
||||
|
||||
.draggable-topic {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background-color: #9b649b;
|
||||
padding: 20px;
|
||||
margin: 15px 0;
|
||||
border-top: 5px outset #503250;
|
||||
border-bottom: 5px outset rgb(96.95, 81.55, 96.95);
|
||||
}
|
||||
.draggable-topic.dragged {
|
||||
background-color: #3c283c;
|
||||
}
|
||||
|
||||
.draggable-collection {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background-color: #9b649b;
|
||||
padding: 20px;
|
||||
margin: 15px 0;
|
||||
border-top: 5px outset #503250;
|
||||
border-bottom: 5px outset rgb(96.95, 81.55, 96.95);
|
||||
}
|
||||
.draggable-collection.dragged {
|
||||
background-color: #3c283c;
|
||||
}
|
||||
.draggable-collection.default {
|
||||
background-color: #8a5584;
|
||||
}
|
||||
|
||||
.editing {
|
||||
background-color: #503250;
|
||||
}
|
||||
@@ -1458,7 +1433,7 @@ a.mention:hover, a.mention:visited:hover {
|
||||
.settings-grid fieldset {
|
||||
border: 1px solid black;
|
||||
border-radius: 8px;
|
||||
background-color: rgb(141.6, 79.65, 141.6);
|
||||
background-color: #503250;
|
||||
}
|
||||
|
||||
.hfc {
|
||||
@@ -1479,10 +1454,10 @@ h1 {
|
||||
margin: 10px 0;
|
||||
}
|
||||
.settings-badge-container:has(input:invalid) {
|
||||
border: 2px dashed red;
|
||||
border: 2px dashed #d53232;
|
||||
}
|
||||
.settings-badge-container input:invalid {
|
||||
border: 2px dashed red;
|
||||
border: 2px dashed #d53232;
|
||||
}
|
||||
|
||||
.settings-badge-file-picker {
|
||||
@@ -1550,6 +1525,58 @@ img.badge-button {
|
||||
padding-right: 20px;
|
||||
}
|
||||
}
|
||||
ol.sortable-list {
|
||||
list-style: none;
|
||||
flex-grow: 1;
|
||||
margin: 0;
|
||||
}
|
||||
ol.sortable-list li {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
background-color: #9b649b;
|
||||
padding: 20px;
|
||||
margin: 15px 0;
|
||||
border-top: 5px outset #503250;
|
||||
border-bottom: 5px outset rgb(96.95, 81.55, 96.95);
|
||||
}
|
||||
ol.sortable-list li.dragged {
|
||||
background-color: #3c283c;
|
||||
}
|
||||
ol.sortable-list li.immovable {
|
||||
background-color: #8a5584;
|
||||
}
|
||||
ol.sortable-list li.immovable .dragger {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.dragger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: rgb(96.95, 81.55, 96.95);
|
||||
padding: 5px 10px;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.sortable-item-inner {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-grow: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
.sortable-item-inner > * {
|
||||
flex-grow: 1;
|
||||
}
|
||||
.sortable-item-inner.row {
|
||||
flex-direction: row;
|
||||
}
|
||||
.sortable-item-inner:not(.row) > * {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.fg {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#topnav {
|
||||
margin-bottom: 10px;
|
||||
border: 10px solid rgb(40, 40, 40);
|
||||
|
||||
@@ -867,6 +867,10 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
|
||||
background-color: rgb(249.8, 201.8, 189);
|
||||
}
|
||||
|
||||
input:not(form input):invalid {
|
||||
border: 2px dashed #f73030;
|
||||
}
|
||||
|
||||
textarea {
|
||||
font-family: "Atkinson Hyperlegible Mono", monospace;
|
||||
}
|
||||
@@ -1069,35 +1073,6 @@ textarea {
|
||||
background-color: #f27a5a;
|
||||
}
|
||||
|
||||
.draggable-topic {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background-color: #f27a5a;
|
||||
padding: 12px;
|
||||
margin: 8px 0;
|
||||
border-top: 5px outset rgb(219.84, 191.04, 183.36);
|
||||
border-bottom: 5px outset rgb(155.8907865169, 93.2211235955, 76.5092134831);
|
||||
}
|
||||
.draggable-topic.dragged {
|
||||
background-color: #f27a5a;
|
||||
}
|
||||
|
||||
.draggable-collection {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background-color: #f27a5a;
|
||||
padding: 12px;
|
||||
margin: 8px 0;
|
||||
border-top: 5px outset rgb(219.84, 191.04, 183.36);
|
||||
border-bottom: 5px outset rgb(155.8907865169, 93.2211235955, 76.5092134831);
|
||||
}
|
||||
.draggable-collection.dragged {
|
||||
background-color: #f27a5a;
|
||||
}
|
||||
.draggable-collection.default {
|
||||
background-color: #b54444;
|
||||
}
|
||||
|
||||
.editing {
|
||||
background-color: rgb(219.84, 191.04, 183.36);
|
||||
}
|
||||
@@ -1479,10 +1454,10 @@ h1 {
|
||||
margin: 6px 0;
|
||||
}
|
||||
.settings-badge-container:has(input:invalid) {
|
||||
border: 2px dashed red;
|
||||
border: 2px dashed #f73030;
|
||||
}
|
||||
.settings-badge-container input:invalid {
|
||||
border: 2px dashed red;
|
||||
border: 2px dashed #f73030;
|
||||
}
|
||||
|
||||
.settings-badge-file-picker {
|
||||
@@ -1550,6 +1525,58 @@ img.badge-button {
|
||||
padding-right: 12px;
|
||||
}
|
||||
}
|
||||
ol.sortable-list {
|
||||
list-style: none;
|
||||
flex-grow: 1;
|
||||
margin: 0;
|
||||
}
|
||||
ol.sortable-list li {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
background-color: #f27a5a;
|
||||
padding: 12px;
|
||||
margin: 8px 0;
|
||||
border-top: 5px outset rgb(219.84, 191.04, 183.36);
|
||||
border-bottom: 5px outset rgb(155.8907865169, 93.2211235955, 76.5092134831);
|
||||
}
|
||||
ol.sortable-list li.dragged {
|
||||
background-color: #f27a5a;
|
||||
}
|
||||
ol.sortable-list li.immovable {
|
||||
background-color: #b54444;
|
||||
}
|
||||
ol.sortable-list li.immovable .dragger {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.dragger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: rgb(155.8907865169, 93.2211235955, 76.5092134831);
|
||||
padding: 3px 6px;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.sortable-item-inner {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-grow: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
.sortable-item-inner > * {
|
||||
flex-grow: 1;
|
||||
}
|
||||
.sortable-item-inner.row {
|
||||
flex-direction: row;
|
||||
}
|
||||
.sortable-item-inner:not(.row) > * {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.fg {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#topnav {
|
||||
border-top-left-radius: 16px;
|
||||
border-top-right-radius: 16px;
|
||||
|
||||
@@ -867,6 +867,10 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
|
||||
background-color: rgb(235.4, 239.8, 248.2);
|
||||
}
|
||||
|
||||
input:not(form input):invalid {
|
||||
border: 2px dashed red;
|
||||
}
|
||||
|
||||
textarea {
|
||||
font-family: "Atkinson Hyperlegible Mono", monospace;
|
||||
}
|
||||
@@ -1069,35 +1073,6 @@ textarea {
|
||||
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 {
|
||||
background-color: rgb(231.36, 234, 239.04);
|
||||
}
|
||||
@@ -1550,3 +1525,54 @@ img.badge-button {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -287,8 +287,7 @@ export class BadgeEditorForm {
|
||||
return;
|
||||
}
|
||||
if (this.#badgeTemplate === undefined){
|
||||
this.#badgeTemplate = document.getElementById('badge-editor-template').content;
|
||||
console.log(this.#badgeTemplate);
|
||||
this.#badgeTemplate = document.getElementById('badge-editor-template').content.firstElementChild.outerHTML;
|
||||
}
|
||||
el.replaceChildren();
|
||||
const addButton = `<button data-disable-if-max="1" data-receive="updateBadgeCount" DISABLE_IF_MAX type="button" data-send="addBadge">Add badge</button>`;
|
||||
@@ -300,14 +299,18 @@ export class BadgeEditorForm {
|
||||
['DISABLE_IF_MAX', badgeCount === 10 ? 'disabled' : ''],
|
||||
];
|
||||
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) {
|
||||
if (this.#badgeTemplate === undefined) {
|
||||
return;
|
||||
}
|
||||
const badge = this.#badgeTemplate.cloneNode(true);
|
||||
const badge = this.api.makeHTML(this.#badgeTemplate).firstElementChild;
|
||||
el.appendChild(badge);
|
||||
this.api.localTrigger('updateBadgeCount');
|
||||
}
|
||||
@@ -341,9 +344,7 @@ export class BadgeEditorForm {
|
||||
noUploads.forEach(e => {
|
||||
e.value = null;
|
||||
})
|
||||
// console.log(noUploads);
|
||||
el.submit();
|
||||
// console.log('would submit now');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,3 +464,80 @@ export class BadgeEditorBadge {
|
||||
el.setCustomValidity('');
|
||||
}
|
||||
}
|
||||
|
||||
const getCollectionDataForEl = el => {
|
||||
const nameInput = el.querySelector(".collection-name");
|
||||
const collectionId = el.dataset.collectionId;
|
||||
|
||||
return {
|
||||
id: collectionId,
|
||||
name: nameInput.value,
|
||||
is_new: !('collectionId' in el.dataset),
|
||||
};
|
||||
}
|
||||
|
||||
export class CollectionsEditor {
|
||||
#collectionTemplate = undefined;
|
||||
#collectionsData = [];
|
||||
#removedCollections = [];
|
||||
#valid = true;
|
||||
|
||||
addCollection(ev, el) {
|
||||
if (this.#collectionTemplate === undefined) {
|
||||
this.#collectionTemplate = document.getElementById('new-collection-template').content;
|
||||
}
|
||||
// interesting
|
||||
const newCollection = this.api.makeHTML(this.#collectionTemplate.firstElementChild.outerHTML);
|
||||
el.appendChild(newCollection);
|
||||
}
|
||||
|
||||
deleteCollection(ev, el) {
|
||||
if (!el.contains(ev.sender)) {
|
||||
return;
|
||||
}
|
||||
if ('collectionId' in el.dataset) {
|
||||
this.#removedCollections.push(el.dataset.collectionId);
|
||||
}
|
||||
el.remove();
|
||||
}
|
||||
|
||||
async saveCollections(ev, el) {
|
||||
this.#valid = true;
|
||||
this.api.localTrigger('testValidity');
|
||||
if (!this.#valid) {
|
||||
return;
|
||||
}
|
||||
this.#collectionsData = [];
|
||||
this.api.localTrigger('getCollectionData');
|
||||
|
||||
const data = {
|
||||
collections: this.#collectionsData,
|
||||
removed_collections: this.#removedCollections,
|
||||
};
|
||||
|
||||
const res = await this.api.getJSON(el.prop('submitHref'), [], {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (res.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
getCollectionData(ev, el) {
|
||||
this.#collectionsData.push(getCollectionDataForEl(el));
|
||||
}
|
||||
|
||||
testValidity(ev, el) {
|
||||
const input = el.querySelector('input');
|
||||
if (!input.validity.valid) {
|
||||
input.reportValidity();
|
||||
this.#valid = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
let removedCollections = [];
|
||||
|
||||
document.getElementById("add-collection-button").addEventListener("click", () => {
|
||||
const container = document.getElementById("collections-container");
|
||||
const currentCount = container.querySelectorAll(".draggable-collection").length;
|
||||
|
||||
const newId = `new-${Date.now()}`
|
||||
const collectionHtml = `
|
||||
<div class="draggable-collection"
|
||||
data-collection-id="${newId}"
|
||||
draggable="true"
|
||||
ondragover="dragOver(event)"
|
||||
ondragstart="dragStart(event)"
|
||||
ondragend="dragEnd()">
|
||||
<input type="text" class="collection-name" value="" required placeholder="Enter collection name" autocomplete="off" maxlength="60"><br>
|
||||
<div>0 threads, 0 posts</div>
|
||||
<button type="button" class="delete-button critical">Delete</button>
|
||||
</div>
|
||||
`;
|
||||
container.insertAdjacentHTML('beforeend', collectionHtml);
|
||||
})
|
||||
|
||||
document.addEventListener("click", e => {
|
||||
if (!e.target.classList.contains("delete-button")) {
|
||||
return;
|
||||
}
|
||||
const collectionDiv = e.target.closest(".draggable-collection");
|
||||
const collectionId = collectionDiv.dataset.collectionId;
|
||||
|
||||
if (!collectionId.startsWith("new-")) {
|
||||
removedCollections.push(collectionId);
|
||||
}
|
||||
|
||||
collectionDiv.remove();
|
||||
})
|
||||
|
||||
document.getElementById("save-button").addEventListener("click", async () => {
|
||||
const collections = [];
|
||||
const collectionDivs = document.querySelectorAll(".draggable-collection");
|
||||
let isValid = true;
|
||||
collectionDivs.forEach((collection, index) => {
|
||||
const collectionId = collection.dataset.collectionId;
|
||||
const nameInput = collection.querySelector(".collection-name");
|
||||
|
||||
if (!nameInput.reportValidity()) {
|
||||
isValid = false;
|
||||
return;
|
||||
}
|
||||
|
||||
collections.push({
|
||||
id: collectionId,
|
||||
name: nameInput.value,
|
||||
is_new: collectionId.startsWith("new-"),
|
||||
});
|
||||
})
|
||||
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
collections: collections,
|
||||
removed_collections: removedCollections,
|
||||
};
|
||||
|
||||
try {
|
||||
const saveHref = document.getElementById('save-button').dataset.submitHref;
|
||||
const response = await fetch(saveHref, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
console.error("Error saving collections");
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error saving collections: ", error);
|
||||
}
|
||||
})
|
||||
|
||||
// drag logic
|
||||
// https://codepen.io/crouchingtigerhiddenadam/pen/qKXgap
|
||||
|
||||
let selected = null;
|
||||
const container = document.getElementById("collections-container");
|
||||
function isBefore(el1, el2) {
|
||||
let cur;
|
||||
if (el2.parentNode === el1.parentNode) {
|
||||
for (cur = el1.previousSibling; cur; cur = cur.previousSibling) {
|
||||
if (cur === el2) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function dragOver(e) {
|
||||
let target = e.target.closest(".draggable-collection")
|
||||
|
||||
if (!target || target === selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isBefore(selected, target)) {
|
||||
container.insertBefore(selected, target)
|
||||
} else {
|
||||
container.insertBefore(selected, target.nextSibling)
|
||||
}
|
||||
}
|
||||
|
||||
function dragEnd() {
|
||||
if (!selected) return;
|
||||
|
||||
selected.classList.remove("dragged")
|
||||
selected = null;
|
||||
}
|
||||
|
||||
function dragStart(e) {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', "")
|
||||
selected = e.target
|
||||
selected.classList.add("dragged")
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
// https://codepen.io/crouchingtigerhiddenadam/pen/qKXgap
|
||||
let selected = null;
|
||||
let container = document.getElementById("topics-container")
|
||||
|
||||
function isBefore(el1, el2) {
|
||||
let cur
|
||||
if (el2.parentNode === el1.parentNode) {
|
||||
for (cur = el1.previousSibling; cur; cur = cur.previousSibling) {
|
||||
if (cur === el2) return true
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function dragOver(e) {
|
||||
let target = e.target.closest(".draggable-topic")
|
||||
|
||||
if (!target || target === selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isBefore(selected, target)) {
|
||||
container.insertBefore(selected, target)
|
||||
} else {
|
||||
container.insertBefore(selected, target.nextSibling)
|
||||
}
|
||||
}
|
||||
|
||||
function dragEnd() {
|
||||
if (!selected) return;
|
||||
|
||||
selected.classList.remove("dragged")
|
||||
selected = null;
|
||||
for (let i = 0; i < container.childElementCount - 1; i++) {
|
||||
let input = container.children[i].querySelector(".topic-input");
|
||||
input.value = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
function dragStart(e) {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', null)
|
||||
selected = e.target
|
||||
selected.classList.add("dragged")
|
||||
}
|
||||
@@ -90,21 +90,16 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
minWidth: origMinWidth,
|
||||
minHeight: origMinHeight,
|
||||
} = getComputedStyle(img);
|
||||
console.log(img, img.naturalWidth, img.naturalHeight, origMinWidth, origMinHeight, origMaxWidth, origMaxHeight)
|
||||
if (img.naturalWidth < parseInt(origMinWidth)) {
|
||||
console.log(1)
|
||||
img.style.minWidth = img.naturalWidth + "px";
|
||||
}
|
||||
if (img.naturalHeight < parseInt(origMinHeight)) {
|
||||
console.log(2)
|
||||
img.style.minHeight = img.naturalHeight + "px";
|
||||
}
|
||||
if (img.naturalWidth < parseInt(origMaxWidth)) {
|
||||
console.log(3)
|
||||
img.style.maxWidth = img.naturalWidth + "px";
|
||||
}
|
||||
if (img.naturalHeight < parseInt(origMaxHeight)) {
|
||||
console.log(4)
|
||||
img.style.maxHeight = img.naturalHeight + "px";
|
||||
}
|
||||
}
|
||||
@@ -133,3 +128,108 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
{
|
||||
function isBefore(el1, el2) {
|
||||
if (el2.parentNode === el1.parentNode) {
|
||||
for (let cur = el1.previousSibling; cur; cur = cur.previousSibling) {
|
||||
if (cur === el2) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
let draggedItem = null;
|
||||
|
||||
function sortableItemDragStart(e, item) {
|
||||
const box = item.getBoundingClientRect();
|
||||
const oX = e.clientX - box.left;
|
||||
const oY = e.clientY - box.top;
|
||||
draggedItem = item;
|
||||
item.classList.add('dragged');
|
||||
e.dataTransfer.setDragImage(item, oX, oY);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
|
||||
function sortableItemDragEnd(e, item) {
|
||||
draggedItem = null;
|
||||
item.classList.remove('dragged');
|
||||
}
|
||||
|
||||
function sortableItemDragOver(e, item) {
|
||||
const target = e.target.closest('.sortable-item');
|
||||
if (!target || target === draggedItem) {
|
||||
return;
|
||||
}
|
||||
const inSameList = draggedItem.dataset.sortableListKey === target.dataset.sortableListKey;
|
||||
if (!inSameList) {
|
||||
return;
|
||||
}
|
||||
const targetList = draggedItem.closest('.sortable-list');
|
||||
if (isBefore(draggedItem, target)) {
|
||||
targetList.insertBefore(draggedItem, target);
|
||||
} else {
|
||||
targetList.insertBefore(draggedItem, target.nextSibling);
|
||||
}
|
||||
}
|
||||
|
||||
const listItemsHandled = new Map();
|
||||
|
||||
const getListItemsHandled = (list) => {
|
||||
return listItemsHandled.get(list) || new Set();
|
||||
}
|
||||
|
||||
function registerSortableList(list) {
|
||||
list.querySelectorAll('li:not(.immovable)').forEach(item => {
|
||||
const listItems = getListItemsHandled(list);
|
||||
listItems.add(item);
|
||||
listItemsHandled.set(list, listItems);
|
||||
const dragger = item.querySelector('.dragger');
|
||||
dragger.addEventListener('dragstart', e => {sortableItemDragStart(e, item)});
|
||||
dragger.addEventListener('dragend', e => {sortableItemDragEnd(e, item)});
|
||||
item.addEventListener('dragover', e => {sortableItemDragOver(e, item)});
|
||||
});
|
||||
|
||||
const obs = new MutationObserver(records => {
|
||||
for (const mutation of records) {
|
||||
mutation.addedNodes.forEach(node => {
|
||||
if (!(node instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
if (!node.classList.contains('sortable-item')) {
|
||||
return;
|
||||
}
|
||||
const listItems = getListItemsHandled(list)
|
||||
if (listItems.has(node)) {
|
||||
return;
|
||||
}
|
||||
const dragger = node.querySelector('.dragger');
|
||||
dragger.addEventListener('dragstart', e => {sortableItemDragStart(e, node)});
|
||||
dragger.addEventListener('dragend', e => {sortableItemDragEnd(e, node)});
|
||||
node.addEventListener('dragover', e => {sortableItemDragOver(e, node)});
|
||||
listItems.add(node);
|
||||
listItemsHandled.set(list, listItems);
|
||||
});
|
||||
}
|
||||
});
|
||||
obs.observe(list, {childList: true});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.sortable-list').forEach(registerSortableList);
|
||||
|
||||
listsObs = new MutationObserver(records => {
|
||||
for (const mutation of records) {
|
||||
mutation.addedNodes.forEach(node => {
|
||||
if (!(node instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
if (!node.classList.contains('sortable-list')) {
|
||||
return;
|
||||
}
|
||||
registerSortableList(node);
|
||||
})
|
||||
}
|
||||
})
|
||||
listsObs.observe(document.body, {childList: true, subtree: true});
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ $PAGE_SIDE_MARGIN: 100px !default;
|
||||
// BORDERS
|
||||
// **************
|
||||
$DEFAULT_BORDER: 1px solid black !default;
|
||||
$DEFAULT_BORDER_INVALID: 2px dashed $BUTTON_COLOR_CRITICAL !default;
|
||||
$DEFAULT_BORDER_RADIUS: 4px !default;
|
||||
|
||||
// other variables can be found before the rule that uses them. they are usually constructed from these basic variables.
|
||||
@@ -709,6 +710,11 @@ input[type="text"], input[type="password"], textarea, select {
|
||||
}
|
||||
}
|
||||
|
||||
// lone required inputs managed by js
|
||||
input:not(form input):invalid {
|
||||
border: $DEFAULT_BORDER_INVALID;
|
||||
}
|
||||
|
||||
textarea {
|
||||
font-family: "Atkinson Hyperlegible Mono", monospace;
|
||||
}
|
||||
@@ -966,54 +972,6 @@ $topic_locked_background: none !default;
|
||||
background-color: $topic_locked_background;
|
||||
}
|
||||
|
||||
$draggable_topic_background: $ACCENT_COLOR !default;
|
||||
$draggable_topic_dragged_color: $BUTTON_COLOR !default;
|
||||
$draggable_topic_padding: $BIG_PADDING !default;
|
||||
$draggable_topic_margin: $MEDIUM_BIG_PADDING 0 !default;
|
||||
$draggable_topic_border: 5px outset !default;
|
||||
$draggable_topic_border_top: $draggable_topic_border $LIGHT !default;
|
||||
$draggable_topic_border_bottom: $draggable_topic_border $DARK_2 !default;
|
||||
.draggable-topic {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background-color: $draggable_topic_background;
|
||||
padding: $draggable_topic_padding;
|
||||
margin: $draggable_topic_margin;
|
||||
border-top: $draggable_topic_border_top;
|
||||
border-bottom: $draggable_topic_border_bottom;
|
||||
|
||||
&.dragged {
|
||||
background-color: $draggable_topic_dragged_color;
|
||||
}
|
||||
}
|
||||
|
||||
$draggable_collection_background: $ACCENT_COLOR !default;
|
||||
$draggable_collection_dragged_color: $BUTTON_COLOR !default;
|
||||
$draggable_collection_default_color: $BUTTON_COLOR_2 !default;
|
||||
$draggable_collection_padding: $BIG_PADDING !default;
|
||||
$draggable_collection_margin: $MEDIUM_BIG_PADDING 0 !default;
|
||||
$draggable_collection_border: 5px outset !default;
|
||||
$draggable_collection_border_top: $draggable_collection_border $LIGHT !default;
|
||||
$draggable_collection_border_bottom: $draggable_collection_border $DARK_2 !default;
|
||||
.draggable-collection {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background-color: $draggable_collection_background;
|
||||
padding: $draggable_collection_padding;
|
||||
margin: $draggable_collection_margin;
|
||||
border-top: $draggable_collection_border_top;
|
||||
border-bottom: $draggable_collection_border_bottom;
|
||||
|
||||
&.dragged {
|
||||
background-color: $draggable_collection_dragged_color;
|
||||
}
|
||||
|
||||
&.default {
|
||||
background-color: $draggable_collection_default_color;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$post_editing_header_color: $LIGHT !default;
|
||||
.editing {
|
||||
background-color: $post_editing_header_color;
|
||||
@@ -1415,6 +1373,7 @@ $settings_grid_gap: $MEDIUM_PADDING !default;
|
||||
$settings_grid_item_min_width: 600px !default;
|
||||
$settings_grid_fieldset_border: 1px solid $DEFAULT_FONT_COLOR_INVERSE !default;
|
||||
$settings_grid_fieldset_border_radius: $DEFAULT_BORDER_RADIUS !default;
|
||||
$settings_grid_fieldset_background_color: $DARK_1_LIGHTER !default;
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
gap: $settings_grid_gap;
|
||||
@@ -1425,7 +1384,7 @@ $settings_grid_fieldset_border_radius: $DEFAULT_BORDER_RADIUS !default;
|
||||
& fieldset {
|
||||
border: $settings_grid_fieldset_border;
|
||||
border-radius: $settings_grid_fieldset_border_radius;
|
||||
background-color: $DARK_1_LIGHTER;
|
||||
background-color: $settings_grid_fieldset_background_color;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1440,7 +1399,7 @@ h1 {
|
||||
|
||||
$settings_badge_container_gap: $SMALL_PADDING !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_padding: $SMALL_PADDING $MEDIUM_PADDING !default;
|
||||
$settings_badge_container_margin: $MEDIUM_PADDING $ZERO_PADDING !default;
|
||||
@@ -1552,3 +1511,73 @@ $rss_button_font_color_active: black !default;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -83,6 +83,8 @@ $br: 8px;
|
||||
|
||||
$mention_font_color: $fc,
|
||||
|
||||
$settings_grid_fieldset_background_color: $lightish_accent,
|
||||
|
||||
// $settings_badge_container_border_invalid: 2px dashed $crit,
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user