diff --git a/server.py b/server.py index 9d251e5..dc37ae7 100644 --- a/server.py +++ b/server.py @@ -275,9 +275,9 @@ def base_html(title: str, content: str, username: str = None) -> str:
@@ -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''' - {name} + {name} {asset_type} - {owner} + {owner} {hash_short} {", ".join(asset.get("tags", []))} @@ -578,11 +584,11 @@ async def ui_activities_page(request: Request): {activity_type} {obj.get("name", "Untitled")} - {actor_name} + {actor_name} {activity.get("published", "")[:10]} - View + View ''' @@ -618,7 +624,7 @@ async def ui_activity_detail(activity_index: int, request: Request): content = '''

Activity Not Found

This activity does not exist.

-

← Back to Activities

+

← Back to Activities

''' 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''' -

← Back to Activities

+

← Back to Activities

{activity_type} @@ -816,7 +822,7 @@ async def ui_activity_detail(activity_index: int, request: Request):
@@ -884,7 +890,7 @@ async def ui_users_page(request: Request): webfinger = f"@{uname}@{DOMAIN}" rows += f''' - {uname} + {uname} {webfinger} {user_data.get("created_at", "")[:10]} @@ -921,7 +927,7 @@ async def ui_asset_detail(name: str, request: Request): content = f'''

Asset Not Found

No asset named "{name}" exists.

-

← Back to Registry

+

← Back to Registry

''' return HTMLResponse(base_html("Asset Not Found", content, username)) @@ -1093,7 +1099,7 @@ async def ui_asset_detail(name: str, request: Request): ''' content = f''' -

← Back to Registry

+

← Back to Registry

{name}

@@ -1106,7 +1112,7 @@ async def ui_asset_detail(name: str, request: Request):

Owner

- {owner} + {owner}
@@ -1166,7 +1172,7 @@ async def ui_user_detail(username: str, request: Request): content = f'''

User Not Found

No user named "{username}" exists.

-

← Back to Users

+

← Back to Users

''' 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''' - {name} + {name} {asset_type} {hash_short} @@ -1220,7 +1226,7 @@ async def ui_user_detail(username: str, request: Request): assets_html = '

No published assets yet.

' content = f''' -

← Back to Users

+

← Back to Users

{username}

@@ -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 = ''' +

Users

+

No users registered yet.

+ ''' + else: + return HTMLResponse("") # Empty for infinite scroll + else: + rows = "" + for uname, user_data in users_page: + webfinger = f"@{uname}@{DOMAIN}" + rows += f''' + + + {uname} + + {webfinger} + {user_data.get("created_at", "")[:10]} + + ''' + + # For infinite scroll, just return rows if not first page + if page > 1: + if has_more: + rows += f''' + + Loading more... + + ''' + return HTMLResponse(rows) + + # First page - full content + infinite_scroll_trigger = "" + if has_more: + infinite_scroll_trigger = f''' + + Loading more... + + ''' + + content = f''' +

Users ({total} total)

+
+ + + + + + + + + + {rows} + {infinite_scroll_trigger} + +
UsernameWebFingerCreated
+
+ ''' + + 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''' +

User Not Found

+

No user named "{username}" exists.

+

← Back to Users

+ ''' + 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 = ''' +

Registry

+

No assets registered yet.

+ ''' + 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''' + + {name} + {asset_type} + + {owner} + + {content_hash} + + View + + + ''' + + # For infinite scroll, just return rows if not first page + if page > 1: + if has_more: + rows += f''' + + Loading more... + + ''' + return HTMLResponse(rows) + + # First page - full content + infinite_scroll_trigger = "" + if has_more: + infinite_scroll_trigger = f''' + + Loading more... + + ''' + + content = f''' +

Registry ({total} assets)

+
+ + + + + + + + + + + + {rows} + {infinite_scroll_trigger} + +
NameTypeOwnerHash
+
+ ''' + + 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''' +

Asset Not Found

+

No asset named "{name}" exists.

+

← Back to Registry

+ ''' + 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 = ''' +

Activities

+

No activities yet.

+ ''' + 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''' + + {activity_type} + {obj.get("name", "Untitled")} + + {actor_name} + + {activity.get("published", "")[:10]} + + View + + + ''' + + # For infinite scroll, just return rows if not first page + if page > 1: + if has_more: + rows += f''' + + Loading more... + + ''' + return HTMLResponse(rows) + + # First page - full content with header + infinite_scroll_trigger = "" + if has_more: + infinite_scroll_trigger = f''' + + Loading more... + + ''' + + content = f''' +

Activities ({total} total)

+
+ + + + + + + + + + + + {rows} + {infinite_scroll_trigger} + +
TypeObjectActorPublished
+
+ ''' + + 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 = ''' +

Activity Not Found

+

This activity does not exist.

+

← Back to Activities

+ ''' + 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}")