ui.py
python
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226
Merge branch 'fix/two-column-scroll-layout' into dev
Human
9 days ago
| 1 | """MuseHub web UI route handlers — global discovery routes. |
| 2 | |
| 3 | Fixed-path global routes only: /search and /explore. |
| 4 | All repo-scoped handlers live in dedicated ui_*.py modules. |
| 5 | Shared helpers live in _ui_helpers.py. |
| 6 | """ |
| 7 | |
| 8 | import logging |
| 9 | |
| 10 | from fastapi import APIRouter, Depends, Query, Request |
| 11 | from sqlalchemy import func, select as sa_select |
| 12 | from sqlalchemy.ext.asyncio import AsyncSession |
| 13 | from starlette.responses import Response |
| 14 | |
| 15 | from musehub.api.routes.musehub._templates import templates |
| 16 | from musehub.api.routes.musehub.htmx_helpers import htmx_fragment_or_full |
| 17 | from musehub.db import get_db |
| 18 | from musehub.db import musehub_domain_models as domain_db |
| 19 | from musehub.db.musehub_repo_models import MusehubCommit, MusehubCommitRef, MusehubRepo |
| 20 | from musehub.services import musehub_discover, musehub_repository |
| 21 | from musehub.services.repo_card_enrichment import enrich_repo_cards |
| 22 | from musehub.types.json_types import IntDict, JSONObject |
| 23 | |
| 24 | logger = logging.getLogger(__name__) |
| 25 | |
| 26 | # Fixed-path routes registered BEFORE the /{owner}/{repo_slug} wildcard. |
| 27 | fixed_router = APIRouter(prefix="", tags=["musehub-ui"]) |
| 28 | |
| 29 | # --------------------------------------------------------------------------- |
| 30 | # Fixed-path routes (registered before wildcard routes in main.py) |
| 31 | # --------------------------------------------------------------------------- |
| 32 | |
| 33 | @fixed_router.get("/search", summary="MuseHub global search page") |
| 34 | async def global_search_page( |
| 35 | request: Request, |
| 36 | q: str = "", |
| 37 | mode: str = "keyword", |
| 38 | cursor: str | None = Query(None, description="Pagination cursor from previous nextCursor"), |
| 39 | limit: int = Query(10, ge=1, le=50), |
| 40 | db: AsyncSession = Depends(get_db), |
| 41 | ) -> Response: |
| 42 | """Render the global cross-repo search page with SSR results. |
| 43 | |
| 44 | Results are fetched server-side and rendered into Jinja2 templates so the |
| 45 | page is fully readable without JavaScript. HTMX live-search (debounced |
| 46 | input trigger) swaps only the ``#search-results`` fragment on subsequent |
| 47 | queries, avoiding a full-page reload. |
| 48 | """ |
| 49 | safe_mode = mode if mode in ("keyword", "pattern") else "keyword" |
| 50 | result = None |
| 51 | if q and len(q.strip()) >= 2: |
| 52 | result = await musehub_repository.global_search( |
| 53 | db, |
| 54 | query=q, |
| 55 | mode=safe_mode, |
| 56 | cursor=cursor, |
| 57 | limit=limit, |
| 58 | ) |
| 59 | logger.info( |
| 60 | "✅ Global search SSR q=%r mode=%s cursor=%r → %d groups", |
| 61 | q, |
| 62 | safe_mode, |
| 63 | cursor, |
| 64 | len(result.groups) if result else 0, |
| 65 | ) |
| 66 | ctx: JSONObject = { |
| 67 | "query": q, |
| 68 | "mode": safe_mode, |
| 69 | "cursor": cursor, |
| 70 | "limit": limit, |
| 71 | "result": result, |
| 72 | "modes": ["keyword", "pattern"], |
| 73 | } |
| 74 | return await htmx_fragment_or_full( |
| 75 | request, |
| 76 | templates, |
| 77 | ctx, |
| 78 | full_template="musehub/pages/global_search.html", |
| 79 | fragment_template="musehub/fragments/global_search_results.html", |
| 80 | ) |
| 81 | |
| 82 | @fixed_router.get("/explore", summary="MuseHub explore page") |
| 83 | async def explore_page( |
| 84 | request: Request, |
| 85 | lang: list[str] = Query(default=[], alias="lang", description="Language/instrument filter chips (multi-select)"), |
| 86 | license_filter: str = Query(default="", alias="license", description="License filter (e.g. CC0, CC BY)"), |
| 87 | sort: str = Query(default="activity", description="Sort order: activity | commits | created | trending"), |
| 88 | topic: list[str] = Query(default=[], alias="topic", description="Topic filter chips (multi-select)"), |
| 89 | cursor: str | None = Query(default=None, description="Pagination cursor from previous nextCursor"), |
| 90 | limit: int = Query(default=24, ge=1, le=100, description="Results per page"), |
| 91 | db: AsyncSession = Depends(get_db), |
| 92 | ) -> Response: |
| 93 | """Render the explore/discover page — an SSR filterable grid of all public repos. |
| 94 | |
| 95 | No auth required. Filter sidebar uses GET params so all filter states are |
| 96 | bookmarkable and shareable. Sidebar data (muse_tag chips, topic chips) is |
| 97 | pre-loaded server-side to avoid an extra round-trip on first paint. |
| 98 | |
| 99 | HTMX fragment requests (HX-Request: true) return only the repo grid fragment |
| 100 | so filter changes can swap the grid without a full page reload. |
| 101 | |
| 102 | Filter sources: |
| 103 | - ``lang`` chips: top 30 distinct tag values from ``musehub_repos.tags`` JSON. |
| 104 | - ``topic`` chips: top 40 distinct tags from ``musehub_repos.tags`` JSON. |
| 105 | - ``license``: fixed enum (CC0, CC BY, CC BY-SA, CC BY-NC, All Rights Reserved). |
| 106 | - ``sort``: stars | updated | forks | trending. |
| 107 | """ |
| 108 | # Build Language/Instrument chip cloud from musehub_repos.tags JSON. |
| 109 | # Tags may be prefixed (emotion:melancholic, genre:baroque, stage:released) or bare. |
| 110 | # We strip the prefix and count distinct values so chips cover all 39 public repos, |
| 111 | # not just the ~10 repos that happen to have muse_cli commit data. |
| 112 | lang_tag_rows = await db.execute( |
| 113 | sa_select(MusehubRepo.tags).where( |
| 114 | MusehubRepo.visibility == "public", |
| 115 | MusehubRepo.domain_id != "mist", |
| 116 | ) |
| 117 | ) |
| 118 | _lang_counts: IntDict = {} |
| 119 | for (tags,) in lang_tag_rows: |
| 120 | for t in tags or []: |
| 121 | raw = str(t) |
| 122 | # Strip known prefixes so "emotion:melancholic" → "melancholic" |
| 123 | value = raw.split(":", 1)[-1].lower() if ":" in raw else raw.lower() |
| 124 | # Skip very short values (keys like "c", "f") and tempo strings |
| 125 | if len(value) >= 3 and not value.replace(".", "").isdigit(): |
| 126 | _lang_counts[value] = _lang_counts.get(value, 0) + 1 |
| 127 | muse_tag_chips: list[str] = [ |
| 128 | name |
| 129 | for name, _ in sorted(_lang_counts.items(), key=lambda kv: (-kv[1], kv[0]))[:30] |
| 130 | ] |
| 131 | |
| 132 | # Fetch top topics from public repo tag JSON arrays (same logic as topics API). |
| 133 | topic_rows = await db.execute( |
| 134 | sa_select(MusehubRepo.tags).where( |
| 135 | MusehubRepo.visibility == "public", |
| 136 | MusehubRepo.domain_id != "mist", |
| 137 | ) |
| 138 | ) |
| 139 | topic_counts: IntDict = {} |
| 140 | for (tags,) in topic_rows: |
| 141 | for t in tags or []: |
| 142 | key = str(t).lower() |
| 143 | topic_counts[key] = topic_counts.get(key, 0) + 1 |
| 144 | topic_chips: list[str] = [ |
| 145 | name |
| 146 | for name, _ in sorted(topic_counts.items(), key=lambda kv: (-kv[1], kv[0]))[:40] |
| 147 | ] |
| 148 | |
| 149 | # Hero stats: total commits and distinct domain count across public repos. |
| 150 | _commit_count_row = await db.execute( |
| 151 | sa_select(func.count()).select_from(MusehubCommitRef).join( |
| 152 | MusehubRepo, |
| 153 | MusehubCommitRef.repo_id == MusehubRepo.repo_id, |
| 154 | ).where( |
| 155 | MusehubRepo.visibility == "public", |
| 156 | MusehubRepo.domain_id != "mist", |
| 157 | ) |
| 158 | ) |
| 159 | total_commits_count: int = _commit_count_row.scalar_one_or_none() or 0 |
| 160 | |
| 161 | _domain_tag_rows = await db.execute( |
| 162 | sa_select(MusehubRepo.tags).where( |
| 163 | MusehubRepo.visibility == "public", |
| 164 | MusehubRepo.domain_id != "mist", |
| 165 | ) |
| 166 | ) |
| 167 | _domain_set: set[str] = set() |
| 168 | for (tags,) in _domain_tag_rows: |
| 169 | for t in tags or []: |
| 170 | raw = str(t) |
| 171 | # Use only prefix-based domain tags (e.g. "domain:audio", "genre:baroque") |
| 172 | # or bare single-word tags that represent a domain category. |
| 173 | value = raw.split(":", 1)[-1].lower() if ":" in raw else raw.lower() |
| 174 | if len(value) >= 3 and not value.replace(".", "").isdigit(): |
| 175 | _domain_set.add(value) |
| 176 | domain_count: int = len(_domain_set) |
| 177 | |
| 178 | # Fetch registered public domains for the sidebar quick-filter. |
| 179 | _domain_rows = await db.execute( |
| 180 | sa_select( |
| 181 | domain_db.MusehubDomain.domain_id, |
| 182 | domain_db.MusehubDomain.display_name, |
| 183 | domain_db.MusehubDomain.slug, |
| 184 | domain_db.MusehubDomain.author_slug, |
| 185 | ).order_by(domain_db.MusehubDomain.install_count.desc()) |
| 186 | ) |
| 187 | explore_domains = [ |
| 188 | {"domain_id": r.domain_id, "name": r.display_name, "slug": r.slug, "owner": r.author_slug} |
| 189 | for r in _domain_rows |
| 190 | ] |
| 191 | |
| 192 | # Map UI sort labels to discover service sort fields. |
| 193 | _sort_map = { |
| 194 | "activity": "activity", |
| 195 | "updated": "activity", |
| 196 | "commits": "commits", |
| 197 | "forks": "commits", |
| 198 | "created": "created", |
| 199 | "trending": "trending", |
| 200 | } |
| 201 | effective_sort: musehub_discover.SortField = _sort_map.get(sort, "activity") |
| 202 | |
| 203 | # Fetch repos server-side for SSR grid, passing all active filters. |
| 204 | explore = await musehub_discover.list_public_repos( |
| 205 | db, |
| 206 | sort=effective_sort, |
| 207 | cursor=cursor, |
| 208 | page_size=limit, |
| 209 | langs=lang or None, |
| 210 | topics=topic or None, |
| 211 | license=license_filter or None, |
| 212 | ) |
| 213 | |
| 214 | repo_ids = [r.repo_id for r in explore.repos] |
| 215 | enrichments = await enrich_repo_cards(db, repo_ids) |
| 216 | |
| 217 | ctx: JSONObject = { |
| 218 | "title": "Explore", |
| 219 | "breadcrumb": "Explore", |
| 220 | "repos": explore.repos, |
| 221 | "total": explore.total, |
| 222 | "next_cursor": explore.next_cursor, |
| 223 | "limit": limit, |
| 224 | "sort": sort, |
| 225 | "muse_tag_chips": muse_tag_chips, |
| 226 | "topic_chips": topic_chips, |
| 227 | "selected_langs": lang, |
| 228 | "selected_license": license_filter, |
| 229 | "selected_topics": topic, |
| 230 | "license_options": ["", "MIT", "Apache-2.0", "GPL-3.0", "AGPL-3.0", "BSD-2-Clause", "BSD-3-Clause", "MPL-2.0", "LGPL-2.1", "Unlicense"], |
| 231 | "sort_options": [ |
| 232 | ("activity", "Recently active"), |
| 233 | ("updated", "Recently updated"), |
| 234 | ("forks", "Most forked"), |
| 235 | ("trending", "Trending"), |
| 236 | ], |
| 237 | "enrichments": enrichments, |
| 238 | "base_explore_url": "/explore", |
| 239 | "total_commits_count": total_commits_count, |
| 240 | "domain_count": domain_count, |
| 241 | "explore_domains": explore_domains, |
| 242 | } |
| 243 | return await htmx_fragment_or_full( |
| 244 | request, |
| 245 | templates, |
| 246 | ctx, |
| 247 | full_template="musehub/pages/explore.html", |
| 248 | fragment_template="musehub/fragments/repo_grid.html", |
| 249 | ) |
| 250 | |
| 251 | @fixed_router.get("/explore/search", summary="MuseHub explore semantic search fragment") |
| 252 | async def explore_search_fragment( |
| 253 | request: Request, |
| 254 | q: str = Query("", min_length=0), |
| 255 | type: str = Query("repos", description="Search type: repos | commits"), |
| 256 | db: AsyncSession = Depends(get_db), |
| 257 | ) -> Response: |
| 258 | """Return a rendered HTML fragment for the explore semantic search results. |
| 259 | |
| 260 | JS fetches this endpoint on input and swaps the result into #search-results. |
| 261 | No HTML is generated in the browser — Jinja owns all markup. |
| 262 | """ |
| 263 | safe_q = q.strip() |
| 264 | if not safe_q or len(safe_q) < 2: |
| 265 | return Response(content="", media_type="text/html") |
| 266 | |
| 267 | if type == "commits": |
| 268 | result = await musehub_repository.global_search( |
| 269 | db, |
| 270 | query=safe_q, |
| 271 | mode="keyword", |
| 272 | limit=10, |
| 273 | ) |
| 274 | ctx = {"request": request, "result": result, "query": safe_q} |
| 275 | return templates.TemplateResponse( |
| 276 | "musehub/fragments/search_commit_results.html", ctx |
| 277 | ) |
| 278 | |
| 279 | repos = await musehub_discover.search_repos_by_text(db, safe_q, limit=20) |
| 280 | repo_ids = [r.repo_id for r in repos] |
| 281 | enrichments = await enrich_repo_cards(db, repo_ids) |
| 282 | ctx = {"request": request, "repos": repos, "query": safe_q, "enrichments": enrichments} |
| 283 | return templates.TemplateResponse( |
| 284 | "musehub/fragments/search_repo_results.html", ctx |
| 285 | ) |
File History
14 commits
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226
Merge branch 'fix/two-column-scroll-layout' into dev
Human
9 days ago
sha256:408916fc5973ba59c6e4eebaa80ebdcc801c0a63205651e25009d11548f79454
chore: bump version to 0.2.0.dev2 — nightly.2, matching muse
Sonnet 4.6
patch
11 days ago
sha256:31491a5f31832312965ba9c8d73c9eb6171069015bde3a471acc9d5006c1e45a
revert: keep pyproject.toml in canonical PEP 440 form
Sonnet 4.6
patch
14 days ago
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2
chore: bump version to 0.2.0rc15 for musehub#113 fix release
Sonnet 4.6
patch
15 days ago
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352
Merge branch 'task/version-tags-phase3-server' into dev
Human
17 days ago
sha256:4669620efda9ff41c55bdefd1f7bfe1c239d468428744c84ead9957e5a003a53
merge: rescue snapshot-recovery hardening (c00aa21d) into d…
Opus 4.8
minor
⚠
30 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a
fix: remove false-positive proposal_comments index drop fro…
Sonnet 4.6
patch
34 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3
feat: render markdown mists as HTML with heading anchor links
Sonnet 4.6
patch
34 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6
Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump …
Human
35 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee
Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump …
Human
35 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c
Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo…
Human
35 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd
Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop…
Human
36 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f
fix: use wire_bytes not mpack_bytes_raw in compute_object_b…
Sonnet 4.6
patch
48 days ago
sha256:b99f2455dc346966d040133f5203297e6e3ef5803a93728a2c30568d0a0f7583
rename: delta_add → delta_upsert across wire format, models…
Sonnet 4.6
patch
50 days ago