topics.ts
typescript
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, '&').replace(/</g, '<').replace(/>/g, '>'); |
| 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 ? ` • ♩ ${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 →</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">✕ 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