gabriel / musehub public
topics.ts typescript
300 lines 11.6 KB
Raw
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor ⚠ breaking 1 day ago
1 /**
2 * topics.ts — Topics index and topic-detail page module.
3 *
4 * Reads config from the #page-data JSON element:
5 * { page: "topics", mode: "index" | "topic", tag?, sort? }
6 *
7 * For topic-detail pages, the current cursor is read from the URL query param
8 * ``?cursor=<token>`` rather than from the page JSON, enabling back/forward
9 * navigation without a server round-trip to re-embed the cursor.
10 */
11
12 type PageData = Record<string, unknown>;
13
14 interface TopicEntry {
15 name: string;
16 repoCount: number;
17 }
18
19 interface CuratedGroup {
20 label: string;
21 topics: TopicEntry[];
22 }
23
24 interface RepoCard {
25 owner: string;
26 slug: string;
27 name: string;
28 description: string | null;
29 tags: string[];
30 keySignature: string | null;
31 tempoBpm: number | null;
32 createdAt: string | null;
33 }
34
35 interface TopicsIndexData {
36 allTopics: TopicEntry[];
37 curatedGroups: CuratedGroup[];
38 }
39
40 interface TopicDetailData {
41 repos: RepoCard[];
42 total: number;
43 nextCursor: string | null;
44 }
45
46 function esc(s: string | null | undefined): string {
47 if (!s) return '';
48 return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
49 }
50
51 function fmtRelative(ts: string | null | undefined): string {
52 if (!ts) return '';
53 const d = new Date(ts);
54 const diff = Math.floor((Date.now() - d.getTime()) / 1000);
55 if (diff < 60) return 'just now';
56 if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
57 if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
58 return Math.floor(diff / 86400) + 'd ago';
59 }
60
61 function repoCardHtml(repo: RepoCard): string {
62 const tagsHtml = (repo.tags ?? []).slice(0, 4).map(t =>
63 `<a href="/topics/${encodeURIComponent(t)}" class="badge badge-tag" style="
64 display:inline-block;padding:2px 8px;margin:2px 2px 0 0;border-radius:12px;
65 font-size:11px;background:#1f6feb22;color:#58a6ff;
66 border:1px solid #1f6feb55;text-decoration:none">#${esc(t)}</a>`,
67 ).join('');
68
69 const meta: string[] = [];
70 if (repo.keySignature) meta.push(`🎵 ${esc(repo.keySignature)}`);
71 if (repo.tempoBpm) meta.push(`♩ ${repo.tempoBpm} BPM`);
72
73 return `
74 <div class="card" style="display:flex;flex-direction:column;gap:6px;padding:14px">
75 <div>
76 <a href="/${esc(repo.owner)}/${esc(repo.slug)}"
77 style="font-weight:600;font-size:15px">${esc(repo.owner)}/${esc(repo.name)}</a>
78 </div>
79 ${repo.description ? `<p style="font-size:13px;color:#8b949e;margin:0">${esc(repo.description)}</p>` : ''}
80 <div>${tagsHtml}</div>
81 <div style="display:flex;gap:12px;font-size:12px;color:#8b949e;margin-top:2px">
82 ${meta.map(m => `<span>${m}</span>`).join('')}
83 <span style="margin-left:auto">${fmtRelative(repo.createdAt)}</span>
84 </div>
85 </div>`;
86 }
87
88 function renderTopicsIndex(data: TopicsIndexData): void {
89 const all = data.allTopics ?? [];
90 const groups = data.curatedGroups ?? [];
91
92 function renderGrid(topics: TopicEntry[]): string {
93 if (!topics.length) return '<p style="color:#8b949e;font-size:14px">No topics found.</p>';
94 return `<div style="display:flex;flex-wrap:wrap;gap:8px">` +
95 topics.map(t => `
96 <a href="/topics/${encodeURIComponent(t.name)}"
97 style="display:flex;align-items:center;gap:6px;padding:6px 12px;
98 border-radius:20px;border:1px solid #30363d;background:#161b22;
99 text-decoration:none;color:#c9d1d9;font-size:13px">
100 <span style="font-weight:600">#${esc(t.name)}</span>
101 <span style="background:#21262d;padding:1px 6px;border-radius:10px;
102 font-size:11px;color:#8b949e">${t.repoCount}</span>
103 </a>`).join('') +
104 `</div>`;
105 }
106
107 function renderCuratedGroups(groupList: CuratedGroup[]): string {
108 return groupList.map(g => {
109 const visible = g.topics.filter(t => t.repoCount > 0);
110 if (!visible.length) return '';
111 return `
112 <div style="margin-bottom:20px">
113 <h3 style="font-size:14px;color:#8b949e;margin:0 0 10px;font-weight:600;
114 text-transform:uppercase;letter-spacing:0.5px">${esc(g.label)}</h3>
115 <div style="display:flex;flex-wrap:wrap;gap:6px">
116 ${visible.map(t => `
117 <a href="/topics/${encodeURIComponent(t.name)}"
118 style="padding:4px 10px;border-radius:14px;background:#21262d;
119 border:1px solid #30363d;font-size:12px;color:#c9d1d9;
120 text-decoration:none">
121 #${esc(t.name)}
122 <span style="color:#8b949e;font-size:11px">${t.repoCount}</span>
123 </a>`).join('')}
124 </div>
125 </div>`;
126 }).join('');
127 }
128
129 const contentEl = document.getElementById('content');
130 if (!contentEl) return;
131
132 let filtered = [...all];
133
134 function applyFilter(): void {
135 const inp = document.getElementById('topic-filter') as HTMLInputElement | null;
136 const filterText = inp ? inp.value.toLowerCase().trim() : '';
137 filtered = filterText ? all.filter(t => t.name.includes(filterText)) : [...all];
138 const gridEl = document.getElementById('topic-grid');
139 if (gridEl) gridEl.innerHTML = renderGrid(filtered);
140 }
141
142 (window as unknown as Record<string, unknown>)['_topicsApplyFilter'] = applyFilter;
143
144 contentEl.innerHTML = `
145 <div style="display:grid;grid-template-columns:1fr 280px;gap:24px;align-items:start">
146 <div>
147 <div style="display:flex;align-items:center;gap:16px;margin-bottom:20px">
148 <h1 style="margin:0;font-size:22px">🏷️ Topics</h1>
149 <span style="color:#8b949e;font-size:14px">${all.length} topic${all.length !== 1 ? 's' : ''}</span>
150 </div>
151 <div style="margin-bottom:16px">
152 <input id="topic-filter" type="text" placeholder="Filter topics…"
153 style="width:100%;max-width:400px;padding:8px 12px;
154 background:#0d1117;color:#c9d1d9;border:1px solid #30363d;
155 border-radius:6px;font-size:14px" />
156 </div>
157 <div id="topic-grid">${renderGrid(all)}</div>
158 </div>
159 <div>
160 <div class="card" style="padding:16px">
161 <h2 style="font-size:14px;margin:0 0 16px;color:#e6edf3">Browse by category</h2>
162 ${renderCuratedGroups(groups)}
163 ${!groups.length ? '<p style="color:#8b949e;font-size:13px">No curated groups available.</p>' : ''}
164 </div>
165 </div>
166 </div>`;
167
168 const filterInput = document.getElementById('topic-filter');
169 if (filterInput) filterInput.addEventListener('input', applyFilter);
170 }
171
172 function renderTopicDetail(
173 tag: string,
174 data: TopicDetailData,
175 sort: string,
176 nextCursor: string | null,
177 ): void {
178 const repos = data.repos ?? [];
179 const total = data.total ?? 0;
180 const featured = repos.slice(0, 3);
181
182 function featuredCardHtml(repo: RepoCard): string {
183 return `
184 <div class="card" style="padding:16px;border:1px solid #30363d;
185 background:linear-gradient(135deg,#161b22 0%,#0d1117 100%)">
186 <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
187 <a href="/${esc(repo.owner)}/${esc(repo.slug)}"
188 style="font-weight:700;font-size:16px">${esc(repo.owner)}/${esc(repo.name)}</a>
189 </div>
190 ${repo.description
191 ? `<p style="font-size:13px;color:#8b949e;margin:0 0 10px">${esc(repo.description)}</p>`
192 : ''}
193 <div style="font-size:12px;color:#8b949e">
194 ${repo.keySignature ? `🎵 ${esc(repo.keySignature)}` : ''}
195 ${repo.tempoBpm ? ` &bull; ♩ ${repo.tempoBpm} BPM` : ''}
196 </div>
197 </div>`;
198 }
199
200 function sortUrl(newSort: string): string {
201 // Reset to first page when changing sort order.
202 return `/topics/${encodeURIComponent(tag)}?sort=${newSort}`;
203 }
204
205 const nextUrl = nextCursor
206 ? `/topics/${encodeURIComponent(tag)}?sort=${sort}&cursor=${encodeURIComponent(nextCursor)}`
207 : null;
208 const pager = nextUrl
209 ? `<div style="display:flex;justify-content:center;margin-top:16px">
210 <a href="${nextUrl}" class="btn btn-secondary">Next &rarr;</a>
211 </div>`
212 : '';
213
214 const contentEl = document.getElementById('content');
215 if (!contentEl) return;
216 contentEl.innerHTML = `
217 <div style="margin-bottom:24px">
218 <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
219 <h1 style="margin:0;font-size:24px">
220 <a href="/topics" style="color:#8b949e;text-decoration:none">Topics</a>
221 / <span style="color:#58a6ff">#${esc(tag)}</span>
222 </h1>
223 <span style="color:#8b949e;font-size:14px">${total} repo${total !== 1 ? 's' : ''}</span>
224 </div>
225 </div>
226 ${featured.length ? `
227 <div style="margin-bottom:24px">
228 <h2 style="font-size:15px;color:#8b949e;margin:0 0 12px;text-transform:uppercase;
229 letter-spacing:0.5px;font-weight:600">⭐ Featured</h2>
230 <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px">
231 ${featured.map(featuredCardHtml).join('')}
232 </div>
233 </div>` : ''}
234 <div>
235 <div style="display:flex;align-items:center;justify-content:space-between;
236 margin-bottom:12px;flex-wrap:wrap;gap:8px">
237 <h2 style="font-size:15px;margin:0;color:#8b949e;text-transform:uppercase;
238 letter-spacing:0.5px;font-weight:600">All Repositories</h2>
239 <div style="display:flex;gap:6px;align-items:center">
240 <span style="font-size:13px;color:#8b949e">Sort:</span>
241 <a href="${sortUrl('activity')}"
242 class="btn ${sort === 'activity' ? 'btn-primary' : 'btn-secondary'}"
243 style="font-size:12px;padding:4px 10px">Recently active</a>
244 <a href="${sortUrl('updated')}"
245 class="btn ${sort === 'updated' ? 'btn-primary' : 'btn-secondary'}"
246 style="font-size:12px;padding:4px 10px">Recently updated</a>
247 </div>
248 </div>
249 ${repos.length === 0
250 ? `<p style="color:#8b949e">No public repositories tagged with <strong>#${esc(tag)}</strong> yet.</p>`
251 : `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:12px">
252 ${repos.map(repoCardHtml).join('')}
253 </div>`}
254 ${pager}
255 </div>`;
256 }
257
258 async function uiFetch(url: string): Promise<unknown> {
259 const headers: Record<string, string> = {};
260 if (window.authHeaders) {
261 const h = window.authHeaders() as Record<string, string>;
262 Object.assign(headers, h);
263 }
264 const res = await fetch(url, { headers });
265 if (!res.ok) {
266 const body = await res.text();
267 throw new Error(res.status + ': ' + body);
268 }
269 return res.json() as Promise<unknown>;
270 }
271
272 export function initTopics(data: PageData): void {
273 const mode = String(data['mode'] ?? 'index');
274 const tag = String(data['tag'] ?? '');
275 const sort = String(data['sort'] ?? 'stars');
276
277 void (async () => {
278 try {
279 if (mode === 'index') {
280 const indexData = (await uiFetch('/topics?format=json')) as TopicsIndexData;
281 renderTopicsIndex(indexData);
282 } else {
283 // Read cursor from the URL (not from page_json) so back/forward navigation works.
284 const urlCursor = new URLSearchParams(window.location.search).get('cursor');
285 const params = new URLSearchParams({ format: 'json', sort });
286 if (urlCursor) params.set('cursor', urlCursor);
287 const detailData = (await uiFetch(
288 `/topics/${encodeURIComponent(tag)}?${params.toString()}`,
289 )) as TopicDetailData;
290 renderTopicDetail(tag, detailData, sort, detailData.nextCursor ?? null);
291 }
292 } catch (e: unknown) {
293 const err = e as { message?: string };
294 const contentEl = document.getElementById('content');
295 if (contentEl) {
296 contentEl.innerHTML = `<p class="error">&#10005; Failed to load topics: ${esc(String(err.message ?? e))}</p>`;
297 }
298 }
299 })();
300 }
File History 1 commit
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor 1 day ago