diff --git a/app/__init__.py b/app/__init__.py index 5f2f3b2..7ee0ff1 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -80,6 +80,7 @@ def create_app(): return { "InfoboxIcons": InfoboxIcons, "InfoboxHTMLClass": InfoboxHTMLClass, + "InfoboxKind": InfoboxKind, } @app.context_processor diff --git a/app/models.py b/app/models.py index ecdadb1..2657ea9 100644 --- a/app/models.py +++ b/app/models.py @@ -56,6 +56,110 @@ class Users(Model): class Topics(Model): table = "topics" + @classmethod + def get_list(_cls): + q = """ + SELECT + topics.id, topics.name, topics.slug, topics.description, topics.is_locked, + users.username AS latest_thread_username, + threads.title AS latest_thread_title, + threads.slug AS latest_thread_slug, + threads.created_at AS latest_thread_created_at + FROM + topics + LEFT JOIN ( + SELECT + *, + row_number() OVER (PARTITION BY threads.topic_id ORDER BY threads.created_at DESC) as rn + FROM + threads + ) threads ON threads.topic_id = topics.id AND threads.rn = 1 + LEFT JOIN + users on users.id = threads.user_id + ORDER BY + topics.sort_order ASC""" + return db.query(q) + + @classmethod + def get_active_threads(cls): + q = """ + WITH ranked_threads AS ( + SELECT + threads.topic_id, threads.id AS thread_id, threads.title AS thread_title, threads.slug AS thread_slug, + posts.id AS post_id, posts.created_at AS post_created_at, + users.username, + ROW_NUMBER() OVER (PARTITION BY threads.topic_id ORDER BY posts.created_at DESC) AS rn + FROM + threads + JOIN + posts ON threads.id = posts.thread_id + LEFT JOIN + users ON posts.user_id = users.id + ) + SELECT + topic_id, + thread_id, thread_title, thread_slug, + post_id, post_created_at, + username + FROM + ranked_threads + WHERE + rn = 1 + ORDER BY + topic_id""" + + active_threads_raw = db.query(q) + active_threads = {} + for thread in active_threads_raw: + active_threads[int(thread['topic_id'])] = { + 'thread_title': thread['thread_title'], + 'thread_slug': thread['thread_slug'], + 'post_id': thread['post_id'], + 'username': thread['username'], + 'post_created_at': thread['post_created_at'] + } + return active_threads + + def get_threads(self, per_page, page, sort_by = "activity"): + order_clause = "" + if sort_by == "thread": + order_clause = "ORDER BY threads.is_stickied DESC, threads.created_at DESC" + else: + order_clause = "ORDER BY threads.is_stickied DESC, latest_post_created_at DESC" + + q = """ + SELECT + threads.title, threads.slug, threads.created_at, threads.is_locked, threads.is_stickied, + users.username AS started_by, + u.username AS latest_post_username, + ph.content AS latest_post_content, + posts.created_at AS latest_post_created_at, + posts.id AS latest_post_id + FROM + threads + JOIN users ON users.id = threads.user_id + JOIN ( + SELECT + posts.thread_id, + posts.id, + posts.user_id, + posts.created_at, + posts.current_revision_id, + ROW_NUMBER() OVER (PARTITION BY posts.thread_id ORDER BY posts.created_at DESC) AS rn + FROM + posts + ) posts ON posts.thread_id = threads.id AND posts.rn = 1 + JOIN + post_history ph ON ph.id = posts.current_revision_id + JOIN + users u ON u.id = posts.user_id + WHERE + threads.topic_id = ? + """ + order_clause + " LIMIT ? OFFSET ?" + + return db.query(q, self.id, per_page, (page - 1) * per_page) + + class Threads(Model): table = "threads" diff --git a/app/routes/threads.py b/app/routes/threads.py new file mode 100644 index 0000000..fdaca97 --- /dev/null +++ b/app/routes/threads.py @@ -0,0 +1,7 @@ +from flask import Blueprint, render_template + +bp = Blueprint("topics", __name__, url_prefix = "/threads/") + +@bp.get("/") +def thread(slug): + return slug diff --git a/app/routes/topics.py b/app/routes/topics.py index 38a1ed8..a49bb84 100644 --- a/app/routes/topics.py +++ b/app/routes/topics.py @@ -1,9 +1,71 @@ -from flask import Blueprint, render_template -from ..models import Users +from flask import ( + Blueprint, render_template, request, redirect, url_for, flash, session + ) +from .users import login_required, mod_only +from ..models import Users, Topics, Threads +from ..constants import InfoboxKind +from slugify import slugify +import time +import math bp = Blueprint("topics", __name__, url_prefix = "/topics/") + @bp.get("/") def all_topics(): admin = Users.find({"id": 1}) - return render_template("topics/topics.html", admin = admin) + return render_template("topics/topics.html", topic_list = Topics.get_list(), active_threads = Topics.get_active_threads()) + + +@bp.get("/create") +@login_required +@mod_only(".all_topics") +def create(): + return render_template("topics/create.html") + + +@bp.post("/create") +@login_required +@mod_only(".all_topics") +def create_post(): + topic_name = request.form['name'].strip() + now = int(time.time()) + slug = f"{slugify(topic_name)}-{now}" + + topic_count = Topics.count() + topic = Topics.create({ + "name": topic_name, + "description": request.form['description'], + "slug": slug, + "sort_order": topic_count + 1, + }) + + flash("Topic created.", InfoboxKind.INFO) + return redirect(url_for("topics.topic", slug = slug)) + + +@bp.get("/") +def topic(slug): + THREADS_PER_PAGE = 10 + target_topic = Topics.find({ + "slug": slug + }) + if not target_topic: + return "no" + + threads_count = Threads.count({ + "topic_id": target_topic.id + }) + + sort_by = session.get('sort_by', default="activity") + + page_count = max(math.ceil(threads_count / THREADS_PER_PAGE), 1) + page = max(1, min(int(request.args.get('page', default=1)), page_count)) + + return render_template( + "topics/topic.html", + threads_list = target_topic.get_threads(THREADS_PER_PAGE, page, sort_by), + topic = target_topic, + current_page = page, + page_count = page_count + ) diff --git a/app/templates/common/pager.html b/app/templates/common/pager.html new file mode 100644 index 0000000..c3a1b4a --- /dev/null +++ b/app/templates/common/pager.html @@ -0,0 +1,29 @@ +{% macro pager(current_page, page_count) %} +{% set left_start = (1, current_page - 5) | max %} +{% set right_end = (page_count, current_page + 5) | min %} + +
+ Page: + {% if current_page > 5 %} + 1 + {% if left_start > 2 %} + + {% endif %} + {% endif %} + {% for i in range(left_start, current_page - 1) %} + {{i}} + {% endfor %} + {% if page_count > 0 %} + {{current_page}} + {% endif %} + {% for i in range(current_page + 1, right_end) %} + {{i}} + {% endfor %} + {% if right_end < page_count %} + {% if right_end < page_count - 1 %} + + {% endif %} + {{page_count}} + {% endif %} +
+{% endmacro %} diff --git a/app/templates/topics/create.html b/app/templates/topics/create.html new file mode 100644 index 0000000..94f12ca --- /dev/null +++ b/app/templates/topics/create.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block title %}creating a topic{% endblock %} +{% block content %} +
+

Create topic

+
+ +
+ +
+ +
+
+{% endblock %} diff --git a/app/templates/topics/topic.html b/app/templates/topics/topic.html new file mode 100644 index 0000000..f508ec8 --- /dev/null +++ b/app/templates/topics/topic.html @@ -0,0 +1,55 @@ +{% from 'common/pager.html' import pager %} +{% extends "base.html" %} +{% block title %}browsing topic {{ topic['name'] }}{% endblock %} +{% block content %} + + +{% if topic['is_locked'] %} + {{ infobox("This topic is locked. Only moderators can create new threads.", InfoboxKind.INFO) }} +{% endif %} + +{% if threads_list | length == 0 %} +

There are no threads in this topic.

+{% else %} + {% for thread in threads_list %} +
+
+ {% if thread['is_stickied'] %} + + Stickied + {% endif %} +
+
+ + {{thread['title']}} + • + Started by {{ thread['started_by'] }} on ... + + + Latest post by {{ thread['latest_post_username'] }} + on ... + + + {{ thread['latest_post_content'] }} + +
+
+ {% if thread['is_locked'] %} + + Locked + {% endif %} +
+
+ {% endfor %} +{% endif %} + +{% endblock %} diff --git a/app/templates/topics/topics.html b/app/templates/topics/topics.html index 6d14113..6379d6b 100644 --- a/app/templates/topics/topics.html +++ b/app/templates/topics/topics.html @@ -2,6 +2,40 @@ {% block content %} +{% if topic_list | length == 0 %} +

There are no topics.

+{% else %} + {% for topic in topic_list %} +
+
+ {{ topic['name'] }} + {{ topic['description'] }} + {% if topic['latest_thread_username'] %} + + Latest thread: {{topic['latest_thread_title']}} by {{topic['latest_thread_username']}} on ... + + {% if topic['id'] in active_threads %} + {% with thread=active_threads[topic['id']] %} + + Latest post in: {{ thread['thread_title'] }} by {{ thread['username'] }} on ... + + {% endwith %} + {% endif %} + {% else %} + No threads yet. + {% endif %} +
+
+ {% if topic['is_locked'] %} + + Locked + {% endif %} +
+
+ {% endfor %} +{% endif %} {% endblock %} diff --git a/data/static/style.css b/data/static/style.css index 3889747..c72806d 100644 --- a/data/static/style.css +++ b/data/static/style.css @@ -463,7 +463,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus justify-content: center; flex-direction: column; } -.contain-svg:not(.full) > svg { +.contain-svg:not(.full) > svg, .contain-svg img { height: 50%; width: 50%; } diff --git a/requirements.txt b/requirements.txt index be66417..12e7ef3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ flask argon2-cffi wand dotenv +python-slugify diff --git a/sass/style.scss b/sass/style.scss index 7793da6..5867ce3 100644 --- a/sass/style.scss +++ b/sass/style.scss @@ -472,7 +472,7 @@ input[type="text"], input[type="password"], textarea, select { align-items: center; justify-content: center; flex-direction: column; - &:not(.full) > svg { + &:not(.full) > svg, img { height: 50%; width: 50%; }