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 .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")
|
||||
|
|
|
|||
|
|
@ -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("> "): # 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
|
||||
|
|
|
|||
|
|
@ -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 %}">
|
||||
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 %}">
|
||||
|
|
|
|||
|
|
@ -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