Remove /ui prefix - clean URLs with content negotiation

- /registry, /activities, /users → HTML (browsers) or JSON (APIs)
- /asset/{name}, /activity/{index}, /users/{username} → same
- Infinite scroll on all list pages via HTMX
- API pagination: ?page=1&limit=20 with has_more
- All internal links updated to use clean URLs

🤖 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 22:09:38 +00:00
parent f1aea5a5f3
commit 11fa01a864

401
server.py
View File

@@ -275,9 +275,9 @@ def base_html(title: str, content: str, username: str = None) -> str:
<nav class="flex flex-wrap gap-6 mb-6 pb-4 border-b border-dark-500">
<a href="/ui" class="text-gray-400 hover:text-white transition-colors">Home</a>
<a href="/ui/registry" class="text-gray-400 hover:text-white transition-colors">Registry</a>
<a href="/ui/activities" class="text-gray-400 hover:text-white transition-colors">Activities</a>
<a href="/ui/users" class="text-gray-400 hover:text-white transition-colors">Users</a>
<a href="/registry" class="text-gray-400 hover:text-white transition-colors">Registry</a>
<a href="/activities" class="text-gray-400 hover:text-white transition-colors">Activities</a>
<a href="/users" class="text-gray-400 hover:text-white transition-colors">Users</a>
</nav>
<main class="bg-dark-700 rounded-lg p-6">
@@ -296,6 +296,12 @@ def get_user_from_cookie(request: Request) -> Optional[str]:
return None
def wants_html(request: Request) -> bool:
"""Check if request wants HTML (browser) vs JSON (API)."""
accept = request.headers.get("accept", "")
return "text/html" in accept and "application/json" not in accept and "application/activity+json" not in accept
# ============ UI Endpoints ============
@app.get("/ui", response_class=HTMLResponse)
@@ -520,11 +526,11 @@ async def ui_registry_page(request: Request):
rows += f'''
<tr class="border-b border-dark-500 hover:bg-dark-600 transition-colors">
<td class="py-3 px-4">
<a href="/ui/asset/{name}" class="text-blue-400 hover:text-blue-300 font-medium">{name}</a>
<a href="/asset/{name}" class="text-blue-400 hover:text-blue-300 font-medium">{name}</a>
</td>
<td class="py-3 px-4"><span class="px-2 py-1 {type_color} text-white text-xs rounded">{asset_type}</span></td>
<td class="py-3 px-4">
<a href="/ui/user/{owner}" class="text-gray-400 hover:text-blue-300">{owner}</a>
<a href="/users/{owner}" class="text-gray-400 hover:text-blue-300">{owner}</a>
</td>
<td class="py-3 px-4"><code class="text-xs bg-dark-600 px-2 py-1 rounded text-green-300">{hash_short}</code></td>
<td class="py-3 px-4 text-gray-400">{", ".join(asset.get("tags", []))}</td>
@@ -578,11 +584,11 @@ async def ui_activities_page(request: Request):
<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", "Untitled")}</td>
<td class="py-3 px-4">
<a href="/ui/user/{actor_name}" class="text-gray-400 hover:text-blue-300">{actor_name}</a>
<a href="/users/{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>
<td class="py-3 px-4">
<a href="/ui/activity/{activity_index}" class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded transition-colors">View</a>
<a href="/activity/{activity_index}" class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded transition-colors">View</a>
</td>
</tr>
'''
@@ -618,7 +624,7 @@ async def ui_activity_detail(activity_index: int, request: Request):
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>
<p class="mt-4"><a href="/activities" class="text-blue-400 hover:text-blue-300">&larr; Back to Activities</a></p>
'''
return HTMLResponse(base_html("Activity Not Found", content, username))
@@ -802,7 +808,7 @@ async def ui_activity_detail(activity_index: int, request: Request):
'''
content = f'''
<p class="mb-4"><a href="/ui/activities" class="text-blue-400 hover:text-blue-300">&larr; Back to Activities</a></p>
<p class="mb-4"><a href="/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>
@@ -816,7 +822,7 @@ async def ui_activity_detail(activity_index: int, request: Request):
<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>
<a href="/users/{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">
@@ -884,7 +890,7 @@ async def ui_users_page(request: Request):
webfinger = f"@{uname}@{DOMAIN}"
rows += f'''
<tr class="border-b border-dark-500 hover:bg-dark-600 transition-colors">
<td class="py-3 px-4"><a href="/ui/user/{uname}" class="text-blue-400 hover:text-blue-300 font-medium">{uname}</a></td>
<td class="py-3 px-4"><a href="/users/{uname}" class="text-blue-400 hover:text-blue-300 font-medium">{uname}</a></td>
<td class="py-3 px-4"><code class="text-xs bg-dark-600 px-2 py-1 rounded text-gray-300">{webfinger}</code></td>
<td class="py-3 px-4 text-gray-400">{user_data.get("created_at", "")[:10]}</td>
</tr>
@@ -921,7 +927,7 @@ async def ui_asset_detail(name: str, request: Request):
content = f'''
<h2 class="text-xl font-semibold text-white mb-4">Asset Not Found</h2>
<p class="text-gray-400">No asset named "{name}" exists.</p>
<p class="mt-4"><a href="/ui/registry" class="text-blue-400 hover:text-blue-300">&larr; Back to Registry</a></p>
<p class="mt-4"><a href="/registry" class="text-blue-400 hover:text-blue-300">&larr; Back to Registry</a></p>
'''
return HTMLResponse(base_html("Asset Not Found", content, username))
@@ -1093,7 +1099,7 @@ async def ui_asset_detail(name: str, request: Request):
'''
content = f'''
<p class="mb-4"><a href="/ui/registry" class="text-blue-400 hover:text-blue-300">&larr; Back to Registry</a></p>
<p class="mb-4"><a href="/registry" class="text-blue-400 hover:text-blue-300">&larr; Back to Registry</a></p>
<div class="flex flex-wrap items-center gap-4 mb-6">
<h2 class="text-2xl font-bold text-white">{name}</h2>
@@ -1106,7 +1112,7 @@ async def ui_asset_detail(name: str, request: Request):
<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">Owner</h3>
<a href="/ui/user/{owner}" class="text-blue-400 hover:text-blue-300 text-lg">{owner}</a>
<a href="/users/{owner}" class="text-blue-400 hover:text-blue-300 text-lg">{owner}</a>
</div>
<div class="bg-dark-600 rounded-lg p-4">
@@ -1166,7 +1172,7 @@ async def ui_user_detail(username: str, request: Request):
content = f'''
<h2 class="text-xl font-semibold text-white mb-4">User Not Found</h2>
<p class="text-gray-400">No user named "{username}" exists.</p>
<p class="mt-4"><a href="/ui/users" class="text-blue-400 hover:text-blue-300">&larr; Back to Users</a></p>
<p class="mt-4"><a href="/users" class="text-blue-400 hover:text-blue-300">&larr; Back to Users</a></p>
'''
return HTMLResponse(base_html("User Not Found", content, current_user))
@@ -1192,7 +1198,7 @@ async def ui_user_detail(username: str, request: Request):
rows += f'''
<tr class="border-b border-dark-500 hover:bg-dark-600 transition-colors">
<td class="py-3 px-4">
<a href="/ui/asset/{name}" class="text-blue-400 hover:text-blue-300 font-medium">{name}</a>
<a href="/asset/{name}" class="text-blue-400 hover:text-blue-300 font-medium">{name}</a>
</td>
<td class="py-3 px-4"><span class="px-2 py-1 {type_color} text-white text-xs rounded">{asset_type}</span></td>
<td class="py-3 px-4"><code class="text-xs bg-dark-600 px-2 py-1 rounded text-green-300">{hash_short}</code></td>
@@ -1220,7 +1226,7 @@ async def ui_user_detail(username: str, request: Request):
assets_html = '<p class="text-gray-400">No published assets yet.</p>'
content = f'''
<p class="mb-4"><a href="/ui/users" class="text-blue-400 hover:text-blue-300">&larr; Back to Users</a></p>
<p class="mb-4"><a href="/users" class="text-blue-400 hover:text-blue-300">&larr; Back to Users</a></p>
<div class="flex flex-wrap items-center gap-4 mb-6">
<h2 class="text-2xl font-bold text-white">{username}</h2>
@@ -1381,20 +1387,114 @@ async def webfinger(resource: str):
)
@app.get("/users")
async def get_users_list(request: Request, page: int = 1, limit: int = 20):
"""Get all users. HTML for browsers (with infinite scroll), JSON for APIs (with pagination)."""
all_users = list(load_users(DATA_DIR).items())
total = len(all_users)
# Sort by username
all_users.sort(key=lambda x: x[0])
# Pagination
start = (page - 1) * limit
end = start + limit
users_page = all_users[start:end]
has_more = end < total
if wants_html(request):
username = get_user_from_cookie(request)
if not users_page:
if page == 1:
content = '''
<h2 class="text-xl font-semibold text-white mb-4">Users</h2>
<p class="text-gray-400">No users registered yet.</p>
'''
else:
return HTMLResponse("") # Empty for infinite scroll
else:
rows = ""
for uname, user_data in users_page:
webfinger = f"@{uname}@{DOMAIN}"
rows += f'''
<tr class="border-b border-dark-500 hover:bg-dark-600 transition-colors">
<td class="py-3 px-4">
<a href="/users/{uname}" class="text-blue-400 hover:text-blue-300 font-medium">{uname}</a>
</td>
<td class="py-3 px-4"><code class="text-xs bg-dark-600 px-2 py-1 rounded text-gray-300">{webfinger}</code></td>
<td class="py-3 px-4 text-gray-400">{user_data.get("created_at", "")[:10]}</td>
</tr>
'''
# For infinite scroll, just return rows if not first page
if page > 1:
if has_more:
rows += f'''
<tr hx-get="/users?page={page + 1}" hx-trigger="revealed" hx-swap="afterend">
<td colspan="3" class="py-4 text-center text-gray-400">Loading more...</td>
</tr>
'''
return HTMLResponse(rows)
# First page - full content
infinite_scroll_trigger = ""
if has_more:
infinite_scroll_trigger = f'''
<tr hx-get="/users?page=2" hx-trigger="revealed" hx-swap="afterend">
<td colspan="3" class="py-4 text-center text-gray-400">Loading more...</td>
</tr>
'''
content = f'''
<h2 class="text-xl font-semibold text-white mb-6">Users ({total} total)</h2>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="bg-dark-600 text-left">
<th class="py-3 px-4 font-medium text-gray-300">Username</th>
<th class="py-3 px-4 font-medium text-gray-300">WebFinger</th>
<th class="py-3 px-4 font-medium text-gray-300">Created</th>
</tr>
</thead>
<tbody>
{rows}
{infinite_scroll_trigger}
</tbody>
</table>
</div>
'''
return HTMLResponse(base_html("Users", content, username))
# JSON response for APIs
return {
"users": [{"username": uname, **data} for uname, data in users_page],
"pagination": {
"page": page,
"limit": limit,
"total": total,
"has_more": has_more
}
}
@app.get("/users/{username}")
async def get_actor(username: str, request: Request):
"""Get actor profile for any registered user. Content negotiation: HTML for browsers, JSON for APIs."""
if not user_exists(username):
if wants_html(request):
content = f'''
<h2 class="text-xl font-semibold text-white mb-4">User Not Found</h2>
<p class="text-gray-400">No user named "{username}" exists.</p>
<p class="mt-4"><a href="/users" class="text-blue-400 hover:text-blue-300">&larr; Back to Users</a></p>
'''
return HTMLResponse(base_html("User Not Found", content, get_user_from_cookie(request)))
raise HTTPException(404, f"Unknown user: {username}")
# Check Accept header for content negotiation
accept = request.headers.get("accept", "")
wants_html = "text/html" in accept and "application/json" not in accept and "application/activity+json" not in accept
if wants_html:
# Redirect to UI page for browsers
from fastapi.responses import RedirectResponse
return RedirectResponse(url=f"/ui/user/{username}", status_code=303)
if wants_html(request):
# Render user detail page
return await ui_user_detail(username, request)
actor = load_actor(username)
@@ -1496,14 +1596,130 @@ async def get_followers(username: str):
# ============ Registry Endpoints ============
@app.get("/registry")
async def get_registry():
"""Get full registry."""
return load_registry()
async def get_registry(request: Request, page: int = 1, limit: int = 20):
"""Get registry. HTML for browsers (with infinite scroll), JSON for APIs (with pagination)."""
registry = load_registry()
all_assets = list(registry.get("assets", {}).items())
total = len(all_assets)
# Sort by created_at descending
all_assets.sort(key=lambda x: x[1].get("created_at", ""), reverse=True)
# Pagination
start = (page - 1) * limit
end = start + limit
assets_page = all_assets[start:end]
has_more = end < total
if wants_html(request):
username = get_user_from_cookie(request)
if not assets_page:
if page == 1:
content = '''
<h2 class="text-xl font-semibold text-white mb-4">Registry</h2>
<p class="text-gray-400">No assets registered yet.</p>
'''
else:
return HTMLResponse("") # Empty for infinite scroll
else:
rows = ""
for name, asset in assets_page:
asset_type = asset.get("asset_type", "")
type_color = "bg-blue-600" if asset_type == "image" else "bg-purple-600" if asset_type == "video" else "bg-gray-600"
owner = asset.get("owner", "unknown")
content_hash = asset.get("content_hash", "")[:16] + "..."
rows += f'''
<tr class="border-b border-dark-500 hover:bg-dark-600 transition-colors">
<td class="py-3 px-4 text-white font-medium">{name}</td>
<td class="py-3 px-4"><span class="px-2 py-1 {type_color} text-white text-xs rounded">{asset_type}</span></td>
<td class="py-3 px-4">
<a href="/users/{owner}" class="text-gray-400 hover:text-blue-300">{owner}</a>
</td>
<td class="py-3 px-4"><code class="text-xs bg-dark-600 px-2 py-1 rounded text-green-300">{content_hash}</code></td>
<td class="py-3 px-4">
<a href="/asset/{name}" class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded transition-colors">View</a>
</td>
</tr>
'''
# For infinite scroll, just return rows if not first page
if page > 1:
if has_more:
rows += f'''
<tr hx-get="/registry?page={page + 1}" hx-trigger="revealed" hx-swap="afterend">
<td colspan="5" class="py-4 text-center text-gray-400">Loading more...</td>
</tr>
'''
return HTMLResponse(rows)
# First page - full content
infinite_scroll_trigger = ""
if has_more:
infinite_scroll_trigger = f'''
<tr hx-get="/registry?page=2" hx-trigger="revealed" hx-swap="afterend">
<td colspan="5" class="py-4 text-center text-gray-400">Loading more...</td>
</tr>
'''
content = f'''
<h2 class="text-xl font-semibold text-white mb-6">Registry ({total} assets)</h2>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="bg-dark-600 text-left">
<th class="py-3 px-4 font-medium text-gray-300">Name</th>
<th class="py-3 px-4 font-medium text-gray-300">Type</th>
<th class="py-3 px-4 font-medium text-gray-300">Owner</th>
<th class="py-3 px-4 font-medium text-gray-300">Hash</th>
<th class="py-3 px-4 font-medium text-gray-300"></th>
</tr>
</thead>
<tbody>
{rows}
{infinite_scroll_trigger}
</tbody>
</table>
</div>
'''
return HTMLResponse(base_html("Registry", content, username))
# JSON response for APIs
return {
"assets": {name: asset for name, asset in assets_page},
"pagination": {
"page": page,
"limit": limit,
"total": total,
"has_more": has_more
}
}
@app.get("/asset/{name}")
async def get_asset_by_name(name: str, request: Request):
"""Get asset by name. HTML for browsers, JSON for APIs."""
registry = load_registry()
if name not in registry.get("assets", {}):
if wants_html(request):
content = f'''
<h2 class="text-xl font-semibold text-white mb-4">Asset Not Found</h2>
<p class="text-gray-400">No asset named "{name}" exists.</p>
<p class="mt-4"><a href="/registry" class="text-blue-400 hover:text-blue-300">&larr; Back to Registry</a></p>
'''
return HTMLResponse(base_html("Asset Not Found", content, get_user_from_cookie(request)))
raise HTTPException(404, f"Asset not found: {name}")
if wants_html(request):
return await ui_asset_detail(name, request)
return registry["assets"][name]
@app.get("/registry/{name}")
async def get_asset(name: str):
"""Get a specific asset."""
"""Get a specific asset (API only, use /asset/{name} for content negotiation)."""
registry = load_registry()
if name not in registry.get("assets", {}):
raise HTTPException(404, f"Asset not found: {name}")
@@ -1777,9 +1993,130 @@ async def publish_cache(req: PublishCacheRequest, user: User = Depends(get_requi
# ============ Activities Endpoints ============
@app.get("/activities")
async def get_activities():
"""Get all activities."""
return {"activities": load_activities()}
async def get_activities(request: Request, page: int = 1, limit: int = 20):
"""Get activities. HTML for browsers (with infinite scroll), JSON for APIs (with pagination)."""
all_activities = load_activities()
total = len(all_activities)
# Reverse for newest first
all_activities = list(reversed(all_activities))
# Pagination
start = (page - 1) * limit
end = start + limit
activities_page = all_activities[start:end]
has_more = end < total
if wants_html(request):
username = get_user_from_cookie(request)
if not activities_page:
if page == 1:
content = '''
<h2 class="text-xl font-semibold text-white mb-4">Activities</h2>
<p class="text-gray-400">No activities yet.</p>
'''
else:
return HTMLResponse("") # Empty for infinite scroll
else:
rows = ""
for i, activity in enumerate(activities_page):
activity_index = total - 1 - (start + i) # Original index
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 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", "Untitled")}</td>
<td class="py-3 px-4">
<a href="/users/{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>
<td class="py-3 px-4">
<a href="/activity/{activity_index}" class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded transition-colors">View</a>
</td>
</tr>
'''
# For infinite scroll, just return rows if not first page
if page > 1:
if has_more:
rows += f'''
<tr hx-get="/activities?page={page + 1}" hx-trigger="revealed" hx-swap="afterend">
<td colspan="5" class="py-4 text-center text-gray-400">Loading more...</td>
</tr>
'''
return HTMLResponse(rows)
# First page - full content with header
infinite_scroll_trigger = ""
if has_more:
infinite_scroll_trigger = f'''
<tr hx-get="/activities?page=2" hx-trigger="revealed" hx-swap="afterend">
<td colspan="5" class="py-4 text-center text-gray-400">Loading more...</td>
</tr>
'''
content = f'''
<h2 class="text-xl font-semibold text-white mb-6">Activities ({total} total)</h2>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<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">Actor</th>
<th class="py-3 px-4 font-medium text-gray-300">Published</th>
<th class="py-3 px-4 font-medium text-gray-300"></th>
</tr>
</thead>
<tbody>
{rows}
{infinite_scroll_trigger}
</tbody>
</table>
</div>
'''
return HTMLResponse(base_html("Activities", content, username))
# JSON response for APIs
return {
"activities": activities_page,
"pagination": {
"page": page,
"limit": limit,
"total": total,
"has_more": has_more
}
}
@app.get("/activity/{activity_index}")
async def get_activity(activity_index: int, request: Request):
"""Get single activity. HTML for browsers, JSON for APIs."""
activities = load_activities()
if activity_index < 0 or activity_index >= len(activities):
if wants_html(request):
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="/activities" class="text-blue-400 hover:text-blue-300">&larr; Back to Activities</a></p>
'''
return HTMLResponse(base_html("Activity Not Found", content, get_user_from_cookie(request)))
raise HTTPException(404, "Activity not found")
activity = activities[activity_index]
if wants_html(request):
# Reuse the UI activity detail logic
return await ui_activity_detail(activity_index, request)
return activity
@app.get("/objects/{content_hash}")