diff --git a/src/smarttools/web/app.py b/src/smarttools/web/app.py index 797ac69..675b0f0 100644 --- a/src/smarttools/web/app.py +++ b/src/smarttools/web/app.py @@ -30,6 +30,7 @@ from smarttools.registry import app as registry_app from . import web_bp from .auth import login, register, logout from .filters import register_filters +from .forum import forum_bp from .sessions import SQLiteSessionInterface, cleanup_expired_sessions @@ -40,6 +41,7 @@ def create_web_app() -> Flask: from . import routes # noqa: F401 app.register_blueprint(web_bp) + app.register_blueprint(forum_bp) # Session configuration app.session_interface = SQLiteSessionInterface(cookie_name="smarttools_session") diff --git a/src/smarttools/web/filters.py b/src/smarttools/web/filters.py index 632c5d4..cbd2b58 100644 --- a/src/smarttools/web/filters.py +++ b/src/smarttools/web/filters.py @@ -2,9 +2,13 @@ from __future__ import annotations +import html +import re from datetime import datetime, timezone from typing import TYPE_CHECKING +from markupsafe import Markup + if TYPE_CHECKING: from flask import Flask @@ -129,12 +133,104 @@ def truncate_words(text: str | None, length: int = 20, suffix: str = "...") -> s return " ".join(words[:length]) + suffix +def render_markdown(text: str | None) -> Markup: + """Render markdown to HTML with safe defaults. + + Supports basic markdown: + - **bold** and *italic* + - `inline code` and ```code blocks``` + - [links](url) + - Lists (- item or 1. item) + - > blockquotes + - Paragraphs (double newline) + """ + if not text: + return Markup("") + + # Escape HTML first for safety + text = html.escape(text) + + # Code blocks (```...```) + def replace_code_block(match): + code = match.group(1) + return f'
{code}
' + + text = re.sub(r"```(\w*)\n?([\s\S]*?)```", lambda m: replace_code_block(type("", (), {"group": lambda s, i: m.group(2) if i == 1 else m.group(1)})()), text) + + # Inline code (`...`) + text = re.sub(r"`([^`]+)`", r'\1', text) + + # Bold (**...**) + text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text) + + # Italic (*...*) + text = re.sub(r"\*([^*]+)\*", r"\1", text) + + # Links [text](url) - only allow http/https URLs + def replace_link(match): + link_text = match.group(1) + url = match.group(2) + if url.startswith(("http://", "https://", "/")): + return f'{link_text}' + return match.group(0) + + text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", replace_link, text) + + # Blockquotes (> ...) + lines = text.split("\n") + result_lines = [] + in_blockquote = False + blockquote_lines = [] + + for line in lines: + if line.startswith("> "): # Escaped > + if not in_blockquote: + in_blockquote = True + blockquote_lines = [] + blockquote_lines.append(line[5:]) + else: + if in_blockquote: + result_lines.append(f'
{" ".join(blockquote_lines)}
') + in_blockquote = False + blockquote_lines = [] + result_lines.append(line) + + if in_blockquote: + result_lines.append(f'
{" ".join(blockquote_lines)}
') + + text = "\n".join(result_lines) + + # Unordered lists (- item) + text = re.sub(r"^- (.+)$", r'
  • \1
  • ', text, flags=re.MULTILINE) + text = re.sub(r"(]*>.*\n?)+", r'', text) + + # Ordered lists (1. item) + text = re.sub(r"^\d+\. (.+)$", r'
  • \1
  • ', text, flags=re.MULTILINE) + + # Paragraphs (double newline) + paragraphs = re.split(r"\n\n+", text) + formatted = [] + for p in paragraphs: + p = p.strip() + if p: + # Don't wrap if it's already a block element + if p.startswith((" within paragraphs + p = p.replace("\n", "
    \n") + formatted.append(f'

    {p}

    ') + + return Markup("\n".join(formatted)) + + def register_filters(app: Flask) -> None: """Register all custom filters with a Flask app.""" app.jinja_env.filters["timeago"] = timeago app.jinja_env.filters["format_number"] = format_number app.jinja_env.filters["date_format"] = date_format app.jinja_env.filters["truncate_words"] = truncate_words + app.jinja_env.filters["markdown"] = render_markdown # Global functions app.jinja_env.globals["now"] = datetime.utcnow diff --git a/src/smarttools/web/forum/__init__.py b/src/smarttools/web/forum/__init__.py new file mode 100644 index 0000000..01dffd8 --- /dev/null +++ b/src/smarttools/web/forum/__init__.py @@ -0,0 +1,5 @@ +"""Forum blueprint for SmartTools community discussions.""" + +from .routes import forum_bp + +__all__ = ["forum_bp"] diff --git a/src/smarttools/web/forum/models.py b/src/smarttools/web/forum/models.py new file mode 100644 index 0000000..6638d36 --- /dev/null +++ b/src/smarttools/web/forum/models.py @@ -0,0 +1,245 @@ +"""Forum database models and schema.""" + +from __future__ import annotations + +import re +import sqlite3 +from typing import Any + +from smarttools.registry.db import connect_db, query_one, query_all + + +FORUM_SCHEMA = """ +CREATE TABLE IF NOT EXISTS forum_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + description TEXT, + icon TEXT, + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS forum_topics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id INTEGER NOT NULL REFERENCES forum_categories(id), + title TEXT NOT NULL, + slug TEXT NOT NULL, + author_name TEXT NOT NULL, + author_email TEXT, + content TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + is_pinned INTEGER DEFAULT 0, + is_locked INTEGER DEFAULT 0, + view_count INTEGER DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS forum_replies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + topic_id INTEGER NOT NULL REFERENCES forum_topics(id) ON DELETE CASCADE, + author_name TEXT NOT NULL, + author_email TEXT, + content TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + is_solution INTEGER DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_forum_topics_category ON forum_topics(category_id); +CREATE INDEX IF NOT EXISTS idx_forum_topics_updated ON forum_topics(updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_forum_replies_topic ON forum_replies(topic_id); +""" + +DEFAULT_CATEGORIES = [ + ("general", "General Discussion", "Chat about anything SmartTools related", "chat", 1), + ("help", "Help & Support", "Get help with installation, configuration, or usage", "question", 2), + ("showcase", "Showcase", "Share tools and projects you've built with SmartTools", "star", 3), + ("ideas", "Ideas & Feedback", "Suggest features and improvements", "lightbulb", 4), + ("tutorials", "Tutorials & Guides", "Community-written guides and how-tos", "book", 5), +] + + +def init_forum_schema(conn: sqlite3.Connection) -> None: + """Initialize forum tables.""" + conn.executescript(FORUM_SCHEMA) + conn.commit() + + +def seed_categories(conn: sqlite3.Connection) -> None: + """Seed default forum categories if they don't exist.""" + for slug, name, description, icon, sort_order in DEFAULT_CATEGORIES: + existing = query_one( + conn, "SELECT id FROM forum_categories WHERE slug = ?", [slug] + ) + if not existing: + conn.execute( + """INSERT INTO forum_categories (slug, name, description, icon, sort_order) + VALUES (?, ?, ?, ?, ?)""", + [slug, name, description, icon, sort_order], + ) + conn.commit() + + +def slugify(text: str) -> str: + """Convert text to URL-friendly slug.""" + text = text.lower().strip() + text = re.sub(r"[^\w\s-]", "", text) + text = re.sub(r"[-\s]+", "-", text) + return text[:100] # Limit length + + +def get_categories_with_stats(conn: sqlite3.Connection) -> list[dict[str, Any]]: + """Get all categories with topic counts and latest activity.""" + rows = query_all( + conn, + """ + SELECT + c.*, + COUNT(t.id) as topic_count, + COALESCE(SUM((SELECT COUNT(*) FROM forum_replies r WHERE r.topic_id = t.id)), 0) as reply_count, + MAX(COALESCE(t.updated_at, c.created_at)) as latest_activity + FROM forum_categories c + LEFT JOIN forum_topics t ON t.category_id = c.id + GROUP BY c.id + ORDER BY c.sort_order + """, + ) + return [dict(row) for row in rows] + + +def get_category_by_slug(conn: sqlite3.Connection, slug: str) -> dict[str, Any] | None: + """Get a category by slug.""" + row = query_one(conn, "SELECT * FROM forum_categories WHERE slug = ?", [slug]) + return dict(row) if row else None + + +def get_topics_for_category( + conn: sqlite3.Connection, category_id: int, page: int = 1, per_page: int = 20 +) -> tuple[list[dict[str, Any]], int]: + """Get topics for a category with pagination.""" + offset = (page - 1) * per_page + + # Get total count + count_row = query_one( + conn, + "SELECT COUNT(*) as total FROM forum_topics WHERE category_id = ?", + [category_id], + ) + total = count_row["total"] if count_row else 0 + + # Get topics with reply counts + rows = query_all( + conn, + """ + SELECT + t.*, + (SELECT COUNT(*) FROM forum_replies WHERE topic_id = t.id) as reply_count, + (SELECT MAX(created_at) FROM forum_replies WHERE topic_id = t.id) as last_reply_at + FROM forum_topics t + WHERE t.category_id = ? + ORDER BY t.is_pinned DESC, COALESCE( + (SELECT MAX(created_at) FROM forum_replies WHERE topic_id = t.id), + t.updated_at + ) DESC + LIMIT ? OFFSET ? + """, + [category_id, per_page, offset], + ) + return [dict(row) for row in rows], total + + +def get_topic_by_id(conn: sqlite3.Connection, topic_id: int) -> dict[str, Any] | None: + """Get a topic by ID.""" + row = query_one(conn, "SELECT * FROM forum_topics WHERE id = ?", [topic_id]) + return dict(row) if row else None + + +def get_replies_for_topic(conn: sqlite3.Connection, topic_id: int) -> list[dict[str, Any]]: + """Get all replies for a topic.""" + rows = query_all( + conn, + "SELECT * FROM forum_replies WHERE topic_id = ? ORDER BY created_at", + [topic_id], + ) + return [dict(row) for row in rows] + + +def create_topic( + conn: sqlite3.Connection, + category_id: int, + title: str, + content: str, + author_name: str, + author_email: str | None = None, +) -> int: + """Create a new topic and return its ID.""" + slug = slugify(title) + cursor = conn.execute( + """INSERT INTO forum_topics + (category_id, title, slug, author_name, author_email, content) + VALUES (?, ?, ?, ?, ?, ?)""", + [category_id, title, slug, author_name, author_email, content], + ) + conn.commit() + return cursor.lastrowid + + +def create_reply( + conn: sqlite3.Connection, + topic_id: int, + content: str, + author_name: str, + author_email: str | None = None, +) -> int: + """Create a new reply and return its ID.""" + cursor = conn.execute( + """INSERT INTO forum_replies (topic_id, author_name, author_email, content) + VALUES (?, ?, ?, ?)""", + [topic_id, author_name, author_email, content], + ) + # Update topic's updated_at timestamp + conn.execute( + "UPDATE forum_topics SET updated_at = CURRENT_TIMESTAMP WHERE id = ?", + [topic_id], + ) + conn.commit() + return cursor.lastrowid + + +def increment_view_count(conn: sqlite3.Connection, topic_id: int) -> None: + """Increment a topic's view count.""" + conn.execute( + "UPDATE forum_topics SET view_count = view_count + 1 WHERE id = ?", + [topic_id], + ) + conn.commit() + + +def get_recent_topics(conn: sqlite3.Connection, limit: int = 5) -> list[dict[str, Any]]: + """Get the most recently active topics across all categories.""" + rows = query_all( + conn, + """ + SELECT + t.*, + c.name as category_name, + c.slug as category_slug, + (SELECT COUNT(*) FROM forum_replies WHERE topic_id = t.id) as reply_count + FROM forum_topics t + JOIN forum_categories c ON c.id = t.category_id + ORDER BY t.updated_at DESC + LIMIT ? + """, + [limit], + ) + return [dict(row) for row in rows] + + +def get_forum_stats(conn: sqlite3.Connection) -> dict[str, int]: + """Get overall forum statistics.""" + topics = query_one(conn, "SELECT COUNT(*) as count FROM forum_topics") + replies = query_one(conn, "SELECT COUNT(*) as count FROM forum_replies") + return { + "topics": topics["count"] if topics else 0, + "replies": replies["count"] if replies else 0, + } diff --git a/src/smarttools/web/forum/routes.py b/src/smarttools/web/forum/routes.py new file mode 100644 index 0000000..e1a5954 --- /dev/null +++ b/src/smarttools/web/forum/routes.py @@ -0,0 +1,291 @@ +"""Forum blueprint routes.""" + +from __future__ import annotations + +import html +import re +from functools import wraps +from typing import Callable + +from flask import ( + Blueprint, + abort, + flash, + g, + redirect, + render_template, + request, + session, + url_for, +) + +from smarttools.registry.db import connect_db + +from .models import ( + create_reply, + create_topic, + get_categories_with_stats, + get_category_by_slug, + get_forum_stats, + get_recent_topics, + get_replies_for_topic, + get_topic_by_id, + get_topics_for_category, + increment_view_count, + init_forum_schema, + seed_categories, +) + +forum_bp = Blueprint( + "forum", + __name__, + url_prefix="/forum", + template_folder="../templates/forum", +) + + +def get_db(): + """Get database connection, creating forum tables if needed.""" + if "forum_db" not in g: + g.forum_db = connect_db() + init_forum_schema(g.forum_db) + seed_categories(g.forum_db) + return g.forum_db + + +@forum_bp.before_request +def before_request(): + """Set up database connection before each request.""" + g.forum_db = get_db() + + +@forum_bp.teardown_request +def teardown_request(exception): + """Close database connection after each request.""" + db = g.pop("forum_db", None) + if db is not None: + db.close() + + +def validate_content(content: str, min_length: int = 10, max_length: int = 50000) -> str | None: + """Validate content and return error message if invalid.""" + if not content or len(content.strip()) < min_length: + return f"Content must be at least {min_length} characters." + if len(content) > max_length: + return f"Content must be less than {max_length} characters." + return None + + +def validate_name(name: str) -> str | None: + """Validate author name and return error message if invalid.""" + if not name or len(name.strip()) < 2: + return "Name must be at least 2 characters." + if len(name) > 100: + return "Name must be less than 100 characters." + # Basic sanitization - allow letters, numbers, spaces, and common punctuation + if not re.match(r"^[\w\s\-'.]+$", name, re.UNICODE): + return "Name contains invalid characters." + return None + + +def validate_email(email: str | None) -> str | None: + """Validate email if provided.""" + if not email: + return None # Email is optional + if len(email) > 254: + return "Email is too long." + if not re.match(r"^[^@]+@[^@]+\.[^@]+$", email): + return "Invalid email format." + return None + + +def validate_title(title: str) -> str | None: + """Validate topic title.""" + if not title or len(title.strip()) < 5: + return "Title must be at least 5 characters." + if len(title) > 200: + return "Title must be less than 200 characters." + return None + + +def honeypot_check(func: Callable) -> Callable: + """Decorator to check honeypot field for spam prevention.""" + @wraps(func) + def wrapper(*args, **kwargs): + # If the honeypot field is filled, it's likely a bot + if request.form.get("website_url"): # Hidden field bots often fill + # Silently redirect without processing + return redirect(url_for("forum.index")) + return func(*args, **kwargs) + return wrapper + + +# ───────────────────────────────────────────────────────────────────────────── +# Forum Index - List all categories +# ───────────────────────────────────────────────────────────────────────────── +@forum_bp.route("/") +def index(): + """Forum home - list all categories with stats.""" + db = get_db() + categories = get_categories_with_stats(db) + recent_topics = get_recent_topics(db, limit=5) + stats = get_forum_stats(db) + + return render_template( + "forum/index.html", + categories=categories, + recent_topics=recent_topics, + stats=stats, + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# Category View - List topics in a category +# ───────────────────────────────────────────────────────────────────────────── +@forum_bp.route("/c/") +def category(slug: str): + """List topics in a category.""" + db = get_db() + cat = get_category_by_slug(db, slug) + if not cat: + abort(404) + + page = request.args.get("page", 1, type=int) + per_page = 20 + topics, total = get_topics_for_category(db, cat["id"], page, per_page) + + total_pages = (total + per_page - 1) // per_page + + return render_template( + "forum/category.html", + category=cat, + topics=topics, + page=page, + total_pages=total_pages, + total=total, + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# Topic View - View a topic and its replies +# ───────────────────────────────────────────────────────────────────────────── +@forum_bp.route("/t//") +def topic(topic_id: int, slug: str): + """View a topic and its replies.""" + db = get_db() + t = get_topic_by_id(db, topic_id) + if not t: + abort(404) + + # Redirect to correct slug if wrong + if t["slug"] != slug: + return redirect(url_for("forum.topic", topic_id=topic_id, slug=t["slug"])) + + # Increment view count (simple - could add session check to prevent refresh abuse) + view_key = f"viewed_topic_{topic_id}" + if not session.get(view_key): + increment_view_count(db, topic_id) + session[view_key] = True + + replies = get_replies_for_topic(db, topic_id) + cat = get_category_by_slug(db, "") # We need to get by ID + cat_row = db.execute( + "SELECT * FROM forum_categories WHERE id = ?", [t["category_id"]] + ).fetchone() + cat = dict(cat_row) if cat_row else None + + return render_template( + "forum/topic.html", + topic=t, + replies=replies, + category=cat, + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# New Topic - Create a new topic +# ───────────────────────────────────────────────────────────────────────────── +@forum_bp.route("/c//new", methods=["GET", "POST"]) +@honeypot_check +def new_topic(slug: str): + """Create a new topic in a category.""" + db = get_db() + cat = get_category_by_slug(db, slug) + if not cat: + abort(404) + + error = None + form_data = {} + + if request.method == "POST": + title = request.form.get("title", "").strip() + content = request.form.get("content", "").strip() + author_name = request.form.get("author_name", "").strip() + author_email = request.form.get("author_email", "").strip() or None + + form_data = { + "title": title, + "content": content, + "author_name": author_name, + "author_email": author_email or "", + } + + # Validate inputs + error = ( + validate_title(title) + or validate_content(content) + or validate_name(author_name) + or validate_email(author_email) + ) + + if not error: + topic_id = create_topic( + db, cat["id"], title, content, author_name, author_email + ) + from .models import slugify + topic_slug = slugify(title) + flash("Topic created successfully!", "success") + return redirect(url_for("forum.topic", topic_id=topic_id, slug=topic_slug)) + + return render_template( + "forum/new_topic.html", + category=cat, + error=error, + form_data=form_data, + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# Reply - Add a reply to a topic +# ───────────────────────────────────────────────────────────────────────────── +@forum_bp.route("/t//reply", methods=["POST"]) +@honeypot_check +def reply(topic_id: int): + """Add a reply to a topic.""" + db = get_db() + t = get_topic_by_id(db, topic_id) + if not t: + abort(404) + + if t["is_locked"]: + flash("This topic is locked and cannot receive new replies.", "error") + return redirect(url_for("forum.topic", topic_id=topic_id, slug=t["slug"])) + + content = request.form.get("content", "").strip() + author_name = request.form.get("author_name", "").strip() + author_email = request.form.get("author_email", "").strip() or None + + # Validate inputs + error = ( + validate_content(content) + or validate_name(author_name) + or validate_email(author_email) + ) + + if error: + flash(error, "error") + else: + create_reply(db, topic_id, content, author_name, author_email) + flash("Reply posted successfully!", "success") + + return redirect(url_for("forum.topic", topic_id=topic_id, slug=t["slug"])) diff --git a/src/smarttools/web/templates/components/header.html b/src/smarttools/web/templates/components/header.html index df54c6b..bc2a5a3 100644 --- a/src/smarttools/web/templates/components/header.html +++ b/src/smarttools/web/templates/components/header.html @@ -22,9 +22,9 @@ class="px-3 py-2 rounded-md text-sm font-medium hover:bg-slate-700 transition-colors {% if request.path.startswith('/tools') %}bg-slate-700{% endif %}"> Registry - - Community + + Forum @@ -115,9 +115,9 @@ class="block px-3 py-2 rounded-md text-base font-medium hover:bg-slate-700 {% if request.path.startswith('/tools') %}bg-slate-700{% endif %}"> Registry - - Community + + Forum diff --git a/src/smarttools/web/templates/forum/category.html b/src/smarttools/web/templates/forum/category.html new file mode 100644 index 0000000..032936a --- /dev/null +++ b/src/smarttools/web/templates/forum/category.html @@ -0,0 +1,109 @@ +{% extends "base.html" %} + +{% block title %}{{ category.name }} - Forum{% endblock %} + +{% block meta_description %}{{ category.description }}{% endblock %} + +{% block content %} +
    + +
    +
    + + + +
    +
    +

    {{ category.name }}

    +

    {{ category.description }}

    +
    + + + + + New Topic + +
    +
    +
    + +
    + {% if topics %} + + + + + {% if total_pages > 1 %} + + {% endif %} + + {% else %} + +
    + + + +

    No topics yet

    +

    Be the first to start a discussion in this category.

    + + Create the first topic + +
    + {% endif %} +
    +
    +{% endblock %} diff --git a/src/smarttools/web/templates/forum/index.html b/src/smarttools/web/templates/forum/index.html new file mode 100644 index 0000000..ca2aaa4 --- /dev/null +++ b/src/smarttools/web/templates/forum/index.html @@ -0,0 +1,136 @@ +{% extends "base.html" %} + +{% block title %}Community Forum{% endblock %} + +{% block meta_description %}Join the SmartTools community. Ask questions, share your projects, and connect with other users.{% endblock %} + +{% block content %} +
    + +
    +
    +

    Community Forum

    +

    + Ask questions, share your projects, and connect with other SmartTools users. +

    +
    + {{ stats.topics }} topics + {{ stats.replies }} replies +
    +
    +
    + + +
    +{% endblock %} diff --git a/src/smarttools/web/templates/forum/new_topic.html b/src/smarttools/web/templates/forum/new_topic.html new file mode 100644 index 0000000..8183aea --- /dev/null +++ b/src/smarttools/web/templates/forum/new_topic.html @@ -0,0 +1,123 @@ +{% extends "base.html" %} + +{% block title %}New Topic in {{ category.name }} - Forum{% endblock %} + +{% block meta_description %}Start a new discussion in {{ category.name }}.{% endblock %} + +{% block content %} +
    + +
    +
    + + + +

    New Topic in {{ category.name }}

    +
    +
    + +
    + {% if error %} +
    + {{ error }} +
    + {% endif %} + +
    + +
    + +
    + + +
    +
    + + +
    +
    + + +

    Used for Gravatar only, not displayed publicly.

    +
    +
    + + +
    + + +

    Be specific and descriptive (5-200 characters).

    +
    + + +
    + + +

    + Markdown is supported: **bold**, *italic*, `code`, ```code blocks```, [links](url) +

    +
    + + +
    +

    Tips for a great post:

    +
      +
    • Search first to avoid duplicate topics
    • +
    • Use a clear, descriptive title
    • +
    • Include relevant details and context
    • +
    • For bugs, include steps to reproduce
    • +
    • Format code with triple backticks: ```code```
    • +
    +
    + + +
    + + Cancel + + +
    +
    +
    +
    +{% endblock %} diff --git a/src/smarttools/web/templates/forum/topic.html b/src/smarttools/web/templates/forum/topic.html new file mode 100644 index 0000000..c3efc7b --- /dev/null +++ b/src/smarttools/web/templates/forum/topic.html @@ -0,0 +1,178 @@ +{% extends "base.html" %} + +{% block title %}{{ topic.title }} - Forum{% endblock %} + +{% block meta_description %}{{ topic.content[:160] }}{% endblock %} + +{% block content %} +
    + +
    +
    + + + +
    +
    +
    + {% if topic.is_pinned %} + + Pinned + + {% endif %} + {% if topic.is_locked %} + + Locked + + {% endif %} +

    {{ topic.title }}

    +
    +

    + {{ topic.view_count }} views · {{ replies|length }} replies +

    +
    +
    +
    +
    + +
    + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
    + {{ message }} +
    + {% endfor %} + {% endif %} + {% endwith %} + + +
    +
    +
    + +
    + {{ topic.author_name[0]|upper }} +
    +
    +
    {{ topic.author_name }}
    +
    {{ topic.created_at | timeago }}
    +
    +
    +
    + {{ topic.content | markdown | safe }} +
    +
    +
    + + + {% if replies %} +
    +

    {{ replies|length }} {% if replies|length == 1 %}Reply{% else %}Replies{% endif %}

    + + {% for reply in replies %} +
    +
    +
    + +
    + {{ reply.author_name[0]|upper }} +
    +
    +
    + {{ reply.author_name }} + {% if reply.is_solution %} + + Solution + + {% endif %} +
    +
    {{ reply.created_at | timeago }}
    +
    +
    +
    + {{ reply.content | markdown | safe }} +
    +
    +
    + {% endfor %} +
    + {% endif %} + + + {% if not topic.is_locked %} +
    +

    Post a Reply

    +
    + +
    + +
    + +
    +
    + + +
    +
    + + +
    +
    + +
    + + +

    + Markdown is supported: **bold**, *italic*, `code`, ```code blocks```, [links](url) +

    +
    + +
    + +
    +
    +
    + {% else %} +
    + + + +

    This topic is locked and cannot receive new replies.

    +
    + {% endif %} +
    +
    +{% endblock %}