gabriel / musehub public
ui.py python
285 lines 11.2 KB
Raw
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