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:
gilesb
2026-01-07 21:43:02 +00:00
parent edc216c81f
commit 0602195ed0

251
server.py
View File

@@ -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">&larr; 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">&larr; 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."""