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:
330
server.py
330
server.py
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user