Add registry improvements, tag filtering, and admin tools
Registry API improvements: - Add /api/v1/me POST endpoint for profile updates - Add /api/v1/me/password POST endpoint for password changes - Fix visibility filtering on categories and tags endpoints to only show approved public tools - Allow underscores in tool names for fabric pattern compatibility - Fix sqlite3.Row access (use bracket notation instead of .get()) Web UI improvements: - Add tag filter to /tools page with three-state buttons (include/exclude/neutral) - Add mobile-friendly tag filter in responsive view - Display tags on tool cards in the tools listing - Add dashboard settings form handlers for profile and password Admin improvements: - Add scrutiny audit page for reviewing tool safety analysis - Improve pending tools page with scrutiny report display - Add scrutiny stats to admin dashboard Fabric sync improvements: - Add direct database publishing with scrutiny vetting - Support auto-approve for tools passing scrutiny - Improve error handling and logging - Add source attribution for imported tools Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
+155
-4
@@ -233,30 +233,68 @@ def tools():
|
||||
return _render_tools()
|
||||
|
||||
|
||||
def _load_tags() -> List[SimpleNamespace]:
|
||||
"""Load all tags with counts."""
|
||||
status, payload = _api_get("/api/v1/tags", params={"per_page": 100})
|
||||
if status != 200:
|
||||
return []
|
||||
tags = []
|
||||
for item in payload.get("data", []):
|
||||
name = item.get("name")
|
||||
if not name:
|
||||
continue
|
||||
tags.append(SimpleNamespace(
|
||||
name=name,
|
||||
count=item.get("count", 0),
|
||||
))
|
||||
return tags
|
||||
|
||||
|
||||
def _render_tools(category_override: Optional[str] = None):
|
||||
page = request.args.get("page", 1)
|
||||
sort = request.args.get("sort", "downloads")
|
||||
category = category_override or request.args.get("category")
|
||||
query = request.args.get("q")
|
||||
|
||||
# Parse tag filter parameters (include/exclude)
|
||||
include_tags_param = request.args.get("include_tags", "")
|
||||
exclude_tags_param = request.args.get("exclude_tags", "")
|
||||
include_tags = [t.strip() for t in include_tags_param.split(",") if t.strip()]
|
||||
exclude_tags = [t.strip() for t in exclude_tags_param.split(",") if t.strip()]
|
||||
|
||||
params = {"page": page, "per_page": 12, "sort": sort}
|
||||
if category:
|
||||
params["category"] = category
|
||||
if include_tags:
|
||||
params["tags"] = ",".join(include_tags)
|
||||
|
||||
status, payload = _api_get("/api/v1/tools", params=params)
|
||||
if status != 200:
|
||||
return render_template("errors/500.html"), 500
|
||||
|
||||
# Filter out excluded tags client-side (API doesn't support exclude)
|
||||
tools = payload.get("data", [])
|
||||
if exclude_tags:
|
||||
def has_excluded_tag(tool):
|
||||
tool_tags = tool.get("tags", [])
|
||||
return any(t in tool_tags for t in exclude_tags)
|
||||
tools = [t for t in tools if not has_excluded_tag(t)]
|
||||
|
||||
meta = payload.get("meta", {})
|
||||
categories, all_tools_total = _load_categories()
|
||||
all_tags = _load_tags()
|
||||
return render_template(
|
||||
"pages/tools.html",
|
||||
tools=payload.get("data", []),
|
||||
tools=tools,
|
||||
categories=categories,
|
||||
current_category=category,
|
||||
total_count=all_tools_total,
|
||||
sort=sort,
|
||||
query=query,
|
||||
pagination=_build_pagination(meta),
|
||||
all_tags=all_tags,
|
||||
include_tags=include_tags,
|
||||
exclude_tags=exclude_tags,
|
||||
)
|
||||
|
||||
|
||||
@@ -625,13 +663,56 @@ def dashboard_tokens():
|
||||
)
|
||||
|
||||
|
||||
@web_bp.route("/dashboard/settings", endpoint="dashboard_settings")
|
||||
@web_bp.route("/dashboard/settings", endpoint="dashboard_settings", methods=["GET", "POST"])
|
||||
def dashboard_settings():
|
||||
redirect_response = _require_login()
|
||||
if redirect_response:
|
||||
return redirect_response
|
||||
token = session.get("auth_token")
|
||||
user = _load_current_publisher() or session.get("user", {})
|
||||
errors = []
|
||||
success_message = None
|
||||
|
||||
if request.method == "POST":
|
||||
form_type = request.form.get("form")
|
||||
|
||||
if form_type == "profile":
|
||||
# Update profile
|
||||
data = {
|
||||
"display_name": request.form.get("display_name", "").strip(),
|
||||
"bio": request.form.get("bio", "").strip(),
|
||||
"website": request.form.get("website", "").strip(),
|
||||
}
|
||||
status, payload = _api_post("/api/v1/me", data=data, token=token)
|
||||
if status == 200:
|
||||
success_message = "Profile updated successfully."
|
||||
user = _load_current_publisher() or session.get("user", {})
|
||||
else:
|
||||
errors.append(payload.get("error", {}).get("message", "Failed to update profile."))
|
||||
|
||||
elif form_type == "password":
|
||||
# Change password
|
||||
current_password = request.form.get("current_password", "")
|
||||
new_password = request.form.get("new_password", "")
|
||||
confirm_password = request.form.get("confirm_password", "")
|
||||
|
||||
if not current_password or not new_password:
|
||||
errors.append("Please fill in all password fields.")
|
||||
elif new_password != confirm_password:
|
||||
errors.append("New passwords do not match.")
|
||||
elif len(new_password) < 8:
|
||||
errors.append("New password must be at least 8 characters.")
|
||||
else:
|
||||
data = {
|
||||
"current_password": current_password,
|
||||
"new_password": new_password,
|
||||
}
|
||||
status, payload = _api_post("/api/v1/me/password", data=data, token=token)
|
||||
if status == 200:
|
||||
success_message = "Password updated successfully."
|
||||
else:
|
||||
errors.append(payload.get("error", {}).get("message", "Failed to update password."))
|
||||
|
||||
tools_status, tools_payload = _api_get("/api/v1/me/tools", token=token)
|
||||
tools = tools_payload.get("data", []) if tools_status == 200 else []
|
||||
token_status, token_payload = _api_get("/api/v1/tokens", token=token)
|
||||
@@ -647,8 +728,8 @@ def dashboard_settings():
|
||||
tools=tools,
|
||||
stats=stats,
|
||||
tokens=tokens,
|
||||
errors=[],
|
||||
success_message=None,
|
||||
errors=errors,
|
||||
success_message=success_message,
|
||||
)
|
||||
|
||||
|
||||
@@ -803,6 +884,8 @@ def sitemap():
|
||||
SELECT owner, name, MAX(id) AS max_id
|
||||
FROM tools
|
||||
WHERE version NOT LIKE '%-%'
|
||||
AND visibility = 'public'
|
||||
AND moderation_status = 'approved'
|
||||
GROUP BY owner, name
|
||||
)
|
||||
SELECT t.owner, t.name, t.published_at
|
||||
@@ -1132,3 +1215,71 @@ def admin_settings():
|
||||
categories=categories,
|
||||
token=token,
|
||||
)
|
||||
|
||||
|
||||
@web_bp.route("/dashboard/admin/scrutiny", endpoint="admin_scrutiny")
|
||||
def admin_scrutiny():
|
||||
"""Scrutiny audit page - view all tool scrutiny results."""
|
||||
forbidden = _require_moderator_role()
|
||||
if forbidden:
|
||||
return forbidden
|
||||
|
||||
user = _load_current_publisher()
|
||||
token = session.get("auth_token")
|
||||
page = request.args.get("page", 1, type=int)
|
||||
scrutiny_filter = request.args.get("scrutiny_status", "")
|
||||
moderation_filter = request.args.get("moderation_status", "")
|
||||
|
||||
params = {"page": page, "per_page": 50}
|
||||
if scrutiny_filter:
|
||||
params["scrutiny_status"] = scrutiny_filter
|
||||
if moderation_filter:
|
||||
params["moderation_status"] = moderation_filter
|
||||
|
||||
status, payload = _api_get("/api/v1/admin/scrutiny", params=params, token=token)
|
||||
|
||||
if status != 200:
|
||||
return render_template(
|
||||
"admin/scrutiny.html",
|
||||
user=user,
|
||||
active_page="admin_scrutiny",
|
||||
tools=[],
|
||||
meta={},
|
||||
stats_summary={},
|
||||
scrutiny_filter=scrutiny_filter,
|
||||
moderation_filter=moderation_filter,
|
||||
error=payload.get("error", "Failed to load scrutiny data"),
|
||||
)
|
||||
|
||||
# Process stats into easy-to-use summary
|
||||
stats = payload.get("stats", [])
|
||||
stats_summary = {
|
||||
"approved_approved": 0,
|
||||
"review_pending": 0,
|
||||
"review_approved": 0,
|
||||
"total_pending": 0,
|
||||
}
|
||||
for s in stats:
|
||||
scrutiny = s.get("scrutiny_status")
|
||||
moderation = s.get("moderation_status")
|
||||
count = s.get("count", 0)
|
||||
|
||||
if scrutiny == "approved" and moderation == "approved":
|
||||
stats_summary["approved_approved"] += count
|
||||
if scrutiny == "pending_review" and moderation == "pending":
|
||||
stats_summary["review_pending"] += count
|
||||
if scrutiny == "pending_review" and moderation == "approved":
|
||||
stats_summary["review_approved"] += count
|
||||
if moderation == "pending":
|
||||
stats_summary["total_pending"] += count
|
||||
|
||||
return render_template(
|
||||
"admin/scrutiny.html",
|
||||
user=user,
|
||||
active_page="admin_scrutiny",
|
||||
tools=payload.get("data", []),
|
||||
meta=payload.get("meta", {}),
|
||||
stats_summary=stats_summary,
|
||||
scrutiny_filter=scrutiny_filter,
|
||||
moderation_filter=moderation_filter,
|
||||
)
|
||||
|
||||
@@ -26,12 +26,13 @@ def generate_sitemap() -> str:
|
||||
|
||||
conn = connect_db()
|
||||
try:
|
||||
rows = query_all(conn, "SELECT DISTINCT owner, name FROM tools")
|
||||
# Only include approved public tools in sitemap
|
||||
rows = query_all(conn, "SELECT DISTINCT owner, name FROM tools WHERE visibility = 'public' AND moderation_status = 'approved'")
|
||||
for row in rows:
|
||||
tool_path = url_for("web.tool_detail", owner=row["owner"], name=row["name"], _external=True)
|
||||
urls.append(_url_entry(tool_path, "daily", "0.9"))
|
||||
|
||||
categories = query_all(conn, "SELECT DISTINCT category FROM tools WHERE category IS NOT NULL")
|
||||
categories = query_all(conn, "SELECT DISTINCT category FROM tools WHERE category IS NOT NULL AND visibility = 'public' AND moderation_status = 'approved'")
|
||||
for row in categories:
|
||||
cat_path = url_for("web.category", name=row["category"], _external=True)
|
||||
urls.append(_url_entry(cat_path, "weekly", "0.7"))
|
||||
|
||||
@@ -83,6 +83,12 @@
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-gray-700">Manage publishers</span>
|
||||
</a>
|
||||
<a href="{{ url_for('web.admin_scrutiny') }}" class="flex items-center p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<svg class="h-5 w-5 text-gray-400 mr-3" 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>
|
||||
<span class="text-sm font-medium text-gray-700">Scrutiny audit</span>
|
||||
</a>
|
||||
{% if user.role == 'admin' %}
|
||||
<a href="{{ url_for('web.admin_settings') }}" class="flex items-center p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<svg class="h-5 w-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Tool</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Publisher</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Category</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Scrutiny</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Submitted</th>
|
||||
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
@@ -37,11 +37,40 @@
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-gray-900">{{ tool.publisher_name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ tool.category or 'Uncategorized' }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800">
|
||||
{{ tool.category or 'Uncategorized' }}
|
||||
<td class="px-6 py-4">
|
||||
{% if tool.scrutiny_status == 'approved' %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||
Passed
|
||||
</span>
|
||||
{% elif tool.scrutiny_status == 'pending_review' %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800">
|
||||
Warnings
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800">
|
||||
{{ tool.scrutiny_status or 'N/A' }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if tool.scrutiny_report and tool.scrutiny_report.findings %}
|
||||
<button onclick="toggleFindings({{ tool.id }})" class="ml-2 text-xs text-indigo-600 hover:text-indigo-800">
|
||||
({{ tool.scrutiny_report.findings | selectattr('result', 'equalto', 'warning') | list | length }} warnings)
|
||||
</button>
|
||||
<div id="findings-{{ tool.id }}" class="hidden mt-2 text-xs space-y-1 max-w-md">
|
||||
{% for finding in tool.scrutiny_report.findings %}
|
||||
{% if finding.result == 'warning' %}
|
||||
<div class="p-2 rounded bg-yellow-50">
|
||||
<span class="font-medium">{{ finding.check }}:</span>
|
||||
<span class="text-yellow-700">{{ finding.message }}</span>
|
||||
{% if finding.suggestion %}
|
||||
<div class="text-gray-500 italic text-xs">{{ finding.suggestion }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ tool.published_at[:10] if tool.published_at else 'Unknown' }}
|
||||
@@ -100,6 +129,11 @@
|
||||
<script>
|
||||
let currentToolId = null;
|
||||
|
||||
function toggleFindings(toolId) {
|
||||
const el = document.getElementById(`findings-${toolId}`);
|
||||
el.classList.toggle('hidden');
|
||||
}
|
||||
|
||||
async function approveTool(toolId) {
|
||||
if (!confirm('Approve this tool?')) return;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
{% extends "dashboard/base.html" %}
|
||||
|
||||
{% block dashboard_header %}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Scrutiny Audit</h1>
|
||||
<p class="mt-1 text-gray-600">Review automated scrutiny results for all tools</p>
|
||||
</div>
|
||||
<a href="{{ url_for('web.admin_dashboard') }}" class="text-sm text-indigo-600 hover:text-indigo-700">← Back to Admin</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block dashboard_content %}
|
||||
<!-- Stats Summary -->
|
||||
<div class="mb-6 grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div class="text-sm text-gray-500">Auto-Approved</div>
|
||||
<div class="text-2xl font-semibold text-green-600">{{ stats_summary.approved_approved or 0 }}</div>
|
||||
<div class="text-xs text-gray-400">Scrutiny passed</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div class="text-sm text-gray-500">Needs Review</div>
|
||||
<div class="text-2xl font-semibold text-yellow-600">{{ stats_summary.review_pending or 0 }}</div>
|
||||
<div class="text-xs text-gray-400">Has warnings</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div class="text-sm text-gray-500">Manually Approved</div>
|
||||
<div class="text-2xl font-semibold text-blue-600">{{ stats_summary.review_approved or 0 }}</div>
|
||||
<div class="text-xs text-gray-400">With warnings</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div class="text-sm text-gray-500">Total Pending</div>
|
||||
<div class="text-2xl font-semibold text-gray-600">{{ stats_summary.total_pending or 0 }}</div>
|
||||
<div class="text-xs text-gray-400">Awaiting review</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mb-4 flex flex-wrap gap-4">
|
||||
<form method="get" class="flex items-center gap-4">
|
||||
<select name="scrutiny_status" class="px-3 py-2 border border-gray-300 rounded-md text-sm">
|
||||
<option value="">All Scrutiny Status</option>
|
||||
<option value="approved" {% if scrutiny_filter == 'approved' %}selected{% endif %}>Approved</option>
|
||||
<option value="pending_review" {% if scrutiny_filter == 'pending_review' %}selected{% endif %}>Pending Review (Warnings)</option>
|
||||
<option value="pending" {% if scrutiny_filter == 'pending' %}selected{% endif %}>Pending</option>
|
||||
</select>
|
||||
<select name="moderation_status" class="px-3 py-2 border border-gray-300 rounded-md text-sm">
|
||||
<option value="">All Moderation Status</option>
|
||||
<option value="approved" {% if moderation_filter == 'approved' %}selected{% endif %}>Approved</option>
|
||||
<option value="pending" {% if moderation_filter == 'pending' %}selected{% endif %}>Pending</option>
|
||||
<option value="rejected" {% if moderation_filter == 'rejected' %}selected{% endif %}>Rejected</option>
|
||||
</select>
|
||||
<button type="submit" class="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700">Filter</button>
|
||||
{% if scrutiny_filter or moderation_filter %}
|
||||
<a href="{{ url_for('web.admin_scrutiny') }}" class="text-sm text-gray-500 hover:text-gray-700">Clear</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg border border-gray-200">
|
||||
{% if tools %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Tool</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Scrutiny</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Moderation</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Findings</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Published</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{% for tool in tools %}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-4">
|
||||
<div class="text-sm font-medium text-gray-900">
|
||||
<a href="{{ url_for('web.tool_detail', owner=tool.owner, name=tool.name) }}" class="hover:text-indigo-600">
|
||||
{{ tool.owner }}/{{ tool.name }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">v{{ tool.version }} - {{ tool.category or 'Uncategorized' }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-4 whitespace-nowrap">
|
||||
{% if tool.scrutiny_status == 'approved' %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||
Passed
|
||||
</span>
|
||||
{% elif tool.scrutiny_status == 'pending_review' %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800">
|
||||
Warnings
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800">
|
||||
{{ tool.scrutiny_status or 'N/A' }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-4 whitespace-nowrap">
|
||||
{% if tool.moderation_status == 'approved' %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||
Approved
|
||||
</span>
|
||||
{% elif tool.moderation_status == 'pending' %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800">
|
||||
Pending
|
||||
</span>
|
||||
{% elif tool.moderation_status == 'rejected' %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
|
||||
Rejected
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
{% if tool.scrutiny_report and tool.scrutiny_report.findings %}
|
||||
<button onclick="showFindings({{ tool.id }})" class="text-sm text-indigo-600 hover:text-indigo-800">
|
||||
View {{ tool.scrutiny_report.findings | length }} finding(s)
|
||||
</button>
|
||||
<div id="findings-{{ tool.id }}" class="hidden mt-2 text-xs space-y-1">
|
||||
{% for finding in tool.scrutiny_report.findings %}
|
||||
<div class="p-2 rounded {% if finding.result == 'pass' %}bg-green-50{% elif finding.result == 'warning' %}bg-yellow-50{% else %}bg-red-50{% endif %}">
|
||||
<span class="font-medium">{{ finding.check }}:</span>
|
||||
<span class="{% if finding.result == 'pass' %}text-green-700{% elif finding.result == 'warning' %}text-yellow-700{% else %}text-red-700{% endif %}">
|
||||
{{ finding.result }}
|
||||
</span>
|
||||
<div class="text-gray-600">{{ finding.message }}</div>
|
||||
{% if finding.suggestion %}
|
||||
<div class="text-gray-500 italic">Suggestion: {{ finding.suggestion }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-xs text-gray-400">No findings</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ tool.published_at[:10] if tool.published_at else 'Unknown' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if meta.total_pages > 1 %}
|
||||
<div class="bg-gray-50 px-6 py-3 flex items-center justify-between border-t border-gray-200">
|
||||
<div class="text-sm text-gray-700">
|
||||
Page {{ meta.page }} of {{ meta.total_pages }} ({{ meta.total }} total)
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
{% if meta.page > 1 %}
|
||||
<a href="?page={{ meta.page - 1 }}{% if scrutiny_filter %}&scrutiny_status={{ scrutiny_filter }}{% endif %}{% if moderation_filter %}&moderation_status={{ moderation_filter }}{% endif %}" class="px-3 py-1 border rounded text-sm hover:bg-gray-100">Previous</a>
|
||||
{% endif %}
|
||||
{% if meta.page < meta.total_pages %}
|
||||
<a href="?page={{ meta.page + 1 }}{% if scrutiny_filter %}&scrutiny_status={{ scrutiny_filter }}{% endif %}{% if moderation_filter %}&moderation_status={{ moderation_filter }}{% endif %}" class="px-3 py-1 border rounded text-sm hover:bg-gray-100">Next</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="text-center py-12">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No tools found</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Try adjusting your filters.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showFindings(toolId) {
|
||||
const el = document.getElementById(`findings-${toolId}`);
|
||||
el.classList.toggle('hidden');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -67,6 +67,43 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Tags Filter -->
|
||||
{% if all_tags %}
|
||||
<div>
|
||||
<button type="button"
|
||||
onclick="toggleTagsFilter()"
|
||||
class="flex items-center justify-between w-full text-sm font-medium text-gray-700 mb-3">
|
||||
<span>Tags</span>
|
||||
<svg id="tags-chevron" class="w-4 h-4 text-gray-500 transition-transform {{ '' if include_tags or exclude_tags else 'rotate-180' }}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="tags-filter" class="{{ '' if include_tags or exclude_tags else 'hidden' }}">
|
||||
{% if include_tags or exclude_tags %}
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-gray-500">Click to cycle: include/exclude/none</span>
|
||||
<a href="{{ url_for('web.tools', sort=sort, category=current_category) }}" class="text-xs text-indigo-600 hover:underline">Clear</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-xs text-gray-500 mb-2">Click to cycle: include/exclude/none</p>
|
||||
{% endif %}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for tag in all_tags[:20] %}
|
||||
{% set tag_state = 'include' if tag.name in include_tags else ('exclude' if tag.name in exclude_tags else 'neutral') %}
|
||||
<button type="button"
|
||||
onclick="cycleTagState('{{ tag.name }}', '{{ tag_state }}')"
|
||||
class="tag-btn px-2 py-1 text-xs rounded-full border transition-colors
|
||||
{% if tag_state == 'include' %}bg-green-100 border-green-300 text-green-700
|
||||
{% elif tag_state == 'exclude' %}bg-red-100 border-red-300 text-red-700 line-through
|
||||
{% else %}bg-white border-gray-300 text-gray-600 hover:border-gray-400{% endif %}">
|
||||
{{ tag.name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Sort -->
|
||||
<div>
|
||||
<label for="sort-select" class="block text-sm font-medium text-gray-700 mb-3">Sort By</label>
|
||||
@@ -133,6 +170,29 @@
|
||||
<option value="name" {{ 'selected' if sort == 'name' }}>Name (A-Z)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Tags Filter -->
|
||||
{% if all_tags %}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Tags</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for tag in all_tags[:12] %}
|
||||
{% set tag_state = 'include' if tag.name in include_tags else ('exclude' if tag.name in exclude_tags else 'neutral') %}
|
||||
<button type="button"
|
||||
onclick="cycleTagState('{{ tag.name }}', '{{ tag_state }}')"
|
||||
class="px-2 py-1 text-xs rounded-full border transition-colors
|
||||
{% if tag_state == 'include' %}bg-green-100 border-green-300 text-green-700
|
||||
{% elif tag_state == 'exclude' %}bg-red-100 border-red-300 text-red-700 line-through
|
||||
{% else %}bg-white border-gray-300 text-gray-600{% endif %}">
|
||||
{{ tag.name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if include_tags or exclude_tags %}
|
||||
<a href="{{ url_for('web.tools', sort=sort, category=current_category) }}" class="text-xs text-indigo-600 hover:underline mt-2 inline-block">Clear tags</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -160,7 +220,8 @@
|
||||
description=tool.description,
|
||||
category=tool.category,
|
||||
downloads=tool.downloads,
|
||||
version=tool.version
|
||||
version=tool.version,
|
||||
tags=tool.tags
|
||||
) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -170,7 +231,7 @@
|
||||
<nav class="mt-12 flex items-center justify-center" aria-label="Pagination">
|
||||
<div class="flex items-center gap-2">
|
||||
{% if pagination.has_prev %}
|
||||
<a href="{{ url_for('web.tools', page=pagination.prev_num, sort=sort, category=current_category) }}"
|
||||
<a href="{{ url_for('web.tools', page=pagination.prev_num, sort=sort, category=current_category, include_tags=include_tags|join(',') if include_tags else none, exclude_tags=exclude_tags|join(',') if exclude_tags else none) }}"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
|
||||
Previous
|
||||
</a>
|
||||
@@ -181,7 +242,7 @@
|
||||
</span>
|
||||
|
||||
{% if pagination.has_next %}
|
||||
<a href="{{ url_for('web.tools', page=pagination.next_num, sort=sort, category=current_category) }}"
|
||||
<a href="{{ url_for('web.tools', page=pagination.next_num, sort=sort, category=current_category, include_tags=include_tags|join(',') if include_tags else none, exclude_tags=exclude_tags|join(',') if exclude_tags else none) }}"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
|
||||
Next
|
||||
</a>
|
||||
@@ -228,5 +289,47 @@ function updateSort(value) {
|
||||
url.searchParams.set('sort', value);
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
|
||||
function toggleTagsFilter() {
|
||||
const panel = document.getElementById('tags-filter');
|
||||
const chevron = document.getElementById('tags-chevron');
|
||||
panel.classList.toggle('hidden');
|
||||
chevron.classList.toggle('rotate-180');
|
||||
}
|
||||
|
||||
function cycleTagState(tagName, currentState) {
|
||||
const url = new URL(window.location.href);
|
||||
let includeTags = (url.searchParams.get('include_tags') || '').split(',').filter(Boolean);
|
||||
let excludeTags = (url.searchParams.get('exclude_tags') || '').split(',').filter(Boolean);
|
||||
|
||||
// Remove from both lists first
|
||||
includeTags = includeTags.filter(t => t !== tagName);
|
||||
excludeTags = excludeTags.filter(t => t !== tagName);
|
||||
|
||||
// Cycle: neutral -> include -> exclude -> neutral
|
||||
if (currentState === 'neutral') {
|
||||
includeTags.push(tagName);
|
||||
} else if (currentState === 'include') {
|
||||
excludeTags.push(tagName);
|
||||
}
|
||||
// else: exclude -> neutral (already removed)
|
||||
|
||||
// Update URL params
|
||||
if (includeTags.length > 0) {
|
||||
url.searchParams.set('include_tags', includeTags.join(','));
|
||||
} else {
|
||||
url.searchParams.delete('include_tags');
|
||||
}
|
||||
if (excludeTags.length > 0) {
|
||||
url.searchParams.set('exclude_tags', excludeTags.join(','));
|
||||
} else {
|
||||
url.searchParams.delete('exclude_tags');
|
||||
}
|
||||
|
||||
// Reset to page 1 when filters change
|
||||
url.searchParams.delete('page');
|
||||
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user