From a3437f006967d69520451e55e05f9ad4fd8827cd Mon Sep 17 00:00:00 2001 From: giles Date: Tue, 24 Feb 2026 21:44:13 +0000 Subject: [PATCH] Add nav-item + link-card fragments and oEmbed endpoint - nav-item fragment handler with template - link-card fragment handler with CID-based lookup, friendly names, batch mode - oEmbed router at GET /oembed for media/recipe/effect/run content - Fragment templates in app/templates/fragments/ Co-Authored-By: Claude Opus 4.6 --- app/__init__.py | 3 +- app/routers/fragments.py | 116 +++++++++++++++++++++++++ app/routers/oembed.py | 74 ++++++++++++++++ app/templates/fragments/link_card.html | 22 +++++ app/templates/fragments/nav_item.html | 7 ++ 5 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 app/routers/oembed.py create mode 100644 app/templates/fragments/link_card.html create mode 100644 app/templates/fragments/nav_item.html diff --git a/app/__init__.py b/app/__init__.py index 01477ba..9728b37 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -164,13 +164,14 @@ def create_app() -> FastAPI: return JSONResponse({"detail": "Not found"}, status_code=404) # Include routers - from .routers import auth, storage, api, recipes, cache, runs, home, effects, inbox, fragments + from .routers import auth, storage, api, recipes, cache, runs, home, effects, inbox, fragments, oembed # Home and auth routers (root level) app.include_router(home.router, tags=["home"]) app.include_router(auth.router, prefix="/auth", tags=["auth"]) app.include_router(inbox.router, tags=["inbox"]) app.include_router(fragments.router, tags=["fragments"]) + app.include_router(oembed.router, tags=["oembed"]) # Feature routers app.include_router(storage.router, prefix="/storage", tags=["storage"]) diff --git a/app/routers/fragments.py b/app/routers/fragments.py index 76935fe..5d6d821 100644 --- a/app/routers/fragments.py +++ b/app/routers/fragments.py @@ -5,6 +5,8 @@ Exposes HTML fragments at ``/internal/fragments/{type}`` for consumption by coop apps via the fragment client. """ +import os + from fastapi import APIRouter, Request, Response router = APIRouter() @@ -25,3 +27,117 @@ async def get_fragment(fragment_type: str, request: Request): return Response(content="", media_type="text/html", status_code=200) html = await handler(request) return Response(content=html, media_type="text/html", status_code=200) + + +# --- nav-item fragment --- + +async def _nav_item_handler(request: Request) -> str: + from artdag_common import render_fragment + + templates = request.app.state.templates + artdag_url = os.getenv("APP_URL_ARTDAG", "https://celery-artdag.rose-ash.com") + return render_fragment(templates, "fragments/nav_item.html", artdag_url=artdag_url) + + +_handlers["nav-item"] = _nav_item_handler + + +# --- link-card fragment --- + +async def _link_card_handler(request: Request) -> str: + from artdag_common import render_fragment + import database + + templates = request.app.state.templates + cid = request.query_params.get("cid", "") + content_type = request.query_params.get("type", "media") + slug = request.query_params.get("slug", "") + keys_raw = request.query_params.get("keys", "") + + # Batch mode: return multiple cards separated by markers + if keys_raw: + keys = [k.strip() for k in keys_raw.split(",") if k.strip()] + parts = [] + for key in keys: + parts.append(f"") + card_html = await _render_single_link_card( + templates, key, content_type, + ) + parts.append(card_html) + return "\n".join(parts) + + # Single mode: use cid or slug + lookup_cid = cid or slug + if not lookup_cid: + return "" + return await _render_single_link_card(templates, lookup_cid, content_type) + + +async def _render_single_link_card(templates, cid: str, content_type: str) -> str: + import database + from artdag_common import render_fragment + + if not cid: + return "" + + artdag_url = os.getenv("APP_URL_ARTDAG", "https://celery-artdag.rose-ash.com") + + # Try item_types first (has metadata) + item = await database.get_item_types(cid) + # get_item_types returns a list; pick best match for content_type + meta = None + if item: + for it in item: + if it.get("type") == content_type: + meta = it + break + if not meta: + meta = item[0] + + # Try friendly name for display + friendly = None + if meta and meta.get("actor_id"): + friendly = await database.get_friendly_name_by_cid(meta["actor_id"], cid) + + # Try run cache if type is "run" + run = None + if content_type == "run": + run = await database.get_run_cache(cid) + + title = "" + description = "" + link = "" + + if friendly: + title = friendly.get("display_name") or friendly.get("base_name", cid[:12]) + elif meta: + title = meta.get("filename") or meta.get("description", cid[:12]) + elif run: + title = f"Run {cid[:12]}" + else: + title = cid[:16] + + if meta: + description = meta.get("description", "") + + if content_type == "run": + link = f"{artdag_url}/runs/{cid}" + elif content_type == "recipe": + link = f"{artdag_url}/recipes/{cid}" + elif content_type == "effect": + link = f"{artdag_url}/effects/{cid}" + else: + link = f"{artdag_url}/cache/{cid}" + + return render_fragment( + templates, "fragments/link_card.html", + title=title, + description=description, + link=link, + cid=cid, + content_type=content_type, + artdag_url=artdag_url, + ) + + +_handlers["link-card"] = _link_card_handler diff --git a/app/routers/oembed.py b/app/routers/oembed.py new file mode 100644 index 0000000..615dfda --- /dev/null +++ b/app/routers/oembed.py @@ -0,0 +1,74 @@ +"""Art-DAG oEmbed endpoint. + +Returns oEmbed JSON responses for Art-DAG content (media, recipes, effects, runs). +""" + +import os + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse + +router = APIRouter() + + +@router.get("/oembed") +async def oembed(request: Request): + url = request.query_params.get("url", "") + if not url: + return JSONResponse({"error": "url parameter required"}, status_code=400) + + # Parse URL to extract content type and CID + # URL patterns: /cache/{cid}, /recipes/{cid}, /effects/{cid}, /runs/{cid} + from urllib.parse import urlparse + + parsed = urlparse(url) + parts = [p for p in parsed.path.strip("/").split("/") if p] + + if len(parts) < 2: + return JSONResponse({"error": "could not parse content URL"}, status_code=404) + + content_type = parts[0].rstrip("s") # recipes -> recipe, runs -> run + cid = parts[1] + + import database + + title = cid[:16] + thumbnail_url = None + + # Look up metadata + items = await database.get_item_types(cid) + if items: + meta = items[0] + title = meta.get("filename") or meta.get("description") or title + + # Try friendly name + actor_id = meta.get("actor_id") + if actor_id: + friendly = await database.get_friendly_name_by_cid(actor_id, cid) + if friendly: + title = friendly.get("display_name") or friendly.get("base_name", title) + + # Media items get a thumbnail + if meta.get("type") == "media": + artdag_url = os.getenv("APP_URL_ARTDAG", "https://celery-artdag.rose-ash.com") + thumbnail_url = f"{artdag_url}/cache/{cid}/raw" + + elif content_type == "run": + run = await database.get_run_cache(cid) + if run: + title = f"Run {cid[:12]}" + + artdag_url = os.getenv("APP_URL_ARTDAG", "https://celery-artdag.rose-ash.com") + + resp = { + "version": "1.0", + "type": "link", + "title": title, + "provider_name": "art-dag", + "provider_url": artdag_url, + "url": url, + } + if thumbnail_url: + resp["thumbnail_url"] = thumbnail_url + + return JSONResponse(resp) diff --git a/app/templates/fragments/link_card.html b/app/templates/fragments/link_card.html new file mode 100644 index 0000000..ecc4450 --- /dev/null +++ b/app/templates/fragments/link_card.html @@ -0,0 +1,22 @@ + +
+
+ {% if content_type == "recipe" %} + + {% elif content_type == "effect" %} + + {% elif content_type == "run" %} + + {% else %} + + {% endif %} +
+
+
{{ title }}
+ {% if description %} +
{{ description }}
+ {% endif %} +
{{ content_type }} · {{ cid[:12] }}…
+
+
+
diff --git a/app/templates/fragments/nav_item.html b/app/templates/fragments/nav_item.html new file mode 100644 index 0000000..e987cc5 --- /dev/null +++ b/app/templates/fragments/nav_item.html @@ -0,0 +1,7 @@ +