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:
parent
d2c668dc99
commit
604b473806
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
"""
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 →
|
||||
</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>
|
||||
· {{ issue.created_at|timeago }}
|
||||
· 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 →
|
||||
</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' }} →
|
||||
</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 →
|
||||
</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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
· 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 %}
|
||||
|
|
@ -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 %}
|
||||
Loading…
Reference in New Issue