add topic sorting & js to support it

This commit is contained in:
2026-05-28 04:19:42 +03:00
parent 303750a281
commit 6b7a0e7a17
8 changed files with 241 additions and 3 deletions

View File

@@ -2,6 +2,7 @@ from flask import Blueprint, abort, redirect, url_for, request, render_template,
from ..constants import InfoboxKind, PermissionLevel, MOTD_BANNED_TAGS from ..constants import InfoboxKind, PermissionLevel, MOTD_BANNED_TAGS
from ..auth import is_logged_in, get_active_user, csrf_verified from ..auth import is_logged_in, get_active_user, csrf_verified
from ..models import Topics, Threads, Users, MOTD from ..models import Topics, Threads, Users, MOTD
from ..db import db
from ..lib.babycode import babycode_to_html, BABYCODE_VERSION from ..lib.babycode import babycode_to_html, BABYCODE_VERSION
from slugify import slugify from slugify import slugify
from functools import wraps from functools import wraps
@@ -26,7 +27,8 @@ def admin_only(view_func):
@bp.get('/') @bp.get('/')
def index(): def index():
motd = MOTD.get_all()[0] if MOTD.has_motd() else None motd = MOTD.get_all()[0] if MOTD.has_motd() else None
return render_template('mod/panel.html', MOTD_BANNED_TAGS=MOTD_BANNED_TAGS, motd=motd) topics = Topics.get_list()
return render_template('mod/panel.html', MOTD_BANNED_TAGS=MOTD_BANNED_TAGS, motd=motd, topics=topics)
@bp.post('/motd/set/') @bp.post('/motd/set/')
def set_motd(): def set_motd():
@@ -143,6 +145,15 @@ def sticky_thread(thread_id):
thread.update({'is_stickied': request.form.get('sticky')}) thread.update({'is_stickied': request.form.get('sticky')})
return redirect(url_for('threads.thread_by_id', thread_id=thread.id)) return redirect(url_for('threads.thread_by_id', thread_id=thread.id))
@bp.post('/threads/sort/')
def sort_topics():
topics_list = request.form.getlist('topics[]')
with db.transaction():
for new_order, topic_id in enumerate(topics_list):
db.execute('UPDATE topics SET sort_order = ? WHERE id = ?', new_order, topic_id)
return redirect(url_for('.index', _anchor='sort-topics'))
@bp.post('/users/<int:user_id>/make-guest/') @bp.post('/users/<int:user_id>/make-guest/')
@csrf_verified @csrf_verified
def make_user_guest(user_id): def make_user_guest(user_id):

View File

@@ -23,5 +23,6 @@
{%- endwith -%} {%- endwith -%}
{%- block content -%}{%- endblock -%} {%- block content -%}{%- endblock -%}
{%- include 'common/footer.html' -%} {%- include 'common/footer.html' -%}
<script src="{{'/static/js/ui.js' | cachebust}}"></script>
</body> </body>
</html> </html>

View File

@@ -25,3 +25,7 @@
{%- macro icn_stickied(width=16) -%} {%- macro icn_stickied(width=16) -%}
<img src="/static/icons/sticky.svg" title="Stickied" alt="paper held by pushpin" style="width: {{width}}px;"> <img src="/static/icons/sticky.svg" title="Stickied" alt="paper held by pushpin" style="width: {{width}}px;">
{%- endmacro -%} {%- endmacro -%}
{%- macro icn_dragger(width=24) -%}
<img src="/static/icons/dragger.svg" style="width: {{width}}px;">
{%- endmacro -%}

View File

@@ -1,4 +1,8 @@
{%- from 'common/icons.html' import icn_info, icn_warn, icn_error, icn_bookmark, icn_megaphone -%} {%- from 'common/icons.html' import icn_info, icn_warn, icn_error, icn_bookmark, icn_megaphone, icn_dragger -%}
{% macro dict_to_attr(attrs) -%}
{%- for key, value in attrs.items() if value is not none -%}{{' '}}{{key}}="{{value}}"{%- endfor -%}
{%- endmacro %}
{% macro timestamp(unix_ts) -%} {% macro timestamp(unix_ts) -%}
<time datetime="{{ unix_ts | iso8601 }}">{{ unix_ts | ts_datetime('%Y-%m-%d %H:%M')}} <abbr title="Server Time">ST</abbr></time> <time datetime="{{ unix_ts | iso8601 }}">{{ unix_ts | ts_datetime('%Y-%m-%d %H:%M')}} <abbr title="Server Time">ST</abbr></time>
@@ -253,3 +257,18 @@
</div> </div>
{%- endif -%} {%- endif -%}
{%- endmacro %} {%- endmacro %}
{% macro sortable_list(attr=none) -%}
<ol class="sortable-list plank even no-shadow minimal tertiary-bg" {% if attr %}{{ dict_to_attr(attr) }}{% endif %}>
{%- if caller -%}
{{ caller() }}
{%- endif -%}
</ol>
{%- endmacro %}
{% macro sortable_list_item(key, immovable=false, attr=none) -%}
<li class="sortable-item{{ ' immovable' if immovable else '' }} plank even no-shadow {{'tertiary-bg' if immovable else ''}}" data-sortable-list-key="{{key}}" {% if attr %}{{ dict_to_attr(attr) }}{% endif %}>
<span class="dragger plank minimal even no-shadow tertiary-bg" draggable="{{ 'true' if not immovable else 'false' }}">{{ icn_dragger() }}</span>
<div class="sortable-item-inner">{{ caller() }}</div>
</li>
{%- endmacro %}

View File

@@ -1,4 +1,4 @@
{%- from 'common/macros.html' import subheader, babycode_editor_component -%} {%- from 'common/macros.html' import subheader, babycode_editor_component, sortable_list, sortable_list_item -%}
{%- extends 'base.html' -%} {%- extends 'base.html' -%}
{%- block title -%}settings{%- endblock -%} {%- block title -%}settings{%- endblock -%}
{%- block content -%} {%- block content -%}
@@ -19,5 +19,18 @@
</fieldset> </fieldset>
<fieldset class="plank" id="sort-topics"> <fieldset class="plank" id="sort-topics">
<legend>Sort topics</legend> <legend>Sort topics</legend>
<p>Drag topics around to reorder them. Press "Save order" when done.</p>
<form method="POST" action="{{url_for('mod.sort_topics')}}">
<input type="submit" value="Save order">
{%- call() sortable_list() -%}
{%- for topic in topics -%}
{%- call() sortable_list_item(key="topics") -%}
<h2 class="info">{{ topic.name }}</h2>
<div>{{topic.description}}</div>
<input type="hidden" name="topics[]" value="{{ topic.id }}">
{%- endcall -%}
{%- endfor -%}
{%- endcall -%}
</form>
</fieldset> </fieldset>
{%- endblock -%} {%- endblock -%}

View File

@@ -859,6 +859,53 @@ a.mention {
} }
} }
ol.sortable-list {
display: flex;
gap: var(--base-padding);
flex-direction: column;
list-style: none;
flex-grow: 1;
margin: 0;
padding-left: 0;
li {
display: flex;
gap: var(--big-padding);
}
li.immovable .dragger {
cursor: not-allowed;
}
}
.plank.dragger {
display: flex;
align-items: center;
/*background-color: var(--bg-color-tertiary);*/
padding: var(--base-padding);
cursor: move;
}
.sortable-item-inner {
display: flex;
gap: var(--base-padding);
flex-grow: 1;
flex-direction: column;
& > * {
flex-grow: 1;
}
&.row {
flex-direction: row;
}
&:not(.row) > * {
margin-right: auto;
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
body { body {
margin-left: 0; margin-left: 0;

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="24"
height="24"
viewBox="0 0 24 24"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<g
id="layer1">
<rect
style="fill:#000000;stroke:none;stroke-width:0.999996;stroke-linecap:round;stroke-linejoin:round"
id="rect1"
width="16"
height="2"
x="4"
y="8" />
<rect
style="fill:#000000;stroke:none;stroke-width:0.999996;stroke-linecap:round;stroke-linejoin:round"
id="rect2"
width="16"
height="2"
x="4"
y="11" />
<rect
style="fill:#000000;stroke:none;stroke-width:0.999996;stroke-linecap:round;stroke-linejoin:round"
id="rect3"
width="16"
height="2"
x="4"
y="14" />
<path
style="fill:#000000;stroke:none;stroke-linecap:round;stroke-linejoin:round"
d="m 6,6 6,-6 6,6 H 16 L 12,2 8,6 Z"
id="path3" />
<path
style="fill:#000000;stroke:none;stroke-linecap:round;stroke-linejoin:round"
d="m 6,18 6,6 6,-6 h -2 l -4,4 -4,-4 z"
id="path4" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

97
data/static/js/ui.js Normal file
View File

@@ -0,0 +1,97 @@
'use strict';
{
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('contrast-bg');
e.dataTransfer.setDragImage(item, oX, oY);
e.dataTransfer.effectAllowed = 'move';
}
function sortableItemDragEnd(e, item) {
draggedItem = null;
item.classList.remove('contrast-bg');
}
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, item) });
dragger.addEventListener('dragend', e => { sortableItemDragEnd(e, item) });
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);
const 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 })
}