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'
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 %}
+
+
+ {% for topic in topics %}
+
+
+
+
+ {% if topic.is_pinned %}
+
+ Pinned
+
+ {% endif %}
+ {% if topic.is_locked %}
+
+ Locked
+
+ {% endif %}
+ {{ topic.title }}
+
+
+ by {{ topic.author_name }} · {{ topic.created_at | timeago }}
+
+
+
+ {{ topic.reply_count }} replies
+ {{ topic.view_count }} views
+
+
+
+ {% endfor %}
+
+
+
+ {% 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
+
+
+
+
+
+
+
+
+ Categories
+
+ {% for cat in categories %}
+
+
+
+
+
+ {% if cat.icon == 'chat' %}
+
+ {% elif cat.icon == 'question' %}
+
+ {% elif cat.icon == 'star' %}
+
+ {% elif cat.icon == 'lightbulb' %}
+
+ {% elif cat.icon == 'book' %}
+
+ {% else %}
+
+ {% endif %}
+
+
+ {{ cat.name }}
+ {{ cat.description }}
+
+
+
+ {{ cat.topic_count }} topics
+ {{ cat.reply_count }} replies
+
+
+
+ {% endfor %}
+
+
+
+
+
+ Recent Activity
+ {% if recent_topics %}
+
+ {% for topic in recent_topics %}
+
+ {{ topic.title }}
+
+ in {{ topic.category_name }} · {{ topic.reply_count }} replies
+
+
+ {% endfor %}
+
+ {% else %}
+
+ No topics yet. Be the first to start a discussion!
+
+ {% endif %}
+
+
+
+ Quick Links
+
+
+
+
+
+
+{% 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 %}
+
+
+
+
+{% 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
+
+
+ {% else %}
+
+
+ This topic is locked and cannot receive new replies.
+
+ {% endif %}
+
+
+{% endblock %}