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