Add community forum feature

- New forum blueprint with categories, topics, and replies
- Markdown rendering for posts with safe HTML escaping
- Honeypot spam protection for forms
- Categories: General, Help, Showcase, Ideas, Tutorials
- View counts and reply tracking
- Updated navigation to link to forum

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rob 2026-01-02 00:45:15 -04:00
parent e2d1002d2e
commit a80adcedfd
10 changed files with 1191 additions and 6 deletions

View File

@ -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")

View File

@ -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'<pre class="bg-gray-900 text-gray-100 rounded-lg p-4 overflow-x-auto my-4 text-sm"><code>{code}</code></pre>'
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'<code class="bg-gray-100 text-indigo-600 px-1.5 py-0.5 rounded text-sm font-mono">\1</code>', text)
# Bold (**...**)
text = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", text)
# Italic (*...*)
text = re.sub(r"\*([^*]+)\*", r"<em>\1</em>", 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'<a href="{url}" class="text-indigo-600 hover:text-indigo-800 underline" rel="nofollow noopener" target="_blank">{link_text}</a>'
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("&gt; "): # Escaped >
if not in_blockquote:
in_blockquote = True
blockquote_lines = []
blockquote_lines.append(line[5:])
else:
if in_blockquote:
result_lines.append(f'<blockquote class="border-l-4 border-gray-300 pl-4 italic text-gray-600 my-4">{" ".join(blockquote_lines)}</blockquote>')
in_blockquote = False
blockquote_lines = []
result_lines.append(line)
if in_blockquote:
result_lines.append(f'<blockquote class="border-l-4 border-gray-300 pl-4 italic text-gray-600 my-4">{" ".join(blockquote_lines)}</blockquote>')
text = "\n".join(result_lines)
# Unordered lists (- item)
text = re.sub(r"^- (.+)$", r'<li class="ml-4">\1</li>', text, flags=re.MULTILINE)
text = re.sub(r"(<li[^>]*>.*</li>\n?)+", r'<ul class="list-disc list-inside my-4">\g<0></ul>', text)
# Ordered lists (1. item)
text = re.sub(r"^\d+\. (.+)$", r'<li class="ml-4">\1</li>', 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(("<pre", "<ul", "<ol", "<blockquote", "<li")):
formatted.append(p)
else:
# Convert single newlines to <br> within paragraphs
p = p.replace("\n", "<br>\n")
formatted.append(f'<p class="mb-4">{p}</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

View File

@ -0,0 +1,5 @@
"""Forum blueprint for SmartTools community discussions."""
from .routes import forum_bp
__all__ = ["forum_bp"]

View File

@ -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,
}

View File

@ -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/<slug>")
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/<int:topic_id>/<slug>")
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/<slug>/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/<int:topic_id>/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"]))

View File

@ -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
</a>
<a href="{{ url_for('web.community') }}"
class="px-3 py-2 rounded-md text-sm font-medium hover:bg-slate-700 transition-colors {% if request.path.startswith('/community') %}bg-slate-700{% endif %}">
Community
<a href="{{ url_for('forum.index') }}"
class="px-3 py-2 rounded-md text-sm font-medium hover:bg-slate-700 transition-colors {% if request.path.startswith('/forum') %}bg-slate-700{% endif %}">
Forum
</a>
<a href="{{ url_for('web.about') }}"
class="px-3 py-2 rounded-md text-sm font-medium hover:bg-slate-700 transition-colors {% if request.path == '/about' %}bg-slate-700{% endif %}">
@ -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
</a>
<a href="{{ url_for('web.community') }}"
class="block px-3 py-2 rounded-md text-base font-medium hover:bg-slate-700 {% if request.path.startswith('/community') %}bg-slate-700{% endif %}">
Community
<a href="{{ url_for('forum.index') }}"
class="block px-3 py-2 rounded-md text-base font-medium hover:bg-slate-700 {% if request.path.startswith('/forum') %}bg-slate-700{% endif %}">
Forum
</a>
<a href="{{ url_for('web.about') }}"
class="block px-3 py-2 rounded-md text-base font-medium hover:bg-slate-700 {% if request.path == '/about' %}bg-slate-700{% endif %}">

View File

@ -0,0 +1,109 @@
{% extends "base.html" %}
{% block title %}{{ category.name }} - Forum{% endblock %}
{% block meta_description %}{{ category.description }}{% endblock %}
{% block content %}
<div class="bg-gray-50 min-h-screen">
<!-- Header -->
<div class="bg-white border-b border-gray-200">
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<!-- Breadcrumb -->
<nav class="text-sm text-gray-500 mb-4">
<a href="{{ url_for('forum.index') }}" class="hover:text-indigo-600">Forum</a>
<span class="mx-2">/</span>
<span class="text-gray-900">{{ category.name }}</span>
</nav>
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">{{ category.name }}</h1>
<p class="mt-1 text-gray-600">{{ category.description }}</p>
</div>
<a href="{{ url_for('forum.new_topic', slug=category.slug) }}"
class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
New Topic
</a>
</div>
</div>
</div>
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{% if topics %}
<!-- Topics list -->
<div class="bg-white border border-gray-200 rounded-lg divide-y divide-gray-100">
{% for topic in topics %}
<a href="{{ url_for('forum.topic', topic_id=topic.id, slug=topic.slug) }}"
class="block p-4 hover:bg-gray-50 transition-colors">
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
{% if topic.is_pinned %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800">
Pinned
</span>
{% endif %}
{% if topic.is_locked %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800">
Locked
</span>
{% endif %}
<h3 class="font-medium text-gray-900 truncate">{{ topic.title }}</h3>
</div>
<div class="mt-1 text-sm text-gray-500">
by {{ topic.author_name }} &middot; {{ topic.created_at | timeago }}
</div>
</div>
<div class="ml-4 flex-shrink-0 text-right">
<div class="text-sm text-gray-900">{{ topic.reply_count }} replies</div>
<div class="text-xs text-gray-500">{{ topic.view_count }} views</div>
</div>
</div>
</a>
{% endfor %}
</div>
<!-- Pagination -->
{% if total_pages > 1 %}
<nav class="mt-6 flex items-center justify-center gap-2">
{% if page > 1 %}
<a href="{{ url_for('forum.category', slug=category.slug, page=page-1) }}"
class="px-3 py-2 text-sm text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
Previous
</a>
{% endif %}
<span class="px-3 py-2 text-sm text-gray-700">
Page {{ page }} of {{ total_pages }}
</span>
{% if page < total_pages %}
<a href="{{ url_for('forum.category', slug=category.slug, page=page+1) }}"
class="px-3 py-2 text-sm text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
Next
</a>
{% endif %}
</nav>
{% endif %}
{% else %}
<!-- Empty state -->
<div class="bg-white border border-gray-200 rounded-lg p-8 text-center">
<svg class="mx-auto w-12 h-12 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
</svg>
<h3 class="mt-4 text-lg font-medium text-gray-900">No topics yet</h3>
<p class="mt-2 text-gray-600">Be the first to start a discussion in this category.</p>
<a href="{{ url_for('forum.new_topic', slug=category.slug) }}"
class="mt-4 inline-flex items-center px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700">
Create the first topic
</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -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 %}
<div class="bg-gray-50 min-h-screen">
<!-- Hero -->
<div class="bg-white border-b border-gray-200">
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 class="text-3xl font-bold text-gray-900">Community Forum</h1>
<p class="mt-2 text-gray-600">
Ask questions, share your projects, and connect with other SmartTools users.
</p>
<div class="mt-4 flex items-center gap-6 text-sm text-gray-500">
<span>{{ stats.topics }} topics</span>
<span>{{ stats.replies }} replies</span>
</div>
</div>
</div>
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="lg:grid lg:grid-cols-3 lg:gap-8">
<!-- Categories -->
<div class="lg:col-span-2">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Categories</h2>
<div class="space-y-3">
{% for cat in categories %}
<a href="{{ url_for('forum.category', slug=cat.slug) }}"
class="block bg-white border border-gray-200 rounded-lg p-4 hover:border-indigo-300 hover:shadow-sm transition-all">
<div class="flex items-start justify-between">
<div class="flex items-start space-x-3">
<!-- Icon -->
<div class="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center
{% if cat.icon == 'chat' %}bg-blue-100 text-blue-600
{% elif cat.icon == 'question' %}bg-amber-100 text-amber-600
{% elif cat.icon == 'star' %}bg-purple-100 text-purple-600
{% elif cat.icon == 'lightbulb' %}bg-green-100 text-green-600
{% elif cat.icon == 'book' %}bg-indigo-100 text-indigo-600
{% else %}bg-gray-100 text-gray-600{% endif %}">
{% if cat.icon == 'chat' %}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
</svg>
{% elif cat.icon == 'question' %}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{% elif cat.icon == 'star' %}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/>
</svg>
{% elif cat.icon == 'lightbulb' %}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
</svg>
{% elif cat.icon == 'book' %}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
{% else %}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"/>
</svg>
{% endif %}
</div>
<div>
<h3 class="font-semibold text-gray-900">{{ cat.name }}</h3>
<p class="text-sm text-gray-600 mt-0.5">{{ cat.description }}</p>
</div>
</div>
<div class="text-right text-sm text-gray-500 flex-shrink-0 ml-4">
<div>{{ cat.topic_count }} topics</div>
<div class="text-xs">{{ cat.reply_count }} replies</div>
</div>
</div>
</a>
{% endfor %}
</div>
</div>
<!-- Sidebar -->
<div class="mt-8 lg:mt-0">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Recent Activity</h2>
{% if recent_topics %}
<div class="bg-white border border-gray-200 rounded-lg divide-y divide-gray-100">
{% for topic in recent_topics %}
<a href="{{ url_for('forum.topic', topic_id=topic.id, slug=topic.slug) }}"
class="block p-3 hover:bg-gray-50 transition-colors">
<div class="font-medium text-gray-900 text-sm truncate">{{ topic.title }}</div>
<div class="text-xs text-gray-500 mt-1">
in {{ topic.category_name }} &middot; {{ topic.reply_count }} replies
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="bg-white border border-gray-200 rounded-lg p-4 text-center text-gray-500">
<p>No topics yet. Be the first to start a discussion!</p>
</div>
{% endif %}
<!-- Quick Links -->
<div class="mt-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Quick Links</h2>
<div class="space-y-2">
<a href="{{ url_for('web.docs', path='getting-started') }}"
class="flex items-center text-sm text-gray-600 hover:text-indigo-600">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
Getting Started Guide
</a>
<a href="{{ url_for('web.tools') }}"
class="flex items-center text-sm text-gray-600 hover:text-indigo-600">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
Browse Tools
</a>
<a href="https://gitea.brrd.tech/rob/SmartTools/issues"
target="_blank" rel="noopener"
class="flex items-center text-sm text-gray-600 hover:text-indigo-600">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Report a Bug
</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -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 %}
<div class="bg-gray-50 min-h-screen">
<!-- Header -->
<div class="bg-white border-b border-gray-200">
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<!-- Breadcrumb -->
<nav class="text-sm text-gray-500 mb-4">
<a href="{{ url_for('forum.index') }}" class="hover:text-indigo-600">Forum</a>
<span class="mx-2">/</span>
<a href="{{ url_for('forum.category', slug=category.slug) }}" class="hover:text-indigo-600">{{ category.name }}</a>
<span class="mx-2">/</span>
<span class="text-gray-900">New Topic</span>
</nav>
<h1 class="text-2xl font-bold text-gray-900">New Topic in {{ category.name }}</h1>
</div>
</div>
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{% if error %}
<div class="mb-4 p-4 bg-red-50 border border-red-200 rounded-md text-red-800">
{{ error }}
</div>
{% endif %}
<form method="POST" class="bg-white border border-gray-200 rounded-lg p-6">
<!-- Honeypot field (hidden, bots fill this) -->
<div style="display: none;">
<input type="text" name="website_url" tabindex="-1" autocomplete="off">
</div>
<!-- Author info -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div>
<label for="author_name" class="block text-sm font-medium text-gray-700 mb-1">
Your Name <span class="text-red-500">*</span>
</label>
<input type="text"
id="author_name"
name="author_name"
required
value="{{ form_data.author_name if form_data else '' }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="John Doe">
</div>
<div>
<label for="author_email" class="block text-sm font-medium text-gray-700 mb-1">
Email <span class="text-gray-400">(optional)</span>
</label>
<input type="email"
id="author_email"
name="author_email"
value="{{ form_data.author_email if form_data else '' }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="john@example.com">
<p class="mt-1 text-xs text-gray-500">Used for Gravatar only, not displayed publicly.</p>
</div>
</div>
<!-- Title -->
<div class="mb-6">
<label for="title" class="block text-sm font-medium text-gray-700 mb-1">
Topic Title <span class="text-red-500">*</span>
</label>
<input type="text"
id="title"
name="title"
required
value="{{ form_data.title if form_data else '' }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="How do I...?">
<p class="mt-1 text-xs text-gray-500">Be specific and descriptive (5-200 characters).</p>
</div>
<!-- Content -->
<div class="mb-6">
<label for="content" class="block text-sm font-medium text-gray-700 mb-1">
Content <span class="text-red-500">*</span>
</label>
<textarea id="content"
name="content"
rows="12"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 font-mono text-sm"
placeholder="Describe your question, idea, or project in detail...">{{ form_data.content if form_data else '' }}</textarea>
<p class="mt-1 text-xs text-gray-500">
Markdown is supported: **bold**, *italic*, `code`, ```code blocks```, [links](url)
</p>
</div>
<!-- Tips -->
<div class="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-md">
<h3 class="text-sm font-medium text-blue-800 mb-2">Tips for a great post:</h3>
<ul class="text-sm text-blue-700 space-y-1 list-disc list-inside">
<li>Search first to avoid duplicate topics</li>
<li>Use a clear, descriptive title</li>
<li>Include relevant details and context</li>
<li>For bugs, include steps to reproduce</li>
<li>Format code with triple backticks: ```code```</li>
</ul>
</div>
<!-- Submit -->
<div class="flex items-center justify-between">
<a href="{{ url_for('forum.category', slug=category.slug) }}"
class="text-gray-600 hover:text-gray-900">
Cancel
</a>
<button type="submit"
class="px-6 py-2 bg-indigo-600 text-white font-medium rounded-md hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Create Topic
</button>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,178 @@
{% extends "base.html" %}
{% block title %}{{ topic.title }} - Forum{% endblock %}
{% block meta_description %}{{ topic.content[:160] }}{% endblock %}
{% block content %}
<div class="bg-gray-50 min-h-screen">
<!-- Header -->
<div class="bg-white border-b border-gray-200">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<!-- Breadcrumb -->
<nav class="text-sm text-gray-500 mb-4">
<a href="{{ url_for('forum.index') }}" class="hover:text-indigo-600">Forum</a>
<span class="mx-2">/</span>
{% if category %}
<a href="{{ url_for('forum.category', slug=category.slug) }}" class="hover:text-indigo-600">{{ category.name }}</a>
<span class="mx-2">/</span>
{% endif %}
<span class="text-gray-900 truncate">{{ topic.title[:50] }}{% if topic.title|length > 50 %}...{% endif %}</span>
</nav>
<div class="flex items-start justify-between">
<div>
<div class="flex items-center gap-2 flex-wrap">
{% if topic.is_pinned %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800">
Pinned
</span>
{% endif %}
{% if topic.is_locked %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800">
Locked
</span>
{% endif %}
<h1 class="text-2xl font-bold text-gray-900">{{ topic.title }}</h1>
</div>
<p class="mt-2 text-sm text-gray-500">
{{ topic.view_count }} views &middot; {{ replies|length }} replies
</p>
</div>
</div>
</div>
</div>
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<!-- Flash messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="mb-4 p-4 rounded-md {% if category == 'error' %}bg-red-50 text-red-800{% else %}bg-green-50 text-green-800{% endif %}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<!-- Original post -->
<article class="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div class="p-6">
<div class="flex items-center mb-4">
<!-- Avatar placeholder -->
<div class="w-10 h-10 rounded-full bg-indigo-100 flex items-center justify-center text-indigo-600 font-semibold">
{{ topic.author_name[0]|upper }}
</div>
<div class="ml-3">
<div class="font-medium text-gray-900">{{ topic.author_name }}</div>
<div class="text-sm text-gray-500">{{ topic.created_at | timeago }}</div>
</div>
</div>
<div class="prose prose-sm max-w-none">
{{ topic.content | markdown | safe }}
</div>
</div>
</article>
<!-- Replies -->
{% if replies %}
<div class="mt-6 space-y-4">
<h2 class="text-lg font-semibold text-gray-900">{{ replies|length }} {% if replies|length == 1 %}Reply{% else %}Replies{% endif %}</h2>
{% for reply in replies %}
<article class="bg-white border border-gray-200 rounded-lg overflow-hidden {% if reply.is_solution %}ring-2 ring-green-500{% endif %}" id="reply-{{ reply.id }}">
<div class="p-6">
<div class="flex items-center mb-4">
<!-- Avatar placeholder -->
<div class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 font-semibold">
{{ reply.author_name[0]|upper }}
</div>
<div class="ml-3 flex-1">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900">{{ reply.author_name }}</span>
{% if reply.is_solution %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
Solution
</span>
{% endif %}
</div>
<div class="text-sm text-gray-500">{{ reply.created_at | timeago }}</div>
</div>
</div>
<div class="prose prose-sm max-w-none">
{{ reply.content | markdown | safe }}
</div>
</div>
</article>
{% endfor %}
</div>
{% endif %}
<!-- Reply form -->
{% if not topic.is_locked %}
<div class="mt-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Post a Reply</h2>
<form method="POST" action="{{ url_for('forum.reply', topic_id=topic.id) }}" class="bg-white border border-gray-200 rounded-lg p-6">
<!-- Honeypot field (hidden, bots fill this) -->
<div style="display: none;">
<input type="text" name="website_url" tabindex="-1" autocomplete="off">
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label for="author_name" class="block text-sm font-medium text-gray-700 mb-1">
Your Name <span class="text-red-500">*</span>
</label>
<input type="text"
id="author_name"
name="author_name"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="John Doe">
</div>
<div>
<label for="author_email" class="block text-sm font-medium text-gray-700 mb-1">
Email <span class="text-gray-400">(optional)</span>
</label>
<input type="email"
id="author_email"
name="author_email"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="john@example.com">
</div>
</div>
<div class="mb-4">
<label for="content" class="block text-sm font-medium text-gray-700 mb-1">
Your Reply <span class="text-red-500">*</span>
</label>
<textarea id="content"
name="content"
rows="6"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 font-mono text-sm"
placeholder="Write your reply here... (Markdown supported)"></textarea>
<p class="mt-1 text-xs text-gray-500">
Markdown is supported: **bold**, *italic*, `code`, ```code blocks```, [links](url)
</p>
</div>
<div class="flex justify-end">
<button type="submit"
class="px-4 py-2 bg-indigo-600 text-white font-medium rounded-md hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Post Reply
</button>
</div>
</form>
</div>
{% else %}
<div class="mt-8 bg-gray-100 border border-gray-200 rounded-lg p-6 text-center">
<svg class="mx-auto w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
</svg>
<p class="mt-2 text-gray-600">This topic is locked and cannot receive new replies.</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}