Add L2 sync button and improve publish status display

- Add "Sync with L2" button on media page to fetch user's outbox
- Link asset names to L2 asset pages in publish status
- Add green "L2" badge to media list for published items
- Create /user/sync-l2 and /ui/sync-l2 endpoints

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gilesb
2026-01-09 03:18:48 +00:00
parent 91f23123cd
commit d3468cd47c

152
server.py
View File

@@ -2002,10 +2002,11 @@ async def ui_cache_meta_form(content_hash: str, request: Request):
asset_name = share.get("asset_name", "")
published_at = share.get("published_at", "")[:10] if share.get("published_at") else ""
last_synced = share.get("last_synced_at", "")[:10] if share.get("last_synced_at") else ""
asset_url = f"{l2_server}/assets/{asset_name}"
shares_html += f'''
<div class="flex justify-between items-start py-2 border-b border-green-800 last:border-0">
<div>
<div class="text-white font-medium">{asset_name}</div>
<a href="{asset_url}" target="_blank" class="text-white font-medium hover:text-blue-300">{asset_name}</a>
<div class="text-xs text-gray-400">{l2_server}</div>
</div>
<div class="text-right text-xs text-gray-400">
@@ -2497,7 +2498,16 @@ async def list_media(
'''
content = f'''
<h2 class="text-xl font-semibold text-white mb-6">Media ({total} items)</h2>
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-white">Media ({total} items)</h2>
<div class="flex items-center gap-3">
<div id="sync-result"></div>
<button hx-post="/ui/sync-l2" hx-target="#sync-result" hx-swap="innerHTML"
class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors">
Sync with L2
</button>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{''.join(html_parts)}
{infinite_scroll_trigger}
@@ -3068,6 +3078,132 @@ async def republish_cache_to_l2(
}
# ============ L2 Sync ============
def _fetch_l2_outbox_sync(l2_server: str, username: str) -> list:
"""Fetch user's outbox from L2 (sync version for asyncio.to_thread)."""
try:
# Fetch outbox page with activities
resp = http_requests.get(
f"{l2_server}/users/{username}/outbox?page=true",
headers={"Accept": "application/activity+json"},
timeout=10
)
if resp.status_code != 200:
logger.warning(f"L2 outbox fetch failed: {resp.status_code}")
return []
data = resp.json()
return data.get("orderedItems", [])
except Exception as e:
logger.error(f"Failed to fetch L2 outbox: {e}")
return []
@app.post("/user/sync-l2")
async def sync_with_l2(ctx: UserContext = Depends(get_required_user_context)):
"""
Sync local L2 share records with user's L2 outbox.
Fetches user's published assets from their L2 server and updates local tracking.
"""
l2_server = ctx.l2_server
username = ctx.username
# Fetch outbox activities
activities = await asyncio.to_thread(_fetch_l2_outbox_sync, l2_server, username)
if not activities:
return {"synced": 0, "message": "No activities found or L2 unavailable"}
# Process Create activities for assets
synced_count = 0
for activity in activities:
if activity.get("type") != "Create":
continue
obj = activity.get("object", {})
if not isinstance(obj, dict):
continue
# Get asset info - look for content_hash in attachment or directly
content_hash = None
asset_name = obj.get("name", "")
# Check attachments for content hash
for attachment in obj.get("attachment", []):
if attachment.get("name") == "content_hash":
content_hash = attachment.get("value")
break
# Also check if there's a hash in the object URL or ID
if not content_hash:
# Try to extract from object ID like /objects/{hash}
obj_id = obj.get("id", "")
if "/objects/" in obj_id:
content_hash = obj_id.split("/objects/")[-1].split("/")[0]
if not content_hash or not asset_name:
continue
# Check if we have this content locally
cache_path = get_cache_path(content_hash)
if not cache_path:
continue # We don't have this content, skip
# Determine content type from object type
obj_type = obj.get("type", "")
if obj_type == "Video":
content_type = "video"
elif obj_type == "Image":
content_type = "image"
else:
content_type = "media"
# Update local L2 share record
await database.save_l2_share(
content_hash=content_hash,
actor_id=ctx.actor_id,
l2_server=l2_server,
asset_name=asset_name,
content_type=content_type
)
synced_count += 1
return {"synced": synced_count, "total_activities": len(activities)}
@app.post("/ui/sync-l2", response_class=HTMLResponse)
async def ui_sync_with_l2(request: Request):
"""HTMX handler: sync with L2 server."""
ctx = get_user_context_from_cookie(request)
if not ctx:
return '<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg">Login required</div>'
try:
result = await sync_with_l2(ctx)
synced = result.get("synced", 0)
total = result.get("total_activities", 0)
if synced > 0:
return f'''
<div class="bg-green-900/50 border border-green-700 text-green-300 px-4 py-3 rounded-lg">
Synced {synced} asset(s) from L2 ({total} activities found)
</div>
'''
else:
return f'''
<div class="bg-yellow-900/50 border border-yellow-700 text-yellow-300 px-4 py-3 rounded-lg">
No new assets to sync ({total} activities found)
</div>
'''
except Exception as e:
logger.error(f"L2 sync failed: {e}")
return f'''
<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg">
Sync failed: {str(e)}
</div>
'''
# ============ Folder & Collection Management ============
@app.get("/user/folders")
@@ -3687,6 +3823,17 @@ async def ui_media_list(
ipfs_cid = cache_item.get("ipfs_cid") if cache_item else None
ipfs_badge = '<span class="px-2 py-1 bg-purple-600 text-white text-xs font-medium rounded-full" title="On IPFS">IPFS</span>' if ipfs_cid else ''
# Check L2 publish status
l2_shares = item["meta"].get("l2_shares", [])
if l2_shares:
first_share = l2_shares[0]
l2_server = first_share.get("l2_server", "")
asset_name = first_share.get("asset_name", "")
asset_url = f"{l2_server}/assets/{asset_name}"
published_badge = f'<span class="px-2 py-1 bg-green-600 text-white text-xs font-medium rounded-full" title="Published to L2">L2</span>'
else:
published_badge = ''
# Format size
size = item["size"]
if size > 1024*1024:
@@ -3703,6 +3850,7 @@ async def ui_media_list(
<div class="flex items-center gap-2">
<span class="px-2 py-1 bg-blue-600 text-white text-xs font-medium rounded-full">{media_type}</span>
{ipfs_badge}
{published_badge}
</div>
<span class="text-xs text-gray-400">{size_str}</span>
</div>