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:
parent
e2d1002d2e
commit
a80adcedfd
|
|
@ -30,6 +30,7 @@ from smarttools.registry import app as registry_app
|
||||||
from . import web_bp
|
from . import web_bp
|
||||||
from .auth import login, register, logout
|
from .auth import login, register, logout
|
||||||
from .filters import register_filters
|
from .filters import register_filters
|
||||||
|
from .forum import forum_bp
|
||||||
from .sessions import SQLiteSessionInterface, cleanup_expired_sessions
|
from .sessions import SQLiteSessionInterface, cleanup_expired_sessions
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -40,6 +41,7 @@ def create_web_app() -> Flask:
|
||||||
from . import routes # noqa: F401
|
from . import routes # noqa: F401
|
||||||
|
|
||||||
app.register_blueprint(web_bp)
|
app.register_blueprint(web_bp)
|
||||||
|
app.register_blueprint(forum_bp)
|
||||||
|
|
||||||
# Session configuration
|
# Session configuration
|
||||||
app.session_interface = SQLiteSessionInterface(cookie_name="smarttools_session")
|
app.session_interface = SQLiteSessionInterface(cookie_name="smarttools_session")
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,13 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
import re
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from markupsafe import Markup
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from flask import Flask
|
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
|
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("> "): # 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:
|
def register_filters(app: Flask) -> None:
|
||||||
"""Register all custom filters with a Flask app."""
|
"""Register all custom filters with a Flask app."""
|
||||||
app.jinja_env.filters["timeago"] = timeago
|
app.jinja_env.filters["timeago"] = timeago
|
||||||
app.jinja_env.filters["format_number"] = format_number
|
app.jinja_env.filters["format_number"] = format_number
|
||||||
app.jinja_env.filters["date_format"] = date_format
|
app.jinja_env.filters["date_format"] = date_format
|
||||||
app.jinja_env.filters["truncate_words"] = truncate_words
|
app.jinja_env.filters["truncate_words"] = truncate_words
|
||||||
|
app.jinja_env.filters["markdown"] = render_markdown
|
||||||
|
|
||||||
# Global functions
|
# Global functions
|
||||||
app.jinja_env.globals["now"] = datetime.utcnow
|
app.jinja_env.globals["now"] = datetime.utcnow
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""Forum blueprint for SmartTools community discussions."""
|
||||||
|
|
||||||
|
from .routes import forum_bp
|
||||||
|
|
||||||
|
__all__ = ["forum_bp"]
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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"]))
|
||||||
|
|
@ -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 %}">
|
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
|
Registry
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url_for('web.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('/community') %}bg-slate-700{% endif %}">
|
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 %}">
|
||||||
Community
|
Forum
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url_for('web.about') }}"
|
<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 %}">
|
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 %}">
|
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
|
Registry
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url_for('web.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('/community') %}bg-slate-700{% endif %}">
|
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 %}">
|
||||||
Community
|
Forum
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url_for('web.about') }}"
|
<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 %}">
|
class="block px-3 py-2 rounded-md text-base font-medium hover:bg-slate-700 {% if request.path == '/about' %}bg-slate-700{% endif %}">
|
||||||
|
|
|
||||||
|
|
@ -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 }} · {{ 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 %}
|
||||||
|
|
@ -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 }} · {{ 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 %}
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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 · {{ 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 %}
|
||||||
Loading…
Reference in New Issue