Registry Features: - Fork tracking with forked_from/forked_version metadata - Forked tools show "Forked from" notice on detail page - Original tools display "Forks" section listing all forks - Fork count in tool stats - API: GET /api/v1/tools/<owner>/<name>/forks GUI Improvements: - Version selector dropdown for registry installs - Fetch available versions via GET /api/v1/tools/<owner>/<name>/versions - "Latest" option plus all available versions listed Admin Features: - POST /api/v1/admin/cleanup/rejected endpoint - Maintenance section in admin dashboard - "Dry Run" and "Run Cleanup" buttons for rejected version cleanup - Configurable grace period (default 7 days) Documentation: - Updated CHANGELOG.md with all recent changes Co-Authored-By: Claude Opus 4.5 <[email protected]>
870 lines
51 KiB
HTML
870 lines
51 KiB
HTML
{% extends "base.html" %}
|
|
{% from "components/callouts.html" import warning, info %}
|
|
|
|
{% block title %}{{ tool.owner }}/{{ tool.name }} - CmdForge Registry{% endblock %}
|
|
|
|
{% block meta_description %}{{ tool.description or 'A CmdForge command-line tool' }}{% endblock %}
|
|
|
|
{% block og_title %}{{ tool.owner }}/{{ tool.name }}{% endblock %}
|
|
{% block og_description %}{{ tool.description or 'A CmdForge command-line tool' }}{% 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-7xl 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.publisher', slug=tool.owner) }}" class="hover:text-gray-700">{{ tool.owner }}</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">{{ tool.name }}</span>
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
<div class="lg:grid lg:grid-cols-3 lg:gap-8">
|
|
<!-- Main Content -->
|
|
<main class="lg:col-span-2">
|
|
<!-- Tool Header -->
|
|
<div class="bg-white rounded-lg border border-gray-200 p-6 mb-6">
|
|
<div class="flex items-start justify-between">
|
|
<div class="flex items-center">
|
|
<div class="w-16 h-16 bg-indigo-100 rounded-lg flex items-center justify-center">
|
|
<svg class="w-8 h-8 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
|
</svg>
|
|
</div>
|
|
<div class="ml-4">
|
|
<h1 class="text-2xl font-bold text-gray-900">{{ tool.name }}</h1>
|
|
<p class="text-gray-500">
|
|
by <a href="{{ url_for('web.publisher', slug=tool.owner) }}" class="text-indigo-600 hover:text-indigo-800">{{ tool.owner }}</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{% if tool.category %}
|
|
<span class="px-3 py-1 text-xs font-medium text-cyan-700 bg-cyan-100 rounded-full">
|
|
{{ tool.category }}
|
|
</span>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{% if tool.description %}
|
|
<p class="mt-4 text-gray-600">{{ tool.description }}</p>
|
|
{% endif %}
|
|
|
|
{% if tool.tags %}
|
|
<div class="mt-4 flex flex-wrap gap-2">
|
|
{% for tag in tool.tags.split(',') %}
|
|
<span class="px-2 py-1 text-xs text-gray-600 bg-gray-100 rounded">{{ tag.strip() }}</span>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if tool.forked_from %}
|
|
<div class="mt-4 p-3 bg-purple-50 border border-purple-200 rounded-lg">
|
|
<div class="flex items-start">
|
|
<svg class="w-5 h-5 text-purple-600 mr-2 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
|
</svg>
|
|
<div class="text-sm text-purple-800">
|
|
<span class="font-medium">Forked from</span>
|
|
{% set fork_parts = tool.forked_from.split('/') %}
|
|
<a href="{{ url_for('web.tool_detail', owner=fork_parts[0], name=fork_parts[1]) }}"
|
|
class="font-medium underline hover:text-purple-900">{{ tool.forked_from }}</a>
|
|
{% if tool.forked_version %}
|
|
<span class="text-purple-600">v{{ tool.forked_version }}</span>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% elif tool.source %}
|
|
<div class="mt-4 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
|
<div class="flex items-start">
|
|
<svg class="w-5 h-5 text-amber-600 mr-2 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
<div class="text-sm text-amber-800">
|
|
{% if tool.source.type == 'forked' %}
|
|
<span class="font-medium">Forked from</span>
|
|
{% elif tool.source.type == 'imported' %}
|
|
<span class="font-medium">Based on</span>
|
|
{% else %}
|
|
<span class="font-medium">Source:</span>
|
|
{% endif %}
|
|
{% if tool.source.original_tool %}
|
|
{% if tool.source.url %}
|
|
<a href="{{ tool.source.url }}" target="_blank" rel="noopener" class="font-medium underline hover:text-amber-900">{{ tool.source.original_tool }}</a>
|
|
{% else %}
|
|
<span class="font-medium">{{ tool.source.original_tool }}</span>
|
|
{% endif %}
|
|
{% elif tool.source.url %}
|
|
<a href="{{ tool.source.url }}" target="_blank" rel="noopener" class="font-medium underline hover:text-amber-900">{{ tool.source.url }}</a>
|
|
{% endif %}
|
|
{% if tool.source.author %}
|
|
<span class="block mt-1">Author: <span class="font-medium">{{ tool.source.author }}</span></span>
|
|
{% endif %}
|
|
{% if tool.source.license %}
|
|
<span class="block mt-1">License: <span class="font-medium">{{ tool.source.license }}</span></span>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Deprecation Warning -->
|
|
{% if tool.deprecated %}
|
|
{{ warning(
|
|
title='This tool is deprecated',
|
|
content=tool.deprecated_message or 'This tool is no longer maintained.'
|
|
) }}
|
|
{% if tool.replacement %}
|
|
{{ info(
|
|
title='Recommended replacement',
|
|
content='Consider using <a href="' ~ url_for('web.tool_detail', owner=tool.replacement.split('/')[0], name=tool.replacement.split('/')[1]) ~ '" class="underline">' ~ tool.replacement ~ '</a> instead.'
|
|
) }}
|
|
{% endif %}
|
|
{% endif %}
|
|
|
|
<!-- README Content -->
|
|
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
|
<div class="prose prose-indigo max-w-none">
|
|
{% if tool.readme %}
|
|
{{ tool.readme_html|safe }}
|
|
{% else %}
|
|
<h2>About {{ tool.name }}</h2>
|
|
<p>{{ tool.description or 'No additional documentation available.' }}</p>
|
|
|
|
<h2>Installation</h2>
|
|
<pre><code>cmdforge install {{ tool.owner }}/{{ tool.name }}</code></pre>
|
|
|
|
<h2>Usage</h2>
|
|
<pre><code>{{ tool.name }} --help</code></pre>
|
|
{% 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 -->
|
|
<aside class="mt-8 lg:mt-0">
|
|
<div class="sticky top-4 space-y-6">
|
|
<!-- Install Card -->
|
|
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-4">Install</h3>
|
|
<div class="flex items-center bg-gray-100 rounded-lg p-3 group">
|
|
<span class="text-gray-400 mr-2 select-none">$</span>
|
|
<code id="install-cmd" class="flex-1 text-sm text-gray-800 font-mono overflow-x-auto">cmdforge install {{ tool.owner }}/{{ tool.name }}</code>
|
|
<button type="button"
|
|
onclick="copyInstall()"
|
|
class="ml-2 p-1.5 text-gray-400 hover:text-gray-600"
|
|
aria-label="Copy install command">
|
|
<svg id="copy-icon" 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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
|
</svg>
|
|
<svg id="check-icon" class="w-4 h-4 hidden text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<button type="button"
|
|
onclick="submitReport('{{ tool.owner }}', '{{ tool.name }}')"
|
|
class="mt-4 w-full inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700">
|
|
Report Tool
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Versions -->
|
|
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-4">Versions</h3>
|
|
<ul class="space-y-2">
|
|
{% for version in versions %}
|
|
<li class="flex items-center justify-between">
|
|
<a href="{{ url_for('web.tool_version', owner=tool.owner, name=tool.name, version=version.version) }}"
|
|
class="text-sm {{ 'font-medium text-indigo-600' if version.version == tool.version else 'text-gray-600 hover:text-indigo-600' }}">
|
|
v{{ version.version }}
|
|
{% if version.version == tool.version %}
|
|
<span class="ml-1 text-xs text-green-600">(current)</span>
|
|
{% endif %}
|
|
</a>
|
|
<span class="text-xs text-gray-400">{{ version.published_at|timeago }}</span>
|
|
</li>
|
|
{% else %}
|
|
<li class="text-sm text-gray-500">v{{ tool.version }}</li>
|
|
{% endfor %}
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Forks -->
|
|
{% if forks %}
|
|
<div class="bg-white rounded-lg border border-purple-200 p-6">
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-4">
|
|
<span class="flex items-center">
|
|
<svg class="w-5 h-5 text-purple-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
|
</svg>
|
|
Forks
|
|
<span class="ml-2 px-2 py-0.5 text-xs font-medium text-purple-700 bg-purple-100 rounded-full">{{ forks|length }}</span>
|
|
</span>
|
|
</h3>
|
|
<ul class="space-y-3">
|
|
{% for fork in forks[:5] %}
|
|
<li>
|
|
<a href="{{ url_for('web.tool_detail', owner=fork.owner, name=fork.name) }}"
|
|
class="block hover:bg-purple-50 -mx-2 px-2 py-1 rounded">
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-sm font-medium text-gray-900 hover:text-purple-600">
|
|
{{ fork.owner }}/{{ fork.name }}
|
|
</span>
|
|
<span class="text-xs text-gray-400">v{{ fork.latest_version }}</span>
|
|
</div>
|
|
{% if fork.description %}
|
|
<p class="text-xs text-gray-500 truncate mt-0.5">{{ fork.description|truncate(60) }}</p>
|
|
{% endif %}
|
|
</a>
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
{% if forks|length > 5 %}
|
|
<p class="mt-3 text-sm text-center text-purple-600">
|
|
+ {{ forks|length - 5 }} more fork{{ 's' if forks|length - 5 != 1 else '' }}
|
|
</p>
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Stats -->
|
|
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-4">Stats</h3>
|
|
<dl class="space-y-3">
|
|
<div class="flex items-center justify-between">
|
|
<dt class="text-sm text-gray-500">Downloads</dt>
|
|
<dd class="text-sm font-medium text-gray-900">{{ tool.downloads|format_number }}</dd>
|
|
</div>
|
|
<div class="flex items-center justify-between">
|
|
<dt class="text-sm text-gray-500">Published</dt>
|
|
<dd class="text-sm font-medium text-gray-900">{{ tool.published_at|timeago }}</dd>
|
|
</div>
|
|
{% if tool.category %}
|
|
<div class="flex items-center justify-between">
|
|
<dt class="text-sm text-gray-500">Category</dt>
|
|
<dd>
|
|
<a href="{{ url_for('web.category', name=tool.category) }}"
|
|
class="text-sm font-medium text-indigo-600 hover:text-indigo-800">
|
|
{{ tool.category }}
|
|
</a>
|
|
</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 %}
|
|
{% if tool.fork_count %}
|
|
<div class="flex items-center justify-between">
|
|
<dt class="text-sm text-gray-500">Forks</dt>
|
|
<dd class="text-sm font-medium text-purple-600">{{ tool.fork_count }}</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>
|
|
<a href="{{ url_for('web.publisher', slug=tool.owner) }}"
|
|
class="flex items-center group">
|
|
<div class="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center">
|
|
<span class="text-gray-600 font-medium">{{ tool.owner[0]|upper }}</span>
|
|
</div>
|
|
<div class="ml-3">
|
|
<p class="text-sm font-medium text-gray-900 group-hover:text-indigo-600">
|
|
{{ publisher.display_name if publisher else tool.owner }}
|
|
</p>
|
|
<p class="text-xs text-gray-500">@{{ tool.owner }}</p>
|
|
</div>
|
|
{% if publisher and publisher.verified %}
|
|
<svg class="ml-auto w-5 h-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
|
</svg>
|
|
{% endif %}
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Report Tool -->
|
|
<div class="text-center">
|
|
<button type="button"
|
|
onclick="submitReport('{{ tool.owner }}', '{{ tool.name }}')"
|
|
class="text-sm text-gray-500 hover:text-red-600 transition-colors">
|
|
Report this tool
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Report Modal -->
|
|
<div id="report-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="closeReportModal()"></div>
|
|
<div class="inline-block w-full max-w-md my-8 text-left align-middle bg-white shadow-xl rounded-lg">
|
|
<form action="/api/v1/reports" method="POST" id="report-form">
|
|
<div class="p-6">
|
|
<h3 class="text-lg font-semibold text-gray-900">Report Tool</h3>
|
|
<p class="mt-2 text-sm text-gray-600">
|
|
Help us maintain a safe registry by reporting tools that violate our guidelines.
|
|
</p>
|
|
|
|
<input type="hidden" name="tool_owner" id="report-owner" value="{{ tool.owner }}">
|
|
<input type="hidden" name="tool_name" id="report-name" value="{{ tool.name }}">
|
|
|
|
<div class="mt-6 space-y-4">
|
|
<div>
|
|
<label for="reason" class="block text-sm font-medium text-gray-700 mb-1">Reason</label>
|
|
<select name="reason" id="reason" required
|
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500">
|
|
<option value="">Select a reason...</option>
|
|
<option value="spam">Spam</option>
|
|
<option value="malicious">Malicious</option>
|
|
<option value="duplicate">Duplicate</option>
|
|
<option value="inappropriate">Inappropriate</option>
|
|
<option value="other">Other</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label for="description" class="block text-sm font-medium text-gray-700 mb-1">Description (optional)</label>
|
|
<textarea name="description" id="description" rows="3"
|
|
placeholder="Provide additional context"
|
|
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="closeReportModal()"
|
|
class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900">
|
|
Cancel
|
|
</button>
|
|
<button type="submit"
|
|
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700">
|
|
Submit Report
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</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 }}';
|
|
navigator.clipboard.writeText(cmd).then(() => {
|
|
const copyIcon = document.getElementById('copy-icon');
|
|
const checkIcon = document.getElementById('check-icon');
|
|
copyIcon.classList.add('hidden');
|
|
checkIcon.classList.remove('hidden');
|
|
setTimeout(() => {
|
|
copyIcon.classList.remove('hidden');
|
|
checkIcon.classList.add('hidden');
|
|
}, 2000);
|
|
});
|
|
}
|
|
|
|
// 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 %}
|