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:
rob
2026-01-14 03:12:31 -04:00
co-authored by Claude Opus 4.5
parent d2c668dc99
commit 604b473806
10 changed files with 2566 additions and 9 deletions
@@ -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>