Add collections CLI commands and admin management
CLI Commands: - cmdforge collections list - List available collections - cmdforge collections info <name> - Show collection details - cmdforge collections install <name> - Install all tools in a collection Admin API: - GET /api/v1/admin/collections - List all collections (admin) - POST /api/v1/admin/collections - Create collection - PUT /api/v1/admin/collections/<name> - Update collection - DELETE /api/v1/admin/collections/<name> - Delete collection Admin Web UI: - /dashboard/admin/collections - List and manage collections - /dashboard/admin/collections/new - Create new collection form - /dashboard/admin/collections/<name>/edit - Edit collection form - Added "Manage collections" link to admin dashboard Registry Client: - Added get_collections() method - Added get_collection(name) method Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
0d91eb7fa0
commit
2c2707679f
|
|
@ -11,6 +11,7 @@ from .tool_commands import (
|
|||
)
|
||||
from .provider_commands import cmd_providers
|
||||
from .registry_commands import cmd_registry
|
||||
from .collections_commands import cmd_collections
|
||||
from .project_commands import cmd_deps, cmd_install_deps, cmd_add, cmd_init
|
||||
from .config_commands import cmd_config
|
||||
|
||||
|
|
@ -209,6 +210,32 @@ def main():
|
|||
# Default for registry with no subcommand
|
||||
p_registry.set_defaults(func=lambda args: cmd_registry(args) if args.registry_cmd else (setattr(args, 'registry_cmd', None) or cmd_registry(args)))
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Collections Commands
|
||||
# -------------------------------------------------------------------------
|
||||
p_collections = subparsers.add_parser("collections", help="Manage tool collections")
|
||||
collections_sub = p_collections.add_subparsers(dest="collections_cmd", help="Collections commands")
|
||||
|
||||
# collections list
|
||||
p_coll_list = collections_sub.add_parser("list", help="List available collections")
|
||||
p_coll_list.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
p_coll_list.set_defaults(func=cmd_collections)
|
||||
|
||||
# collections info
|
||||
p_coll_info = collections_sub.add_parser("info", help="Show collection details")
|
||||
p_coll_info.add_argument("name", help="Collection name")
|
||||
p_coll_info.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
p_coll_info.set_defaults(func=cmd_collections)
|
||||
|
||||
# collections install
|
||||
p_coll_install = collections_sub.add_parser("install", help="Install all tools in a collection")
|
||||
p_coll_install.add_argument("name", help="Collection name")
|
||||
p_coll_install.add_argument("--pinned", action="store_true", help="Use pinned versions from collection")
|
||||
p_coll_install.set_defaults(func=cmd_collections)
|
||||
|
||||
# Default for collections with no subcommand
|
||||
p_collections.set_defaults(func=lambda args: cmd_collections(args) if args.collections_cmd else (setattr(args, 'collections_cmd', None) or cmd_collections(args)))
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Project Commands
|
||||
# -------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -0,0 +1,194 @@
|
|||
"""Collections commands for CmdForge CLI."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
from ..resolver import install_from_registry
|
||||
|
||||
|
||||
def cmd_collections(args):
|
||||
"""Handle collections subcommands."""
|
||||
if args.collections_cmd == "list":
|
||||
return _cmd_collections_list(args)
|
||||
elif args.collections_cmd == "info":
|
||||
return _cmd_collections_info(args)
|
||||
elif args.collections_cmd == "install":
|
||||
return _cmd_collections_install(args)
|
||||
else:
|
||||
# Default: show help
|
||||
print("Collections commands:")
|
||||
print(" list List available collections")
|
||||
print(" info <name> Show collection details")
|
||||
print(" install <name> Install all tools in a collection")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_collections_list(args):
|
||||
"""List available collections."""
|
||||
from ..registry_client import RegistryError, get_client
|
||||
|
||||
try:
|
||||
client = get_client()
|
||||
collections = client.get_collections()
|
||||
|
||||
# JSON output
|
||||
if getattr(args, 'json', False):
|
||||
print(json.dumps({"collections": collections}, indent=2))
|
||||
return 0
|
||||
|
||||
if not collections:
|
||||
print("No collections available.")
|
||||
return 0
|
||||
|
||||
print(f"Available collections ({len(collections)}):\n")
|
||||
for coll in collections:
|
||||
name = coll.get("name", "")
|
||||
display_name = coll.get("display_name", name)
|
||||
description = coll.get("description", "")
|
||||
tools = coll.get("tools", [])
|
||||
tool_count = len(tools) if isinstance(tools, list) else 0
|
||||
|
||||
print(f" {name}")
|
||||
print(f" {display_name}")
|
||||
if description:
|
||||
desc_short = description[:60] + ('...' if len(description) > 60 else '')
|
||||
print(f" {desc_short}")
|
||||
print(f" Tools: {tool_count}")
|
||||
print()
|
||||
|
||||
print("Install a collection with: cmdforge collections install <name>")
|
||||
|
||||
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_collections_info(args):
|
||||
"""Show collection details."""
|
||||
from ..registry_client import RegistryError, get_client
|
||||
|
||||
name = args.name
|
||||
|
||||
try:
|
||||
client = get_client()
|
||||
coll = client.get_collection(name)
|
||||
|
||||
# JSON output
|
||||
if getattr(args, 'json', False):
|
||||
print(json.dumps(coll, indent=2))
|
||||
return 0
|
||||
|
||||
display_name = coll.get("display_name", name)
|
||||
description = coll.get("description", "")
|
||||
maintainer = coll.get("maintainer", "")
|
||||
tools = coll.get("tools", [])
|
||||
pinned = coll.get("pinned", {})
|
||||
tags = coll.get("tags", [])
|
||||
|
||||
print(f"{display_name}")
|
||||
print("=" * 50)
|
||||
if description:
|
||||
print(f"\n{description}\n")
|
||||
if maintainer:
|
||||
print(f"Maintainer: {maintainer}")
|
||||
if tags:
|
||||
print(f"Tags: {', '.join(tags)}")
|
||||
|
||||
print(f"\nTools ({len(tools)}):")
|
||||
for tool_ref in tools:
|
||||
version_constraint = pinned.get(tool_ref, "")
|
||||
if version_constraint:
|
||||
print(f" - {tool_ref} @ {version_constraint}")
|
||||
else:
|
||||
print(f" - {tool_ref}")
|
||||
|
||||
print(f"\nInstall all: cmdforge collections install {name}")
|
||||
|
||||
except RegistryError as e:
|
||||
if e.code == "COLLECTION_NOT_FOUND":
|
||||
print(f"Collection '{name}' not found.", file=sys.stderr)
|
||||
print("List available collections: cmdforge collections list", file=sys.stderr)
|
||||
elif 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_collections_install(args):
|
||||
"""Install all tools in a collection."""
|
||||
from ..registry_client import RegistryError, get_client
|
||||
|
||||
name = args.name
|
||||
use_pinned = getattr(args, 'pinned', False)
|
||||
|
||||
try:
|
||||
client = get_client()
|
||||
coll = client.get_collection(name)
|
||||
|
||||
tools = coll.get("tools", [])
|
||||
pinned = coll.get("pinned", {}) if use_pinned else {}
|
||||
display_name = coll.get("display_name", name)
|
||||
|
||||
if not tools:
|
||||
print(f"Collection '{name}' has no tools.")
|
||||
return 0
|
||||
|
||||
print(f"Installing collection: {display_name}")
|
||||
print(f"Tools to install: {len(tools)}")
|
||||
if use_pinned and pinned:
|
||||
print(f"Using pinned versions: {len(pinned)} tools")
|
||||
print()
|
||||
|
||||
installed = 0
|
||||
failed = 0
|
||||
|
||||
for tool_ref in tools:
|
||||
version = pinned.get(tool_ref)
|
||||
try:
|
||||
print(f" Installing {tool_ref}...", end=" ", flush=True)
|
||||
resolved = install_from_registry(tool_ref, version)
|
||||
print(f"v{resolved.version}")
|
||||
installed += 1
|
||||
except RegistryError as e:
|
||||
print(f"FAILED: {e.message}")
|
||||
failed += 1
|
||||
except Exception as e:
|
||||
print(f"FAILED: {e}")
|
||||
failed += 1
|
||||
|
||||
print()
|
||||
print(f"Installed: {installed}/{len(tools)}")
|
||||
if failed:
|
||||
print(f"Failed: {failed}")
|
||||
return 1
|
||||
|
||||
print(f"\nCollection '{name}' installed successfully!")
|
||||
|
||||
except RegistryError as e:
|
||||
if e.code == "COLLECTION_NOT_FOUND":
|
||||
print(f"Collection '{name}' not found.", file=sys.stderr)
|
||||
print("List available collections: cmdforge collections list", file=sys.stderr)
|
||||
elif 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
|
||||
|
|
@ -1352,6 +1352,203 @@ def create_app() -> Flask:
|
|||
response.headers["Cache-Control"] = "max-age=3600"
|
||||
return response
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Admin Collections Management
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@app.route("/api/v1/admin/collections", methods=["GET"])
|
||||
@require_auth
|
||||
def admin_list_collections() -> Response:
|
||||
"""List all collections with full details (admin)."""
|
||||
user = get_current_user()
|
||||
if not user or user.get("role") not in ("admin", "moderator"):
|
||||
return error_response("FORBIDDEN", "Admin access required", 403)
|
||||
|
||||
rows = query_all(
|
||||
g.db,
|
||||
"SELECT * FROM collections ORDER BY name",
|
||||
)
|
||||
data = []
|
||||
for row in rows:
|
||||
tools = json.loads(row["tools"]) if row["tools"] else []
|
||||
pinned = json.loads(row["pinned"]) if row["pinned"] else {}
|
||||
tags = json.loads(row["tags"]) if row["tags"] else []
|
||||
data.append({
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"display_name": row["display_name"],
|
||||
"description": row["description"],
|
||||
"icon": row["icon"],
|
||||
"maintainer": row["maintainer"],
|
||||
"tools": tools,
|
||||
"pinned": pinned,
|
||||
"tags": tags,
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
})
|
||||
return jsonify({"data": data})
|
||||
|
||||
@app.route("/api/v1/admin/collections", methods=["POST"])
|
||||
@require_auth
|
||||
def admin_create_collection() -> Response:
|
||||
"""Create a new collection (admin)."""
|
||||
user = get_current_user()
|
||||
if not user or user.get("role") != "admin":
|
||||
return error_response("FORBIDDEN", "Admin access required", 403)
|
||||
|
||||
data = request.get_json() or {}
|
||||
name = data.get("name", "").strip().lower()
|
||||
display_name = data.get("display_name", "").strip()
|
||||
description = data.get("description", "").strip()
|
||||
icon = data.get("icon", "").strip()
|
||||
maintainer = data.get("maintainer", user.get("username", "")).strip()
|
||||
tools = data.get("tools", [])
|
||||
pinned = data.get("pinned", {})
|
||||
tags = data.get("tags", [])
|
||||
|
||||
# Validation
|
||||
if not name:
|
||||
return error_response("INVALID_INPUT", "Collection name is required", 400)
|
||||
if not display_name:
|
||||
display_name = name.replace("-", " ").title()
|
||||
if not isinstance(tools, list):
|
||||
return error_response("INVALID_INPUT", "Tools must be a list", 400)
|
||||
|
||||
# Check for duplicate
|
||||
existing = query_one(g.db, "SELECT id FROM collections WHERE name = ?", [name])
|
||||
if existing:
|
||||
return error_response("DUPLICATE", f"Collection '{name}' already exists", 409)
|
||||
|
||||
# Validate tool references
|
||||
validated_tools = []
|
||||
for tool_ref in tools:
|
||||
if isinstance(tool_ref, str) and "/" in tool_ref:
|
||||
validated_tools.append(tool_ref)
|
||||
|
||||
try:
|
||||
g.db.execute(
|
||||
"""
|
||||
INSERT INTO collections (name, display_name, description, icon, maintainer, tools, pinned, tags)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
name, display_name, description, icon, maintainer,
|
||||
json.dumps(validated_tools),
|
||||
json.dumps(pinned) if pinned else None,
|
||||
json.dumps(tags) if tags else None,
|
||||
],
|
||||
)
|
||||
g.db.commit()
|
||||
|
||||
# Audit log
|
||||
log_admin_action(g.db, user["id"], "create_collection", {"collection": name})
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": f"Collection '{name}' created",
|
||||
"name": name,
|
||||
}), 201
|
||||
|
||||
except Exception as e:
|
||||
return error_response("SERVER_ERROR", str(e), 500)
|
||||
|
||||
@app.route("/api/v1/admin/collections/<name>", methods=["PUT"])
|
||||
@require_auth
|
||||
def admin_update_collection(name: str) -> Response:
|
||||
"""Update a collection (admin)."""
|
||||
user = get_current_user()
|
||||
if not user or user.get("role") != "admin":
|
||||
return error_response("FORBIDDEN", "Admin access required", 403)
|
||||
|
||||
existing = query_one(g.db, "SELECT * FROM collections WHERE name = ?", [name])
|
||||
if not existing:
|
||||
return error_response("COLLECTION_NOT_FOUND", f"Collection '{name}' not found", 404)
|
||||
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Get updated values (keep existing if not provided)
|
||||
display_name = data.get("display_name", existing["display_name"])
|
||||
description = data.get("description", existing["description"])
|
||||
icon = data.get("icon", existing["icon"])
|
||||
maintainer = data.get("maintainer", existing["maintainer"])
|
||||
|
||||
# Handle tools - parse existing if not provided in request
|
||||
if "tools" in data:
|
||||
tools = data["tools"]
|
||||
if not isinstance(tools, list):
|
||||
return error_response("INVALID_INPUT", "Tools must be a list", 400)
|
||||
validated_tools = [t for t in tools if isinstance(t, str) and "/" in t]
|
||||
else:
|
||||
validated_tools = json.loads(existing["tools"]) if existing["tools"] else []
|
||||
|
||||
# Handle pinned versions
|
||||
if "pinned" in data:
|
||||
pinned = data["pinned"] if isinstance(data["pinned"], dict) else {}
|
||||
else:
|
||||
pinned = json.loads(existing["pinned"]) if existing["pinned"] else {}
|
||||
|
||||
# Handle tags
|
||||
if "tags" in data:
|
||||
tags = data["tags"] if isinstance(data["tags"], list) else []
|
||||
else:
|
||||
tags = json.loads(existing["tags"]) if existing["tags"] else []
|
||||
|
||||
try:
|
||||
g.db.execute(
|
||||
"""
|
||||
UPDATE collections
|
||||
SET display_name = ?, description = ?, icon = ?, maintainer = ?,
|
||||
tools = ?, pinned = ?, tags = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE name = ?
|
||||
""",
|
||||
[
|
||||
display_name, description, icon, maintainer,
|
||||
json.dumps(validated_tools),
|
||||
json.dumps(pinned) if pinned else None,
|
||||
json.dumps(tags) if tags else None,
|
||||
name,
|
||||
],
|
||||
)
|
||||
g.db.commit()
|
||||
|
||||
# Audit log
|
||||
log_admin_action(g.db, user["id"], "update_collection", {"collection": name})
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": f"Collection '{name}' updated",
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return error_response("SERVER_ERROR", str(e), 500)
|
||||
|
||||
@app.route("/api/v1/admin/collections/<name>", methods=["DELETE"])
|
||||
@require_auth
|
||||
def admin_delete_collection(name: str) -> Response:
|
||||
"""Delete a collection (admin)."""
|
||||
user = get_current_user()
|
||||
if not user or user.get("role") != "admin":
|
||||
return error_response("FORBIDDEN", "Admin access required", 403)
|
||||
|
||||
existing = query_one(g.db, "SELECT id FROM collections WHERE name = ?", [name])
|
||||
if not existing:
|
||||
return error_response("COLLECTION_NOT_FOUND", f"Collection '{name}' not found", 404)
|
||||
|
||||
try:
|
||||
g.db.execute("DELETE FROM collections WHERE name = ?", [name])
|
||||
g.db.commit()
|
||||
|
||||
# Audit log
|
||||
log_admin_action(g.db, user["id"], "delete_collection", {"collection": name})
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": f"Collection '{name}' deleted",
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return error_response("SERVER_ERROR", str(e), 500)
|
||||
|
||||
@app.route("/api/v1/stats/popular", methods=["GET"])
|
||||
def popular_tools() -> Response:
|
||||
limit = min(int(request.args.get("limit", 10)), 50)
|
||||
|
|
|
|||
|
|
@ -563,6 +563,44 @@ class RegistryClient:
|
|||
|
||||
return response.json().get("data", [])
|
||||
|
||||
def get_collections(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get list of tool collections.
|
||||
|
||||
Returns:
|
||||
List of collection dicts with name, display_name, description, tools
|
||||
"""
|
||||
response = self._request("GET", "/collections")
|
||||
|
||||
if response.status_code != 200:
|
||||
self._handle_error_response(response)
|
||||
|
||||
return response.json().get("data", [])
|
||||
|
||||
def get_collection(self, name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get detailed information about a collection.
|
||||
|
||||
Args:
|
||||
name: Collection name
|
||||
|
||||
Returns:
|
||||
Collection dict with tools and pinned versions
|
||||
"""
|
||||
response = self._request("GET", f"/collections/{name}")
|
||||
|
||||
if response.status_code == 404:
|
||||
raise RegistryError(
|
||||
code="COLLECTION_NOT_FOUND",
|
||||
message=f"Collection '{name}' not found",
|
||||
http_status=404
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
self._handle_error_response(response)
|
||||
|
||||
return response.json().get("data", {})
|
||||
|
||||
def publish_tool(
|
||||
self,
|
||||
config_yaml: str,
|
||||
|
|
|
|||
|
|
@ -1323,3 +1323,67 @@ def admin_scrutiny():
|
|||
scrutiny_filter=scrutiny_filter,
|
||||
moderation_filter=moderation_filter,
|
||||
)
|
||||
|
||||
|
||||
@web_bp.route("/dashboard/admin/collections", endpoint="admin_collections")
|
||||
def admin_collections():
|
||||
"""Manage tool collections."""
|
||||
forbidden = _require_admin_role()
|
||||
if forbidden:
|
||||
return forbidden
|
||||
|
||||
user = _load_current_publisher()
|
||||
token = session.get("auth_token")
|
||||
|
||||
status, payload = _api_get("/api/v1/admin/collections", token=token)
|
||||
collections = payload.get("data", []) if status == 200 else []
|
||||
|
||||
return render_template(
|
||||
"admin/collections.html",
|
||||
user=user,
|
||||
active_page="admin_collections",
|
||||
collections=collections,
|
||||
)
|
||||
|
||||
|
||||
@web_bp.route("/dashboard/admin/collections/new", endpoint="admin_collection_new")
|
||||
def admin_collection_new():
|
||||
"""Create a new collection form."""
|
||||
forbidden = _require_admin_role()
|
||||
if forbidden:
|
||||
return forbidden
|
||||
|
||||
user = _load_current_publisher()
|
||||
return render_template(
|
||||
"admin/collection_form.html",
|
||||
user=user,
|
||||
active_page="admin_collections",
|
||||
collection=None,
|
||||
is_new=True,
|
||||
)
|
||||
|
||||
|
||||
@web_bp.route("/dashboard/admin/collections/<name>/edit", endpoint="admin_collection_edit")
|
||||
def admin_collection_edit(name):
|
||||
"""Edit a collection form."""
|
||||
forbidden = _require_admin_role()
|
||||
if forbidden:
|
||||
return forbidden
|
||||
|
||||
user = _load_current_publisher()
|
||||
token = session.get("auth_token")
|
||||
|
||||
status, payload = _api_get(f"/api/v1/collections/{name}", token=token)
|
||||
if status != 200:
|
||||
flash(f"Collection not found: {name}", "error")
|
||||
return redirect(url_for("web.admin_collections"))
|
||||
|
||||
collection = payload.get("data", {})
|
||||
|
||||
return render_template(
|
||||
"admin/collection_form.html",
|
||||
user=user,
|
||||
active_page="admin_collections",
|
||||
collection=collection,
|
||||
is_new=False,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,222 @@
|
|||
{% extends "dashboard/base.html" %}
|
||||
|
||||
{% block dashboard_header %}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ 'New Collection' if is_new else 'Edit Collection' }}</h1>
|
||||
<p class="mt-1 text-gray-600">{{ 'Create a new tool collection' if is_new else 'Update collection details' }}</p>
|
||||
</div>
|
||||
<a href="{{ url_for('web.admin_collections') }}" class="text-sm text-indigo-600 hover:text-indigo-700">← Back to Collections</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block dashboard_content %}
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<form id="collection-form" class="space-y-6">
|
||||
<!-- Basic Info -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700">Name (slug)</label>
|
||||
<input type="text" id="name" name="name"
|
||||
value="{{ collection.name if collection else '' }}"
|
||||
{% if not is_new %}readonly{% endif %}
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 {% if not is_new %}bg-gray-100{% endif %}"
|
||||
placeholder="e.g., writing-toolkit"
|
||||
pattern="[a-z0-9-]+"
|
||||
required>
|
||||
<p class="mt-1 text-xs text-gray-500">Lowercase letters, numbers, and hyphens only</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="display_name" class="block text-sm font-medium text-gray-700">Display Name</label>
|
||||
<input type="text" id="display_name" name="display_name"
|
||||
value="{{ collection.display_name if collection else '' }}"
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="e.g., Writing Toolkit"
|
||||
required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700">Description</label>
|
||||
<textarea id="description" name="description" rows="3"
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="Describe what this collection is for...">{{ collection.description if collection else '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="maintainer" class="block text-sm font-medium text-gray-700">Maintainer</label>
|
||||
<input type="text" id="maintainer" name="maintainer"
|
||||
value="{{ collection.maintainer if collection else user.username }}"
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="e.g., official">
|
||||
</div>
|
||||
<div>
|
||||
<label for="icon" class="block text-sm font-medium text-gray-700">Icon (optional)</label>
|
||||
<input type="text" id="icon" name="icon"
|
||||
value="{{ collection.icon if collection else '' }}"
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="e.g., pencil, code, data">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="tags" class="block text-sm font-medium text-gray-700">Tags</label>
|
||||
<input type="text" id="tags" name="tags"
|
||||
value="{{ collection.tags|join(', ') if collection and collection.tags else '' }}"
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="e.g., writing, text, productivity">
|
||||
<p class="mt-1 text-xs text-gray-500">Comma-separated list of tags</p>
|
||||
</div>
|
||||
|
||||
<!-- Tools Section -->
|
||||
<div class="border-t border-gray-200 pt-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700">Tools</label>
|
||||
<button type="button" onclick="addToolRow()" class="text-sm text-indigo-600 hover:text-indigo-700">+ Add Tool</button>
|
||||
</div>
|
||||
<div id="tools-container" class="space-y-2">
|
||||
{% if collection and collection.tools %}
|
||||
{% for tool in collection.tools %}
|
||||
<div class="tool-row flex items-center space-x-2">
|
||||
<input type="text" name="tool" value="{{ tool.owner }}/{{ tool.name }}"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="owner/tool-name">
|
||||
<input type="text" name="pinned" value="{{ tool.pinned_version or '' }}"
|
||||
class="w-32 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="version">
|
||||
<button type="button" onclick="removeToolRow(this)" class="text-red-500 hover:text-red-700 p-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="tool-row flex items-center space-x-2">
|
||||
<input type="text" name="tool"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="owner/tool-name">
|
||||
<input type="text" name="pinned"
|
||||
class="w-32 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="version">
|
||||
<button type="button" onclick="removeToolRow(this)" class="text-red-500 hover:text-red-700 p-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">Enter tool references as owner/name. Version is optional (leave blank for latest).</p>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="flex justify-end space-x-4 pt-4 border-t border-gray-200">
|
||||
<a href="{{ url_for('web.admin_collections') }}" class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-500">Cancel</a>
|
||||
<button type="submit" class="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700">
|
||||
{{ 'Create Collection' if is_new else 'Save Changes' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function addToolRow() {
|
||||
const container = document.getElementById('tools-container');
|
||||
const row = document.createElement('div');
|
||||
row.className = 'tool-row flex items-center space-x-2';
|
||||
row.innerHTML = `
|
||||
<input type="text" name="tool"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="owner/tool-name">
|
||||
<input type="text" name="pinned"
|
||||
class="w-32 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="version">
|
||||
<button type="button" onclick="removeToolRow(this)" class="text-red-500 hover:text-red-700 p-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
container.appendChild(row);
|
||||
}
|
||||
|
||||
function removeToolRow(button) {
|
||||
const row = button.closest('.tool-row');
|
||||
const container = document.getElementById('tools-container');
|
||||
if (container.children.length > 1) {
|
||||
row.remove();
|
||||
} else {
|
||||
// Clear the inputs instead of removing the last row
|
||||
row.querySelectorAll('input').forEach(input => input.value = '');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('collection-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(this);
|
||||
const isNew = {{ 'true' if is_new else 'false' }};
|
||||
const collectionName = document.getElementById('name').value;
|
||||
|
||||
// Collect tools and pinned versions
|
||||
const toolInputs = document.querySelectorAll('input[name="tool"]');
|
||||
const pinnedInputs = document.querySelectorAll('input[name="pinned"]');
|
||||
const tools = [];
|
||||
const pinned = {};
|
||||
|
||||
toolInputs.forEach((input, index) => {
|
||||
const toolRef = input.value.trim();
|
||||
if (toolRef && toolRef.includes('/')) {
|
||||
tools.push(toolRef);
|
||||
const pinnedVersion = pinnedInputs[index].value.trim();
|
||||
if (pinnedVersion) {
|
||||
pinned[toolRef] = pinnedVersion;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Parse tags
|
||||
const tagsStr = document.getElementById('tags').value;
|
||||
const tags = tagsStr.split(',').map(t => t.trim()).filter(t => t);
|
||||
|
||||
const payload = {
|
||||
name: collectionName,
|
||||
display_name: document.getElementById('display_name').value,
|
||||
description: document.getElementById('description').value,
|
||||
maintainer: document.getElementById('maintainer').value,
|
||||
icon: document.getElementById('icon').value,
|
||||
tools: tools,
|
||||
pinned: pinned,
|
||||
tags: tags
|
||||
};
|
||||
|
||||
try {
|
||||
const url = isNew
|
||||
? '/api/v1/admin/collections'
|
||||
: `/api/v1/admin/collections/${collectionName}`;
|
||||
const method = isNew ? 'POST' : 'PUT';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Authorization': 'Bearer {{ session.get("auth_token") }}',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
window.location.href = '{{ url_for("web.admin_collections") }}';
|
||||
} else {
|
||||
alert(data.error?.message || 'Failed to save collection');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Network error: ' + error.message);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
{% extends "dashboard/base.html" %}
|
||||
|
||||
{% block dashboard_header %}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Collections</h1>
|
||||
<p class="mt-1 text-gray-600">Manage tool collections</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="{{ url_for('web.admin_collection_new') }}" class="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700">
|
||||
New Collection
|
||||
</a>
|
||||
<a href="{{ url_for('web.admin_dashboard') }}" class="text-sm text-indigo-600 hover:text-indigo-700">← Back to Admin</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block dashboard_content %}
|
||||
<div class="bg-white rounded-lg border border-gray-200">
|
||||
{% if collections %}
|
||||
<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-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Collection</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Maintainer</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Tools</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Tags</th>
|
||||
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{% for coll in collections %}
|
||||
<tr id="coll-row-{{ coll.name }}" class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900">{{ coll.display_name }}</div>
|
||||
<div class="text-sm text-gray-500">{{ coll.name }}</div>
|
||||
{% if coll.description %}
|
||||
<div class="text-xs text-gray-400 mt-1">{{ coll.description[:80] }}{% if coll.description|length > 80 %}...{% endif %}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ coll.maintainer }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ coll.tools|length }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
{% for tag in coll.tags[:3] %}
|
||||
<span class="px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600 mr-1">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
{% if coll.tags|length > 3 %}
|
||||
<span class="text-xs text-gray-400">+{{ coll.tags|length - 3 }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<a href="{{ url_for('web.admin_collection_edit', name=coll.name) }}" class="text-indigo-600 hover:text-indigo-900 mr-3">Edit</a>
|
||||
<button onclick="deleteCollection('{{ coll.name }}', '{{ coll.display_name }}')" class="text-red-600 hover:text-red-900">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-12">
|
||||
<h3 class="text-sm font-medium text-gray-900">No collections</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Get started by creating a new collection.</p>
|
||||
<div class="mt-6">
|
||||
<a href="{{ url_for('web.admin_collection_new') }}" class="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700">
|
||||
New Collection
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Delete Modal -->
|
||||
<div id="delete-modal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||
<div class="mt-3">
|
||||
<h3 class="text-lg font-medium text-red-600 mb-2">Delete Collection</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Are you sure you want to delete <strong id="delete-collection-name"></strong>?
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
<div class="mt-4 flex justify-end space-x-3">
|
||||
<button onclick="closeDeleteModal()" class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-500">Cancel</button>
|
||||
<button onclick="confirmDelete()" class="px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-md hover:bg-red-700">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let deleteCollectionName = null;
|
||||
|
||||
function deleteCollection(name, displayName) {
|
||||
deleteCollectionName = name;
|
||||
document.getElementById('delete-collection-name').textContent = displayName;
|
||||
document.getElementById('delete-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
document.getElementById('delete-modal').classList.add('hidden');
|
||||
deleteCollectionName = null;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteCollectionName) return;
|
||||
try {
|
||||
const response = await fetch(`/api/v1/admin/collections/${deleteCollectionName}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': 'Bearer {{ session.get("auth_token") }}'
|
||||
}
|
||||
});
|
||||
if (response.ok) {
|
||||
location.reload();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
alert(data.error?.message || 'Failed to delete collection');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Network error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
@ -97,6 +97,12 @@
|
|||
</svg>
|
||||
<span class="text-sm font-medium text-gray-700">Registry settings</span>
|
||||
</a>
|
||||
<a href="{{ url_for('web.admin_collections') }}" 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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-gray-700">Manage collections</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('web.dashboard') }}" 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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue