gabriel / musehub public
ui_mists.py python
330 lines 11.2 KB
Raw
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor ⚠ breaking 143 days ago
1 """MuseHub Mist UI route handlers.
2
3 URL schema
4 ----------
5 GET /mists/explore — global discovery feed (all public)
6 GET /{owner}/mists — owner's mist list
7 GET /{owner}/mists/{mist_id} — mist detail (syntax-highlighted)
8 GET /{owner}/mists/{mist_id}/raw — raw artifact download
9 GET /{owner}/mists/{mist_id}/embed — iframe-safe embed card
10
11 Route ordering note
12 -------------------
13 ``/mists/explore`` is a fixed two-segment path that must be registered BEFORE
14 any ``/{owner}/{repo_slug}`` wildcard router so Starlette matches it correctly.
15 The same applies to ``/{owner}/mists`` which must precede ``/{owner}/{repo_slug}``
16 in the router chain.
17 """
18
19 from __future__ import annotations
20
21 import logging
22
23 from fastapi import APIRouter, Depends, HTTPException, Query, Request
24 from fastapi import status as http_status
25 from fastapi.responses import PlainTextResponse, Response
26 from sqlalchemy.ext.asyncio import AsyncSession
27
28 from musehub.api.routes.musehub._templates import templates
29 from musehub.api.routes.musehub._ui_helpers import _breadcrumbs
30 from musehub.api.routes.musehub.htmx_helpers import htmx_fragment_or_full
31 from musehub.db import get_db
32 from musehub.services import musehub_mists
33
34 logger = logging.getLogger(__name__)
35
36 router = APIRouter(prefix="", tags=["musehub-ui"])
37
38
39 # ── Artifact-type color tokens ────────────────────────────────────────────────
40
41 _TYPE_COLORS: dict[str, str] = {
42 "code": "#58a6ff",
43 "midi": "#a371f7",
44 "schema": "#3fb950",
45 "abi": "#d29922",
46 "dataset": "#fb8500",
47 "text": "#8b949e",
48 "config": "#2dd4bf",
49 "unknown": "#6e7681",
50 }
51
52
53 def _type_color(artifact_type: str) -> str:
54 return _TYPE_COLORS.get(artifact_type, _TYPE_COLORS["unknown"])
55
56
57 # ── Explore ───────────────────────────────────────────────────────────────────
58
59
60 @router.get("/mists/explore", summary="Global Mist discovery feed")
61 async def mist_explore_page(
62 request: Request,
63 artifact_type: str | None = Query(None),
64 cursor: str | None = Query(None),
65 limit: int = Query(20, ge=1, le=100),
66 db: AsyncSession = Depends(get_db),
67 ) -> Response:
68 """Render the global Mist discovery feed (all public mists, newest first).
69
70 Supports cursor-based pagination and optional artifact-type filtering.
71 No authentication required.
72 """
73 result = await musehub_mists.list_mists(
74 db,
75 owner=None,
76 artifact_type=artifact_type,
77 include_secret=False,
78 cursor=cursor,
79 limit=limit,
80 )
81
82 # Build type-chip list for filter bar (well-known types only)
83 type_chips = [
84 {"key": t, "label": t.title(), "color": c}
85 for t, c in _TYPE_COLORS.items()
86 if t != "unknown"
87 ]
88
89 mists_data = [m.model_dump(mode="json") for m in result.mists]
90 for m in mists_data:
91 m["_type_color"] = _type_color(m.get("artifact_type", "unknown"))
92
93 ctx = {
94 "mists": mists_data,
95 "total": result.total,
96 "next_cursor": result.next_cursor,
97 "limit": limit,
98 "active_type": artifact_type or "",
99 "type_chips": type_chips,
100 "breadcrumb_data": _breadcrumbs(
101 ("Mists", "/mists/explore"),
102 ("Explore", ""),
103 ),
104 }
105 return await htmx_fragment_or_full(
106 request,
107 templates,
108 ctx,
109 full_template="musehub/pages/mist_explore.html",
110 fragment_template="musehub/fragments/mist_rows.html",
111 )
112
113
114 # ── Owner list ────────────────────────────────────────────────────────────────
115
116
117 @router.get("/{owner}/mists", summary="Owner Mist list")
118 async def mist_list_page(
119 request: Request,
120 owner: str,
121 artifact_type: str | None = Query(None),
122 cursor: str | None = Query(None),
123 limit: int = Query(20, ge=1, le=100),
124 db: AsyncSession = Depends(get_db),
125 ) -> Response:
126 """Render the mist list page for a specific owner.
127
128 Public mists are always shown. Secret mists are shown only when the
129 authenticated caller is the owner — currently auth is optional on this
130 route (the secret filter is enforced by the service layer via ``include_secret``).
131 """
132 result = await musehub_mists.list_mists(
133 db,
134 owner=owner,
135 artifact_type=artifact_type,
136 include_secret=False,
137 cursor=cursor,
138 limit=limit,
139 )
140
141 type_chips = [
142 {"key": t, "label": t.title(), "color": c}
143 for t, c in _TYPE_COLORS.items()
144 if t != "unknown"
145 ]
146
147 mists_data = [m.model_dump(mode="json") for m in result.mists]
148 for m in mists_data:
149 m["_type_color"] = _type_color(m.get("artifact_type", "unknown"))
150
151 ctx = {
152 "owner": owner,
153 "mists": mists_data,
154 "total": result.total,
155 "next_cursor": result.next_cursor,
156 "limit": limit,
157 "active_type": artifact_type or "",
158 "type_chips": type_chips,
159 "breadcrumb_data": _breadcrumbs(
160 (owner, f"/{owner}"),
161 ("Mists", ""),
162 ),
163 }
164 return await htmx_fragment_or_full(
165 request,
166 templates,
167 ctx,
168 full_template="musehub/pages/mist_list.html",
169 fragment_template="musehub/fragments/mist_rows.html",
170 )
171
172
173 # ── Detail ────────────────────────────────────────────────────────────────────
174
175
176 @router.get("/{owner}/mists/{mist_id}", summary="Mist detail page")
177 async def mist_detail_page(
178 request: Request,
179 owner: str,
180 mist_id: str,
181 db: AsyncSession = Depends(get_db),
182 ) -> Response:
183 """Render the mist detail page with syntax-highlighted content.
184
185 Public mists are accessible without authentication. Secret mists return
186 404 to prevent leaking existence — callers that know the ID and are the
187 owner should use the API directly.
188 """
189 mist = await musehub_mists.get_mist(db, mist_id)
190 if mist is None or mist.owner != owner:
191 raise HTTPException(
192 status_code=http_status.HTTP_404_NOT_FOUND,
193 detail=f"Mist '{mist_id}' not found.",
194 )
195 if mist.visibility == "secret":
196 # Secret mists are not accessible via the UI (no session auth yet)
197 raise HTTPException(
198 status_code=http_status.HTTP_404_NOT_FOUND,
199 detail=f"Mist '{mist_id}' not found.",
200 )
201
202 await musehub_mists.increment_mist_view(db, mist_id)
203 await db.commit()
204
205 # Fetch forks for the forks panel
206 forks = await musehub_mists.get_mist_forks(db, mist_id, limit=10)
207
208 mist_data = mist.model_dump(mode="json")
209 mist_data["_type_color"] = _type_color(mist.artifact_type)
210
211 # Map artifact_type → Prism.js language class
212 _prism_map = {
213 "code": mist.language or "text",
214 "schema": "json",
215 "abi": "json",
216 "config": "yaml",
217 "text": "text",
218 "midi": "none",
219 "dataset": "text",
220 "unknown": "text",
221 }
222 prism_lang = _prism_map.get(mist.artifact_type, mist.language or "text")
223
224 ctx = {
225 "owner": owner,
226 "mist": mist_data,
227 "forks": [f.model_dump(mode="json") for f in forks],
228 "fork_count": mist.fork_count,
229 "prism_lang": prism_lang,
230 "type_color": mist_data["_type_color"],
231 "embed_url": f"/{owner}/mists/{mist_id}/embed",
232 "raw_url": f"/{owner}/mists/{mist_id}/raw",
233 "breadcrumb_data": _breadcrumbs(
234 (owner, f"/{owner}"),
235 ("Mists", f"/{owner}/mists"),
236 (mist.filename, ""),
237 ),
238 }
239 return templates.TemplateResponse(request, "musehub/pages/mist_detail.html", ctx)
240
241
242 # ── Raw ───────────────────────────────────────────────────────────────────────
243
244
245 @router.get("/{owner}/mists/{mist_id}/raw", summary="Mist raw artifact")
246 async def mist_raw(
247 owner: str,
248 mist_id: str,
249 db: AsyncSession = Depends(get_db),
250 ) -> Response:
251 """Return the raw artifact content as plain text.
252
253 Sets ``Content-Disposition: inline; filename=<filename>`` so browsers
254 display or download the artifact appropriately.
255 """
256 mist = await musehub_mists.get_mist(db, mist_id)
257 if mist is None or mist.owner != owner or mist.visibility == "secret":
258 raise HTTPException(
259 status_code=http_status.HTTP_404_NOT_FOUND,
260 detail=f"Mist '{mist_id}' not found.",
261 )
262 return PlainTextResponse(
263 content=mist.content,
264 headers={
265 "Content-Disposition": f'inline; filename="{mist.filename}"',
266 "Cache-Control": "public, max-age=31536000, immutable",
267 },
268 )
269
270
271 # ── Embed card ────────────────────────────────────────────────────────────────
272
273
274 @router.get("/{owner}/mists/{mist_id}/embed", summary="Mist embed card")
275 async def mist_embed_page(
276 request: Request,
277 owner: str,
278 mist_id: str,
279 db: AsyncSession = Depends(get_db),
280 ) -> Response:
281 """Render an iframe-safe embed card for a public Mist.
282
283 Returns a standalone HTML page (no base.html layout, no nav) suitable for
284 embedding in third-party pages via an ``<iframe>``. The embed count is
285 incremented on each render.
286
287 The response omits ``X-Frame-Options`` and relaxes ``frame-ancestors`` so
288 the card can be loaded cross-origin.
289 """
290 mist = await musehub_mists.get_mist(db, mist_id)
291 if mist is None or mist.owner != owner or mist.visibility != "public":
292 raise HTTPException(
293 status_code=http_status.HTTP_404_NOT_FOUND,
294 detail=f"Mist '{mist_id}' not found.",
295 )
296
297 await musehub_mists.increment_mist_embed(db, mist_id)
298 await db.commit()
299
300 _prism_map = {
301 "code": mist.language or "text",
302 "schema": "json",
303 "abi": "json",
304 "config": "yaml",
305 "text": "text",
306 "midi": "none",
307 "dataset": "text",
308 "unknown": "text",
309 }
310 prism_lang = _prism_map.get(mist.artifact_type, mist.language or "text")
311
312 ctx = {
313 "owner": owner,
314 "mist": mist.model_dump(mode="json"),
315 "prism_lang": prism_lang,
316 "type_color": _type_color(mist.artifact_type),
317 "detail_url": f"/{owner}/mists/{mist_id}",
318 }
319
320 resp = templates.TemplateResponse(request, "musehub/pages/mist_embed.html", ctx)
321 # Allow cross-origin framing for embed cards
322 resp.headers["X-Frame-Options"] = "ALLOWALL"
323 resp.headers["Content-Security-Policy"] = (
324 "default-src 'self'; "
325 "script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; "
326 "style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; "
327 "frame-ancestors *; "
328 "upgrade-insecure-requests"
329 )
330 return resp
File History 1 commit
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor 143 days ago