Add enhanced search and filtering (M2 feature)
- Add /api/v1/tags endpoint for listing available tags - Enhance search API with tag filtering (AND logic), multi-category filtering (OR logic), owner filter, download range, and date range - Add faceted response support (category/tag/owner counts) - Update registry client with new search parameters and get_tags method - Add CLI search options: -t/--tag, -o/--owner, --min-downloads, --popular, --new, --since, --before, --json, --show-facets - Add new 'registry tags' CLI subcommand - Add web UI filter sidebar with checkboxes, dropdowns, and active filter chips with URL-based state management - Display clickable tags on tool cards Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
{# Tool card macro #}
|
||||
{% macro tool_card(tool=none, owner=none, name=none, description=none, category=none, downloads=none, version=none) %}
|
||||
{% macro tool_card(tool=none, owner=none, name=none, description=none, category=none, downloads=none, version=none, tags=none) %}
|
||||
{% if tool is none %}
|
||||
{% set tool = {
|
||||
"owner": owner,
|
||||
@@ -7,7 +7,8 @@
|
||||
"description": description,
|
||||
"category": category,
|
||||
"downloads": downloads,
|
||||
"version": version
|
||||
"version": version,
|
||||
"tags": tags
|
||||
} %}
|
||||
{% endif %}
|
||||
<article class="bg-white rounded-lg border border-gray-200 shadow-sm hover:shadow-md transition-shadow p-4 relative">
|
||||
@@ -39,6 +40,23 @@
|
||||
{{ tool.description or 'No description available.' }}
|
||||
</p>
|
||||
|
||||
<!-- Tags -->
|
||||
{% if tool.tags %}
|
||||
<div class="mt-3 flex flex-wrap gap-1">
|
||||
{% for tag in tool.tags[:5] %}
|
||||
<a href="{{ url_for('web.search', tags=tag) }}"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700 hover:bg-indigo-100 hover:text-indigo-700 transition-colors">
|
||||
{{ tag }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% if tool.tags|length > 5 %}
|
||||
<span class="inline-flex items-center px-2 py-0.5 text-xs text-gray-500">
|
||||
+{{ tool.tags|length - 5 }} more
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Meta info -->
|
||||
<div class="mt-4 flex items-center justify-between text-sm text-gray-500">
|
||||
<span class="flex items-center">
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<div class="bg-gray-50 min-h-screen">
|
||||
<!-- Search Header -->
|
||||
<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-8">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<form action="{{ url_for('web.search') }}" method="GET" class="max-w-2xl mx-auto">
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
@@ -31,87 +31,266 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{% if query %}
|
||||
<!-- Results Count -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">
|
||||
{% if results|length == 1 %}
|
||||
1 result for "{{ query }}"
|
||||
<div class="lg:grid lg:grid-cols-4 lg:gap-8">
|
||||
<!-- Filter Sidebar -->
|
||||
<aside class="hidden lg:block">
|
||||
<div class="sticky top-4 space-y-6">
|
||||
<!-- Active Filters -->
|
||||
{% if active_categories or active_tags or active_owner %}
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-semibold text-gray-900">Active Filters</h3>
|
||||
<a href="{{ url_for('web.search', q=query) }}" class="text-xs text-indigo-600 hover:underline">Clear all</a>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for cat in active_categories %}
|
||||
<span class="inline-flex items-center px-2 py-1 text-xs bg-indigo-100 text-indigo-700 rounded-full">
|
||||
{{ cat }}
|
||||
<button onclick="removeFilter('categories', '{{ cat }}')" class="ml-1 hover:text-indigo-900">×</button>
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% for tag in active_tags %}
|
||||
<span class="inline-flex items-center px-2 py-1 text-xs bg-green-100 text-green-700 rounded-full">
|
||||
{{ tag }}
|
||||
<button onclick="removeFilter('tags', '{{ tag }}')" class="ml-1 hover:text-green-900">×</button>
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% if active_owner %}
|
||||
<span class="inline-flex items-center px-2 py-1 text-xs bg-purple-100 text-purple-700 rounded-full">
|
||||
by: {{ active_owner }}
|
||||
<button onclick="removeFilter('owner', '')" class="ml-1 hover:text-purple-900">×</button>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Category Facets -->
|
||||
{% if facets.categories %}
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">Categories</h3>
|
||||
<div class="space-y-2">
|
||||
{% for cat in facets.categories[:10] %}
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
onchange="toggleFilter('categories', '{{ cat.name }}', this.checked)"
|
||||
{% if cat.name in active_categories %}checked{% endif %}
|
||||
class="h-4 w-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500">
|
||||
<span class="ml-2 text-sm text-gray-600">{{ cat.name }}</span>
|
||||
<span class="ml-auto text-xs text-gray-400">({{ cat.count }})</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Tag Facets -->
|
||||
{% if facets.tags %}
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">Tags</h3>
|
||||
<div class="space-y-2">
|
||||
{% for tag in facets.tags[:10] %}
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
onchange="toggleFilter('tags', '{{ tag.name }}', this.checked)"
|
||||
{% if tag.name in active_tags %}checked{% endif %}
|
||||
class="h-4 w-4 text-green-600 border-gray-300 rounded focus:ring-green-500">
|
||||
<span class="ml-2 text-sm text-gray-600">{{ tag.name }}</span>
|
||||
<span class="ml-auto text-xs text-gray-400">({{ tag.count }})</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Owner Facets -->
|
||||
{% if facets.owners %}
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">Publisher</h3>
|
||||
<select onchange="setFilter('owner', this.value)" class="w-full text-sm border-gray-300 rounded-md">
|
||||
<option value="">All publishers</option>
|
||||
{% for owner in facets.owners[:15] %}
|
||||
<option value="{{ owner.name }}" {% if owner.name == active_owner %}selected{% endif %}>
|
||||
{{ owner.name }} ({{ owner.count }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Sort -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">Sort By</h3>
|
||||
<select onchange="setFilter('sort', this.value)" class="w-full text-sm border-gray-300 rounded-md">
|
||||
<option value="relevance" {% if active_sort == 'relevance' %}selected{% endif %}>Relevance</option>
|
||||
<option value="downloads" {% if active_sort == 'downloads' %}selected{% endif %}>Downloads</option>
|
||||
<option value="published_at" {% if active_sort == 'published_at' %}selected{% endif %}>Recently Published</option>
|
||||
<option value="name" {% if active_sort == 'name' %}selected{% endif %}>Name</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Mobile Filters Button -->
|
||||
<div class="lg:hidden mb-4">
|
||||
<button onclick="toggleMobileFilters()" class="w-full flex items-center justify-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
|
||||
<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="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/>
|
||||
</svg>
|
||||
Filters
|
||||
{% if active_categories or active_tags or active_owner %}
|
||||
<span class="ml-2 px-2 py-0.5 text-xs bg-indigo-100 text-indigo-700 rounded-full">
|
||||
{{ (active_categories|length) + (active_tags|length) + (1 if active_owner else 0) }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Filters Panel (hidden by default) -->
|
||||
<div id="mobile-filters" class="hidden lg:hidden mb-6 bg-white rounded-lg border border-gray-200 p-4">
|
||||
<!-- Active filter chips -->
|
||||
{% if active_categories or active_tags or active_owner %}
|
||||
<div class="flex flex-wrap gap-2 mb-4 pb-4 border-b border-gray-200">
|
||||
{% for cat in active_categories %}
|
||||
<span class="inline-flex items-center px-2 py-1 text-xs bg-indigo-100 text-indigo-700 rounded-full">
|
||||
{{ cat }}
|
||||
<button onclick="removeFilter('categories', '{{ cat }}')" class="ml-1">×</button>
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% for tag in active_tags %}
|
||||
<span class="inline-flex items-center px-2 py-1 text-xs bg-green-100 text-green-700 rounded-full">
|
||||
{{ tag }}
|
||||
<button onclick="removeFilter('tags', '{{ tag }}')" class="ml-1">×</button>
|
||||
</span>
|
||||
{% endfor %}
|
||||
<a href="{{ url_for('web.search', q=query) }}" class="text-xs text-indigo-600 hover:underline self-center ml-2">Clear all</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Sort -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Sort</label>
|
||||
<select onchange="setFilter('sort', this.value)" class="w-full text-sm border-gray-300 rounded-md">
|
||||
<option value="relevance" {% if active_sort == 'relevance' %}selected{% endif %}>Relevance</option>
|
||||
<option value="downloads" {% if active_sort == 'downloads' %}selected{% endif %}>Downloads</option>
|
||||
<option value="published_at" {% if active_sort == 'published_at' %}selected{% endif %}>Recent</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Publisher -->
|
||||
{% if facets.owners %}
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Publisher</label>
|
||||
<select onchange="setFilter('owner', this.value)" class="w-full text-sm border-gray-300 rounded-md">
|
||||
<option value="">All</option>
|
||||
{% for owner in facets.owners[:10] %}
|
||||
<option value="{{ owner.name }}" {% if owner.name == active_owner %}selected{% endif %}>{{ owner.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Category chips -->
|
||||
{% if facets.categories %}
|
||||
<div class="mt-4">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-2">Categories</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for cat in facets.categories[:8] %}
|
||||
<button onclick="toggleFilter('categories', '{{ cat.name }}', {{ 'false' if cat.name in active_categories else 'true' }})"
|
||||
class="px-2 py-1 text-xs rounded-full border {{ 'bg-indigo-100 border-indigo-300 text-indigo-700' if cat.name in active_categories else 'bg-white border-gray-300 text-gray-600 hover:border-indigo-300' }}">
|
||||
{{ cat.name }} ({{ cat.count }})
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div class="lg:col-span-3">
|
||||
<!-- Results Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">
|
||||
{{ pagination.total if pagination else results|length }} results for "{{ query }}"
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{% if results %}
|
||||
<!-- Search Results -->
|
||||
<div class="space-y-4">
|
||||
{% for tool in results %}
|
||||
{{ tool_card(
|
||||
owner=tool.owner,
|
||||
name=tool.name,
|
||||
description=tool.description,
|
||||
category=tool.category,
|
||||
downloads=tool.downloads,
|
||||
version=tool.version,
|
||||
tags=tool.tags
|
||||
) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if pagination and pagination.pages > 1 %}
|
||||
<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.search', q=query, page=pagination.prev_num, categories=active_categories|join(',') if active_categories else none, tags=active_tags|join(',') if active_tags else none, owner=active_owner, sort=active_sort) }}"
|
||||
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>
|
||||
{% endif %}
|
||||
|
||||
<span class="px-4 py-2 text-sm text-gray-700">
|
||||
Page {{ pagination.page }} of {{ pagination.pages }}
|
||||
</span>
|
||||
|
||||
{% if pagination.has_next %}
|
||||
<a href="{{ url_for('web.search', q=query, page=pagination.next_num, categories=active_categories|join(',') if active_categories else none, tags=active_tags|join(',') if active_tags else none, owner=active_owner, sort=active_sort) }}"
|
||||
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>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
{{ results|length }} results for "{{ query }}"
|
||||
{% endif %}
|
||||
</h1>
|
||||
</div>
|
||||
<!-- No Results -->
|
||||
<div class="text-center py-16 bg-white rounded-lg border border-gray-200">
|
||||
<svg class="mx-auto w-16 h-16 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<h2 class="mt-4 text-lg font-medium text-gray-900">No results found</h2>
|
||||
<p class="mt-2 text-gray-600">
|
||||
We couldn't find any tools matching "{{ query }}"{% if active_categories or active_tags %} with the current filters{% endif %}.
|
||||
</p>
|
||||
|
||||
{% if results %}
|
||||
<!-- Search Results -->
|
||||
<div class="space-y-4">
|
||||
{% for tool in results %}
|
||||
{{ tool_card(
|
||||
owner=tool.owner,
|
||||
name=tool.name,
|
||||
description=tool.description,
|
||||
category=tool.category,
|
||||
downloads=tool.downloads,
|
||||
version=tool.version
|
||||
) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if active_categories or active_tags or active_owner %}
|
||||
<div class="mt-6">
|
||||
<a href="{{ url_for('web.search', q=query) }}"
|
||||
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">
|
||||
Clear Filters
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if pagination and pagination.pages > 1 %}
|
||||
<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.search', q=query, page=pagination.prev_num) }}"
|
||||
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>
|
||||
{% endif %}
|
||||
|
||||
<span class="px-4 py-2 text-sm text-gray-700">
|
||||
Page {{ pagination.page }} of {{ pagination.pages }}
|
||||
</span>
|
||||
|
||||
{% if pagination.has_next %}
|
||||
<a href="{{ url_for('web.search', q=query, page=pagination.next_num) }}"
|
||||
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>
|
||||
<div class="mt-8">
|
||||
<h3 class="text-sm font-medium text-gray-700 mb-4">Suggestions:</h3>
|
||||
<ul class="text-sm text-gray-600 space-y-2">
|
||||
<li>Check your spelling</li>
|
||||
<li>Try more general keywords</li>
|
||||
<li>Remove some filters</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<!-- No Results -->
|
||||
<div class="text-center py-16">
|
||||
<svg class="mx-auto w-16 h-16 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<h2 class="mt-4 text-lg font-medium text-gray-900">No results found</h2>
|
||||
<p class="mt-2 text-gray-600">
|
||||
We couldn't find any tools matching "{{ query }}".
|
||||
</p>
|
||||
|
||||
<div class="mt-8">
|
||||
<h3 class="text-sm font-medium text-gray-700 mb-4">Suggestions:</h3>
|
||||
<ul class="text-sm text-gray-600 space-y-2">
|
||||
<li>Check your spelling</li>
|
||||
<li>Try more general keywords</li>
|
||||
<li>Use fewer keywords</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<a href="{{ url_for('web.tools') }}"
|
||||
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">
|
||||
Browse All Tools
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<!-- Initial Search State -->
|
||||
@@ -140,4 +319,61 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleFilter(filterType, value, checked) {
|
||||
const url = new URL(window.location.href);
|
||||
const params = url.searchParams;
|
||||
|
||||
// Get current values
|
||||
let values = (params.get(filterType) || '').split(',').filter(Boolean);
|
||||
|
||||
if (checked) {
|
||||
if (!values.includes(value)) {
|
||||
values.push(value);
|
||||
}
|
||||
} else {
|
||||
values = values.filter(v => v !== value);
|
||||
}
|
||||
|
||||
// Update or remove param
|
||||
if (values.length > 0) {
|
||||
params.set(filterType, values.join(','));
|
||||
} else {
|
||||
params.delete(filterType);
|
||||
}
|
||||
|
||||
// Reset to page 1 when filters change
|
||||
params.delete('page');
|
||||
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
|
||||
function setFilter(filterType, value) {
|
||||
const url = new URL(window.location.href);
|
||||
const params = url.searchParams;
|
||||
|
||||
if (value) {
|
||||
params.set(filterType, value);
|
||||
} else {
|
||||
params.delete(filterType);
|
||||
}
|
||||
|
||||
params.delete('page');
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
|
||||
function removeFilter(filterType, value) {
|
||||
if (filterType === 'owner') {
|
||||
setFilter('owner', '');
|
||||
} else {
|
||||
toggleFilter(filterType, value, false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMobileFilters() {
|
||||
const panel = document.getElementById('mobile-filters');
|
||||
panel.classList.toggle('hidden');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user