add most mod routes

This commit is contained in:
2026-04-16 23:11:19 +03:00
parent d6b44da6c2
commit 9c4f271259
14 changed files with 216 additions and 34 deletions

View File

@@ -231,6 +231,7 @@ def create_app():
@app.before_request @app.before_request
def make_session_permanent(): def make_session_permanent():
if is_logged_in():
session.permanent = True session.permanent = True
commit = '' commit = ''

View File

@@ -1,4 +1,4 @@
from flask import session, flash, redirect, url_for from flask import session, flash, redirect, url_for, abort
from .models import Sessions, Users from .models import Sessions, Users
from argon2 import PasswordHasher from argon2 import PasswordHasher
from functools import wraps from functools import wraps
@@ -51,3 +51,13 @@ def login_required(view_func):
return redirect(url_for('users.log_in_page')) return redirect(url_for('users.log_in_page'))
return view_func(*args, **kwargs) return view_func(*args, **kwargs)
return wrapper return wrapper
def mod_only(view_func):
@wraps(view_func)
def wrapper(*args, **kwargs):
if not is_logged_in():
abort(403)
if not get_active_user().is_mod():
abort(403)
return view_func(*args, **kwargs)
return wrapper

View File

@@ -400,7 +400,7 @@ def tag_image(children, attr):
def tag_quote(children, attr): def tag_quote(children, attr):
if attr: if attr:
quotee = attr.strip() quotee = f'Quoting: {attr.strip()}'
else: else:
quotee = 'Quote' quotee = 'Quote'

View File

@@ -123,6 +123,22 @@ class Topics(Model):
GROUP BY topics.id ORDER BY topics.sort_order ASC""" GROUP BY topics.id ORDER BY topics.sort_order ASC"""
return db.query(q) return db.query(q)
@classmethod
def new(_cls, name: str, description: str) -> Topics:
from slugify import slugify
name = name.strip()
description = description.strip()
now = int(time.time())
slug = f'{slugify(name)}-{now}'
topic_count = Topics.count()
return Topics.create({
'name': name,
'description': description,
'slug': slug,
'sort_order': topic_count + 1,
})
def get_threads(self, per_page, page, sort_by = 'activity'): def get_threads(self, per_page, page, sort_by = 'activity'):
order_clause = '' order_clause = ''
if sort_by == 'thread': if sort_by == 'thread':
@@ -202,6 +218,9 @@ class Topics(Model):
return db.query(q, self.id) return db.query(q, self.id)
def locked(self):
return bool(self.is_locked)
class Threads(Model): class Threads(Model):
table = 'threads' table = 'threads'

View File

@@ -1,27 +1,81 @@
from flask import Blueprint from flask import Blueprint, abort, redirect, url_for, request, render_template
from ..auth import is_logged_in, get_active_user
from ..models import Topics, Threads
bp = Blueprint('mod', __name__, url_prefix='/mod/') bp = Blueprint('mod', __name__, url_prefix='/mod/')
@bp.before_request
def mod_only():
if not is_logged_in():
abort(403)
if not get_active_user().is_mod():
abort(403)
@bp.get('/') @bp.get('/')
def index(): def index():
return 'stub' return 'stub'
@bp.get('/topics/new') @bp.get('/topics/new')
def new_topic(): def new_topic():
return 'stub' return render_template('mod/new_topic.html')
@bp.post('/topics/new')
def new_topic_post():
topic = Topics.new(request.form.get('name'), request.form.get('description'))
return redirect(url_for('topics.topic', slug=topic.slug))
@bp.get('/topics/sort') @bp.get('/topics/sort')
def sort_topics(): def sort_topics():
return 'stub' return 'stub'
@bp.get('/topics/<int:topic_id>/edit')
def edit_topic(topic_id):
topic = Topics.find({'id': topic_id})
if not topic:
abort(404)
return render_template('mod/edit_topic.html', topic=topic)
@bp.post('/topics/<int:topic_id>/edit')
def edit_topic_post(topic_id):
topic = Topics.find({'id': topic_id})
if not topic:
abort(404)
topic.update({
'name': request.form.get('name').strip(),
'description': request.form.get('description').strip(),
})
return redirect(url_for('topics.topic', slug=topic.slug))
@bp.post('/topics/<int:topic_id>/lock')
def lock_topic(topic_id):
topic = Topics.find({'id': topic_id})
if not topic:
abort(404)
topic.update({'is_locked': request.form.get('lock', default=0)})
return redirect(url_for('topics.topic', slug=topic.slug))
@bp.post('/threads/<int:thread_id>/move') @bp.post('/threads/<int:thread_id>/move')
def move_thread(thread_id): def move_thread(thread_id):
return 'stub' thread = Threads.find({'id': thread_id})
if not thread:
abort(404)
target_topic = Topics.find({'id': request.form.get('new_topic_id', default=None)})
if not target_topic:
abort(404)
thread.update({'topic_id': target_topic.id})
return redirect(url_for('threads.thread', slug=thread.slug))
@bp.post('/threads/<int:thread_id>/lock') @bp.post('/threads/<int:thread_id>/lock')
def lock_thread(thread_id): def lock_thread(thread_id):
return 'stub' thread = Threads.find({'id': thread_id})
if not thread:
abort(404)
thread.update({'is_locked': request.form.get('lock')})
return redirect(url_for('threads.thread', slug=thread.slug))
@bp.post('/threads/<int:thread_id>/sticky') @bp.post('/threads/<int:thread_id>/sticky')
def sticky_thread(thread_id): def sticky_thread(thread_id):
return 'stub' thread = Threads.find({'id': thread_id})
if not thread:
abort(404)
thread.update({'is_stickied': request.form.get('sticky')})
return redirect(url_for('threads.thread', slug=thread.slug))

View File

@@ -52,5 +52,11 @@ def feed(slug):
return 'stub' return 'stub'
@bp.get('/new') @bp.get('/new')
@login_required
def new(): def new():
return 'stub' topics = Topics.select()
try:
selected_topic = int(request.args.get('topic_id', default=None))
except ValueError, TypeError:
selected_topic = None
return render_template('threads/new_thread.html', topics=topics, selected_topic=selected_topic)

View File

@@ -12,10 +12,8 @@
{%- endif -%} {%- endif -%}
</head> </head>
<body> <body>
<div id="wrapper">
{%- include 'common/topnav.html' -%} {%- include 'common/topnav.html' -%}
{%- block content -%}{%- endblock -%} {%- block content -%}{%- endblock -%}
{%- include 'common/footer.html' -%} {%- include 'common/footer.html' -%}
</div>
</body> </body>
</html> </html>

View File

@@ -83,7 +83,9 @@
{% macro babycode_editor_component( {% macro babycode_editor_component(
placeholder='Post content', placeholder='Post content',
prefill='' prefill='',
required=true,
id='babycode-content'
) -%} ) -%}
{%- call(idx) tabs(prefix='babycode', labels=['Write', 'Preview']) -%} {%- call(idx) tabs(prefix='babycode', labels=['Write', 'Preview']) -%}
{%- if idx == 0 -%} {%- if idx == 0 -%}
@@ -101,7 +103,7 @@
</span> </span>
<a href="##">babycode help</a> <a href="##">babycode help</a>
</span> </span>
<textarea name="babycode_content" class="babycode-editor" placeholder="{{placeholder}}" required>{{ prefill }}</textarea> <textarea name="babycode_content" id="{{id}}" class="babycode-editor" placeholder="{{placeholder}}" {{'required' if required else ''}}>{{ prefill }}</textarea>
{%- endif -%} {%- endif -%}
{%- endcall -%} {%- endcall -%}
{%- endmacro %} {%- endmacro %}

View File

@@ -0,0 +1,13 @@
{%- from 'common/macros.html' import subheader -%}
{%- extends 'base.html' -%}
{%- block title -%}editing topic {{topic.name}}{%- endblock -%}
{%- block content -%}
{{subheader('Editing topic %s' % topic.name, 'To preserve history, the URL of the topic can not be changed.')}}
<form class="plank primary-bg full-width" method="POST">
<label for="name">Name</label>
<input type="text" id="name" name="name" required value="{{topic.name}}">
<label for="description">Description</label>
<textarea name="description" id="description" rows="5" required>{{topic.description}}</textarea>
<input type="submit" value="Save">
</form>
{%- endblock -%}

View File

@@ -0,0 +1,13 @@
{%- from 'common/macros.html' import subheader -%}
{%- extends 'base.html' -%}
{%- block title -%}creating a topic{%- endblock -%}
{%- block content -%}
{{subheader('Create topic', 'The new topic will appear at the bottom of the current topic list. You can sort it later.')}}
<form class="plank primary-bg full-width" method="POST">
<label for="name">Name</label>
<input type="text" id="name" name="name" required>
<label for="description">Description</label>
<textarea name="description" id="description" rows="5" required></textarea>
<input type="submit" value="Create">
</form>
{%- endblock -%}

View File

@@ -0,0 +1,19 @@
{%- from 'common/macros.html' import subheader, babycode_editor_component -%}
{%- extends 'base.html' -%}
{%- block title -%}drafting a thread{%- endblock -%}
{%- block content -%}
{{subheader('New thread')}}
<form class="plank primary-bg full-width" method="POST">
<label for="topic">Topic</label>
<select name="topic_id" id="topic" autocomplete="off">
{%- for topic in topics -%}
<option value="{{topic.id}}" {{'selected' if selected_topic == topic.id else ''}} {{'disabled' if not get_active_user().can_post_to_topic(topic) else ''}}>{{topic.name}}</option>
{%- endfor -%}
</select>
<label for="title">Title</label>
<input type="text" id="title" name="title" required>
<label for="babycode-content">Starting post</label>
{{ babycode_editor_component() }}
<input type="submit" value="Create">
</form>
{%- endblock -%}

View File

@@ -1,10 +1,20 @@
{%- from 'common/macros.html' import subheader, timestamp, pager, babycode_editor_component -%} {%- from 'common/macros.html' import subheader, timestamp, pager, babycode_editor_component -%}
{%- from 'common/macros.html' import full_post with context -%} {%- from 'common/macros.html' import full_post with context -%}
{%- extends 'base.html' -%} {%- extends 'base.html' -%}
{%- block title -%}{%- endblock -%} {%- block title -%}{{thread.title}}{%- endblock -%}
{%- block content -%} {%- block content -%}
{%- set td -%} {%- set td -%}
Started by <a href="{{url_for('users.user_page', username=started_by.username)}}">{{started_by.get_readable_name()}}</a> in topic <a href="{{url_for('topics.topic', slug=topic.slug)}}">{{topic.name}}</a> <ul class="horizontal">
<li>Started by <a href="{{url_for('users.user_page', username=started_by.username)}}">{{started_by.get_readable_name()}}</a> in topic <a href="{{url_for('topics.topic', slug=topic.slug)}}">{{topic.name}}</a></li>
{%- if thread.locked() or thread.stickied() -%}
{%- if thread.locked() -%}
<li class="visible">Locked</li>
{%- endif -%}
{%- if thread.stickied() -%}
<li class="visible">Stickied</li>
{%- endif -%}
{%- endif -%}
</ul>
{%- endset -%} {%- endset -%}
{%- call() subheader(thread.title, td) -%} {%- call() subheader(thread.title, td) -%}
<fieldset class="plank even no-shadow minimal thread-actions"> <fieldset class="plank even no-shadow minimal thread-actions">
@@ -19,13 +29,13 @@ Started by <a href="{{url_for('users.user_page', username=started_by.username)}}
<fieldset class="plank even no-shadow minimal thread-actions"> <fieldset class="plank even no-shadow minimal thread-actions">
<legend>Moderation actions</legend> <legend>Moderation actions</legend>
<form method="POST"> <form method="POST">
<input type="hidden" name="lock" value="{{not thread.locked()}}"> <input type="hidden" name="lock" value="{{(not thread.locked()) | int}}">
<input type="hidden" name="sticky" value="{{not thread.stickied()}}"> <input type="hidden" name="sticky" value="{{(not thread.stickied()) | int}}">
<input type="submit" class="warn" value="{{'Unlock' if thread.locked() else 'Lock'}}" formaction="{{url_for('mod.lock_thread', thread_id=thread.id)}}"> <input type="submit" class="warn" value="{{'Unlock' if thread.locked() else 'Lock'}}" formaction="{{url_for('mod.lock_thread', thread_id=thread.id)}}">
<input type="submit" class="warn" value="{{'Unsticky' if thread.stickied() else 'Sticky'}}" formaction="{{url_for('mod.sticky_thread', thread_id=thread.id)}}"> <input type="submit" class="warn" value="{{'Unsticky' if thread.stickied() else 'Sticky'}}" formaction="{{url_for('mod.sticky_thread', thread_id=thread.id)}}">
</form> </form>
<form class="horizontal wrap" method="POST" action="{{url_for('mod.move_thread', thread_id=thread.id)}}"> <form class="horizontal wrap" method="POST" action="{{url_for('mod.move_thread', thread_id=thread.id)}}">
<select name="new_topic_id" id="new_topic_id"> <select name="new_topic_id" id="new-topic-id">
{%- for t in topics -%} {%- for t in topics -%}
<option value="{{t.id}}" {{'selected disabled' if t.id == topic.id else ''}} autocomplete="off">{{t.name}}</option> <option value="{{t.id}}" {{'selected disabled' if t.id == topic.id else ''}} autocomplete="off">{{t.name}}</option>
{%- endfor -%} {%- endfor -%}

View File

@@ -2,10 +2,18 @@
{%- extends 'base.html' -%} {%- extends 'base.html' -%}
{%- block title -%}browsing topic {{topic.name}}{%- endblock -%} {%- block title -%}browsing topic {{topic.name}}{%- endblock -%}
{%- block content -%} {%- block content -%}
{%- call() subheader(('Threads in "%s"' % topic.name), topic.description) -%} {%- set td -%}
<ul class="horizontal">
<li>{{topic.description}}</li>
{%- if topic.locked() -%}
<li class="visible">Locked</li>
{%- endif -%}
</ul>
{%- endset -%}
{%- call() subheader(('Threads in "%s"' % topic.name), td) -%}
<fieldset class="plank even no-shadow minimal thread-actions"> <fieldset class="plank even no-shadow minimal thread-actions">
<legend>Actions</legend> <legend>Actions</legend>
{%- if is_logged_in() -%} {%- if is_logged_in() and get_active_user().can_post_to_topic(topic) -%}
<a href="{{url_for('threads.new', topic_id=topic.id)}}" class="linkbutton">New thread</a> <a href="{{url_for('threads.new', topic_id=topic.id)}}" class="linkbutton">New thread</a>
{%- endif -%} {%- endif -%}
<a href="{{url_for('topics.feed', slug=topic.slug)}}" class="linkbutton rss">Subscribe via RSS</a> <a href="{{url_for('topics.feed', slug=topic.slug)}}" class="linkbutton rss">Subscribe via RSS</a>
@@ -20,13 +28,23 @@
{%- if is_mod() -%} {%- if is_mod() -%}
<fieldset class="plank even no-shadow minimal thread-actions"> <fieldset class="plank even no-shadow minimal thread-actions">
<legend>Moderation actions</legend> <legend>Moderation actions</legend>
<a href="{{url_for('mod.edit_topic', topic_id=topic.id)}}" class="linkbutton">Edit</a>
<form action="{{url_for('mod.lock_topic', topic_id=topic.id)}}" method="POST">
<input type="hidden" value="{{(not topic.locked()) | int}}" name="lock">
<input type="submit" class="warn" value="{{'Unlock' if topic.locked() else 'Lock'}}">
</form>
</fieldset> </fieldset>
{%- endif -%} {%- endif -%}
{%- if threads | length > 0 -%}
<fieldset class="plank even no-shadow minimal thread-actions"> <fieldset class="plank even no-shadow minimal thread-actions">
<legend>Page</legend> <legend>Page</legend>
{{- pager(page, page_count, args=request.args) -}} {{- pager(page, page_count, args=request.args) -}}
</fieldset> </fieldset>
{%- endif -%}
{%- endcall -%} {%- endcall -%}
{%- if threads | length == 0 -%}
<div class="plank"><p>There are no threads in this topic yet.{%- if is_logged_in() and get_active_user().can_post_to_topic(topic) %} Be the first to start a discussion!{%- endif -%}</p></div>
{%- endif -%}
{%- for thread in threads -%} {%- for thread in threads -%}
<div class="topic-info plank"> <div class="topic-info plank">
<div class="title-container"> <div class="title-container">
@@ -41,10 +59,12 @@
<span>Latest post by <a href="{{get_post_url(thread.latest_post_id, _anchor=true)}}">{{thread.latest_post_display_name if thread.latest_post_display_name else thread.latest_post_username}} on {{timestamp(thread.latest_post_created_at)}}</a>{{' (OP)' if thread.posts_count == 1 else ''}}</span> <span>Latest post by <a href="{{get_post_url(thread.latest_post_id, _anchor=true)}}">{{thread.latest_post_display_name if thread.latest_post_display_name else thread.latest_post_username}} on {{timestamp(thread.latest_post_created_at)}}</a>{{' (OP)' if thread.posts_count == 1 else ''}}</span>
</div> </div>
{%- endfor -%} {%- endfor -%}
{%- if threads | length > 0 -%}
<div class="plank secondary-bg"> <div class="plank secondary-bg">
<fieldset class="plank even no-shadow minimal thread-actions"> <fieldset class="plank even no-shadow minimal thread-actions">
<legend>Page</legend> <legend>Page</legend>
{{- pager(page, page_count, args=request.args) -}} {{- pager(page, page_count, args=request.args) -}}
</fieldset> </fieldset>
</div> </div>
{%- endif -%}
{%- endblock -%} {%- endblock -%}

View File

@@ -82,9 +82,6 @@ body {
background-color: var(--bg-color-tertiary); background-color: var(--bg-color-tertiary);
font-family: Cadman; font-family: Cadman;
color: var(--font-color-main); color: var(--font-color-main);
}
#wrapper {
margin: var(--big-padding) var(--wrapper-side-margin); margin: var(--big-padding) var(--wrapper-side-margin);
} }
@@ -164,6 +161,10 @@ button, .linkbutton, input[type="submit"] {
} }
} }
.tab-container {
width: 100%;
}
.tab-bar { .tab-bar {
display: flex; display: flex;
gap: var(--base-padding); gap: var(--base-padding);
@@ -187,7 +188,6 @@ button, .linkbutton, input[type="submit"] {
.babycode-editor { .babycode-editor {
width: 100%; width: 100%;
height: 150px; height: 150px;
resize: vertical;
} }
.post-edit-form { .post-edit-form {
@@ -202,6 +202,7 @@ input[type="text"], input[type="password"], textarea, select {
background-color: var(--main-color); background-color: var(--main-color);
border-radius: var(--border-radius); border-radius: var(--border-radius);
border: solid var(--border-thickness) var(--border-color); border: solid var(--border-thickness) var(--border-color);
resize: vertical;
padding: var(--small-padding) var(--medium-padding); padding: var(--small-padding) var(--medium-padding);
margin: var(--base-padding) 0px; margin: var(--base-padding) 0px;
@@ -211,6 +212,10 @@ input[type="text"], input[type="password"], textarea, select {
} }
} }
textarea {
font-family: 'Atkinson Hyperlegible Mono'
}
h1 { h1 {
margin: 0; margin: 0;
} }
@@ -324,10 +329,14 @@ ul.horizontal, ol.horizontal {
padding: 0; padding: 0;
gap: var(--base-padding); gap: var(--base-padding);
& li { & li:not(.visible) {
list-style-type: none; list-style-type: none;
} }
& li.visible {
margin-left: var(--big-padding);
}
&.wrap { &.wrap {
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -456,7 +465,6 @@ footer {
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--base-padding); gap: var(--base-padding);
width: fit-content; width: fit-content;
justify-content: center;
} }
.actions-group { .actions-group {
@@ -584,6 +592,15 @@ footer {
flex-wrap: wrap; flex-wrap: wrap;
} }
form.full-width {
display: flex;
flex-direction: column;
align-items: start;
&> textarea, &> select, &> input[type="text"], &> input[type="password"] {
width: 100%;
}
}
/* babycode tags */ /* babycode tags */
.inline-code { .inline-code {
background-color: var(--code-bg-color); background-color: var(--code-bg-color);
@@ -688,7 +705,7 @@ a.mention {
} }
@media (max-width: 768px) { @media (max-width: 768px) {
#wrapper { body {
margin-left: 0; margin-left: 0;
margin-right: 0; margin-right: 0;
} }