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 <[email protected]>
This commit is contained in:
@@ -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 %}
|
||||
Reference in New Issue
Block a user