From 6b7a0e7a174e5332a54896cbbd3c6526314a12b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lera=20Elvo=C3=A9?= Date: Thu, 28 May 2026 04:19:42 +0300 Subject: [PATCH] add topic sorting & js to support it --- app/routes/mod.py | 13 ++++- app/templates/base.html | 1 + app/templates/common/icons.html | 4 ++ app/templates/common/macros.html | 21 ++++++- app/templates/mod/panel.html | 15 ++++- data/static/css/style.css | 47 ++++++++++++++++ data/static/icons/dragger.svg | 46 +++++++++++++++ data/static/js/ui.js | 97 ++++++++++++++++++++++++++++++++ 8 files changed, 241 insertions(+), 3 deletions(-) create mode 100644 data/static/icons/dragger.svg create mode 100644 data/static/js/ui.js diff --git a/app/routes/mod.py b/app/routes/mod.py index a98ccfb..91cf217 100644 --- a/app/routes/mod.py +++ b/app/routes/mod.py @@ -2,6 +2,7 @@ from flask import Blueprint, abort, redirect, url_for, request, render_template, from ..constants import InfoboxKind, PermissionLevel, MOTD_BANNED_TAGS from ..auth import is_logged_in, get_active_user, csrf_verified from ..models import Topics, Threads, Users, MOTD +from ..db import db from ..lib.babycode import babycode_to_html, BABYCODE_VERSION from slugify import slugify from functools import wraps @@ -26,7 +27,8 @@ def admin_only(view_func): @bp.get('/') def index(): 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/') def set_motd(): @@ -143,6 +145,15 @@ def sticky_thread(thread_id): thread.update({'is_stickied': request.form.get('sticky')}) return redirect(url_for('threads.thread_by_id', thread_id=thread.id)) +@bp.post('/threads/sort/') +def sort_topics(): + topics_list = request.form.getlist('topics[]') + with db.transaction(): + for new_order, topic_id in enumerate(topics_list): + db.execute('UPDATE topics SET sort_order = ? WHERE id = ?', new_order, topic_id) + + return redirect(url_for('.index', _anchor='sort-topics')) + @bp.post('/users//make-guest/') @csrf_verified def make_user_guest(user_id): diff --git a/app/templates/base.html b/app/templates/base.html index 9a9e64d..cd9656b 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -23,5 +23,6 @@ {%- endwith -%} {%- block content -%}{%- endblock -%} {%- include 'common/footer.html' -%} + diff --git a/app/templates/common/icons.html b/app/templates/common/icons.html index fd17191..18eec06 100644 --- a/app/templates/common/icons.html +++ b/app/templates/common/icons.html @@ -25,3 +25,7 @@ {%- macro icn_stickied(width=16) -%} paper held by pushpin {%- endmacro -%} + +{%- macro icn_dragger(width=24) -%} + +{%- endmacro -%} diff --git a/app/templates/common/macros.html b/app/templates/common/macros.html index 7d51fe0..d3b92a0 100644 --- a/app/templates/common/macros.html +++ b/app/templates/common/macros.html @@ -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) -%} @@ -253,3 +257,18 @@ {%- endif -%} {%- endmacro %} + +{% macro sortable_list(attr=none) -%} +
    + {%- if caller -%} + {{ caller() }} + {%- endif -%} +
+{%- endmacro %} + +{% macro sortable_list_item(key, immovable=false, attr=none) -%} +
  • + {{ icn_dragger() }} +
    {{ caller() }}
    +
  • +{%- endmacro %} diff --git a/app/templates/mod/panel.html b/app/templates/mod/panel.html index fb69f10..ff71139 100644 --- a/app/templates/mod/panel.html +++ b/app/templates/mod/panel.html @@ -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' -%} {%- block title -%}settings{%- endblock -%} {%- block content -%} @@ -19,5 +19,18 @@
    Sort topics +

    Drag topics around to reorder them. Press "Save order" when done.

    +
    + + {%- call() sortable_list() -%} + {%- for topic in topics -%} + {%- call() sortable_list_item(key="topics") -%} +

    {{ topic.name }}

    +
    {{topic.description}}
    + + {%- endcall -%} + {%- endfor -%} + {%- endcall -%} +
    {%- endblock -%} diff --git a/data/static/css/style.css b/data/static/css/style.css index a0ece7f..f218e7c 100644 --- a/data/static/css/style.css +++ b/data/static/css/style.css @@ -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) { body { margin-left: 0; diff --git a/data/static/icons/dragger.svg b/data/static/icons/dragger.svg new file mode 100644 index 0000000..d55607b --- /dev/null +++ b/data/static/icons/dragger.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + diff --git a/data/static/js/ui.js b/data/static/js/ui.js new file mode 100644 index 0000000..30f8f11 --- /dev/null +++ b/data/static/js/ui.js @@ -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 }) +}