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 <noreply@anthropic.com>
This commit is contained in:
parent
614b145f5f
commit
14448408af
|
|
@ -135,9 +135,27 @@ def main():
|
||||||
p_reg_search = registry_sub.add_parser("search", help="Search for tools")
|
p_reg_search = registry_sub.add_parser("search", help="Search for tools")
|
||||||
p_reg_search.add_argument("query", help="Search query")
|
p_reg_search.add_argument("query", help="Search query")
|
||||||
p_reg_search.add_argument("-c", "--category", help="Filter by category")
|
p_reg_search.add_argument("-c", "--category", help="Filter by category")
|
||||||
|
p_reg_search.add_argument("-t", "--tag", action="append", dest="tags", help="Filter by tag (repeatable, AND logic)")
|
||||||
|
p_reg_search.add_argument("-o", "--owner", help="Filter by publisher/owner")
|
||||||
|
p_reg_search.add_argument("--min-downloads", type=int, help="Minimum downloads")
|
||||||
|
p_reg_search.add_argument("--popular", action="store_true", help="Shortcut for --min-downloads 100")
|
||||||
|
p_reg_search.add_argument("--new", action="store_true", help="Shortcut for --max-downloads 10")
|
||||||
|
p_reg_search.add_argument("--since", help="Published after date (YYYY-MM-DD)")
|
||||||
|
p_reg_search.add_argument("--before", help="Published before date (YYYY-MM-DD)")
|
||||||
|
p_reg_search.add_argument("-s", "--sort", choices=["relevance", "downloads", "published_at", "name"], default="relevance", help="Sort by field")
|
||||||
p_reg_search.add_argument("-l", "--limit", type=int, help="Max results (default: 20)")
|
p_reg_search.add_argument("-l", "--limit", type=int, help="Max results (default: 20)")
|
||||||
|
p_reg_search.add_argument("--json", action="store_true", help="Output as JSON")
|
||||||
|
p_reg_search.add_argument("--show-facets", action="store_true", help="Show category/tag counts")
|
||||||
|
p_reg_search.add_argument("--deprecated", action="store_true", help="Include deprecated tools")
|
||||||
p_reg_search.set_defaults(func=cmd_registry)
|
p_reg_search.set_defaults(func=cmd_registry)
|
||||||
|
|
||||||
|
# registry tags
|
||||||
|
p_reg_tags = registry_sub.add_parser("tags", help="List available tags")
|
||||||
|
p_reg_tags.add_argument("-c", "--category", help="Filter tags by category")
|
||||||
|
p_reg_tags.add_argument("-l", "--limit", type=int, default=50, help="Max tags to show (default: 50)")
|
||||||
|
p_reg_tags.add_argument("--json", action="store_true", help="Output as JSON")
|
||||||
|
p_reg_tags.set_defaults(func=cmd_registry)
|
||||||
|
|
||||||
# registry install
|
# registry install
|
||||||
p_reg_install = registry_sub.add_parser("install", help="Install a tool from registry")
|
p_reg_install = registry_sub.add_parser("install", help="Install a tool from registry")
|
||||||
p_reg_install.add_argument("tool", help="Tool to install (owner/name or name)")
|
p_reg_install.add_argument("tool", help="Tool to install (owner/name or name)")
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"""Registry commands."""
|
"""Registry commands."""
|
||||||
|
|
||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -15,6 +16,8 @@ def cmd_registry(args):
|
||||||
|
|
||||||
if args.registry_cmd == "search":
|
if args.registry_cmd == "search":
|
||||||
return _cmd_registry_search(args)
|
return _cmd_registry_search(args)
|
||||||
|
elif args.registry_cmd == "tags":
|
||||||
|
return _cmd_registry_tags(args)
|
||||||
elif args.registry_cmd == "install":
|
elif args.registry_cmd == "install":
|
||||||
return _cmd_registry_install(args)
|
return _cmd_registry_install(args)
|
||||||
elif args.registry_cmd == "uninstall":
|
elif args.registry_cmd == "uninstall":
|
||||||
|
|
@ -33,6 +36,7 @@ def cmd_registry(args):
|
||||||
# Default: show registry help
|
# Default: show registry help
|
||||||
print("Registry commands:")
|
print("Registry commands:")
|
||||||
print(" search <query> Search for tools")
|
print(" search <query> Search for tools")
|
||||||
|
print(" tags List available tags")
|
||||||
print(" install <tool> Install a tool")
|
print(" install <tool> Install a tool")
|
||||||
print(" uninstall <tool> Uninstall a tool")
|
print(" uninstall <tool> Uninstall a tool")
|
||||||
print(" info <tool> Show tool information")
|
print(" info <tool> Show tool information")
|
||||||
|
|
@ -49,27 +53,73 @@ def _cmd_registry_search(args):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client = get_client()
|
client = get_client()
|
||||||
|
|
||||||
|
# Handle shortcut flags
|
||||||
|
min_downloads = getattr(args, 'min_downloads', None)
|
||||||
|
max_downloads = None
|
||||||
|
if getattr(args, 'popular', False):
|
||||||
|
min_downloads = 100
|
||||||
|
if getattr(args, 'new', False):
|
||||||
|
max_downloads = 10
|
||||||
|
|
||||||
results = client.search_tools(
|
results = client.search_tools(
|
||||||
query=args.query,
|
query=args.query,
|
||||||
category=args.category,
|
category=getattr(args, 'category', None),
|
||||||
per_page=args.limit or 20
|
tags=getattr(args, 'tags', None),
|
||||||
|
owner=getattr(args, 'owner', None),
|
||||||
|
min_downloads=min_downloads,
|
||||||
|
max_downloads=max_downloads,
|
||||||
|
published_after=getattr(args, 'since', None),
|
||||||
|
published_before=getattr(args, 'before', None),
|
||||||
|
include_deprecated=getattr(args, 'deprecated', False),
|
||||||
|
include_facets=getattr(args, 'show_facets', False),
|
||||||
|
per_page=args.limit or 20,
|
||||||
|
sort=getattr(args, 'sort', 'relevance')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# JSON output
|
||||||
|
if getattr(args, 'json', False):
|
||||||
|
output = {
|
||||||
|
"query": args.query,
|
||||||
|
"total": results.total,
|
||||||
|
"results": results.data
|
||||||
|
}
|
||||||
|
if results.facets:
|
||||||
|
output["facets"] = results.facets
|
||||||
|
print(json.dumps(output, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
if not results.data:
|
if not results.data:
|
||||||
print(f"No tools found matching '{args.query}'")
|
print(f"No tools found matching '{args.query}'")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
print(f"Found {results.total} tools:\n")
|
print(f"Found {results.total} tools matching \"{args.query}\":")
|
||||||
|
|
||||||
|
# Show facets summary if requested
|
||||||
|
if results.facets:
|
||||||
|
cats = results.facets.get("categories", [])[:5]
|
||||||
|
tags = results.facets.get("tags", [])[:5]
|
||||||
|
if cats:
|
||||||
|
cat_str = ", ".join(f"{c['name']} ({c['count']})" for c in cats)
|
||||||
|
print(f"\nCategories: {cat_str}")
|
||||||
|
if tags:
|
||||||
|
tag_str = ", ".join(f"{t['name']} ({t['count']})" for t in tags)
|
||||||
|
print(f"Top Tags: {tag_str}")
|
||||||
|
|
||||||
|
print()
|
||||||
for tool in results.data:
|
for tool in results.data:
|
||||||
owner = tool.get("owner", "")
|
owner = tool.get("owner", "")
|
||||||
name = tool.get("name", "")
|
name = tool.get("name", "")
|
||||||
version = tool.get("version", "")
|
version = tool.get("version", "")
|
||||||
desc = tool.get("description", "")
|
desc = tool.get("description", "")
|
||||||
downloads = tool.get("downloads", 0)
|
downloads = tool.get("downloads", 0)
|
||||||
|
tags = tool.get("tags", [])
|
||||||
|
|
||||||
print(f" {owner}/{name} v{version}")
|
print(f" {owner}/{name} v{version}")
|
||||||
print(f" {desc[:60]}{'...' if len(desc) > 60 else ''}")
|
print(f" {desc[:60]}{'...' if len(desc) > 60 else ''}")
|
||||||
print(f" Downloads: {downloads}")
|
if tags:
|
||||||
|
print(f" Tags: {', '.join(tags[:5])}")
|
||||||
|
print(f" Downloads: {downloads:,}")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
if results.total_pages > 1:
|
if results.total_pages > 1:
|
||||||
|
|
@ -92,6 +142,43 @@ def _cmd_registry_search(args):
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_registry_tags(args):
|
||||||
|
"""List available tags."""
|
||||||
|
from ..registry_client import RegistryError, get_client
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
tags = client.get_tags(
|
||||||
|
category=getattr(args, 'category', None),
|
||||||
|
limit=getattr(args, 'limit', 50)
|
||||||
|
)
|
||||||
|
|
||||||
|
# JSON output
|
||||||
|
if getattr(args, 'json', False):
|
||||||
|
print(json.dumps({"tags": tags}, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not tags:
|
||||||
|
print("No tags found")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(f"Available tags ({len(tags)}):\n")
|
||||||
|
for tag in tags:
|
||||||
|
print(f" {tag['name']:20} ({tag['count']} tools)")
|
||||||
|
|
||||||
|
except RegistryError as e:
|
||||||
|
if e.code == "CONNECTION_ERROR":
|
||||||
|
print("Could not connect to the registry.", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print(f"Error: {e.message}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _cmd_registry_install(args):
|
def _cmd_registry_install(args):
|
||||||
"""Install a tool from the registry."""
|
"""Install a tool from the registry."""
|
||||||
from ..registry_client import RegistryError
|
from ..registry_client import RegistryError
|
||||||
|
|
|
||||||
|
|
@ -482,14 +482,77 @@ def create_app() -> Flask:
|
||||||
page, per_page, sort, order, error = parse_pagination("/tools/search", "downloads")
|
page, per_page, sort, order, error = parse_pagination("/tools/search", "downloads")
|
||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
category = request.args.get("category")
|
|
||||||
offset = (page - 1) * per_page
|
offset = (page - 1) * per_page
|
||||||
|
|
||||||
where_clause = "WHERE tools_fts MATCH ?"
|
# Parse filter parameters
|
||||||
|
category = request.args.get("category") # Single category (backward compat)
|
||||||
|
categories_param = request.args.get("categories", "") # Multi-category (OR logic)
|
||||||
|
tags_param = request.args.get("tags", "") # Tags (AND logic)
|
||||||
|
owner_filter = request.args.get("owner")
|
||||||
|
min_downloads = request.args.get("min_downloads", type=int)
|
||||||
|
max_downloads = request.args.get("max_downloads", type=int)
|
||||||
|
published_after = request.args.get("published_after")
|
||||||
|
published_before = request.args.get("published_before")
|
||||||
|
include_deprecated = request.args.get("deprecated", "false").lower() == "true"
|
||||||
|
include_facets = request.args.get("include_facets", "false").lower() == "true"
|
||||||
|
|
||||||
|
# Parse multi-value params
|
||||||
|
categories = [c.strip() for c in categories_param.split(",") if c.strip()]
|
||||||
|
if category and category not in categories:
|
||||||
|
categories.append(category)
|
||||||
|
tags = [t.strip() for t in tags_param.split(",") if t.strip()]
|
||||||
|
|
||||||
|
# Build WHERE clause
|
||||||
|
where_clauses = ["tools_fts MATCH ?"]
|
||||||
params: List[Any] = [query_text]
|
params: List[Any] = [query_text]
|
||||||
if category:
|
|
||||||
where_clause += " AND tools.category = ?"
|
if categories:
|
||||||
params.append(category)
|
placeholders = ",".join(["?" for _ in categories])
|
||||||
|
where_clauses.append(f"tools.category IN ({placeholders})")
|
||||||
|
params.extend(categories)
|
||||||
|
|
||||||
|
if owner_filter:
|
||||||
|
where_clauses.append("tools.owner = ?")
|
||||||
|
params.append(owner_filter)
|
||||||
|
|
||||||
|
if min_downloads is not None:
|
||||||
|
where_clauses.append("tools.downloads >= ?")
|
||||||
|
params.append(min_downloads)
|
||||||
|
|
||||||
|
if max_downloads is not None:
|
||||||
|
where_clauses.append("tools.downloads <= ?")
|
||||||
|
params.append(max_downloads)
|
||||||
|
|
||||||
|
if published_after:
|
||||||
|
where_clauses.append("tools.published_at >= ?")
|
||||||
|
params.append(published_after)
|
||||||
|
|
||||||
|
if published_before:
|
||||||
|
where_clauses.append("tools.published_at <= ?")
|
||||||
|
params.append(published_before)
|
||||||
|
|
||||||
|
if not include_deprecated:
|
||||||
|
where_clauses.append("tools.deprecated = 0")
|
||||||
|
|
||||||
|
where_clause = "WHERE " + " AND ".join(where_clauses)
|
||||||
|
|
||||||
|
# Tag filtering CTE (AND logic - must have ALL specified tags)
|
||||||
|
tag_cte = ""
|
||||||
|
tag_join = ""
|
||||||
|
if tags:
|
||||||
|
tag_placeholders = ",".join(["?" for _ in tags])
|
||||||
|
tag_cte = f"""
|
||||||
|
tag_matches AS (
|
||||||
|
SELECT tools.id
|
||||||
|
FROM tools, json_each(tools.tags) AS tag
|
||||||
|
WHERE tag.value IN ({tag_placeholders})
|
||||||
|
GROUP BY tools.id
|
||||||
|
HAVING COUNT(DISTINCT tag.value) = ?
|
||||||
|
),
|
||||||
|
"""
|
||||||
|
tag_join = "JOIN tag_matches tm ON m.id = tm.id"
|
||||||
|
# Prepend tag params
|
||||||
|
params = tags + [len(tags)] + params
|
||||||
|
|
||||||
order_dir = "DESC" if order == "desc" else "ASC"
|
order_dir = "DESC" if order == "desc" else "ASC"
|
||||||
if sort == "relevance":
|
if sort == "relevance":
|
||||||
|
|
@ -500,46 +563,57 @@ def create_app() -> Flask:
|
||||||
rows = query_all(
|
rows = query_all(
|
||||||
g.db,
|
g.db,
|
||||||
f"""
|
f"""
|
||||||
WITH matches AS (
|
WITH {tag_cte}
|
||||||
|
matches AS (
|
||||||
SELECT tools.*, bm25(tools_fts) AS rank
|
SELECT tools.*, bm25(tools_fts) AS rank
|
||||||
FROM tools_fts
|
FROM tools_fts
|
||||||
JOIN tools ON tools_fts.rowid = tools.id
|
JOIN tools ON tools_fts.rowid = tools.id
|
||||||
{where_clause}
|
{where_clause}
|
||||||
),
|
),
|
||||||
|
filtered AS (
|
||||||
|
SELECT m.* FROM matches m
|
||||||
|
{tag_join}
|
||||||
|
),
|
||||||
latest_any AS (
|
latest_any AS (
|
||||||
SELECT owner, name, MAX(id) AS max_id
|
SELECT owner, name, MAX(id) AS max_id
|
||||||
FROM matches
|
FROM filtered
|
||||||
GROUP BY owner, name
|
GROUP BY owner, name
|
||||||
),
|
),
|
||||||
latest_stable AS (
|
latest_stable AS (
|
||||||
SELECT owner, name, MAX(id) AS max_id
|
SELECT owner, name, MAX(id) AS max_id
|
||||||
FROM matches
|
FROM filtered
|
||||||
WHERE version NOT LIKE '%-%'
|
WHERE version NOT LIKE '%-%'
|
||||||
GROUP BY owner, name
|
GROUP BY owner, name
|
||||||
)
|
)
|
||||||
SELECT m.* FROM matches m
|
SELECT f.* FROM filtered f
|
||||||
JOIN (
|
JOIN (
|
||||||
SELECT a.owner, a.name, COALESCE(s.max_id, a.max_id) AS max_id
|
SELECT a.owner, a.name, COALESCE(s.max_id, a.max_id) AS max_id
|
||||||
FROM latest_any a
|
FROM latest_any a
|
||||||
LEFT JOIN latest_stable s ON s.owner = a.owner AND s.name = a.name
|
LEFT JOIN latest_stable s ON s.owner = a.owner AND s.name = a.name
|
||||||
) latest
|
) latest
|
||||||
ON m.owner = latest.owner AND m.name = latest.name AND m.id = latest.max_id
|
ON f.owner = latest.owner AND f.name = latest.name AND f.id = latest.max_id
|
||||||
ORDER BY {order_sql}
|
ORDER BY {order_sql}
|
||||||
LIMIT ? OFFSET ?
|
LIMIT ? OFFSET ?
|
||||||
""",
|
""",
|
||||||
params + [per_page, offset],
|
params + [per_page, offset],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Count query (reuse same params without pagination)
|
||||||
count_row = query_one(
|
count_row = query_one(
|
||||||
g.db,
|
g.db,
|
||||||
f"""
|
f"""
|
||||||
WITH matches AS (
|
WITH {tag_cte}
|
||||||
|
matches AS (
|
||||||
SELECT tools.*
|
SELECT tools.*
|
||||||
FROM tools_fts
|
FROM tools_fts
|
||||||
JOIN tools ON tools_fts.rowid = tools.id
|
JOIN tools ON tools_fts.rowid = tools.id
|
||||||
{where_clause}
|
{where_clause}
|
||||||
|
),
|
||||||
|
filtered AS (
|
||||||
|
SELECT m.* FROM matches m
|
||||||
|
{tag_join}
|
||||||
)
|
)
|
||||||
SELECT COUNT(DISTINCT owner || '/' || name) AS total FROM matches
|
SELECT COUNT(DISTINCT owner || '/' || name) AS total FROM filtered
|
||||||
""",
|
""",
|
||||||
params,
|
params,
|
||||||
)
|
)
|
||||||
|
|
@ -560,7 +634,90 @@ def create_app() -> Flask:
|
||||||
"score": score,
|
"score": score,
|
||||||
})
|
})
|
||||||
|
|
||||||
return jsonify({"data": data, "meta": paginate(page, per_page, total)})
|
result: dict = {"data": data, "meta": paginate(page, per_page, total)}
|
||||||
|
|
||||||
|
# Compute facets if requested
|
||||||
|
if include_facets:
|
||||||
|
# Category facets
|
||||||
|
cat_rows = query_all(
|
||||||
|
g.db,
|
||||||
|
f"""
|
||||||
|
WITH {tag_cte}
|
||||||
|
matches AS (
|
||||||
|
SELECT tools.*
|
||||||
|
FROM tools_fts
|
||||||
|
JOIN tools ON tools_fts.rowid = tools.id
|
||||||
|
{where_clause}
|
||||||
|
),
|
||||||
|
filtered AS (
|
||||||
|
SELECT m.* FROM matches m
|
||||||
|
{tag_join}
|
||||||
|
)
|
||||||
|
SELECT category, COUNT(DISTINCT owner || '/' || name) AS count
|
||||||
|
FROM filtered
|
||||||
|
WHERE category IS NOT NULL
|
||||||
|
GROUP BY category
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT 20
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tag facets
|
||||||
|
tag_rows = query_all(
|
||||||
|
g.db,
|
||||||
|
f"""
|
||||||
|
WITH {tag_cte}
|
||||||
|
matches AS (
|
||||||
|
SELECT tools.*
|
||||||
|
FROM tools_fts
|
||||||
|
JOIN tools ON tools_fts.rowid = tools.id
|
||||||
|
{where_clause}
|
||||||
|
),
|
||||||
|
filtered AS (
|
||||||
|
SELECT m.* FROM matches m
|
||||||
|
{tag_join}
|
||||||
|
)
|
||||||
|
SELECT tag.value AS name, COUNT(DISTINCT filtered.owner || '/' || filtered.name) AS count
|
||||||
|
FROM filtered, json_each(filtered.tags) AS tag
|
||||||
|
GROUP BY tag.value
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT 30
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Owner facets
|
||||||
|
owner_rows = query_all(
|
||||||
|
g.db,
|
||||||
|
f"""
|
||||||
|
WITH {tag_cte}
|
||||||
|
matches AS (
|
||||||
|
SELECT tools.*
|
||||||
|
FROM tools_fts
|
||||||
|
JOIN tools ON tools_fts.rowid = tools.id
|
||||||
|
{where_clause}
|
||||||
|
),
|
||||||
|
filtered AS (
|
||||||
|
SELECT m.* FROM matches m
|
||||||
|
{tag_join}
|
||||||
|
)
|
||||||
|
SELECT owner, COUNT(DISTINCT owner || '/' || name) AS count
|
||||||
|
FROM filtered
|
||||||
|
GROUP BY owner
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT 20
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
result["facets"] = {
|
||||||
|
"categories": [{"name": r["category"], "count": r["count"]} for r in cat_rows],
|
||||||
|
"tags": [{"name": r["name"], "count": r["count"]} for r in tag_rows],
|
||||||
|
"owners": [{"name": r["owner"], "count": r["count"]} for r in owner_rows],
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
@app.route("/api/v1/tools/<owner>/<name>", methods=["GET"])
|
@app.route("/api/v1/tools/<owner>/<name>", methods=["GET"])
|
||||||
def get_tool(owner: str, name: str) -> Response:
|
def get_tool(owner: str, name: str) -> Response:
|
||||||
|
|
@ -726,6 +883,44 @@ def create_app() -> Flask:
|
||||||
response.headers["Cache-Control"] = "max-age=3600"
|
response.headers["Cache-Control"] = "max-age=3600"
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
@app.route("/api/v1/tags", methods=["GET"])
|
||||||
|
def list_tags() -> Response:
|
||||||
|
"""List all tags with usage counts."""
|
||||||
|
category = request.args.get("category")
|
||||||
|
limit = min(int(request.args.get("limit", 100)), 500)
|
||||||
|
|
||||||
|
# Build query - extract tags from JSON array and count occurrences
|
||||||
|
if category:
|
||||||
|
rows = query_all(
|
||||||
|
g.db,
|
||||||
|
"""
|
||||||
|
SELECT tag.value AS name, COUNT(DISTINCT tools.owner || '/' || tools.name) AS count
|
||||||
|
FROM tools, json_each(tools.tags) AS tag
|
||||||
|
WHERE tools.category = ?
|
||||||
|
GROUP BY tag.value
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(category, limit),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rows = query_all(
|
||||||
|
g.db,
|
||||||
|
"""
|
||||||
|
SELECT tag.value AS name, COUNT(DISTINCT tools.owner || '/' || tools.name) AS count
|
||||||
|
FROM tools, json_each(tools.tags) AS tag
|
||||||
|
GROUP BY tag.value
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
)
|
||||||
|
|
||||||
|
data = [{"name": row["name"], "count": row["count"]} for row in rows]
|
||||||
|
response = jsonify({"data": data, "meta": {"total": len(data)}})
|
||||||
|
response.headers["Cache-Control"] = "max-age=3600"
|
||||||
|
return response
|
||||||
|
|
||||||
# ─── Collections ─────────────────────────────────────────────────────────────
|
# ─── Collections ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.route("/api/v1/collections", methods=["GET"])
|
@app.route("/api/v1/collections", methods=["GET"])
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,7 @@ CREATE TABLE IF NOT EXISTS web_sessions (
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tools_owner_name ON tools(owner, name);
|
CREATE INDEX IF NOT EXISTS idx_tools_owner_name ON tools(owner, name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tools_owner ON tools(owner);
|
||||||
CREATE INDEX IF NOT EXISTS idx_tools_category ON tools(category);
|
CREATE INDEX IF NOT EXISTS idx_tools_category ON tools(category);
|
||||||
CREATE INDEX IF NOT EXISTS idx_tools_published_at ON tools(published_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_tools_published_at ON tools(published_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_tools_downloads ON tools(downloads DESC);
|
CREATE INDEX IF NOT EXISTS idx_tools_downloads ON tools(downloads DESC);
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ class PaginatedResponse:
|
||||||
per_page: int = 20
|
per_page: int = 20
|
||||||
total: int = 0
|
total: int = 0
|
||||||
total_pages: int = 0
|
total_pages: int = 0
|
||||||
|
facets: Optional[Dict] = None # Category/tag/owner counts when requested
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -329,6 +330,15 @@ class RegistryClient:
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str,
|
||||||
category: Optional[str] = None,
|
category: Optional[str] = None,
|
||||||
|
categories: Optional[List[str]] = None,
|
||||||
|
tags: Optional[List[str]] = None,
|
||||||
|
owner: Optional[str] = None,
|
||||||
|
min_downloads: Optional[int] = None,
|
||||||
|
max_downloads: Optional[int] = None,
|
||||||
|
published_after: Optional[str] = None,
|
||||||
|
published_before: Optional[str] = None,
|
||||||
|
include_deprecated: bool = False,
|
||||||
|
include_facets: bool = False,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
per_page: int = 20,
|
per_page: int = 20,
|
||||||
sort: str = "relevance"
|
sort: str = "relevance"
|
||||||
|
|
@ -338,15 +348,24 @@ class RegistryClient:
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: Search query
|
query: Search query
|
||||||
category: Filter by category
|
category: Filter by single category (backward compat)
|
||||||
|
categories: Filter by multiple categories (OR logic)
|
||||||
|
tags: Filter by tags (AND logic - must have all)
|
||||||
|
owner: Filter by publisher/owner
|
||||||
|
min_downloads: Minimum download count
|
||||||
|
max_downloads: Maximum download count
|
||||||
|
published_after: Published after date (ISO format)
|
||||||
|
published_before: Published before date (ISO format)
|
||||||
|
include_deprecated: Include deprecated tools
|
||||||
|
include_facets: Include category/tag/owner counts in response
|
||||||
page: Page number
|
page: Page number
|
||||||
per_page: Items per page
|
per_page: Items per page
|
||||||
sort: Sort field (relevance, downloads, published_at)
|
sort: Sort field (relevance, downloads, published_at)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
PaginatedResponse with matching tools
|
PaginatedResponse with matching tools (and facets if requested)
|
||||||
"""
|
"""
|
||||||
params = {
|
params: Dict[str, Any] = {
|
||||||
"q": query,
|
"q": query,
|
||||||
"page": page,
|
"page": page,
|
||||||
"per_page": min(per_page, 100),
|
"per_page": min(per_page, 100),
|
||||||
|
|
@ -354,6 +373,24 @@ class RegistryClient:
|
||||||
}
|
}
|
||||||
if category:
|
if category:
|
||||||
params["category"] = category
|
params["category"] = category
|
||||||
|
if categories:
|
||||||
|
params["categories"] = ",".join(categories)
|
||||||
|
if tags:
|
||||||
|
params["tags"] = ",".join(tags)
|
||||||
|
if owner:
|
||||||
|
params["owner"] = owner
|
||||||
|
if min_downloads is not None:
|
||||||
|
params["min_downloads"] = min_downloads
|
||||||
|
if max_downloads is not None:
|
||||||
|
params["max_downloads"] = max_downloads
|
||||||
|
if published_after:
|
||||||
|
params["published_after"] = published_after
|
||||||
|
if published_before:
|
||||||
|
params["published_before"] = published_before
|
||||||
|
if include_deprecated:
|
||||||
|
params["deprecated"] = "true"
|
||||||
|
if include_facets:
|
||||||
|
params["include_facets"] = "true"
|
||||||
|
|
||||||
response = self._request("GET", "/tools/search", params=params)
|
response = self._request("GET", "/tools/search", params=params)
|
||||||
|
|
||||||
|
|
@ -368,9 +405,37 @@ class RegistryClient:
|
||||||
page=meta.get("page", page),
|
page=meta.get("page", page),
|
||||||
per_page=meta.get("per_page", per_page),
|
per_page=meta.get("per_page", per_page),
|
||||||
total=meta.get("total", 0),
|
total=meta.get("total", 0),
|
||||||
total_pages=meta.get("total_pages", 0)
|
total_pages=meta.get("total_pages", 0),
|
||||||
|
facets=data.get("facets")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_tags(
|
||||||
|
self,
|
||||||
|
category: Optional[str] = None,
|
||||||
|
limit: int = 100
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Get all tags with usage counts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
category: Filter tags by category
|
||||||
|
limit: Maximum tags to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of tag objects with name and count
|
||||||
|
"""
|
||||||
|
params: Dict[str, Any] = {"limit": min(limit, 500)}
|
||||||
|
if category:
|
||||||
|
params["category"] = category
|
||||||
|
|
||||||
|
response = self._request("GET", "/tags", params=params)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
self._handle_error_response(response)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
return data.get("data", [])
|
||||||
|
|
||||||
def get_tool(self, owner: str, name: str) -> ToolInfo:
|
def get_tool(self, owner: str, name: str) -> ToolInfo:
|
||||||
"""
|
"""
|
||||||
Get detailed information about a tool.
|
Get detailed information about a tool.
|
||||||
|
|
|
||||||
|
|
@ -267,22 +267,57 @@ def category(name: str):
|
||||||
def search():
|
def search():
|
||||||
query = request.args.get("q", "").strip()
|
query = request.args.get("q", "").strip()
|
||||||
page = request.args.get("page", 1)
|
page = request.args.get("page", 1)
|
||||||
|
|
||||||
|
# Parse filter parameters
|
||||||
category = request.args.get("category")
|
category = request.args.get("category")
|
||||||
|
categories_param = request.args.get("categories", "")
|
||||||
|
tags_param = request.args.get("tags", "")
|
||||||
|
owner = request.args.get("owner")
|
||||||
|
min_downloads = request.args.get("min_downloads")
|
||||||
|
sort = request.args.get("sort", "relevance")
|
||||||
|
|
||||||
|
# Parse multi-value params
|
||||||
|
active_categories = [c.strip() for c in categories_param.split(",") if c.strip()]
|
||||||
|
if category and category not in active_categories:
|
||||||
|
active_categories.append(category)
|
||||||
|
active_tags = [t.strip() for t in tags_param.split(",") if t.strip()]
|
||||||
|
|
||||||
if query:
|
if query:
|
||||||
params = {"q": query, "page": page, "per_page": 12}
|
params = {
|
||||||
if category:
|
"q": query,
|
||||||
params["category"] = category
|
"page": page,
|
||||||
|
"per_page": 12,
|
||||||
|
"sort": sort,
|
||||||
|
"include_facets": "true" # Always get facets for the UI
|
||||||
|
}
|
||||||
|
if active_categories:
|
||||||
|
params["categories"] = ",".join(active_categories)
|
||||||
|
if active_tags:
|
||||||
|
params["tags"] = ",".join(active_tags)
|
||||||
|
if owner:
|
||||||
|
params["owner"] = owner
|
||||||
|
if min_downloads:
|
||||||
|
params["min_downloads"] = min_downloads
|
||||||
|
|
||||||
status, payload = _api_get("/api/v1/tools/search", params=params)
|
status, payload = _api_get("/api/v1/tools/search", params=params)
|
||||||
if status != 200:
|
if status != 200:
|
||||||
return render_template("errors/500.html"), 500
|
return render_template("errors/500.html"), 500
|
||||||
|
|
||||||
meta = payload.get("meta", {})
|
meta = payload.get("meta", {})
|
||||||
|
facets = payload.get("facets", {})
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"pages/search.html",
|
"pages/search.html",
|
||||||
query=query,
|
query=query,
|
||||||
results=payload.get("data", []),
|
results=payload.get("data", []),
|
||||||
pagination=_build_pagination(meta),
|
pagination=_build_pagination(meta),
|
||||||
popular_categories=[],
|
popular_categories=[],
|
||||||
|
facets=facets,
|
||||||
|
active_categories=active_categories,
|
||||||
|
active_tags=active_tags,
|
||||||
|
active_owner=owner,
|
||||||
|
active_sort=sort,
|
||||||
|
active_min_downloads=min_downloads,
|
||||||
)
|
)
|
||||||
|
|
||||||
categories = sorted(_load_categories(), key=lambda c: c.count, reverse=True)[:8]
|
categories = sorted(_load_categories(), key=lambda c: c.count, reverse=True)[:8]
|
||||||
|
|
@ -292,6 +327,12 @@ def search():
|
||||||
results=[],
|
results=[],
|
||||||
pagination=None,
|
pagination=None,
|
||||||
popular_categories=categories,
|
popular_categories=categories,
|
||||||
|
facets={},
|
||||||
|
active_categories=[],
|
||||||
|
active_tags=[],
|
||||||
|
active_owner=None,
|
||||||
|
active_sort="relevance",
|
||||||
|
active_min_downloads=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{# Tool card macro #}
|
{# 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 %}
|
{% if tool is none %}
|
||||||
{% set tool = {
|
{% set tool = {
|
||||||
"owner": owner,
|
"owner": owner,
|
||||||
|
|
@ -7,7 +7,8 @@
|
||||||
"description": description,
|
"description": description,
|
||||||
"category": category,
|
"category": category,
|
||||||
"downloads": downloads,
|
"downloads": downloads,
|
||||||
"version": version
|
"version": version,
|
||||||
|
"tags": tags
|
||||||
} %}
|
} %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<article class="bg-white rounded-lg border border-gray-200 shadow-sm hover:shadow-md transition-shadow p-4 relative">
|
<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.' }}
|
{{ tool.description or 'No description available.' }}
|
||||||
</p>
|
</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 -->
|
<!-- Meta info -->
|
||||||
<div class="mt-4 flex items-center justify-between text-sm text-gray-500">
|
<div class="mt-4 flex items-center justify-between text-sm text-gray-500">
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
<div class="bg-gray-50 min-h-screen">
|
<div class="bg-gray-50 min-h-screen">
|
||||||
<!-- Search Header -->
|
<!-- Search Header -->
|
||||||
<div class="bg-white border-b border-gray-200">
|
<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">
|
<form action="{{ url_for('web.search') }}" method="GET" class="max-w-2xl mx-auto">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input type="text"
|
<input type="text"
|
||||||
|
|
@ -31,87 +31,266 @@
|
||||||
</div>
|
</div>
|
||||||
</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 %}
|
{% if query %}
|
||||||
<!-- Results Count -->
|
<div class="lg:grid lg:grid-cols-4 lg:gap-8">
|
||||||
<div class="mb-6">
|
<!-- Filter Sidebar -->
|
||||||
<h1 class="text-2xl font-bold text-gray-900">
|
<aside class="hidden lg:block">
|
||||||
{% if results|length == 1 %}
|
<div class="sticky top-4 space-y-6">
|
||||||
1 result for "{{ query }}"
|
<!-- 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 %}
|
{% else %}
|
||||||
{{ results|length }} results for "{{ query }}"
|
<!-- No Results -->
|
||||||
{% endif %}
|
<div class="text-center py-16 bg-white rounded-lg border border-gray-200">
|
||||||
</h1>
|
<svg class="mx-auto w-16 h-16 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</div>
|
<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 %}
|
{% if active_categories or active_tags or active_owner %}
|
||||||
<!-- Search Results -->
|
<div class="mt-6">
|
||||||
<div class="space-y-4">
|
<a href="{{ url_for('web.search', q=query) }}"
|
||||||
{% for tool in results %}
|
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">
|
||||||
{{ tool_card(
|
Clear Filters
|
||||||
owner=tool.owner,
|
</a>
|
||||||
name=tool.name,
|
</div>
|
||||||
description=tool.description,
|
{% endif %}
|
||||||
category=tool.category,
|
|
||||||
downloads=tool.downloads,
|
|
||||||
version=tool.version
|
|
||||||
) }}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
<div class="mt-8">
|
||||||
{% if pagination and pagination.pages > 1 %}
|
<h3 class="text-sm font-medium text-gray-700 mb-4">Suggestions:</h3>
|
||||||
<nav class="mt-12 flex items-center justify-center" aria-label="Pagination">
|
<ul class="text-sm text-gray-600 space-y-2">
|
||||||
<div class="flex items-center gap-2">
|
<li>Check your spelling</li>
|
||||||
{% if pagination.has_prev %}
|
<li>Try more general keywords</li>
|
||||||
<a href="{{ url_for('web.search', q=query, page=pagination.prev_num) }}"
|
<li>Remove some filters</li>
|
||||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
|
</ul>
|
||||||
Previous
|
</div>
|
||||||
</a>
|
</div>
|
||||||
{% 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>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<!-- Initial Search State -->
|
<!-- Initial Search State -->
|
||||||
|
|
@ -140,4 +319,61 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</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 %}
|
{% endblock %}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue