const httpService = { baseUrl: window.GITEA_SUB_URL || '', async fetchRss() { const resp = await fetch(`${this.baseUrl}/alex.rss`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const text = await resp.text(); return new DOMParser().parseFromString(text, 'application/xml'); }, }; const dataDomain = { timeAgo(dateStr) { const diff = (Date.now() - new Date(dateStr)) / 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'; if (diff < 2592000) return Math.floor(diff / 86400) + 'd ago'; if (diff < 31536000) return Math.floor(diff / 2592000) + 'mo ago'; return Math.floor(diff / 31536000) + 'y ago'; }, esc(str) { return (str || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); }, safeTitleHtml(rawTitleText) { const doc = new DOMParser().parseFromString(rawTitleText, 'text/html'); doc.body.querySelectorAll('*:not(a)').forEach(el => el.replaceWith(el.textContent)); return doc.body.innerHTML; }, titlePlainText(rawTitleText) { const doc = new DOMParser().parseFromString(rawTitleText, 'text/html'); return doc.body.textContent || rawTitleText; }, activityIcon(titleText) { const t = titleText.toLowerCase(); if (t.includes('push') || t.includes('commit')) return '📤'; if (t.includes('creat') && t.includes('repo')) return '📁'; if (t.includes('fork')) return '🍴'; if (t.includes('open') && t.includes('issue')) return '🔴'; if (t.includes('clos') && t.includes('issue')) return '🟢'; if (t.includes('pull request') || t.includes('merge')) return '🔀'; if (t.includes('tag')) return '🏷️'; if (t.includes('branch')) return '🌿'; if (t.includes('comment')) return '💬'; if (t.includes('release')) return '🚀'; return '⚡'; }, parseCommits(descriptionText) { const doc = new DOMParser().parseFromString(descriptionText, 'text/html'); return Array.from(doc.querySelectorAll('a')).map(anchor => { const sha = anchor.textContent.trim().slice(0, 7); const href = anchor.getAttribute('href') || '#'; let msg = ''; let node = anchor.nextSibling; while (node) { const t = (node.textContent || '').trim(); if (t) { msg = t; break; } node = node.nextSibling; } return { sha, href, msg }; }); }, parseRepos(xmlDoc) { const items = Array.from(xmlDoc.querySelectorAll('channel > item')); const seen = new Map(); for (const item of items) { const titleHtml = item.querySelector('title')?.textContent || ''; const titleDoc = new DOMParser().parseFromString(titleHtml, 'text/html'); const anchors = titleDoc.querySelectorAll('a'); if (anchors.length < 2) continue; const repoAnchor = anchors[anchors.length - 1]; const repoName = repoAnchor.textContent.trim(); if (!repoName || seen.has(repoName)) continue; seen.set(repoName, { repoName, repoUrl: repoAnchor.getAttribute('href') || '#', shortName: repoName.includes('/') ? repoName.split('/').pop() : repoName, pubDate: item.querySelector('pubDate')?.textContent || '', firstCommit: this.parseCommits(item.querySelector('description')?.textContent || '')[0] || null, }); } return Array.from(seen.values()); }, parseActivity(xmlDoc, limit = 20) { return Array.from(xmlDoc.querySelectorAll('channel > item')) .slice(0, limit) .map(item => { const rawTitle = item.querySelector('title')?.textContent || ''; const titleText = this.titlePlainText(rawTitle); return { titleHtmlSafe: this.safeTitleHtml(rawTitle), titleText, link: item.querySelector('link')?.textContent || '#', pubDate: item.querySelector('pubDate')?.textContent || '', icon: this.activityIcon(titleText), commits: this.parseCommits(item.querySelector('description')?.textContent || '').slice(0, 3), }; }); }, }; const uiRendering = { async renderRepos(xmlDoc) { const grid = document.getElementById('repo-grid'); if (!grid) return; const repos = dataDomain.parseRepos(xmlDoc); if (repos.length === 0) { grid.innerHTML = `
No repositories found in feed.
`; return; } grid.innerHTML = ''; for (const { shortName, repoName, repoUrl, pubDate, firstCommit } of repos) { const when = dataDomain.timeAgo(pubDate); const commitMsg = firstCommit?.msg || firstCommit?.sha || ''; const card = document.createElement('a'); card.className = 'repo-card'; card.href = dataDomain.esc(repoUrl); card.innerHTML = `
📦 ${dataDomain.esc(shortName)}
${dataDomain.esc(repoName)}
${dataDomain.esc(commitMsg)} ${dataDomain.esc(when)}
`.trim(); grid.appendChild(card); } }, async renderActivity(xmlDoc) { const feed = document.getElementById('activity-feed'); if (!feed) return; const items = dataDomain.parseActivity(xmlDoc); if (items.length === 0) { feed.innerHTML = `
No public activity yet.
`; return; } feed.innerHTML = ''; for (const { titleHtmlSafe, icon, pubDate, commits } of items) { const when = dataDomain.timeAgo(pubDate); const commitsHtml = commits.length === 0 ? '' : `
` + commits.map(({ sha, href, msg }) => `
${dataDomain.esc(sha)} ${dataDomain.esc(msg)}
`).join('') + `
`; const el = document.createElement('div'); el.className = 'activity-item'; el.innerHTML = `
${icon}
${titleHtmlSafe}
${when}
${commitsHtml}
`; feed.appendChild(el); } }, async render() { const baseUrl = httpService.baseUrl; let xmlDoc; try { xmlDoc = await httpService.fetchRss(); } catch (e) { console.error('Gitea landing: RSS fetch failed', e); const grid = document.getElementById('repo-grid'); const feed = document.getElementById('activity-feed'); if (grid) grid.innerHTML = `
Could not load feed (${e.message}). Browse manually →
`; if (feed) feed.innerHTML = `
Could not load activity (${e.message})
`; return; } await Promise.all([ this.renderRepos(xmlDoc), this.renderActivity(xmlDoc), ]); }, }; document.addEventListener('DOMContentLoaded', () => uiRendering.render());