Add activity detail page with full content display
- Activities list now links to detail page - Activity detail shows: - Video/image content inline (from L1) - Download button - Actor, description, origin - Full provenance (effect link, inputs, infrastructure) - ActivityPub URLs - Updated activities table to show actor column 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
251
server.py
251
server.py
@@ -565,15 +565,23 @@ async def ui_activities_page(request: Request):
|
||||
'''
|
||||
else:
|
||||
rows = ""
|
||||
for activity in reversed(activities):
|
||||
for i, activity in enumerate(reversed(activities)):
|
||||
# Index from end since we reversed
|
||||
activity_index = len(activities) - 1 - i
|
||||
obj = activity.get("object_data", {})
|
||||
activity_type = activity.get("activity_type", "")
|
||||
type_color = "bg-green-600" if activity_type == "Create" else "bg-yellow-600" if activity_type == "Update" else "bg-gray-600"
|
||||
actor_id = activity.get("actor_id", "")
|
||||
actor_name = actor_id.split("/")[-1] if actor_id else "unknown"
|
||||
rows += f'''
|
||||
<tr class="border-b border-dark-500">
|
||||
<tr class="border-b border-dark-500 hover:bg-dark-600 transition-colors">
|
||||
<td class="py-3 px-4"><span class="px-2 py-1 {type_color} text-white text-xs font-medium rounded">{activity_type}</span></td>
|
||||
<td class="py-3 px-4 text-white">{obj.get("name", "")}</td>
|
||||
<td class="py-3 px-4"><code class="text-xs bg-dark-600 px-2 py-1 rounded text-green-300">{obj.get("contentHash", {}).get("value", "")[:16]}...</code></td>
|
||||
<td class="py-3 px-4">
|
||||
<a href="/ui/activity/{activity_index}" class="text-blue-400 hover:text-blue-300 font-medium">{obj.get("name", "Untitled")}</a>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<a href="/ui/user/{actor_name}" class="text-gray-400 hover:text-blue-300">{actor_name}</a>
|
||||
</td>
|
||||
<td class="py-3 px-4 text-gray-400">{activity.get("published", "")[:10]}</td>
|
||||
</tr>
|
||||
'''
|
||||
@@ -585,7 +593,7 @@ async def ui_activities_page(request: Request):
|
||||
<tr class="bg-dark-600 text-left">
|
||||
<th class="py-3 px-4 font-medium text-gray-300">Type</th>
|
||||
<th class="py-3 px-4 font-medium text-gray-300">Object</th>
|
||||
<th class="py-3 px-4 font-medium text-gray-300">Content Hash</th>
|
||||
<th class="py-3 px-4 font-medium text-gray-300">Actor</th>
|
||||
<th class="py-3 px-4 font-medium text-gray-300">Published</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -598,6 +606,239 @@ async def ui_activities_page(request: Request):
|
||||
return HTMLResponse(base_html("Activities", content, username))
|
||||
|
||||
|
||||
@app.get("/ui/activity/{activity_index}", response_class=HTMLResponse)
|
||||
async def ui_activity_detail(activity_index: int, request: Request):
|
||||
"""Activity detail page with full content display."""
|
||||
username = get_user_from_cookie(request)
|
||||
activities = load_activities()
|
||||
|
||||
if activity_index < 0 or activity_index >= len(activities):
|
||||
content = '''
|
||||
<h2 class="text-xl font-semibold text-white mb-4">Activity Not Found</h2>
|
||||
<p class="text-gray-400">This activity does not exist.</p>
|
||||
<p class="mt-4"><a href="/ui/activities" class="text-blue-400 hover:text-blue-300">← Back to Activities</a></p>
|
||||
'''
|
||||
return HTMLResponse(base_html("Activity Not Found", content, username))
|
||||
|
||||
activity = activities[activity_index]
|
||||
activity_type = activity.get("activity_type", "")
|
||||
activity_id = activity.get("activity_id", "")
|
||||
actor_id = activity.get("actor_id", "")
|
||||
actor_name = actor_id.split("/")[-1] if actor_id else "unknown"
|
||||
published = activity.get("published", "")[:10]
|
||||
obj = activity.get("object_data", {})
|
||||
|
||||
# Object details
|
||||
obj_name = obj.get("name", "Untitled")
|
||||
obj_type = obj.get("type", "")
|
||||
content_hash_obj = obj.get("contentHash", {})
|
||||
content_hash = content_hash_obj.get("value", "") if isinstance(content_hash_obj, dict) else ""
|
||||
media_type = obj.get("mediaType", "")
|
||||
description = obj.get("summary", "") or obj.get("content", "")
|
||||
|
||||
# Provenance from object
|
||||
provenance = obj.get("provenance", {})
|
||||
origin = obj.get("origin", {})
|
||||
|
||||
# Type colors
|
||||
type_color = "bg-green-600" if activity_type == "Create" else "bg-yellow-600" if activity_type == "Update" else "bg-gray-600"
|
||||
obj_type_color = "bg-blue-600" if "Image" in obj_type else "bg-purple-600" if "Video" in obj_type else "bg-gray-600"
|
||||
|
||||
# Determine L1 server and asset type
|
||||
l1_server = provenance.get("l1_server", L1_PUBLIC_URL).rstrip("/") if provenance else L1_PUBLIC_URL.rstrip("/")
|
||||
is_video = "Video" in obj_type or "video" in media_type
|
||||
|
||||
# Content display
|
||||
if is_video:
|
||||
content_html = f'''
|
||||
<div class="bg-dark-600 rounded-lg p-4 mb-6">
|
||||
<video src="{l1_server}/cache/{content_hash}/mp4" controls autoplay muted loop playsinline
|
||||
class="max-w-full max-h-96 mx-auto rounded-lg"></video>
|
||||
<div class="mt-3 text-center">
|
||||
<a href="{l1_server}/cache/{content_hash}" download
|
||||
class="inline-block px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors">
|
||||
Download Original
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
'''
|
||||
elif "Image" in obj_type or "image" in media_type:
|
||||
content_html = f'''
|
||||
<div class="bg-dark-600 rounded-lg p-4 mb-6">
|
||||
<img src="{l1_server}/cache/{content_hash}" alt="{obj_name}"
|
||||
class="max-w-full max-h-96 mx-auto rounded-lg">
|
||||
<div class="mt-3 text-center">
|
||||
<a href="{l1_server}/cache/{content_hash}" download
|
||||
class="inline-block px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors">
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
'''
|
||||
else:
|
||||
content_html = f'''
|
||||
<div class="bg-dark-600 rounded-lg p-4 mb-6 text-center">
|
||||
<p class="text-gray-400 mb-3">Content type: {media_type or obj_type}</p>
|
||||
<a href="{l1_server}/cache/{content_hash}" download
|
||||
class="inline-block px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors">
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
'''
|
||||
|
||||
# Origin display
|
||||
origin_html = '<span class="text-gray-500">Not specified</span>'
|
||||
if origin:
|
||||
origin_type = origin.get("type", "")
|
||||
if origin_type == "self":
|
||||
origin_html = '<span class="text-green-400">Original content by author</span>'
|
||||
elif origin_type == "external":
|
||||
origin_url = origin.get("url", "")
|
||||
origin_note = origin.get("note", "")
|
||||
origin_html = f'<a href="{origin_url}" target="_blank" class="text-blue-400 hover:text-blue-300 break-all">{origin_url}</a>'
|
||||
if origin_note:
|
||||
origin_html += f'<p class="text-gray-500 text-sm mt-1">{origin_note}</p>'
|
||||
|
||||
# Provenance section
|
||||
provenance_html = ""
|
||||
if provenance and provenance.get("recipe"):
|
||||
recipe = provenance.get("recipe", "")
|
||||
inputs = provenance.get("inputs", [])
|
||||
l1_run_id = provenance.get("l1_run_id", "")
|
||||
rendered_at = provenance.get("rendered_at", "")[:10] if provenance.get("rendered_at") else ""
|
||||
effects_commit = provenance.get("effects_commit", "")
|
||||
effect_url = provenance.get("effect_url")
|
||||
infrastructure = provenance.get("infrastructure", {})
|
||||
|
||||
if not effect_url:
|
||||
if effects_commit and effects_commit != "unknown":
|
||||
effect_url = f"{EFFECTS_REPO_URL}/src/commit/{effects_commit}/{recipe}"
|
||||
else:
|
||||
effect_url = f"{EFFECTS_REPO_URL}/src/branch/main/{recipe}"
|
||||
|
||||
# Build inputs display
|
||||
inputs_html = ""
|
||||
for inp in inputs:
|
||||
inp_hash = inp.get("content_hash", "") if isinstance(inp, dict) else inp
|
||||
if inp_hash:
|
||||
inputs_html += f'''
|
||||
<a href="{l1_server}/cache/{inp_hash}" target="_blank"
|
||||
class="inline-block px-2 py-1 bg-dark-500 rounded text-blue-400 hover:text-blue-300 font-mono text-xs mr-2 mb-2">{inp_hash[:20]}...</a>
|
||||
'''
|
||||
|
||||
# Infrastructure display
|
||||
infra_html = ""
|
||||
if infrastructure:
|
||||
software = infrastructure.get("software", {})
|
||||
hardware = infrastructure.get("hardware", {})
|
||||
if software or hardware:
|
||||
infra_parts = []
|
||||
if software:
|
||||
infra_parts.append(f"Software: {software.get('name', 'unknown')}")
|
||||
if hardware:
|
||||
infra_parts.append(f"Hardware: {hardware.get('name', 'unknown')}")
|
||||
infra_html = f'<p class="text-sm text-gray-400 mt-2">{" | ".join(infra_parts)}</p>'
|
||||
|
||||
provenance_html = f'''
|
||||
<div class="border-t border-dark-500 pt-6 mt-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Provenance</h3>
|
||||
<p class="text-sm text-gray-400 mb-4">This content was created by applying an effect to input content.</p>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="bg-dark-600 rounded-lg p-4">
|
||||
<h4 class="text-sm font-medium text-gray-400 mb-2">Effect</h4>
|
||||
<a href="{effect_url}" target="_blank"
|
||||
class="inline-flex items-center gap-2 px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
|
||||
</svg>
|
||||
{recipe}
|
||||
</a>
|
||||
{f'<div class="mt-2 text-xs text-gray-500">Commit: {effects_commit[:12]}...</div>' if effects_commit else ''}
|
||||
</div>
|
||||
<div class="bg-dark-600 rounded-lg p-4">
|
||||
<h4 class="text-sm font-medium text-gray-400 mb-2">Input(s)</h4>
|
||||
{inputs_html if inputs_html else '<span class="text-gray-500">No inputs recorded</span>'}
|
||||
</div>
|
||||
<div class="bg-dark-600 rounded-lg p-4">
|
||||
<h4 class="text-sm font-medium text-gray-400 mb-2">L1 Run</h4>
|
||||
<a href="{l1_server}/ui/detail/{l1_run_id}" target="_blank"
|
||||
class="text-blue-400 hover:text-blue-300 font-mono text-xs">{l1_run_id[:20]}...</a>
|
||||
</div>
|
||||
<div class="bg-dark-600 rounded-lg p-4">
|
||||
<h4 class="text-sm font-medium text-gray-400 mb-2">Rendered</h4>
|
||||
<span class="text-white">{rendered_at if rendered_at else 'Unknown'}</span>
|
||||
{infra_html}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
'''
|
||||
|
||||
content = f'''
|
||||
<p class="mb-4"><a href="/ui/activities" class="text-blue-400 hover:text-blue-300">← Back to Activities</a></p>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 mb-6">
|
||||
<span class="px-3 py-1 {type_color} text-white text-sm font-medium rounded">{activity_type}</span>
|
||||
<h2 class="text-2xl font-bold text-white">{obj_name}</h2>
|
||||
<span class="px-3 py-1 {obj_type_color} text-white text-sm rounded">{obj_type}</span>
|
||||
</div>
|
||||
|
||||
{content_html}
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<div class="space-y-4">
|
||||
<div class="bg-dark-600 rounded-lg p-4">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-2">Actor</h3>
|
||||
<a href="/ui/user/{actor_name}" class="text-blue-400 hover:text-blue-300 text-lg">{actor_name}</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-dark-600 rounded-lg p-4">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-2">Description</h3>
|
||||
<p class="text-white">{description if description else '<span class="text-gray-500">No description</span>'}</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-dark-600 rounded-lg p-4">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-2">Origin</h3>
|
||||
{origin_html}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="bg-dark-600 rounded-lg p-4">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-2">Content Hash</h3>
|
||||
<code class="text-green-300 text-sm break-all">{content_hash}</code>
|
||||
</div>
|
||||
|
||||
<div class="bg-dark-600 rounded-lg p-4">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-2">Published</h3>
|
||||
<span class="text-white">{published}</span>
|
||||
</div>
|
||||
|
||||
<div class="bg-dark-600 rounded-lg p-4">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-2">Activity ID</h3>
|
||||
<code class="text-gray-300 text-xs break-all">{activity_id}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{provenance_html}
|
||||
|
||||
<div class="border-t border-dark-500 pt-6 mt-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">ActivityPub</h3>
|
||||
<div class="bg-dark-600 rounded-lg p-4">
|
||||
<p class="text-sm text-gray-300 mb-2">
|
||||
<span class="text-gray-400">Object URL:</span>
|
||||
<a href="https://{DOMAIN}/objects/{content_hash}" target="_blank" class="text-blue-400 hover:text-blue-300 ml-2">https://{DOMAIN}/objects/{content_hash}</a>
|
||||
</p>
|
||||
<p class="text-sm text-gray-300">
|
||||
<span class="text-gray-400">Actor:</span>
|
||||
<a href="{actor_id}" target="_blank" class="text-blue-400 hover:text-blue-300 ml-2">{actor_id}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
'''
|
||||
return HTMLResponse(base_html(f"Activity: {obj_name}", content, username))
|
||||
|
||||
|
||||
@app.get("/ui/users", response_class=HTMLResponse)
|
||||
async def ui_users_page(request: Request):
|
||||
"""Users page showing all registered users."""
|
||||
|
||||
Reference in New Issue
Block a user