Add metadata editing UI to cache detail page

- Added /ui/cache/{hash}/meta-form endpoint for HTMX form
- Origin selector (self vs external URL)
- Description and tags fields
- Publish to L2 with asset name
- Update on L2 for already-published items

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gilesb
2026-01-07 21:22:48 +00:00
parent 5f58bf117e
commit a3a37c568a

330
server.py
View File

@@ -560,6 +560,12 @@ async def ui_cache_view(content_hash: str, request: Request):
</div>
</div>
</div>
<!-- Metadata Section -->
<div class="border-t border-dark-500 pt-6 mt-6" id="metadata-section"
hx-get="/ui/cache/{content_hash}/meta-form" hx-trigger="load" hx-swap="innerHTML">
<div class="text-gray-400">Loading metadata...</div>
</div>
</div>
</div>
</body>
@@ -568,6 +574,330 @@ async def ui_cache_view(content_hash: str, request: Request):
return html
@app.get("/ui/cache/{content_hash}/meta-form", response_class=HTMLResponse)
async def ui_cache_meta_form(content_hash: str, request: Request):
"""HTMX partial: metadata editing form for a cached item."""
current_user = get_user_from_cookie(request)
if not current_user:
return '<div class="text-red-400">Login required to edit metadata</div>'
# Check ownership
user_hashes = get_user_cache_hashes(current_user)
if content_hash not in user_hashes:
return '<div class="text-red-400">Access denied</div>'
# Load metadata
meta = load_cache_meta(content_hash)
origin = meta.get("origin", {})
origin_type = origin.get("type", "")
origin_url = origin.get("url", "")
origin_note = origin.get("note", "")
description = meta.get("description", "")
tags = meta.get("tags", [])
tags_str = ", ".join(tags) if tags else ""
published = meta.get("published", {})
# Detect media type for publish
cache_path = CACHE_DIR / content_hash
media_type = detect_media_type(cache_path) if cache_path.exists() else "unknown"
asset_type = "video" if media_type == "video" else "image"
# Origin radio checked states
self_checked = 'checked' if origin_type == "self" else ''
external_checked = 'checked' if origin_type == "external" else ''
# Build publish section
if published.get("to_l2"):
asset_name = published.get("asset_name", "")
published_at = published.get("published_at", "")[:10]
last_synced = published.get("last_synced_at", "")[:10]
publish_html = f'''
<div class="bg-green-900/30 border border-green-700 rounded-lg p-4 mb-4">
<div class="text-green-400 font-medium mb-2">Published to L2</div>
<div class="text-sm text-gray-300">
Asset name: <strong>{asset_name}</strong><br>
Published: {published_at}<br>
Last synced: {last_synced}
</div>
</div>
<div id="republish-result"></div>
<button hx-patch="/ui/cache/{content_hash}/republish" hx-target="#republish-result" hx-swap="innerHTML"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors">
Update on L2
</button>
'''
else:
# Show publish form only if origin is set
if origin_type:
publish_html = f'''
<div id="publish-result"></div>
<form hx-post="/ui/cache/{content_hash}/publish" hx-target="#publish-result" hx-swap="innerHTML"
class="flex flex-wrap gap-3 items-end">
<div>
<label class="block text-sm text-gray-400 mb-1">Asset Name</label>
<input type="text" name="asset_name" placeholder="my-{asset_type}" required
class="px-4 py-2 bg-dark-600 border border-dark-500 rounded-lg text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none min-w-[200px]">
</div>
<input type="hidden" name="asset_type" value="{asset_type}">
<button type="submit"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors">
Publish to L2
</button>
</form>
'''
else:
publish_html = '''
<div class="bg-yellow-900/30 border border-yellow-700 text-yellow-300 px-4 py-3 rounded-lg">
Set an origin (self or external URL) before publishing.
</div>
'''
return f'''
<h2 class="text-lg font-semibold text-white mb-4">Metadata</h2>
<div id="meta-save-result"></div>
<form hx-patch="/ui/cache/{content_hash}/meta" hx-target="#meta-save-result" hx-swap="innerHTML" class="space-y-4 mb-6">
<!-- Origin -->
<div class="bg-dark-600 rounded-lg p-4">
<label class="block text-sm font-medium text-gray-300 mb-3">Origin</label>
<div class="space-y-3">
<label class="flex items-center gap-3 cursor-pointer">
<input type="radio" name="origin_type" value="self" {self_checked}
class="w-4 h-4 text-blue-600 bg-dark-500 border-dark-400">
<span class="text-gray-200">Created by me (original content)</span>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input type="radio" name="origin_type" value="external" {external_checked}
class="w-4 h-4 text-blue-600 bg-dark-500 border-dark-400">
<span class="text-gray-200">External source</span>
</label>
<div class="ml-7 space-y-2">
<input type="url" name="origin_url" value="{origin_url}" placeholder="https://example.com/source"
class="w-full px-3 py-2 bg-dark-500 border border-dark-400 rounded-lg text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none text-sm">
<input type="text" name="origin_note" value="{origin_note}" placeholder="Note (optional)"
class="w-full px-3 py-2 bg-dark-500 border border-dark-400 rounded-lg text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none text-sm">
</div>
</div>
</div>
<!-- Description -->
<div class="bg-dark-600 rounded-lg p-4">
<label class="block text-sm font-medium text-gray-300 mb-2">Description</label>
<textarea name="description" rows="2" placeholder="Optional description..."
class="w-full px-3 py-2 bg-dark-500 border border-dark-400 rounded-lg text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none text-sm">{description}</textarea>
</div>
<!-- Tags -->
<div class="bg-dark-600 rounded-lg p-4">
<label class="block text-sm font-medium text-gray-300 mb-2">Tags</label>
<input type="text" name="tags" value="{tags_str}" placeholder="tag1, tag2, tag3"
class="w-full px-3 py-2 bg-dark-500 border border-dark-400 rounded-lg text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none text-sm">
<p class="text-xs text-gray-500 mt-1">Comma-separated list</p>
</div>
<button type="submit"
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors">
Save Metadata
</button>
</form>
<!-- Publishing Section -->
<div class="border-t border-dark-500 pt-6">
<h3 class="text-lg font-semibold text-white mb-4">Publish to L2 (ActivityPub)</h3>
{publish_html}
</div>
'''
@app.patch("/ui/cache/{content_hash}/meta", response_class=HTMLResponse)
async def ui_update_cache_meta(content_hash: str, request: Request):
"""HTMX handler: update cache metadata from form."""
current_user = get_user_from_cookie(request)
if not current_user:
return '<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Login required</div>'
# Check ownership
user_hashes = get_user_cache_hashes(current_user)
if content_hash not in user_hashes:
return '<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Access denied</div>'
# Parse form data
form = await request.form()
origin_type = form.get("origin_type", "")
origin_url = form.get("origin_url", "").strip()
origin_note = form.get("origin_note", "").strip()
description = form.get("description", "").strip()
tags_str = form.get("tags", "").strip()
# Build origin
origin = None
if origin_type == "self":
origin = {"type": "self"}
elif origin_type == "external":
if not origin_url:
return '<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">External origin requires a URL</div>'
origin = {"type": "external", "url": origin_url}
if origin_note:
origin["note"] = origin_note
# Parse tags
tags = [t.strip() for t in tags_str.split(",") if t.strip()] if tags_str else []
# Build updates
updates = {}
if origin:
updates["origin"] = origin
if description:
updates["description"] = description
updates["tags"] = tags
# Save
save_cache_meta(content_hash, **updates)
return '<div class="bg-green-900/50 border border-green-700 text-green-300 px-4 py-3 rounded-lg mb-4">Metadata saved!</div>'
@app.post("/ui/cache/{content_hash}/publish", response_class=HTMLResponse)
async def ui_publish_cache(content_hash: str, request: Request):
"""HTMX handler: publish cache item to L2."""
current_user = get_user_from_cookie(request)
if not current_user:
return '<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Login required</div>'
token = request.cookies.get("auth_token")
if not token:
return '<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Auth token required</div>'
# Check ownership
user_hashes = get_user_cache_hashes(current_user)
if content_hash not in user_hashes:
return '<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Access denied</div>'
# Parse form
form = await request.form()
asset_name = form.get("asset_name", "").strip()
asset_type = form.get("asset_type", "image")
if not asset_name:
return '<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Asset name required</div>'
# Load metadata
meta = load_cache_meta(content_hash)
origin = meta.get("origin")
if not origin or "type" not in origin:
return '<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Set origin before publishing</div>'
# Call L2
try:
resp = http_requests.post(
f"{L2_SERVER}/registry/publish-cache",
headers={"Authorization": f"Bearer {token}"},
json={
"content_hash": content_hash,
"asset_name": asset_name,
"asset_type": asset_type,
"origin": origin,
"description": meta.get("description"),
"tags": meta.get("tags", []),
"metadata": {
"filename": meta.get("filename"),
"folder": meta.get("folder"),
"collections": meta.get("collections", [])
}
},
timeout=30
)
resp.raise_for_status()
except http_requests.exceptions.HTTPError as e:
error_detail = ""
try:
error_detail = e.response.json().get("detail", str(e))
except Exception:
error_detail = str(e)
return f'<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Error: {error_detail}</div>'
except Exception as e:
return f'<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Error: {e}</div>'
# Update local metadata
publish_info = {
"to_l2": True,
"asset_name": asset_name,
"published_at": datetime.now(timezone.utc).isoformat(),
"last_synced_at": datetime.now(timezone.utc).isoformat()
}
save_cache_meta(content_hash, published=publish_info)
return f'''
<div class="bg-green-900/50 border border-green-700 text-green-300 px-4 py-3 rounded-lg mb-4">
Published to L2 as <strong>{asset_name}</strong>!
<a href="{L2_SERVER.replace("http://", "https://")}/ui/asset/{asset_name}" target="_blank" class="underline">View on L2</a>
</div>
'''
@app.patch("/ui/cache/{content_hash}/republish", response_class=HTMLResponse)
async def ui_republish_cache(content_hash: str, request: Request):
"""HTMX handler: re-publish (update) cache item on L2."""
current_user = get_user_from_cookie(request)
if not current_user:
return '<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Login required</div>'
token = request.cookies.get("auth_token")
if not token:
return '<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Auth token required</div>'
# Check ownership
user_hashes = get_user_cache_hashes(current_user)
if content_hash not in user_hashes:
return '<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Access denied</div>'
# Load metadata
meta = load_cache_meta(content_hash)
published = meta.get("published", {})
if not published.get("to_l2"):
return '<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Item not published yet</div>'
asset_name = published.get("asset_name")
if not asset_name:
return '<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">No asset name found</div>'
# Call L2 update
try:
resp = http_requests.patch(
f"{L2_SERVER}/registry/{asset_name}",
headers={"Authorization": f"Bearer {token}"},
json={
"description": meta.get("description"),
"tags": meta.get("tags"),
"origin": meta.get("origin"),
"metadata": {
"filename": meta.get("filename"),
"folder": meta.get("folder"),
"collections": meta.get("collections", [])
}
},
timeout=30
)
resp.raise_for_status()
except http_requests.exceptions.HTTPError as e:
error_detail = ""
try:
error_detail = e.response.json().get("detail", str(e))
except Exception:
error_detail = str(e)
return f'<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Error: {error_detail}</div>'
except Exception as e:
return f'<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Error: {e}</div>'
# Update local metadata
published["last_synced_at"] = datetime.now(timezone.utc).isoformat()
save_cache_meta(content_hash, published=published)
return '<div class="bg-green-900/50 border border-green-700 text-green-300 px-4 py-3 rounded-lg mb-4">Updated on L2!</div>'
@app.get("/cache")
async def list_cache():
"""List cached content hashes."""