/** * topics.ts — Topics index and topic-detail page module. * * Reads config from the #page-data JSON element: * { page: "topics", mode: "index" | "topic", tag?, sort? } * * For topic-detail pages, the current cursor is read from the URL query param * ``?cursor=`` rather than from the page JSON, enabling back/forward * navigation without a server round-trip to re-embed the cursor. */ type PageData = Record; interface TopicEntry { name: string; repoCount: number; } interface CuratedGroup { label: string; topics: TopicEntry[]; } interface RepoCard { owner: string; slug: string; name: string; description: string | null; tags: string[]; keySignature: string | null; tempoBpm: number | null; createdAt: string | null; } interface TopicsIndexData { allTopics: TopicEntry[]; curatedGroups: CuratedGroup[]; } interface TopicDetailData { repos: RepoCard[]; total: number; nextCursor: string | null; } function esc(s: string | null | undefined): string { if (!s) return ''; return String(s).replace(/&/g, '&').replace(//g, '>'); } function fmtRelative(ts: string | null | undefined): string { if (!ts) return ''; const d = new Date(ts); const diff = Math.floor((Date.now() - d.getTime()) / 1000); if (diff < 60) return 'just now'; if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'; return Math.floor(diff / 86400) + 'd ago'; } function repoCardHtml(repo: RepoCard): string { const tagsHtml = (repo.tags ?? []).slice(0, 4).map(t => `#${esc(t)}`, ).join(''); const meta: string[] = []; if (repo.keySignature) meta.push(`🎵 ${esc(repo.keySignature)}`); if (repo.tempoBpm) meta.push(`♩ ${repo.tempoBpm} BPM`); return `
${esc(repo.owner)}/${esc(repo.name)}
${repo.description ? `

${esc(repo.description)}

` : ''}
${tagsHtml}
${meta.map(m => `${m}`).join('')} ${fmtRelative(repo.createdAt)}
`; } function renderTopicsIndex(data: TopicsIndexData): void { const all = data.allTopics ?? []; const groups = data.curatedGroups ?? []; function renderGrid(topics: TopicEntry[]): string { if (!topics.length) return '

No topics found.

'; return `
` + topics.map(t => ` #${esc(t.name)} ${t.repoCount} `).join('') + `
`; } function renderCuratedGroups(groupList: CuratedGroup[]): string { return groupList.map(g => { const visible = g.topics.filter(t => t.repoCount > 0); if (!visible.length) return ''; return `

${esc(g.label)}

${visible.map(t => ` #${esc(t.name)} ${t.repoCount} `).join('')}
`; }).join(''); } const contentEl = document.getElementById('content'); if (!contentEl) return; let filtered = [...all]; function applyFilter(): void { const inp = document.getElementById('topic-filter') as HTMLInputElement | null; const filterText = inp ? inp.value.toLowerCase().trim() : ''; filtered = filterText ? all.filter(t => t.name.includes(filterText)) : [...all]; const gridEl = document.getElementById('topic-grid'); if (gridEl) gridEl.innerHTML = renderGrid(filtered); } (window as unknown as Record)['_topicsApplyFilter'] = applyFilter; contentEl.innerHTML = `

🏷️ Topics

${all.length} topic${all.length !== 1 ? 's' : ''}
${renderGrid(all)}

Browse by category

${renderCuratedGroups(groups)} ${!groups.length ? '

No curated groups available.

' : ''}
`; const filterInput = document.getElementById('topic-filter'); if (filterInput) filterInput.addEventListener('input', applyFilter); } function renderTopicDetail( tag: string, data: TopicDetailData, sort: string, nextCursor: string | null, ): void { const repos = data.repos ?? []; const total = data.total ?? 0; const featured = repos.slice(0, 3); function featuredCardHtml(repo: RepoCard): string { return `
${esc(repo.owner)}/${esc(repo.name)}
${repo.description ? `

${esc(repo.description)}

` : ''}
${repo.keySignature ? `🎵 ${esc(repo.keySignature)}` : ''} ${repo.tempoBpm ? ` • ♩ ${repo.tempoBpm} BPM` : ''}
`; } function sortUrl(newSort: string): string { // Reset to first page when changing sort order. return `/topics/${encodeURIComponent(tag)}?sort=${newSort}`; } const nextUrl = nextCursor ? `/topics/${encodeURIComponent(tag)}?sort=${sort}&cursor=${encodeURIComponent(nextCursor)}` : null; const pager = nextUrl ? `
Next →
` : ''; const contentEl = document.getElementById('content'); if (!contentEl) return; contentEl.innerHTML = `

Topics / #${esc(tag)}

${total} repo${total !== 1 ? 's' : ''}
${featured.length ? `

⭐ Featured

${featured.map(featuredCardHtml).join('')}
` : ''}

All Repositories

${repos.length === 0 ? `

No public repositories tagged with #${esc(tag)} yet.

` : `
${repos.map(repoCardHtml).join('')}
`} ${pager}
`; } async function uiFetch(url: string): Promise { const headers: Record = {}; if (window.authHeaders) { const h = window.authHeaders() as Record; Object.assign(headers, h); } const res = await fetch(url, { headers }); if (!res.ok) { const body = await res.text(); throw new Error(res.status + ': ' + body); } return res.json() as Promise; } export function initTopics(data: PageData): void { const mode = String(data['mode'] ?? 'index'); const tag = String(data['tag'] ?? ''); const sort = String(data['sort'] ?? 'stars'); void (async () => { try { if (mode === 'index') { const indexData = (await uiFetch('/topics?format=json')) as TopicsIndexData; renderTopicsIndex(indexData); } else { // Read cursor from the URL (not from page_json) so back/forward navigation works. const urlCursor = new URLSearchParams(window.location.search).get('cursor'); const params = new URLSearchParams({ format: 'json', sort }); if (urlCursor) params.set('cursor', urlCursor); const detailData = (await uiFetch( `/topics/${encodeURIComponent(tag)}?${params.toString()}`, )) as TopicDetailData; renderTopicDetail(tag, detailData, sort, detailData.nextCursor ?? null); } } catch (e: unknown) { const err = e as { message?: string }; const contentEl = document.getElementById('content'); if (contentEl) { contentEl.innerHTML = `

✕ Failed to load topics: ${esc(String(err.message ?? e))}

`; } } })(); }