Add ratings, reviews, and publisher reputation system

- Add database schema for reviews, issues, and stats caching
- Add API endpoints for reviews (CRUD, voting, flagging)
- Add API endpoints for issues (report, list, resolve)
- Add publisher stats and badge system
- Add trust score calculation (0-100 scale)
- Update tool detail page with ratings, reviews, issues sections
- Update publisher profile with stats, badges, trust score
- Add dedicated reviews and issues pages with filtering
- Update dashboard tools table with rating/issues columns
- Update tool cards with inline rating display

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rob 2026-01-14 03:12:31 -04:00
parent d2c668dc99
commit 604b473806
10 changed files with 2566 additions and 9 deletions

View File

@ -21,6 +21,11 @@ from argon2.exceptions import VerifyMismatchError
from .db import connect_db, init_db, query_all, query_one
from .rate_limit import RateLimiter
from .sync import process_webhook, get_categories_cache_path, get_repo_dir
from .stats import (
refresh_tool_stats, get_tool_stats, refresh_publisher_stats,
get_publisher_stats, track_tool_usage, calculate_badges, BADGES,
get_badge_info, format_count,
)
MAX_BODY_BYTES = 512 * 1024
MAX_CONFIG_BYTES = 64 * 1024
@ -40,6 +45,9 @@ RATE_LIMITS = {
"login_failed": {"limit": 5, "window": 900},
"tokens": {"limit": 10, "window": 3600},
"publish": {"limit": 20, "window": 3600},
"review": {"limit": 10, "window": 3600},
"issue": {"limit": 20, "window": 3600},
"vote": {"limit": 100, "window": 3600},
}
ALLOWED_SORT = {
@ -2924,6 +2932,912 @@ def create_app() -> Flask:
"meta": paginate(page, per_page, total),
})
# ─── Reviews & Ratings API ────────────────────────────────────────────────────
@app.route("/api/v1/tools/<owner>/<name>/reviews", methods=["POST"])
@require_token
def submit_review(owner: str, name: str) -> Response:
"""Submit a review for a tool. One review per user per tool."""
if not OWNER_RE.match(owner) or not TOOL_NAME_RE.match(name):
return error_response("VALIDATION_ERROR", "Invalid owner or tool name")
# Find the tool
tool = query_one(
g.db,
"SELECT id, publisher_id FROM tools WHERE owner = ? AND name = ? ORDER BY id DESC LIMIT 1",
[owner, name],
)
if not tool:
return error_response("TOOL_NOT_FOUND", f"Tool '{owner}/{name}' not found", 404)
# Can't review your own tool
if tool["publisher_id"] == g.current_publisher["id"]:
return error_response("VALIDATION_ERROR", "You cannot review your own tool", 400)
# Check rate limit
rate_resp = enforce_token_rate_limit("review", g.current_token["hash"])
if rate_resp:
return rate_resp
data = request.get_json() or {}
rating = data.get("rating")
title = (data.get("title") or "").strip()[:100]
content = (data.get("content") or "").strip()[:2000]
if not isinstance(rating, int) or rating < 1 or rating > 5:
return error_response("VALIDATION_ERROR", "Rating must be an integer from 1 to 5")
# Check for minimum content length (spam prevention)
if content and len(content) < 10:
return error_response("VALIDATION_ERROR", "Review content must be at least 10 characters")
# Check if user already reviewed this tool
existing = query_one(
g.db,
"SELECT id FROM reviews WHERE tool_id = ? AND reviewer_id = ?",
[tool["id"], g.current_publisher["id"]],
)
if existing:
return error_response(
"ALREADY_REVIEWED",
"You have already reviewed this tool. Use PUT to update.",
409,
)
now = datetime.utcnow().isoformat()
g.db.execute(
"""
INSERT INTO reviews (tool_id, reviewer_id, rating, title, content, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
[tool["id"], g.current_publisher["id"], rating, title or None, content or None, now, now],
)
review_id = g.db.execute("SELECT last_insert_rowid()").fetchone()[0]
g.db.commit()
# Refresh tool stats
refresh_tool_stats(g.db, tool["id"])
response = jsonify({
"data": {
"id": review_id,
"rating": rating,
"title": title,
"content": content,
"created_at": now,
}
})
response.status_code = 201
return response
@app.route("/api/v1/tools/<owner>/<name>/reviews", methods=["GET"])
def list_reviews(owner: str, name: str) -> Response:
"""List reviews for a tool with pagination."""
if not OWNER_RE.match(owner) or not TOOL_NAME_RE.match(name):
return error_response("VALIDATION_ERROR", "Invalid owner or tool name")
tool = query_one(
g.db,
"SELECT id FROM tools WHERE owner = ? AND name = ? ORDER BY id DESC LIMIT 1",
[owner, name],
)
if not tool:
return error_response("TOOL_NOT_FOUND", f"Tool '{owner}/{name}' not found", 404)
page = request.args.get("page", 1, type=int)
per_page = min(request.args.get("per_page", 20, type=int), 100)
sort = request.args.get("sort", "recent") # recent, helpful, highest, lowest
offset = (page - 1) * per_page
if sort == "helpful":
order_sql = "helpful_count DESC, created_at DESC"
elif sort == "highest":
order_sql = "rating DESC, created_at DESC"
elif sort == "lowest":
order_sql = "rating ASC, created_at DESC"
else:
order_sql = "created_at DESC"
rows = query_all(
g.db,
f"""
SELECT r.*, p.slug as reviewer_slug, p.display_name as reviewer_name
FROM reviews r
LEFT JOIN publishers p ON r.reviewer_id = p.id
WHERE r.tool_id = ? AND r.status = 'published'
ORDER BY {order_sql}
LIMIT ? OFFSET ?
""",
[tool["id"], per_page, offset],
)
count_row = query_one(
g.db,
"SELECT COUNT(*) as total FROM reviews WHERE tool_id = ? AND status = 'published'",
[tool["id"]],
)
total = count_row["total"] if count_row else 0
data = []
for row in rows:
data.append({
"id": row["id"],
"rating": row["rating"],
"title": row["title"],
"content": row["content"],
"reviewer_slug": row["reviewer_slug"],
"reviewer_name": row["reviewer_name"] or "Anonymous",
"helpful_count": row["helpful_count"],
"unhelpful_count": row["unhelpful_count"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
})
return jsonify({
"data": data,
"meta": paginate(page, per_page, total),
})
@app.route("/api/v1/tools/<owner>/<name>/rating", methods=["GET"])
def get_tool_rating(owner: str, name: str) -> Response:
"""Get rating summary for a tool."""
if not OWNER_RE.match(owner) or not TOOL_NAME_RE.match(name):
return error_response("VALIDATION_ERROR", "Invalid owner or tool name")
tool = query_one(
g.db,
"SELECT id FROM tools WHERE owner = ? AND name = ? ORDER BY id DESC LIMIT 1",
[owner, name],
)
if not tool:
return error_response("TOOL_NOT_FOUND", f"Tool '{owner}/{name}' not found", 404)
stats = get_tool_stats(g.db, tool["id"])
if not stats:
stats = {
"average_rating": 0,
"rating_count": 0,
"rating_1": 0,
"rating_2": 0,
"rating_3": 0,
"rating_4": 0,
"rating_5": 0,
"unique_users": 0,
"open_issues": 0,
"security_issues": 0,
}
return jsonify({
"data": {
"average_rating": stats["average_rating"],
"rating_count": stats["rating_count"],
"distribution": {
"1": stats["rating_1"],
"2": stats["rating_2"],
"3": stats["rating_3"],
"4": stats["rating_4"],
"5": stats["rating_5"],
},
"unique_users": stats["unique_users"],
"open_issues": stats["open_issues"],
"security_issues": stats["security_issues"],
}
})
@app.route("/api/v1/reviews/<int:review_id>", methods=["PUT"])
@require_token
def update_review(review_id: int) -> Response:
"""Update an existing review (owner only)."""
review = query_one(g.db, "SELECT * FROM reviews WHERE id = ?", [review_id])
if not review:
return error_response("REVIEW_NOT_FOUND", "Review not found", 404)
if review["reviewer_id"] != g.current_publisher["id"]:
return error_response("FORBIDDEN", "You can only edit your own reviews", 403)
data = request.get_json() or {}
rating = data.get("rating")
title = data.get("title")
content = data.get("content")
updates = []
params = []
if rating is not None:
if not isinstance(rating, int) or rating < 1 or rating > 5:
return error_response("VALIDATION_ERROR", "Rating must be 1-5")
updates.append("rating = ?")
params.append(rating)
if title is not None:
updates.append("title = ?")
params.append(title.strip()[:100] if title else None)
if content is not None:
content = content.strip()[:2000]
if content and len(content) < 10:
return error_response("VALIDATION_ERROR", "Review content must be at least 10 characters")
updates.append("content = ?")
params.append(content if content else None)
if not updates:
return error_response("VALIDATION_ERROR", "No fields to update")
updates.append("updated_at = ?")
params.append(datetime.utcnow().isoformat())
params.append(review_id)
g.db.execute(f"UPDATE reviews SET {', '.join(updates)} WHERE id = ?", params)
g.db.commit()
# Refresh tool stats
refresh_tool_stats(g.db, review["tool_id"])
return jsonify({"data": {"status": "updated", "review_id": review_id}})
@app.route("/api/v1/reviews/<int:review_id>", methods=["DELETE"])
@require_token
def delete_review(review_id: int) -> Response:
"""Delete a review (owner only)."""
review = query_one(g.db, "SELECT * FROM reviews WHERE id = ?", [review_id])
if not review:
return error_response("REVIEW_NOT_FOUND", "Review not found", 404)
if review["reviewer_id"] != g.current_publisher["id"]:
return error_response("FORBIDDEN", "You can only delete your own reviews", 403)
tool_id = review["tool_id"]
g.db.execute("DELETE FROM reviews WHERE id = ?", [review_id])
g.db.commit()
# Refresh tool stats
refresh_tool_stats(g.db, tool_id)
return jsonify({"data": {"status": "deleted", "review_id": review_id}})
@app.route("/api/v1/reviews/<int:review_id>/vote", methods=["POST"])
def vote_review(review_id: int) -> Response:
"""Vote a review as helpful or unhelpful."""
review = query_one(g.db, "SELECT * FROM reviews WHERE id = ?", [review_id])
if not review:
return error_response("REVIEW_NOT_FOUND", "Review not found", 404)
data = request.get_json() or {}
vote_type = data.get("vote")
if vote_type not in ("helpful", "unhelpful"):
return error_response("VALIDATION_ERROR", "Vote must be 'helpful' or 'unhelpful'")
# Use publisher ID if logged in, otherwise hash IP
if hasattr(g, "current_publisher") and g.current_publisher:
voter_id = f"pub:{g.current_publisher['id']}"
else:
voter_id = f"ip:{hashlib.sha256((request.remote_addr or 'unknown').encode()).hexdigest()[:16]}"
# Check for existing vote
existing = query_one(
g.db,
"SELECT vote_type FROM review_votes WHERE review_id = ? AND voter_id = ?",
[review_id, voter_id],
)
if existing:
if existing["vote_type"] == vote_type:
# Remove vote (toggle off)
g.db.execute("DELETE FROM review_votes WHERE review_id = ? AND voter_id = ?", [review_id, voter_id])
# Decrement count
if vote_type == "helpful":
g.db.execute("UPDATE reviews SET helpful_count = helpful_count - 1 WHERE id = ?", [review_id])
else:
g.db.execute("UPDATE reviews SET unhelpful_count = unhelpful_count - 1 WHERE id = ?", [review_id])
g.db.commit()
return jsonify({"data": {"status": "removed", "vote": None}})
else:
# Change vote
g.db.execute(
"UPDATE review_votes SET vote_type = ?, created_at = ? WHERE review_id = ? AND voter_id = ?",
[vote_type, datetime.utcnow().isoformat(), review_id, voter_id],
)
# Adjust counts
if vote_type == "helpful":
g.db.execute(
"UPDATE reviews SET helpful_count = helpful_count + 1, unhelpful_count = unhelpful_count - 1 WHERE id = ?",
[review_id],
)
else:
g.db.execute(
"UPDATE reviews SET helpful_count = helpful_count - 1, unhelpful_count = unhelpful_count + 1 WHERE id = ?",
[review_id],
)
g.db.commit()
return jsonify({"data": {"status": "changed", "vote": vote_type}})
else:
# New vote
g.db.execute(
"INSERT INTO review_votes (review_id, voter_id, vote_type, created_at) VALUES (?, ?, ?, ?)",
[review_id, voter_id, vote_type, datetime.utcnow().isoformat()],
)
# Increment count
if vote_type == "helpful":
g.db.execute("UPDATE reviews SET helpful_count = helpful_count + 1 WHERE id = ?", [review_id])
else:
g.db.execute("UPDATE reviews SET unhelpful_count = unhelpful_count + 1 WHERE id = ?", [review_id])
g.db.commit()
return jsonify({"data": {"status": "added", "vote": vote_type}})
@app.route("/api/v1/reviews/<int:review_id>/flag", methods=["POST"])
def flag_review(review_id: int) -> Response:
"""Flag a review as inappropriate."""
review = query_one(g.db, "SELECT * FROM reviews WHERE id = ?", [review_id])
if not review:
return error_response("REVIEW_NOT_FOUND", "Review not found", 404)
data = request.get_json() or {}
reason = (data.get("reason") or "").strip()[:200]
if not reason:
return error_response("VALIDATION_ERROR", "Reason is required")
# Update review status to flagged
g.db.execute(
"UPDATE reviews SET status = 'flagged', updated_at = ? WHERE id = ?",
[datetime.utcnow().isoformat(), review_id],
)
g.db.commit()
# Log the flag action
log_audit("flag_review", "review", str(review_id), {"reason": reason})
return jsonify({"data": {"status": "flagged", "review_id": review_id}})
# ─── Issues API ───────────────────────────────────────────────────────────────
@app.route("/api/v1/tools/<owner>/<name>/issues", methods=["POST"])
def submit_issue(owner: str, name: str) -> Response:
"""Report an issue for a tool. Auth optional for security reports."""
if not OWNER_RE.match(owner) or not TOOL_NAME_RE.match(name):
return error_response("VALIDATION_ERROR", "Invalid owner or tool name")
tool = query_one(
g.db,
"SELECT id FROM tools WHERE owner = ? AND name = ? ORDER BY id DESC LIMIT 1",
[owner, name],
)
if not tool:
return error_response("TOOL_NOT_FOUND", f"Tool '{owner}/{name}' not found", 404)
data = request.get_json() or {}
issue_type = data.get("issue_type", "bug")
severity = data.get("severity", "medium")
title = (data.get("title") or "").strip()[:200]
description = (data.get("description") or "").strip()[:5000]
if issue_type not in ("bug", "security", "compatibility"):
return error_response("VALIDATION_ERROR", "issue_type must be: bug, security, compatibility")
if severity not in ("low", "medium", "high", "critical"):
return error_response("VALIDATION_ERROR", "severity must be: low, medium, high, critical")
if not title:
return error_response("VALIDATION_ERROR", "Title is required")
# Rate limit by IP
ip = request.remote_addr or "unknown"
limit_config = RATE_LIMITS["issue"]
allowed, _ = rate_limiter.check(f"issue:{ip}", limit_config["limit"], limit_config["window"])
if not allowed:
return error_response("RATE_LIMITED", "Too many issue reports. Try again later.", 429)
# Get reporter if authenticated
reporter_id = None
user_slug, _ = get_current_user_context()
if user_slug:
pub = query_one(g.db, "SELECT id FROM publishers WHERE slug = ?", [user_slug])
if pub:
reporter_id = pub["id"]
now = datetime.utcnow().isoformat()
g.db.execute(
"""
INSERT INTO tool_issues (tool_id, reporter_id, issue_type, severity, title, description, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
[tool["id"], reporter_id, issue_type, severity, title, description or None, now, now],
)
issue_id = g.db.execute("SELECT last_insert_rowid()").fetchone()[0]
g.db.commit()
# Refresh tool stats
refresh_tool_stats(g.db, tool["id"])
response = jsonify({
"data": {
"id": issue_id,
"issue_type": issue_type,
"severity": severity,
"title": title,
"status": "open",
"created_at": now,
}
})
response.status_code = 201
return response
@app.route("/api/v1/tools/<owner>/<name>/issues", methods=["GET"])
def list_issues(owner: str, name: str) -> Response:
"""List issues for a tool."""
if not OWNER_RE.match(owner) or not TOOL_NAME_RE.match(name):
return error_response("VALIDATION_ERROR", "Invalid owner or tool name")
tool = query_one(
g.db,
"SELECT id FROM tools WHERE owner = ? AND name = ? ORDER BY id DESC LIMIT 1",
[owner, name],
)
if not tool:
return error_response("TOOL_NOT_FOUND", f"Tool '{owner}/{name}' not found", 404)
page = request.args.get("page", 1, type=int)
per_page = min(request.args.get("per_page", 20, type=int), 100)
status_filter = request.args.get("status") # open, confirmed, fixed, wontfix, duplicate
type_filter = request.args.get("type") # bug, security, compatibility
offset = (page - 1) * per_page
where_clauses = ["tool_id = ?"]
params: List[Any] = [tool["id"]]
if status_filter:
where_clauses.append("status = ?")
params.append(status_filter)
if type_filter:
where_clauses.append("issue_type = ?")
params.append(type_filter)
where_sql = "WHERE " + " AND ".join(where_clauses)
rows = query_all(
g.db,
f"""
SELECT i.*, p.display_name as reporter_name
FROM tool_issues i
LEFT JOIN publishers p ON i.reporter_id = p.id
{where_sql}
ORDER BY
CASE severity WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 ELSE 4 END,
created_at DESC
LIMIT ? OFFSET ?
""",
params + [per_page, offset],
)
count_row = query_one(
g.db, f"SELECT COUNT(*) as total FROM tool_issues {where_sql}", params
)
total = count_row["total"] if count_row else 0
data = []
for row in rows:
data.append({
"id": row["id"],
"issue_type": row["issue_type"],
"severity": row["severity"],
"title": row["title"],
"status": row["status"],
"reporter_name": row["reporter_name"] or "Anonymous",
"created_at": row["created_at"],
"resolved_at": row["resolved_at"],
})
return jsonify({
"data": data,
"meta": paginate(page, per_page, total),
})
@app.route("/api/v1/issues/<int:issue_id>", methods=["GET"])
def get_issue(issue_id: int) -> Response:
"""Get issue details."""
row = query_one(
g.db,
"""
SELECT i.*, p.display_name as reporter_name, r.display_name as resolver_name,
t.owner, t.name as tool_name
FROM tool_issues i
LEFT JOIN publishers p ON i.reporter_id = p.id
LEFT JOIN publishers r ON i.resolved_by = r.id
JOIN tools t ON i.tool_id = t.id
WHERE i.id = ?
""",
[issue_id],
)
if not row:
return error_response("ISSUE_NOT_FOUND", "Issue not found", 404)
return jsonify({
"data": {
"id": row["id"],
"tool": f"{row['owner']}/{row['tool_name']}",
"issue_type": row["issue_type"],
"severity": row["severity"],
"title": row["title"],
"description": row["description"],
"status": row["status"],
"reporter_name": row["reporter_name"] or "Anonymous",
"resolver_name": row["resolver_name"],
"resolution_note": row["resolution_note"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
"resolved_at": row["resolved_at"],
}
})
@app.route("/api/v1/issues/<int:issue_id>", methods=["PUT"])
@require_token
def update_issue(issue_id: int) -> Response:
"""Update an issue (reporter or tool owner only)."""
issue = query_one(
g.db,
"""
SELECT i.*, t.owner as tool_owner
FROM tool_issues i
JOIN tools t ON i.tool_id = t.id
WHERE i.id = ?
""",
[issue_id],
)
if not issue:
return error_response("ISSUE_NOT_FOUND", "Issue not found", 404)
# Check permissions: reporter or tool owner
is_reporter = issue["reporter_id"] == g.current_publisher["id"]
is_owner = issue["tool_owner"] == g.current_publisher["slug"]
is_mod = g.current_publisher["role"] in ("moderator", "admin")
if not (is_reporter or is_owner or is_mod):
return error_response("FORBIDDEN", "You don't have permission to update this issue", 403)
data = request.get_json() or {}
updates = []
params = []
# Reporter can update title and description
if is_reporter and "title" in data:
updates.append("title = ?")
params.append(data["title"].strip()[:200])
if is_reporter and "description" in data:
updates.append("description = ?")
params.append(data["description"].strip()[:5000])
# Owner/mod can update status and severity
if (is_owner or is_mod) and "severity" in data:
severity = data["severity"]
if severity in ("low", "medium", "high", "critical"):
updates.append("severity = ?")
params.append(severity)
if (is_owner or is_mod) and "status" in data:
status = data["status"]
if status in ("open", "confirmed"):
updates.append("status = ?")
params.append(status)
if not updates:
return error_response("VALIDATION_ERROR", "No valid fields to update")
updates.append("updated_at = ?")
params.append(datetime.utcnow().isoformat())
params.append(issue_id)
g.db.execute(f"UPDATE tool_issues SET {', '.join(updates)} WHERE id = ?", params)
g.db.commit()
return jsonify({"data": {"status": "updated", "issue_id": issue_id}})
@app.route("/api/v1/issues/<int:issue_id>/resolve", methods=["POST"])
@require_token
def resolve_issue(issue_id: int) -> Response:
"""Resolve an issue (tool owner or moderator)."""
issue = query_one(
g.db,
"""
SELECT i.*, t.owner as tool_owner, t.id as tool_id
FROM tool_issues i
JOIN tools t ON i.tool_id = t.id
WHERE i.id = ?
""",
[issue_id],
)
if not issue:
return error_response("ISSUE_NOT_FOUND", "Issue not found", 404)
is_owner = issue["tool_owner"] == g.current_publisher["slug"]
is_mod = g.current_publisher["role"] in ("moderator", "admin")
if not (is_owner or is_mod):
return error_response("FORBIDDEN", "Only tool owner or moderators can resolve issues", 403)
data = request.get_json() or {}
resolution = data.get("resolution", "fixed")
note = (data.get("note") or "").strip()[:500]
if resolution not in ("fixed", "wontfix", "duplicate"):
return error_response("VALIDATION_ERROR", "resolution must be: fixed, wontfix, duplicate")
now = datetime.utcnow().isoformat()
g.db.execute(
"""
UPDATE tool_issues
SET status = ?, resolved_by = ?, resolved_at = ?, resolution_note = ?, updated_at = ?
WHERE id = ?
""",
[resolution, g.current_publisher["id"], now, note or None, now, issue_id],
)
g.db.commit()
# Refresh tool stats
refresh_tool_stats(g.db, issue["tool_id"])
return jsonify({
"data": {
"status": resolution,
"issue_id": issue_id,
"resolved_at": now,
}
})
# ─── Publisher Stats API ──────────────────────────────────────────────────────
@app.route("/api/v1/publishers/<slug>/stats", methods=["GET"])
def get_publisher_stats_endpoint(slug: str) -> Response:
"""Get publisher reputation stats and badges."""
publisher = query_one(
g.db,
"SELECT id, slug, display_name, verified, created_at FROM publishers WHERE slug = ?",
[slug],
)
if not publisher:
return error_response("PUBLISHER_NOT_FOUND", "Publisher not found", 404)
stats = get_publisher_stats(g.db, publisher["id"])
if not stats:
stats = {
"tool_count": 0,
"total_downloads": 0,
"average_rating": 0,
"total_reviews": 0,
"trust_score": 0,
"badges": [],
}
# Get badge details
badge_details = []
for badge_id in stats.get("badges", []):
info = get_badge_info(badge_id)
if info:
badge_details.append({
"id": badge_id,
"name": info["name"],
"icon": info["icon"],
"color": info["color"],
"description": info["description"],
})
return jsonify({
"data": {
"slug": publisher["slug"],
"display_name": publisher["display_name"],
"verified": bool(publisher["verified"]),
"member_since": publisher["created_at"],
"tool_count": stats["tool_count"],
"total_downloads": stats["total_downloads"],
"total_downloads_formatted": format_count(stats["total_downloads"]),
"average_rating": stats["average_rating"],
"total_reviews": stats["total_reviews"],
"trust_score": stats["trust_score"],
"badges": badge_details,
}
})
@app.route("/api/v1/publishers/<slug>/reviews", methods=["GET"])
def list_publisher_reviews(slug: str) -> Response:
"""List all reviews across publisher's tools."""
publisher = query_one(g.db, "SELECT id FROM publishers WHERE slug = ?", [slug])
if not publisher:
return error_response("PUBLISHER_NOT_FOUND", "Publisher not found", 404)
page = request.args.get("page", 1, type=int)
per_page = min(request.args.get("per_page", 20, type=int), 100)
offset = (page - 1) * per_page
rows = query_all(
g.db,
"""
SELECT r.*, t.owner, t.name as tool_name, p.display_name as reviewer_name
FROM reviews r
JOIN tools t ON r.tool_id = t.id
LEFT JOIN publishers p ON r.reviewer_id = p.id
WHERE t.publisher_id = ? AND r.status = 'published'
ORDER BY r.created_at DESC
LIMIT ? OFFSET ?
""",
[publisher["id"], per_page, offset],
)
count_row = query_one(
g.db,
"""
SELECT COUNT(*) as total FROM reviews r
JOIN tools t ON r.tool_id = t.id
WHERE t.publisher_id = ? AND r.status = 'published'
""",
[publisher["id"]],
)
total = count_row["total"] if count_row else 0
data = []
for row in rows:
data.append({
"id": row["id"],
"tool": f"{row['owner']}/{row['tool_name']}",
"rating": row["rating"],
"title": row["title"],
"content": row["content"],
"reviewer_name": row["reviewer_name"] or "Anonymous",
"created_at": row["created_at"],
})
return jsonify({
"data": data,
"meta": paginate(page, per_page, total),
})
# ─── Admin Reviews & Issues API ───────────────────────────────────────────────
@app.route("/api/v1/admin/reviews", methods=["GET"])
@require_moderator
def admin_list_reviews() -> Response:
"""List reviews pending moderation (flagged)."""
page = request.args.get("page", 1, type=int)
per_page = min(request.args.get("per_page", 20, type=int), 100)
status_filter = request.args.get("status", "flagged")
offset = (page - 1) * per_page
rows = query_all(
g.db,
"""
SELECT r.*, t.owner, t.name as tool_name, p.display_name as reviewer_name
FROM reviews r
JOIN tools t ON r.tool_id = t.id
LEFT JOIN publishers p ON r.reviewer_id = p.id
WHERE r.status = ?
ORDER BY r.updated_at DESC
LIMIT ? OFFSET ?
""",
[status_filter, per_page, offset],
)
count_row = query_one(
g.db, "SELECT COUNT(*) as total FROM reviews WHERE status = ?", [status_filter]
)
total = count_row["total"] if count_row else 0
data = []
for row in rows:
data.append({
"id": row["id"],
"tool": f"{row['owner']}/{row['tool_name']}",
"rating": row["rating"],
"title": row["title"],
"content": row["content"],
"reviewer_name": row["reviewer_name"] or "Anonymous",
"status": row["status"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
})
return jsonify({
"data": data,
"meta": paginate(page, per_page, total),
})
@app.route("/api/v1/admin/reviews/<int:review_id>/hide", methods=["POST"])
@require_moderator
def admin_hide_review(review_id: int) -> Response:
"""Hide a review (moderation action)."""
review = query_one(g.db, "SELECT * FROM reviews WHERE id = ?", [review_id])
if not review:
return error_response("REVIEW_NOT_FOUND", "Review not found", 404)
g.db.execute(
"UPDATE reviews SET status = 'hidden', updated_at = ? WHERE id = ?",
[datetime.utcnow().isoformat(), review_id],
)
g.db.commit()
# Refresh tool stats
refresh_tool_stats(g.db, review["tool_id"])
log_audit("hide_review", "review", str(review_id), {"previous_status": review["status"]})
return jsonify({"data": {"status": "hidden", "review_id": review_id}})
@app.route("/api/v1/admin/reviews/<int:review_id>/restore", methods=["POST"])
@require_moderator
def admin_restore_review(review_id: int) -> Response:
"""Restore a hidden review."""
review = query_one(g.db, "SELECT * FROM reviews WHERE id = ?", [review_id])
if not review:
return error_response("REVIEW_NOT_FOUND", "Review not found", 404)
g.db.execute(
"UPDATE reviews SET status = 'published', updated_at = ? WHERE id = ?",
[datetime.utcnow().isoformat(), review_id],
)
g.db.commit()
# Refresh tool stats
refresh_tool_stats(g.db, review["tool_id"])
log_audit("restore_review", "review", str(review_id), {"previous_status": review["status"]})
return jsonify({"data": {"status": "published", "review_id": review_id}})
@app.route("/api/v1/admin/issues", methods=["GET"])
@require_moderator
def admin_list_issues() -> Response:
"""List all issues across tools."""
page = request.args.get("page", 1, type=int)
per_page = min(request.args.get("per_page", 20, type=int), 100)
status_filter = request.args.get("status")
type_filter = request.args.get("type")
offset = (page - 1) * per_page
where_clauses = []
params: List[Any] = []
if status_filter:
where_clauses.append("i.status = ?")
params.append(status_filter)
if type_filter:
where_clauses.append("i.issue_type = ?")
params.append(type_filter)
where_sql = "WHERE " + " AND ".join(where_clauses) if where_clauses else ""
rows = query_all(
g.db,
f"""
SELECT i.*, t.owner, t.name as tool_name, p.display_name as reporter_name
FROM tool_issues i
JOIN tools t ON i.tool_id = t.id
LEFT JOIN publishers p ON i.reporter_id = p.id
{where_sql}
ORDER BY
CASE i.severity WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 ELSE 4 END,
i.created_at DESC
LIMIT ? OFFSET ?
""",
params + [per_page, offset],
)
count_row = query_one(
g.db, f"SELECT COUNT(*) as total FROM tool_issues i {where_sql}", params
)
total = count_row["total"] if count_row else 0
data = []
for row in rows:
data.append({
"id": row["id"],
"tool": f"{row['owner']}/{row['tool_name']}",
"issue_type": row["issue_type"],
"severity": row["severity"],
"title": row["title"],
"status": row["status"],
"reporter_name": row["reporter_name"] or "Anonymous",
"created_at": row["created_at"],
})
return jsonify({
"data": data,
"meta": paginate(page, per_page, total),
})
return app

View File

@ -292,6 +292,97 @@ CREATE TABLE IF NOT EXISTS collections (
CREATE INDEX IF NOT EXISTS idx_collections_name ON collections(name);
CREATE INDEX IF NOT EXISTS idx_collections_maintainer ON collections(maintainer);
-- Reviews and Ratings System
CREATE TABLE IF NOT EXISTS reviews (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tool_id INTEGER NOT NULL REFERENCES tools(id) ON DELETE CASCADE,
reviewer_id INTEGER NOT NULL REFERENCES publishers(id),
rating INTEGER NOT NULL CHECK(rating >= 1 AND rating <= 5),
title TEXT,
content TEXT,
helpful_count INTEGER DEFAULT 0,
unhelpful_count INTEGER DEFAULT 0,
status TEXT DEFAULT 'published',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(tool_id, reviewer_id)
);
CREATE INDEX IF NOT EXISTS idx_reviews_tool ON reviews(tool_id, status);
CREATE INDEX IF NOT EXISTS idx_reviews_reviewer ON reviews(reviewer_id);
CREATE TABLE IF NOT EXISTS review_votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
review_id INTEGER NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
voter_id TEXT NOT NULL,
vote_type TEXT NOT NULL CHECK(vote_type IN ('helpful', 'unhelpful')),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(review_id, voter_id)
);
CREATE INDEX IF NOT EXISTS idx_review_votes_review ON review_votes(review_id);
-- Issue Tracking (bugs, security, compatibility)
CREATE TABLE IF NOT EXISTS tool_issues (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tool_id INTEGER NOT NULL REFERENCES tools(id) ON DELETE CASCADE,
reporter_id INTEGER REFERENCES publishers(id),
issue_type TEXT NOT NULL CHECK(issue_type IN ('bug', 'security', 'compatibility')),
severity TEXT DEFAULT 'medium' CHECK(severity IN ('low', 'medium', 'high', 'critical')),
title TEXT NOT NULL,
description TEXT,
status TEXT DEFAULT 'open' CHECK(status IN ('open', 'confirmed', 'fixed', 'wontfix', 'duplicate')),
resolved_by INTEGER REFERENCES publishers(id),
resolved_at TIMESTAMP,
resolution_note TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_issues_tool ON tool_issues(tool_id, status);
CREATE INDEX IF NOT EXISTS idx_issues_type ON tool_issues(issue_type, severity);
-- Tool Stats Cache (for fast queries)
CREATE TABLE IF NOT EXISTS tool_stats (
tool_id INTEGER PRIMARY KEY REFERENCES tools(id) ON DELETE CASCADE,
average_rating REAL DEFAULT 0,
rating_count INTEGER DEFAULT 0,
rating_1 INTEGER DEFAULT 0,
rating_2 INTEGER DEFAULT 0,
rating_3 INTEGER DEFAULT 0,
rating_4 INTEGER DEFAULT 0,
rating_5 INTEGER DEFAULT 0,
unique_users INTEGER DEFAULT 0,
open_issues INTEGER DEFAULT 0,
security_issues INTEGER DEFAULT 0,
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Publisher Stats Cache (for fast queries)
CREATE TABLE IF NOT EXISTS publisher_stats (
publisher_id INTEGER PRIMARY KEY REFERENCES publishers(id) ON DELETE CASCADE,
tool_count INTEGER DEFAULT 0,
total_downloads INTEGER DEFAULT 0,
average_rating REAL DEFAULT 0,
total_reviews INTEGER DEFAULT 0,
trust_score REAL DEFAULT 0,
badges TEXT,
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Tool Usage Tracking (unique users)
CREATE TABLE IF NOT EXISTS tool_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tool_id INTEGER NOT NULL REFERENCES tools(id) ON DELETE CASCADE,
user_hash TEXT NOT NULL,
first_used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
use_count INTEGER DEFAULT 1,
UNIQUE(tool_id, user_hash)
);
CREATE INDEX IF NOT EXISTS idx_usage_tool ON tool_usage(tool_id);
"""

View File

@ -0,0 +1,463 @@
"""Stats calculation functions for tools and publishers."""
from __future__ import annotations
import json
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Any
import sqlite3
# Badge definitions
BADGES = {
"verified": {
"name": "Verified",
"icon": "shield-check",
"color": "blue",
"description": "Verified publisher identity",
},
"trusted": {
"name": "Trusted",
"icon": "award",
"color": "gold",
"description": "Trust score above 80",
},
"prolific": {
"name": "Prolific",
"icon": "layers",
"color": "purple",
"description": "Published 10+ tools",
},
"veteran": {
"name": "Veteran",
"icon": "clock",
"color": "gray",
"description": "Member for 1+ year",
},
"popular": {
"name": "Popular",
"icon": "trending-up",
"color": "green",
"description": "1000+ total downloads",
},
"responsive": {
"name": "Responsive",
"icon": "message-circle",
"color": "cyan",
"description": "Resolves 90%+ of issues",
},
"top_rated": {
"name": "Top Rated",
"icon": "star",
"color": "yellow",
"description": "Average rating 4.5+",
},
}
def refresh_tool_stats(conn: sqlite3.Connection, tool_id: int) -> Dict[str, Any]:
"""Recalculate and cache stats for a tool.
Returns the updated stats dict.
"""
cursor = conn.cursor()
# Get rating stats
cursor.execute("""
SELECT
COUNT(*) as rating_count,
COALESCE(AVG(rating), 0) as average_rating,
SUM(CASE WHEN rating = 1 THEN 1 ELSE 0 END) as rating_1,
SUM(CASE WHEN rating = 2 THEN 1 ELSE 0 END) as rating_2,
SUM(CASE WHEN rating = 3 THEN 1 ELSE 0 END) as rating_3,
SUM(CASE WHEN rating = 4 THEN 1 ELSE 0 END) as rating_4,
SUM(CASE WHEN rating = 5 THEN 1 ELSE 0 END) as rating_5
FROM reviews
WHERE tool_id = ? AND status = 'published'
""", [tool_id])
rating_row = cursor.fetchone()
# Get unique users count
cursor.execute("""
SELECT COUNT(DISTINCT user_hash) as unique_users
FROM tool_usage
WHERE tool_id = ?
""", [tool_id])
usage_row = cursor.fetchone()
# Get issue counts
cursor.execute("""
SELECT
SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END) as open_issues,
SUM(CASE WHEN status = 'open' AND issue_type = 'security' THEN 1 ELSE 0 END) as security_issues
FROM tool_issues
WHERE tool_id = ?
""", [tool_id])
issues_row = cursor.fetchone()
stats = {
"tool_id": tool_id,
"average_rating": round(rating_row[1], 2) if rating_row else 0,
"rating_count": rating_row[0] if rating_row else 0,
"rating_1": rating_row[2] or 0 if rating_row else 0,
"rating_2": rating_row[3] or 0 if rating_row else 0,
"rating_3": rating_row[4] or 0 if rating_row else 0,
"rating_4": rating_row[5] or 0 if rating_row else 0,
"rating_5": rating_row[6] or 0 if rating_row else 0,
"unique_users": usage_row[0] if usage_row else 0,
"open_issues": issues_row[0] or 0 if issues_row else 0,
"security_issues": issues_row[1] or 0 if issues_row else 0,
"last_updated": datetime.utcnow().isoformat(),
}
# Upsert into tool_stats
cursor.execute("""
INSERT INTO tool_stats (
tool_id, average_rating, rating_count,
rating_1, rating_2, rating_3, rating_4, rating_5,
unique_users, open_issues, security_issues, last_updated
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(tool_id) DO UPDATE SET
average_rating = excluded.average_rating,
rating_count = excluded.rating_count,
rating_1 = excluded.rating_1,
rating_2 = excluded.rating_2,
rating_3 = excluded.rating_3,
rating_4 = excluded.rating_4,
rating_5 = excluded.rating_5,
unique_users = excluded.unique_users,
open_issues = excluded.open_issues,
security_issues = excluded.security_issues,
last_updated = excluded.last_updated
""", [
tool_id, stats["average_rating"], stats["rating_count"],
stats["rating_1"], stats["rating_2"], stats["rating_3"],
stats["rating_4"], stats["rating_5"],
stats["unique_users"], stats["open_issues"], stats["security_issues"],
stats["last_updated"],
])
conn.commit()
return stats
def get_tool_stats(conn: sqlite3.Connection, tool_id: int) -> Optional[Dict[str, Any]]:
"""Get cached stats for a tool, refreshing if stale or missing."""
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM tool_stats WHERE tool_id = ?
""", [tool_id])
row = cursor.fetchone()
if not row:
# No cached stats, calculate fresh
return refresh_tool_stats(conn, tool_id)
# Check if stale (older than 1 hour)
try:
last_updated = datetime.fromisoformat(row[11]) # last_updated column
if datetime.utcnow() - last_updated > timedelta(hours=1):
return refresh_tool_stats(conn, tool_id)
except (ValueError, TypeError):
return refresh_tool_stats(conn, tool_id)
return {
"tool_id": row[0],
"average_rating": row[1],
"rating_count": row[2],
"rating_1": row[3],
"rating_2": row[4],
"rating_3": row[5],
"rating_4": row[6],
"rating_5": row[7],
"unique_users": row[8],
"open_issues": row[9],
"security_issues": row[10],
"last_updated": row[11],
}
def calculate_trust_score(
tool_count: int,
total_downloads: int,
average_rating: float,
total_reviews: int,
issues_resolved_pct: float,
tenure_days: int,
is_verified: bool,
) -> float:
"""Calculate publisher trust score (0-100).
Scoring breakdown:
- Tenure: max 20 points (5 points per year, max 4 years)
- Tools: max 20 points (2 points per tool, max 10 tools)
- Rating: max 30 points ((avg_rating / 5) * 30, requires min 3 reviews)
- Downloads: max 15 points (logarithmic scale)
- Issue response: max 10 points (resolved % * 10)
- Verified bonus: 5 points
"""
score = 0.0
# Tenure score (max 20)
years = min(tenure_days / 365, 4)
score += years * 5
# Tools contribution (max 20)
score += min(tool_count, 10) * 2
# Rating score (max 30, requires minimum reviews)
if total_reviews >= 3:
score += (average_rating / 5) * 30
elif total_reviews > 0:
# Partial credit for few reviews
score += (average_rating / 5) * 15
# Downloads score (max 15, logarithmic)
if total_downloads > 0:
import math
# log10(1000) = 3, so 1000 downloads = 15 points
download_score = min(math.log10(total_downloads + 1) * 5, 15)
score += download_score
# Issue response score (max 10)
score += issues_resolved_pct * 10
# Verified bonus (5 points)
if is_verified:
score += 5
return round(min(100, score), 1)
def calculate_badges(
is_verified: bool,
trust_score: float,
tool_count: int,
tenure_days: int,
total_downloads: int,
issues_resolved_pct: float,
average_rating: float,
total_reviews: int,
) -> List[str]:
"""Determine which badges a publisher has earned."""
earned = []
if is_verified:
earned.append("verified")
if trust_score >= 80:
earned.append("trusted")
if tool_count >= 10:
earned.append("prolific")
if tenure_days >= 365:
earned.append("veteran")
if total_downloads >= 1000:
earned.append("popular")
if issues_resolved_pct >= 0.9:
earned.append("responsive")
if average_rating >= 4.5 and total_reviews >= 5:
earned.append("top_rated")
return earned
def refresh_publisher_stats(conn: sqlite3.Connection, publisher_id: int) -> Dict[str, Any]:
"""Recalculate and cache stats for a publisher.
Returns the updated stats dict.
"""
cursor = conn.cursor()
# Get publisher info
cursor.execute("""
SELECT verified, created_at FROM publishers WHERE id = ?
""", [publisher_id])
pub_row = cursor.fetchone()
if not pub_row:
return {}
is_verified = bool(pub_row[0])
try:
created_at = datetime.fromisoformat(pub_row[1].replace("Z", "+00:00").replace("+00:00", ""))
tenure_days = (datetime.utcnow() - created_at).days
except (ValueError, TypeError, AttributeError):
tenure_days = 0
# Get tool stats
cursor.execute("""
SELECT
COUNT(DISTINCT t.id) as tool_count,
COALESCE(SUM(t.downloads), 0) as total_downloads
FROM tools t
WHERE t.publisher_id = ? AND t.moderation_status = 'approved'
""", [publisher_id])
tools_row = cursor.fetchone()
tool_count = tools_row[0] if tools_row else 0
total_downloads = tools_row[1] if tools_row else 0
# Get aggregate rating across all tools
cursor.execute("""
SELECT
COUNT(*) as total_reviews,
COALESCE(AVG(r.rating), 0) as average_rating
FROM reviews r
JOIN tools t ON r.tool_id = t.id
WHERE t.publisher_id = ? AND r.status = 'published'
""", [publisher_id])
reviews_row = cursor.fetchone()
total_reviews = reviews_row[0] if reviews_row else 0
average_rating = round(reviews_row[1], 2) if reviews_row else 0
# Get issue resolution rate
cursor.execute("""
SELECT
COUNT(*) as total_issues,
SUM(CASE WHEN i.status IN ('fixed', 'wontfix', 'duplicate') THEN 1 ELSE 0 END) as resolved_issues
FROM tool_issues i
JOIN tools t ON i.tool_id = t.id
WHERE t.publisher_id = ?
""", [publisher_id])
issues_row = cursor.fetchone()
total_issues = issues_row[0] if issues_row else 0
resolved_issues = issues_row[1] or 0 if issues_row else 0
issues_resolved_pct = resolved_issues / total_issues if total_issues > 0 else 1.0
# Calculate trust score
trust_score = calculate_trust_score(
tool_count=tool_count,
total_downloads=total_downloads,
average_rating=average_rating,
total_reviews=total_reviews,
issues_resolved_pct=issues_resolved_pct,
tenure_days=tenure_days,
is_verified=is_verified,
)
# Calculate badges
badges = calculate_badges(
is_verified=is_verified,
trust_score=trust_score,
tool_count=tool_count,
tenure_days=tenure_days,
total_downloads=total_downloads,
issues_resolved_pct=issues_resolved_pct,
average_rating=average_rating,
total_reviews=total_reviews,
)
stats = {
"publisher_id": publisher_id,
"tool_count": tool_count,
"total_downloads": total_downloads,
"average_rating": average_rating,
"total_reviews": total_reviews,
"trust_score": trust_score,
"badges": badges,
"tenure_days": tenure_days,
"is_verified": is_verified,
"last_updated": datetime.utcnow().isoformat(),
}
# Upsert into publisher_stats
cursor.execute("""
INSERT INTO publisher_stats (
publisher_id, tool_count, total_downloads, average_rating,
total_reviews, trust_score, badges, last_updated
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(publisher_id) DO UPDATE SET
tool_count = excluded.tool_count,
total_downloads = excluded.total_downloads,
average_rating = excluded.average_rating,
total_reviews = excluded.total_reviews,
trust_score = excluded.trust_score,
badges = excluded.badges,
last_updated = excluded.last_updated
""", [
publisher_id, tool_count, total_downloads, average_rating,
total_reviews, trust_score, json.dumps(badges),
stats["last_updated"],
])
conn.commit()
return stats
def get_publisher_stats(conn: sqlite3.Connection, publisher_id: int) -> Optional[Dict[str, Any]]:
"""Get cached stats for a publisher, refreshing if stale or missing."""
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM publisher_stats WHERE publisher_id = ?
""", [publisher_id])
row = cursor.fetchone()
if not row:
# No cached stats, calculate fresh
return refresh_publisher_stats(conn, publisher_id)
# Check if stale (older than 1 hour)
try:
last_updated = datetime.fromisoformat(row[7]) # last_updated column
if datetime.utcnow() - last_updated > timedelta(hours=1):
return refresh_publisher_stats(conn, publisher_id)
except (ValueError, TypeError):
return refresh_publisher_stats(conn, publisher_id)
badges = []
try:
badges = json.loads(row[6]) if row[6] else []
except (json.JSONDecodeError, TypeError):
pass
return {
"publisher_id": row[0],
"tool_count": row[1],
"total_downloads": row[2],
"average_rating": row[3],
"total_reviews": row[4],
"trust_score": row[5],
"badges": badges,
"last_updated": row[7],
}
def track_tool_usage(conn: sqlite3.Connection, tool_id: int, client_id: str) -> None:
"""Track a tool usage (download/run) for unique user counting."""
import hashlib
# Hash the client_id for privacy
user_hash = hashlib.sha256(client_id.encode()).hexdigest()[:16]
cursor = conn.cursor()
cursor.execute("""
INSERT INTO tool_usage (tool_id, user_hash, first_used_at, last_used_at, use_count)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1)
ON CONFLICT(tool_id, user_hash) DO UPDATE SET
last_used_at = CURRENT_TIMESTAMP,
use_count = use_count + 1
""", [tool_id, user_hash])
conn.commit()
def format_count(count: int) -> str:
"""Format a count for display (e.g., 1234 -> '1.2K')."""
if count < 1000:
return str(count)
elif count < 10000:
return f"{count / 1000:.1f}K"
elif count < 1000000:
return f"{count // 1000}K"
else:
return f"{count / 1000000:.1f}M"
def get_badge_info(badge_id: str) -> Optional[Dict[str, str]]:
"""Get display info for a badge."""
return BADGES.get(badge_id)

View File

@ -365,11 +365,31 @@ def tool_detail(owner: str, name: str):
publisher = _load_publisher(owner)
versions = _load_tool_versions(owner, name)
# Load rating summary
_, rating_payload = _api_get(f"/api/v1/tools/{owner}/{name}/rating")
rating = rating_payload.get("data", {})
# Load reviews (first page)
_, reviews_payload = _api_get(f"/api/v1/tools/{owner}/{name}/reviews", params={"per_page": 5})
reviews = reviews_payload.get("data", [])
reviews_meta = reviews_payload.get("meta", {})
# Load issues summary (open only)
_, issues_payload = _api_get(f"/api/v1/tools/{owner}/{name}/issues", params={"status": "open", "per_page": 5})
issues = issues_payload.get("data", [])
issues_meta = issues_payload.get("meta", {})
return render_template(
"pages/tool_detail.html",
tool=tool,
publisher=publisher,
versions=versions,
rating=rating,
reviews=reviews,
reviews_total=reviews_meta.get("total", 0),
issues=issues,
issues_total=issues_meta.get("total", 0),
)
@ -387,11 +407,31 @@ def tool_version(owner: str, name: str, version: str):
publisher = _load_publisher(owner)
versions = _load_tool_versions(owner, name)
# Load rating summary
_, rating_payload = _api_get(f"/api/v1/tools/{owner}/{name}/rating")
rating = rating_payload.get("data", {})
# Load reviews (first page)
_, reviews_payload = _api_get(f"/api/v1/tools/{owner}/{name}/reviews", params={"per_page": 5})
reviews = reviews_payload.get("data", [])
reviews_meta = reviews_payload.get("meta", {})
# Load issues summary (open only)
_, issues_payload = _api_get(f"/api/v1/tools/{owner}/{name}/issues", params={"status": "open", "per_page": 5})
issues = issues_payload.get("data", [])
issues_meta = issues_payload.get("meta", {})
return render_template(
"pages/tool_detail.html",
tool=tool,
publisher=publisher,
versions=versions,
rating=rating,
reviews=reviews,
reviews_total=reviews_meta.get("total", 0),
issues=issues,
issues_total=issues_meta.get("total", 0),
)
@ -401,10 +441,16 @@ def publisher(slug: str):
if not publisher_row:
return render_template("errors/404.html"), 404
tools = _load_publisher_tools(slug)
# Load publisher stats and badges
_, stats_payload = _api_get(f"/api/v1/publishers/{slug}/stats")
stats = stats_payload.get("data", {})
return render_template(
"pages/publisher.html",
publisher=publisher_row,
tools=tools,
stats=stats,
)
@ -415,6 +461,73 @@ def _publisher_alias(slug: str):
web_bp.add_url_rule("/publishers/<slug>", endpoint="publisher_profile", view_func=_publisher_alias)
@web_bp.route("/tools/<owner>/<name>/reviews", endpoint="tool_reviews")
def tool_reviews(owner: str, name: str):
"""Full reviews page for a tool."""
status, payload = _api_get(f"/api/v1/tools/{owner}/{name}")
if status == 404:
return render_template("errors/404.html"), 404
if status != 200:
return render_template("errors/500.html"), 500
tool = payload.get("data", {})
page = request.args.get("page", 1, type=int)
sort = request.args.get("sort", "recent")
_, reviews_payload = _api_get(
f"/api/v1/tools/{owner}/{name}/reviews",
params={"page": page, "per_page": 20, "sort": sort}
)
reviews = reviews_payload.get("data", [])
meta = reviews_payload.get("meta", {})
_, rating_payload = _api_get(f"/api/v1/tools/{owner}/{name}/rating")
rating = rating_payload.get("data", {})
return render_template(
"pages/tool_reviews.html",
tool=tool,
reviews=reviews,
rating=rating,
pagination=_build_pagination(meta),
current_sort=sort,
)
@web_bp.route("/tools/<owner>/<name>/issues", endpoint="tool_issues")
def tool_issues(owner: str, name: str):
"""Full issues page for a tool."""
status, payload = _api_get(f"/api/v1/tools/{owner}/{name}")
if status == 404:
return render_template("errors/404.html"), 404
if status != 200:
return render_template("errors/500.html"), 500
tool = payload.get("data", {})
page = request.args.get("page", 1, type=int)
status_filter = request.args.get("status")
type_filter = request.args.get("type")
params = {"page": page, "per_page": 20}
if status_filter:
params["status"] = status_filter
if type_filter:
params["type"] = type_filter
_, issues_payload = _api_get(f"/api/v1/tools/{owner}/{name}/issues", params=params)
issues = issues_payload.get("data", [])
meta = issues_payload.get("meta", {})
return render_template(
"pages/tool_issues.html",
tool=tool,
issues=issues,
pagination=_build_pagination(meta),
current_status=status_filter,
current_type=type_filter,
)
def _render_dashboard_overview():
redirect_response = _require_login()
if redirect_response:
@ -452,6 +565,16 @@ def dashboard_tools():
user = _load_current_publisher() or session.get("user", {})
status, payload = _api_get("/api/v1/me/tools", token=token)
tools = payload.get("data", []) if status == 200 else []
# Load rating/issue data for each tool
for tool in tools:
_, rating_payload = _api_get(f"/api/v1/tools/{tool.get('owner')}/{tool.get('name')}/rating")
rating_data = rating_payload.get("data", {})
tool["rating"] = rating_data.get("average_rating", 0)
tool["rating_count"] = rating_data.get("rating_count", 0)
tool["open_issues"] = rating_data.get("open_issues", 0)
tool["security_issues"] = rating_data.get("security_issues", 0)
token_status, token_payload = _api_get("/api/v1/tokens", token=token)
tokens = token_payload.get("data", []) if token_status == 200 else []
stats = {

View File

@ -1,5 +1,5 @@
{# Tool card macro #}
{% macro tool_card(tool=none, owner=none, name=none, description=none, category=none, downloads=none, version=none, tags=none) %}
{% macro tool_card(tool=none, owner=none, name=none, description=none, category=none, downloads=none, version=none, tags=none, rating=none, rating_count=none, has_issues=none) %}
{% if tool is none %}
{% set tool = {
"owner": owner,
@ -8,7 +8,10 @@
"category": category,
"downloads": downloads,
"version": version,
"tags": tags
"tags": tags,
"rating": rating,
"rating_count": rating_count,
"has_issues": has_issues
} %}
{% endif %}
<article class="bg-white rounded-lg border border-gray-200 shadow-sm hover:shadow-md transition-shadow p-4 relative">
@ -59,12 +62,29 @@
<!-- Meta info -->
<div class="mt-4 flex items-center justify-between text-sm text-gray-500">
<span class="flex items-center">
<svg class="w-4 h-4 mr-1 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
{{ tool.downloads|default(0) }} downloads
</span>
<div class="flex items-center gap-3">
<span class="flex items-center">
<svg class="w-4 h-4 mr-1 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
{{ tool.downloads|default(0)|format_number if tool.downloads else 0 }}
</span>
{% if tool.rating and tool.rating > 0 %}
<span class="flex items-center">
<svg class="w-4 h-4 mr-0.5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
</svg>
{{ "%.1f"|format(tool.rating) }}{% if tool.rating_count %}<span class="text-gray-400 text-xs ml-0.5">({{ tool.rating_count }})</span>{% endif %}
</span>
{% endif %}
{% if tool.has_issues %}
<span class="flex items-center text-red-500" title="Has open issues">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</span>
{% endif %}
</div>
<span class="text-xs text-gray-600">v{{ tool.version }}</span>
</div>

View File

@ -34,6 +34,12 @@
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Downloads
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Rating
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Issues
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
@ -68,6 +74,33 @@
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ tool.downloads|format_number }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
{% if tool.rating and tool.rating > 0 %}
<div class="flex items-center">
<svg class="w-4 h-4 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
</svg>
<span class="ml-1 text-sm text-gray-900">{{ "%.1f"|format(tool.rating) }}</span>
<span class="ml-1 text-xs text-gray-500">({{ tool.rating_count }})</span>
</div>
{% else %}
<span class="text-sm text-gray-400">No ratings</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap">
{% if tool.open_issues %}
<div class="flex items-center gap-2">
<span class="text-sm text-gray-900">{{ tool.open_issues }}</span>
{% if tool.security_issues %}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
{{ tool.security_issues }} security
</span>
{% endif %}
</div>
{% else %}
<span class="text-sm text-gray-400">None</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap">
{% if tool.deprecated %}
<span class="px-2 py-1 text-xs font-medium text-amber-800 bg-amber-100 rounded-full">

View File

@ -32,7 +32,7 @@
<p class="mt-4 text-gray-600">{{ publisher.bio }}</p>
{% endif %}
<div class="mt-4 flex items-center gap-4 text-sm text-gray-500">
<div class="mt-4 flex flex-wrap items-center gap-4 text-sm text-gray-500">
{% if publisher.website %}
<a href="{{ publisher.website }}"
target="_blank"
@ -56,7 +56,92 @@
</svg>
Joined {{ publisher.created_at|date_format }}
</span>
{% if stats.total_downloads %}
<span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
{{ stats.total_downloads_formatted or stats.total_downloads }} downloads
</span>
{% endif %}
{% if stats.average_rating and stats.total_reviews %}
<span class="flex items-center gap-1">
<svg class="w-4 h-4 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
</svg>
{{ "%.1f"|format(stats.average_rating) }} ({{ stats.total_reviews }} review{{ 's' if stats.total_reviews != 1 else '' }})
</span>
{% endif %}
</div>
<!-- Badges -->
{% if stats.badges %}
<div class="mt-4 flex flex-wrap gap-2">
{% for badge in stats.badges %}
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium
{{ 'bg-blue-100 text-blue-800' if badge.color == 'blue' else
'bg-yellow-100 text-yellow-800' if badge.color == 'gold' or badge.color == 'yellow' else
'bg-purple-100 text-purple-800' if badge.color == 'purple' else
'bg-green-100 text-green-800' if badge.color == 'green' else
'bg-cyan-100 text-cyan-800' if badge.color == 'cyan' else
'bg-gray-100 text-gray-800' }}"
title="{{ badge.description }}">
{% if badge.icon == 'shield-check' %}
<svg class="w-3.5 h-3.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
{% elif badge.icon == 'award' %}
<svg class="w-3.5 h-3.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"/>
</svg>
{% elif badge.icon == 'layers' %}
<svg class="w-3.5 h-3.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
</svg>
{% elif badge.icon == 'clock' %}
<svg class="w-3.5 h-3.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{% elif badge.icon == 'trending-up' %}
<svg class="w-3.5 h-3.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/>
</svg>
{% elif badge.icon == 'message-circle' %}
<svg class="w-3.5 h-3.5 mr-1" 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 badge.icon == 'star' %}
<svg class="w-3.5 h-3.5 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
</svg>
{% endif %}
{{ badge.name }}
</span>
{% endfor %}
</div>
{% endif %}
<!-- Trust Score -->
{% if stats.trust_score and stats.trust_score > 0 %}
<div class="mt-4">
<div class="flex items-center gap-2">
<span class="text-sm text-gray-500">Trust Score:</span>
<div class="flex-1 max-w-xs h-2 bg-gray-200 rounded-full overflow-hidden">
<div class="h-full rounded-full
{{ 'bg-green-500' if stats.trust_score >= 80 else
'bg-yellow-500' if stats.trust_score >= 50 else
'bg-red-500' }}"
style="width: {{ stats.trust_score }}%"></div>
</div>
<span class="text-sm font-medium
{{ 'text-green-600' if stats.trust_score >= 80 else
'text-yellow-600' if stats.trust_score >= 50 else
'text-red-600' }}">
{{ stats.trust_score|int }}
</span>
</div>
</div>
{% endif %}
</div>
</div>
</div>

View File

@ -132,6 +132,153 @@
{% endif %}
</div>
</div>
<!-- Reviews Section -->
<div id="reviews" class="bg-white rounded-lg border border-gray-200 p-6 mt-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold text-gray-900">Reviews</h2>
{% if session.get('auth_token') %}
<button type="button" onclick="openReviewModal()"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
Write Review
</button>
{% else %}
<a href="{{ url_for('web.login') }}?next={{ request.path }}"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-indigo-600 border border-indigo-600 rounded-md hover:bg-indigo-50">
Login to review
</a>
{% endif %}
</div>
{% if reviews %}
<div class="space-y-6">
{% for review in reviews %}
<div class="border-b border-gray-200 pb-6 last:border-0 last:pb-0">
<div class="flex items-start justify-between">
<div>
<div class="flex items-center mb-1">
{% for i in range(1, 6) %}
<svg class="w-4 h-4 {{ 'text-yellow-400' if i <= review.rating else 'text-gray-300' }}" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
</svg>
{% endfor %}
</div>
{% if review.title %}
<h4 class="font-semibold text-gray-900">{{ review.title }}</h4>
{% endif %}
</div>
<span class="text-xs text-gray-400">{{ review.created_at|timeago }}</span>
</div>
{% if review.content %}
<p class="mt-2 text-sm text-gray-600">{{ review.content }}</p>
{% endif %}
<div class="mt-3 flex items-center justify-between">
<span class="text-sm text-gray-500">
by <a href="{{ url_for('web.publisher', slug=review.reviewer_slug) if review.reviewer_slug else '#' }}"
class="text-indigo-600 hover:text-indigo-800">{{ review.reviewer_name }}</a>
</span>
<div class="flex items-center space-x-4 text-sm text-gray-400">
<button type="button" onclick="voteReview({{ review.id }}, 'helpful')"
class="flex items-center hover:text-gray-600">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
</svg>
{{ review.helpful_count }}
</button>
<button type="button" onclick="voteReview({{ review.id }}, 'unhelpful')"
class="flex items-center hover:text-gray-600">
<svg class="w-4 h-4 mr-1 transform rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
</svg>
{{ review.unhelpful_count }}
</button>
</div>
</div>
</div>
{% endfor %}
</div>
{% if reviews_total > 5 %}
<div class="mt-6 text-center">
<a href="{{ url_for('web.tool_reviews', owner=tool.owner, name=tool.name) }}"
class="text-sm text-indigo-600 hover:text-indigo-800">
View all {{ reviews_total }} reviews &rarr;
</a>
</div>
{% endif %}
{% else %}
<p class="text-gray-500 text-center py-8">
No reviews yet. {% if session.get('auth_token') %}Be the first to review this tool!{% endif %}
</p>
{% endif %}
</div>
<!-- Issues Section -->
<div id="issues" class="bg-white rounded-lg border border-gray-200 p-6 mt-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold text-gray-900">Issues</h2>
<button type="button" onclick="openIssueModal()"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50">
<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 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
Report Issue
</button>
</div>
{% if issues %}
<div class="space-y-3">
{% for issue in issues %}
<div class="flex items-start p-3 border border-gray-200 rounded-lg hover:bg-gray-50">
<div class="flex-shrink-0">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
{{ 'bg-red-100 text-red-800' if issue.issue_type == 'security' else
'bg-blue-100 text-blue-800' if issue.issue_type == 'bug' else
'bg-purple-100 text-purple-800' }}">
{{ issue.issue_type }}
</span>
</div>
<div class="ml-3 flex-1">
<p class="text-sm font-medium text-gray-900">{{ issue.title }}</p>
<p class="text-xs text-gray-500 mt-1">
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs
{{ 'bg-red-100 text-red-800' if issue.severity == 'critical' else
'bg-orange-100 text-orange-800' if issue.severity == 'high' else
'bg-yellow-100 text-yellow-800' if issue.severity == 'medium' else
'bg-gray-100 text-gray-800' }}">
{{ issue.severity }}
</span>
&middot; {{ issue.created_at|timeago }}
&middot; by {{ issue.reporter_name }}
</p>
</div>
<div class="flex-shrink-0 ml-2">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
{{ 'bg-green-100 text-green-800' if issue.status in ('fixed', 'wontfix', 'duplicate') else
'bg-yellow-100 text-yellow-800' if issue.status == 'confirmed' else
'bg-gray-100 text-gray-800' }}">
{{ issue.status }}
</span>
</div>
</div>
{% endfor %}
</div>
{% if issues_total > 5 %}
<div class="mt-6 text-center">
<a href="{{ url_for('web.tool_issues', owner=tool.owner, name=tool.name) }}"
class="text-sm text-indigo-600 hover:text-indigo-800">
View all {{ issues_total }} issues &rarr;
</a>
</div>
{% endif %}
{% else %}
<p class="text-gray-500 text-center py-8">
No issues reported for this tool.
</p>
{% endif %}
</div>
</main>
<!-- Sidebar -->
@ -206,9 +353,98 @@
</dd>
</div>
{% endif %}
{% if rating.unique_users %}
<div class="flex items-center justify-between">
<dt class="text-sm text-gray-500">Users</dt>
<dd class="text-sm font-medium text-gray-900">{{ rating.unique_users|format_number }}</dd>
</div>
{% endif %}
</dl>
</div>
<!-- Rating Summary -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Rating</h3>
{% if rating.rating_count and rating.rating_count > 0 %}
<div class="flex items-center mb-3">
<div class="flex items-center">
{% set avg = rating.average_rating|default(0)|float %}
{% for i in range(1, 6) %}
<svg class="w-5 h-5 {{ 'text-yellow-400' if i <= avg else 'text-gray-300' }}" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
</svg>
{% endfor %}
</div>
<span class="ml-2 text-lg font-bold text-gray-900">{{ "%.1f"|format(avg) }}</span>
</div>
<p class="text-sm text-gray-500 mb-4">{{ rating.rating_count }} review{{ 's' if rating.rating_count != 1 else '' }}</p>
<!-- Rating Distribution -->
<div class="space-y-1">
{% for star in range(5, 0, -1) %}
{% set count = rating.distribution[star|string]|default(0) %}
{% set pct = (count / rating.rating_count * 100)|int if rating.rating_count else 0 %}
<div class="flex items-center text-sm">
<span class="w-4 text-gray-500">{{ star }}</span>
<svg class="w-4 h-4 text-yellow-400 mx-1" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
</svg>
<div class="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden mx-2">
<div class="h-full bg-yellow-400 rounded-full" style="width: {{ pct }}%"></div>
</div>
<span class="w-8 text-right text-gray-400">{{ count }}</span>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-gray-500">No reviews yet</p>
{% endif %}
<a href="#reviews" class="mt-4 block text-sm text-indigo-600 hover:text-indigo-800">
{{ 'Write a review' if session.get('auth_token') else 'View all reviews' }} &rarr;
</a>
</div>
<!-- Issues Summary -->
{% if issues_total > 0 or rating.security_issues %}
<div class="bg-white rounded-lg border {{ 'border-red-300' if rating.security_issues else 'border-gray-200' }} p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Known Issues</h3>
{% if rating.security_issues %}
<div class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<div class="flex items-center text-red-700">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<span class="font-medium">{{ rating.security_issues }} security issue{{ 's' if rating.security_issues != 1 else '' }}</span>
</div>
</div>
{% endif %}
<dl class="space-y-2">
<div class="flex items-center justify-between">
<dt class="text-sm text-gray-500">Open issues</dt>
<dd class="text-sm font-medium text-gray-900">{{ rating.open_issues|default(0) }}</dd>
</div>
</dl>
{% if issues %}
<ul class="mt-4 space-y-2">
{% for issue in issues[:3] %}
<li class="text-sm">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
{{ 'bg-red-100 text-red-800' if issue.severity == 'critical' else
'bg-orange-100 text-orange-800' if issue.severity == 'high' else
'bg-yellow-100 text-yellow-800' if issue.severity == 'medium' else
'bg-gray-100 text-gray-800' }}">
{{ issue.severity }}
</span>
<span class="ml-1 text-gray-700">{{ issue.title|truncate(40) }}</span>
</li>
{% endfor %}
</ul>
{% endif %}
<a href="#issues" class="mt-4 block text-sm text-indigo-600 hover:text-indigo-800">
View all issues &rarr;
</a>
</div>
{% endif %}
<!-- Publisher -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Publisher</h3>
@ -299,6 +535,122 @@
</div>
</div>
<!-- Review Modal -->
<div id="review-modal" class="hidden fixed inset-0 z-50 overflow-y-auto" aria-modal="true" role="dialog">
<div class="min-h-screen px-4 text-center">
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity" onclick="closeReviewModal()"></div>
<div class="inline-block w-full max-w-lg my-8 text-left align-middle bg-white shadow-xl rounded-lg">
<div class="p-6">
<h3 class="text-lg font-semibold text-gray-900">Write a Review</h3>
<p class="mt-2 text-sm text-gray-600">Share your experience with this tool.</p>
<div class="mt-6 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Rating</label>
<div class="flex items-center space-x-1" id="star-rating">
{% for i in range(1, 6) %}
<button type="button" onclick="setRating({{ i }})" data-star="{{ i }}"
class="star-btn p-1 text-gray-300 hover:text-yellow-400 transition-colors">
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
</svg>
</button>
{% endfor %}
<span id="rating-text" class="ml-3 text-sm text-gray-500">Select a rating</span>
</div>
<input type="hidden" id="review-rating" value="">
</div>
<div>
<label for="review-title" class="block text-sm font-medium text-gray-700 mb-1">Title (optional)</label>
<input type="text" id="review-title" maxlength="100" placeholder="Summarize your experience"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label for="review-content" class="block text-sm font-medium text-gray-700 mb-1">Review</label>
<textarea id="review-content" rows="4" maxlength="2000"
placeholder="Tell others about your experience with this tool..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 resize-none"></textarea>
<p class="mt-1 text-xs text-gray-400"><span id="review-char-count">0</span>/2000</p>
</div>
</div>
</div>
<div class="bg-gray-50 px-6 py-4 rounded-b-lg flex justify-end gap-3">
<button type="button" onclick="closeReviewModal()"
class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900">
Cancel
</button>
<button type="button" onclick="submitReview()"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Submit Review
</button>
</div>
</div>
</div>
</div>
<!-- Issue Modal -->
<div id="issue-modal" class="hidden fixed inset-0 z-50 overflow-y-auto" aria-modal="true" role="dialog">
<div class="min-h-screen px-4 text-center">
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity" onclick="closeIssueModal()"></div>
<div class="inline-block w-full max-w-lg my-8 text-left align-middle bg-white shadow-xl rounded-lg">
<div class="p-6">
<h3 class="text-lg font-semibold text-gray-900">Report an Issue</h3>
<p class="mt-2 text-sm text-gray-600">Help improve this tool by reporting bugs or security issues.</p>
<div class="mt-6 space-y-4">
<div>
<label for="issue-type" class="block text-sm font-medium text-gray-700 mb-1">Issue Type</label>
<select id="issue-type" required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500">
<option value="bug">Bug</option>
<option value="security">Security Issue</option>
<option value="compatibility">Compatibility</option>
</select>
</div>
<div>
<label for="issue-severity" class="block text-sm font-medium text-gray-700 mb-1">Severity</label>
<select id="issue-severity" required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500">
<option value="low">Low</option>
<option value="medium" selected>Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
</div>
<div>
<label for="issue-title" class="block text-sm font-medium text-gray-700 mb-1">Title</label>
<input type="text" id="issue-title" maxlength="200" required placeholder="Brief description of the issue"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label for="issue-description" class="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea id="issue-description" rows="4" maxlength="5000"
placeholder="Steps to reproduce, expected behavior, actual behavior..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 resize-none"></textarea>
</div>
</div>
</div>
<div class="bg-gray-50 px-6 py-4 rounded-b-lg flex justify-end gap-3">
<button type="button" onclick="closeIssueModal()"
class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900">
Cancel
</button>
<button type="button" onclick="submitIssue()"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Submit Issue
</button>
</div>
</div>
</div>
</div>
<script>
function copyInstall() {
const cmd = 'cmdforge install {{ tool.owner }}/{{ tool.name }}';
@ -314,5 +666,143 @@ function copyInstall() {
});
}
// Review Modal Functions
let selectedRating = 0;
const ratingTexts = ['', 'Poor', 'Fair', 'Good', 'Very Good', 'Excellent'];
function openReviewModal() {
document.getElementById('review-modal').classList.remove('hidden');
document.body.style.overflow = 'hidden';
}
function closeReviewModal() {
document.getElementById('review-modal').classList.add('hidden');
document.body.style.overflow = '';
// Reset form
selectedRating = 0;
updateStars();
document.getElementById('review-title').value = '';
document.getElementById('review-content').value = '';
document.getElementById('review-char-count').textContent = '0';
}
function setRating(rating) {
selectedRating = rating;
document.getElementById('review-rating').value = rating;
document.getElementById('rating-text').textContent = ratingTexts[rating];
updateStars();
}
function updateStars() {
document.querySelectorAll('.star-btn').forEach(btn => {
const star = parseInt(btn.dataset.star);
if (star <= selectedRating) {
btn.classList.remove('text-gray-300');
btn.classList.add('text-yellow-400');
} else {
btn.classList.add('text-gray-300');
btn.classList.remove('text-yellow-400');
}
});
}
document.getElementById('review-content')?.addEventListener('input', function() {
document.getElementById('review-char-count').textContent = this.value.length;
});
function submitReview() {
const rating = selectedRating;
const title = document.getElementById('review-title').value.trim();
const content = document.getElementById('review-content').value.trim();
if (!rating) {
alert('Please select a rating');
return;
}
fetch('/api/v1/tools/{{ tool.owner }}/{{ tool.name }}/reviews', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + (document.cookie.match(/auth_token=([^;]+)/)?.[1] || '')
},
body: JSON.stringify({ rating, title, content })
})
.then(response => response.json())
.then(data => {
if (data.error) {
alert(data.error.message || 'Failed to submit review');
} else {
closeReviewModal();
window.location.reload();
}
})
.catch(err => {
alert('Failed to submit review. Please try again.');
});
}
// Issue Modal Functions
function openIssueModal() {
document.getElementById('issue-modal').classList.remove('hidden');
document.body.style.overflow = 'hidden';
}
function closeIssueModal() {
document.getElementById('issue-modal').classList.add('hidden');
document.body.style.overflow = '';
// Reset form
document.getElementById('issue-type').value = 'bug';
document.getElementById('issue-severity').value = 'medium';
document.getElementById('issue-title').value = '';
document.getElementById('issue-description').value = '';
}
function submitIssue() {
const issue_type = document.getElementById('issue-type').value;
const severity = document.getElementById('issue-severity').value;
const title = document.getElementById('issue-title').value.trim();
const description = document.getElementById('issue-description').value.trim();
if (!title) {
alert('Please provide a title');
return;
}
fetch('/api/v1/tools/{{ tool.owner }}/{{ tool.name }}/issues', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ issue_type, severity, title, description })
})
.then(response => response.json())
.then(data => {
if (data.error) {
alert(data.error.message || 'Failed to submit issue');
} else {
closeIssueModal();
window.location.reload();
}
})
.catch(err => {
alert('Failed to submit issue. Please try again.');
});
}
// Vote Functions
function voteReview(reviewId, voteType) {
fetch('/api/v1/reviews/' + reviewId + '/vote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vote: voteType })
})
.then(response => response.json())
.then(data => {
if (!data.error) {
window.location.reload();
}
});
}
</script>
{% endblock %}

View File

@ -0,0 +1,182 @@
{% extends "base.html" %}
{% block title %}Issues - {{ tool.owner }}/{{ tool.name }} - CmdForge{% endblock %}
{% block content %}
<div class="bg-gray-50 min-h-screen">
<!-- Breadcrumb -->
<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-4">
<nav class="flex items-center text-sm text-gray-500" aria-label="Breadcrumb">
<a href="{{ url_for('web.tools') }}" class="hover:text-gray-700">Tools</a>
<svg class="w-4 h-4 mx-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
</svg>
<a href="{{ url_for('web.tool_detail', owner=tool.owner, name=tool.name) }}" class="hover:text-gray-700">{{ tool.owner }}/{{ tool.name }}</a>
<svg class="w-4 h-4 mx-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
</svg>
<span class="text-gray-900 font-medium">Issues</span>
</nav>
</div>
</div>
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">Issues for {{ tool.name }}</h1>
<a href="{{ url_for('web.tool_detail', owner=tool.owner, name=tool.name) }}#issues"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50">
<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 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
Report Issue
</a>
</div>
<!-- Filters -->
<div class="bg-white rounded-lg border border-gray-200 p-4 mb-6">
<div class="flex flex-wrap items-center gap-4">
<div>
<label class="block text-xs text-gray-500 mb-1">Status</label>
<select id="status-filter" onchange="applyFilters()"
class="px-3 py-1.5 text-sm border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500">
<option value="" {{ 'selected' if not current_status else '' }}>All</option>
<option value="open" {{ 'selected' if current_status == 'open' else '' }}>Open</option>
<option value="confirmed" {{ 'selected' if current_status == 'confirmed' else '' }}>Confirmed</option>
<option value="fixed" {{ 'selected' if current_status == 'fixed' else '' }}>Fixed</option>
<option value="wontfix" {{ 'selected' if current_status == 'wontfix' else '' }}>Won't Fix</option>
<option value="duplicate" {{ 'selected' if current_status == 'duplicate' else '' }}>Duplicate</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Type</label>
<select id="type-filter" onchange="applyFilters()"
class="px-3 py-1.5 text-sm border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500">
<option value="" {{ 'selected' if not current_type else '' }}>All</option>
<option value="bug" {{ 'selected' if current_type == 'bug' else '' }}>Bug</option>
<option value="security" {{ 'selected' if current_type == 'security' else '' }}>Security</option>
<option value="compatibility" {{ 'selected' if current_type == 'compatibility' else '' }}>Compatibility</option>
</select>
</div>
{% if current_status or current_type %}
<a href="{{ url_for('web.tool_issues', owner=tool.owner, name=tool.name) }}"
class="px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700">
Clear filters
</a>
{% endif %}
</div>
</div>
<!-- Issues List -->
{% if issues %}
<div class="bg-white rounded-lg border border-gray-200 divide-y divide-gray-200">
{% for issue in issues %}
<div class="p-4 hover:bg-gray-50">
<div class="flex items-start">
<div class="flex-shrink-0 mr-3">
<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium
{{ 'bg-red-100 text-red-800' if issue.issue_type == 'security' else
'bg-blue-100 text-blue-800' if issue.issue_type == 'bug' else
'bg-purple-100 text-purple-800' }}">
{{ issue.issue_type }}
</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<h3 class="text-sm font-medium text-gray-900">{{ issue.title }}</h3>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
{{ 'bg-red-100 text-red-800' if issue.severity == 'critical' else
'bg-orange-100 text-orange-800' if issue.severity == 'high' else
'bg-yellow-100 text-yellow-800' if issue.severity == 'medium' else
'bg-gray-100 text-gray-800' }}">
{{ issue.severity }}
</span>
</div>
<p class="mt-1 text-xs text-gray-500">
Reported {{ issue.created_at|timeago }} by {{ issue.reporter_name }}
{% if issue.resolved_at %}
&middot; Resolved {{ issue.resolved_at|timeago }}
{% endif %}
</p>
</div>
<div class="flex-shrink-0 ml-4">
<span class="inline-flex items-center px-2.5 py-1 rounded text-xs font-medium
{{ 'bg-green-100 text-green-800' if issue.status in ('fixed', 'wontfix', 'duplicate') else
'bg-yellow-100 text-yellow-800' if issue.status == 'confirmed' else
'bg-gray-100 text-gray-800' }}">
{% if issue.status == 'open' %}
<svg class="w-3 h-3 mr-1 text-gray-500" fill="currentColor" viewBox="0 0 8 8">
<circle cx="4" cy="4" r="3"/>
</svg>
{% elif issue.status == 'confirmed' %}
<svg class="w-3 h-3 mr-1 text-yellow-600" fill="currentColor" viewBox="0 0 8 8">
<circle cx="4" cy="4" r="3"/>
</svg>
{% else %}
<svg class="w-3 h-3 mr-1 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"/>
</svg>
{% endif %}
{{ issue.status }}
</span>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Pagination -->
{% if pagination.pages > 1 %}
<div class="mt-6 flex items-center justify-center gap-2">
{% set url_params = {} %}
{% if current_status %}{% set _ = url_params.update({'status': current_status}) %}{% endif %}
{% if current_type %}{% set _ = url_params.update({'type': current_type}) %}{% endif %}
{% if pagination.has_prev %}
<a href="?page={{ pagination.prev_num }}{% if current_status %}&status={{ current_status }}{% endif %}{% if current_type %}&type={{ current_type }}{% endif %}"
class="px-4 py-2 text-sm text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
Previous
</a>
{% endif %}
<span class="text-sm text-gray-500">Page {{ pagination.page }} of {{ pagination.pages }}</span>
{% if pagination.has_next %}
<a href="?page={{ pagination.next_num }}{% if current_status %}&status={{ current_status }}{% endif %}{% if current_type %}&type={{ current_type }}{% endif %}"
class="px-4 py-2 text-sm text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
Next
</a>
{% endif %}
</div>
{% endif %}
{% else %}
<div class="bg-white rounded-lg border border-gray-200 p-12 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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<p class="mt-4 text-gray-600">
{% if current_status or current_type %}
No issues match your filters.
{% else %}
No issues reported for this tool.
{% endif %}
</p>
</div>
{% endif %}
</div>
</div>
<script>
function applyFilters() {
const status = document.getElementById('status-filter').value;
const type = document.getElementById('type-filter').value;
let url = window.location.pathname;
const params = new URLSearchParams();
if (status) params.set('status', status);
if (type) params.set('type', type);
if (params.toString()) {
url += '?' + params.toString();
}
window.location.href = url;
}
</script>
{% endblock %}

View File

@ -0,0 +1,156 @@
{% extends "base.html" %}
{% block title %}Reviews - {{ tool.owner }}/{{ tool.name }} - CmdForge{% endblock %}
{% block content %}
<div class="bg-gray-50 min-h-screen">
<!-- Breadcrumb -->
<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-4">
<nav class="flex items-center text-sm text-gray-500" aria-label="Breadcrumb">
<a href="{{ url_for('web.tools') }}" class="hover:text-gray-700">Tools</a>
<svg class="w-4 h-4 mx-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
</svg>
<a href="{{ url_for('web.tool_detail', owner=tool.owner, name=tool.name) }}" class="hover:text-gray-700">{{ tool.owner }}/{{ tool.name }}</a>
<svg class="w-4 h-4 mx-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
</svg>
<span class="text-gray-900 font-medium">Reviews</span>
</nav>
</div>
</div>
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">Reviews for {{ tool.name }}</h1>
{% if session.get('auth_token') %}
<a href="{{ url_for('web.tool_detail', owner=tool.owner, name=tool.name) }}#reviews"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Write Review
</a>
{% endif %}
</div>
<!-- Rating Summary -->
{% if rating.rating_count and rating.rating_count > 0 %}
<div class="bg-white rounded-lg border border-gray-200 p-6 mb-6">
<div class="flex items-start gap-8">
<div class="text-center">
<div class="text-5xl font-bold text-gray-900">{{ "%.1f"|format(rating.average_rating) }}</div>
<div class="flex items-center justify-center mt-2">
{% set avg = rating.average_rating|default(0)|float %}
{% for i in range(1, 6) %}
<svg class="w-5 h-5 {{ 'text-yellow-400' if i <= avg else 'text-gray-300' }}" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
</svg>
{% endfor %}
</div>
<p class="text-sm text-gray-500 mt-1">{{ rating.rating_count }} reviews</p>
</div>
<div class="flex-1">
{% for star in range(5, 0, -1) %}
{% set count = rating.distribution[star|string]|default(0) %}
{% set pct = (count / rating.rating_count * 100)|int if rating.rating_count else 0 %}
<div class="flex items-center text-sm mb-1">
<span class="w-4 text-gray-500">{{ star }}</span>
<svg class="w-4 h-4 text-yellow-400 mx-1" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
</svg>
<div class="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden mx-2">
<div class="h-full bg-yellow-400 rounded-full" style="width: {{ pct }}%"></div>
</div>
<span class="w-12 text-right text-gray-400">{{ count }}</span>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<!-- Sort Options -->
<div class="flex items-center gap-2 mb-4">
<span class="text-sm text-gray-500">Sort by:</span>
<a href="?sort=recent" class="px-3 py-1 text-sm rounded {{ 'bg-indigo-100 text-indigo-700' if current_sort == 'recent' else 'text-gray-600 hover:bg-gray-100' }}">Most Recent</a>
<a href="?sort=helpful" class="px-3 py-1 text-sm rounded {{ 'bg-indigo-100 text-indigo-700' if current_sort == 'helpful' else 'text-gray-600 hover:bg-gray-100' }}">Most Helpful</a>
<a href="?sort=highest" class="px-3 py-1 text-sm rounded {{ 'bg-indigo-100 text-indigo-700' if current_sort == 'highest' else 'text-gray-600 hover:bg-gray-100' }}">Highest Rating</a>
<a href="?sort=lowest" class="px-3 py-1 text-sm rounded {{ 'bg-indigo-100 text-indigo-700' if current_sort == 'lowest' else 'text-gray-600 hover:bg-gray-100' }}">Lowest Rating</a>
</div>
<!-- Reviews List -->
{% if reviews %}
<div class="bg-white rounded-lg border border-gray-200 divide-y divide-gray-200">
{% for review in reviews %}
<div class="p-6">
<div class="flex items-start justify-between">
<div>
<div class="flex items-center mb-1">
{% for i in range(1, 6) %}
<svg class="w-4 h-4 {{ 'text-yellow-400' if i <= review.rating else 'text-gray-300' }}" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
</svg>
{% endfor %}
</div>
{% if review.title %}
<h3 class="font-semibold text-gray-900">{{ review.title }}</h3>
{% endif %}
</div>
<span class="text-xs text-gray-400">{{ review.created_at|timeago }}</span>
</div>
{% if review.content %}
<p class="mt-2 text-sm text-gray-600">{{ review.content }}</p>
{% endif %}
<div class="mt-3 flex items-center justify-between">
<span class="text-sm text-gray-500">
by <a href="{{ url_for('web.publisher', slug=review.reviewer_slug) if review.reviewer_slug else '#' }}"
class="text-indigo-600 hover:text-indigo-800">{{ review.reviewer_name }}</a>
</span>
<div class="flex items-center space-x-4 text-sm text-gray-400">
<span class="flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
</svg>
{{ review.helpful_count }} found helpful
</span>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Pagination -->
{% if pagination.pages > 1 %}
<div class="mt-6 flex items-center justify-center gap-2">
{% if pagination.has_prev %}
<a href="?page={{ pagination.prev_num }}&sort={{ current_sort }}"
class="px-4 py-2 text-sm text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
Previous
</a>
{% endif %}
<span class="text-sm text-gray-500">Page {{ pagination.page }} of {{ pagination.pages }}</span>
{% if pagination.has_next %}
<a href="?page={{ pagination.next_num }}&sort={{ current_sort }}"
class="px-4 py-2 text-sm text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
Next
</a>
{% endif %}
</div>
{% endif %}
{% else %}
<div class="bg-white rounded-lg border border-gray-200 p-12 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 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"/>
</svg>
<p class="mt-4 text-gray-600">No reviews yet for this tool.</p>
{% if session.get('auth_token') %}
<a href="{{ url_for('web.tool_detail', owner=tool.owner, name=tool.name) }}#reviews"
class="mt-4 inline-flex items-center px-4 py-2 text-sm font-medium text-indigo-600 border border-indigo-600 rounded-md hover:bg-indigo-50">
Be the first to review
</a>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endblock %}