gabriel / musehub public
feed.ts typescript
236 lines 10.0 KB
Raw
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor ⚠ breaking 1 day ago
1 /**
2 * feed.ts — Activity feed page module.
3 *
4 * Responsibilities:
5 * 1. Fetch /api/feed and render event cards into #content.
6 * 2. Mark single notifications as read via POST /notifications/{id}/read.
7 * 3. Mark all notifications as read via POST /notifications/read-all.
8 * 4. Update the nav badge count in-place without a page reload.
9 *
10 * Registered as: window.MusePages['feed']
11 */
12
13 declare global {
14 interface Window {
15 escHtml: (s: unknown) => string;
16 fmtRelative: (iso: string | null | undefined) => string;
17 apiFetch: (path: string, init?: RequestInit) => Promise<unknown>;
18 }
19 }
20
21 // ── Types ─────────────────────────────────────────────────────────────────────
22
23 interface FeedItem {
24 notif_id: string;
25 event_type: string;
26 actor: string;
27 repo_id?: string;
28 created_at: string;
29 is_read: boolean;
30 }
31
32 interface EventMeta {
33 icon: string;
34 sentence: (actor: string, repoId: string) => string;
35 }
36
37 // ── Actor helpers ─────────────────────────────────────────────────────────────
38
39 function actorHsl(actor: string): string {
40 let hash = 0;
41 for (let i = 0; i < actor.length; i++) {
42 hash = actor.charCodeAt(i) + ((hash << 5) - hash);
43 }
44 return `hsl(${Math.abs(hash) % 360},50%,38%)`;
45 }
46
47 function actorAvatar(actor: string): string {
48 const bg = actorHsl(actor);
49 const initial = window.escHtml((actor || '?').charAt(0).toUpperCase());
50 return `<div class="comment-avatar" style="background:${bg};color:#e6edf3;font-weight:700;font-size:14px;border:none">${initial}</div>`;
51 }
52
53 function actorLink(actor: string): string {
54 return `<a href="/${encodeURIComponent(actor)}" style="color:var(--text-primary);font-weight:600">${window.escHtml(actor)}</a>`;
55 }
56
57 function repoLink(repoId: string): string {
58 if (!repoId) return '';
59 const parts = repoId.split('/');
60 const label = parts.length >= 2 ? window.escHtml(parts[1]) : window.escHtml(repoId);
61 return `<a href="/${encodeURIComponent(repoId)}" style="color:var(--color-accent)">${label}</a>`;
62 }
63
64 // ── Event metadata ────────────────────────────────────────────────────────────
65
66 const EVENT_META: Record<string, EventMeta> = {
67 comment: { icon: '🗨️', sentence: (a, r) => `${actorLink(a)} commented on ${repoLink(r)}` },
68 mention: { icon: '💬', sentence: (a, r) => `${actorLink(a)} mentioned you in ${repoLink(r)}` },
69 proposal_opened: { icon: '🔀', sentence: (a, r) => `${actorLink(a)} opened a proposal in ${repoLink(r)}` },
70 proposal_merged: { icon: '✅', sentence: (a, r) => `${actorLink(a)} merged a proposal in ${repoLink(r)}` },
71 issue_opened: { icon: '🐛', sentence: (a, r) => `${actorLink(a)} opened an issue in ${repoLink(r)}` },
72 issue_closed: { icon: '✔️', sentence: (a, r) => `${actorLink(a)} closed an issue in ${repoLink(r)}` },
73 new_commit: { icon: '🎵', sentence: (a, r) => `${actorLink(a)} committed to ${repoLink(r)}` },
74 new_follower: { icon: '👤', sentence: (a) => `${actorLink(a)} followed you` },
75 };
76
77 // ── Card renderer ─────────────────────────────────────────────────────────────
78
79 function eventCard(item: FeedItem): string {
80 const meta = EVENT_META[item.event_type] ?? { icon: '•', sentence: (a: string) => actorLink(a) };
81 const icon = meta.icon;
82 const sentence = meta.sentence(item.actor, item.repo_id ?? '');
83 const timestamp = window.fmtRelative(item.created_at);
84 const isUnread = !item.is_read;
85
86 const unreadStyle = isUnread
87 ? 'border-left:3px solid var(--color-accent);padding-left:calc(var(--space-3) - 3px);'
88 : 'border-left:3px solid transparent;padding-left:calc(var(--space-3) - 3px);opacity:0.75;';
89
90 const markReadBtn = isUnread
91 ? `<button
92 class="mark-read-btn"
93 data-notif-id="${window.escHtml(item.notif_id)}"
94 data-action="mark-read"
95 title="Mark as read"
96 style="background:none;border:1px solid var(--border-color);border-radius:50%;width:22px;height:22px;cursor:pointer;color:var(--text-muted);font-size:12px;line-height:1;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;margin-left:var(--space-2)"
97 >&#10003;</button>`
98 : '';
99
100 return `
101 <div class="comment-item" data-notif-id="${window.escHtml(item.notif_id)}" style="${unreadStyle}">
102 ${actorAvatar(item.actor)}
103 <div class="comment-body" style="flex:1;min-width:0">
104 <div class="comment-meta" style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap">
105 <span style="font-size:16px;line-height:1">${icon}</span>
106 <span>${sentence}</span>
107 <span style="margin-left:auto;white-space:nowrap;display:flex;align-items:center;gap:var(--space-2)">
108 ${window.escHtml(timestamp)}
109 ${markReadBtn}
110 </span>
111 </div>
112 ${isUnread ? '<div class="unread-dot" style="width:6px;height:6px;border-radius:50%;background:var(--color-accent);display:inline-block;margin-top:var(--space-1)"></div>' : ''}
113 </div>
114 </div>`;
115 }
116
117 // ── Mark-read helpers ─────────────────────────────────────────────────────────
118
119 function decrementNavBadge(): void {
120 const badge = document.getElementById('nav-notif-badge');
121 if (!badge) return;
122 const current = parseInt(badge.textContent ?? '', 10);
123 if (isNaN(current) || current <= 1) {
124 badge.style.display = 'none';
125 } else {
126 badge.textContent = String(current - 1);
127 }
128 }
129
130 async function markOneRead(btn: HTMLElement): Promise<void> {
131 const notifId = btn.dataset.notifId;
132 if (!notifId) return;
133 try {
134 await window.apiFetch('/notifications/' + encodeURIComponent(notifId) + '/read', { method: 'POST' });
135 const card = document.querySelector<HTMLElement>(`.comment-item[data-notif-id="${CSS.escape(notifId)}"]`);
136 if (card) {
137 card.style.borderLeft = '3px solid transparent';
138 card.style.opacity = '0.75';
139 card.querySelector('.unread-dot')?.remove();
140 }
141 btn.remove();
142 decrementNavBadge();
143 } catch (e) {
144 if ((e as Error).message !== 'auth') btn.style.color = 'var(--color-danger)';
145 }
146 }
147
148 async function markAllRead(): Promise<void> {
149 const markAllBtn = document.getElementById('mark-all-read-btn') as HTMLButtonElement | null;
150 if (markAllBtn) markAllBtn.disabled = true;
151 try {
152 await window.apiFetch('/notifications/read-all', { method: 'POST' });
153 document.querySelectorAll<HTMLElement>('.comment-item').forEach(card => {
154 card.style.borderLeft = '3px solid transparent';
155 card.style.opacity = '0.75';
156 card.querySelector('.unread-dot')?.remove();
157 card.querySelector('.mark-read-btn')?.remove();
158 });
159 const badge = document.getElementById('nav-notif-badge');
160 if (badge) badge.style.display = 'none';
161 markAllBtn?.remove();
162 } catch (e) {
163 if (markAllBtn) markAllBtn.disabled = false;
164 if ((e as Error).message !== 'auth') {
165 const err = document.getElementById('feed-error');
166 if (err) err.textContent = 'Could not mark all as read: ' + (e as Error).message;
167 }
168 }
169 }
170
171 // ── Event delegation ──────────────────────────────────────────────────────────
172
173 function bindActions(): void {
174 document.addEventListener('click', (e) => {
175 const el = (e.target as HTMLElement).closest<HTMLElement>('[data-action]');
176 if (!el) return;
177 if (el.dataset.action === 'mark-read') {
178 void markOneRead(el);
179 } else if (el.dataset.action === 'mark-all-read') {
180 void markAllRead();
181 }
182 });
183 }
184
185 // ── Main load ─────────────────────────────────────────────────────────────────
186
187 async function load(): Promise<void> {
188 const contentEl = document.getElementById('content');
189 if (!contentEl) return;
190
191 try {
192 const items = ((await window.apiFetch('/feed?limit=50')) ?? []) as FeedItem[];
193
194 if (items.length === 0) {
195 contentEl.innerHTML = `
196 <div class="empty-state">
197 <div class="empty-icon">&#127926;</div>
198 <p class="empty-title">Your feed is empty</p>
199 <p class="empty-desc">Follow musicians and watch repos to see their activity here.</p>
200 <a href="/explore" class="btn btn-primary">Explore repos</a>
201 </div>`;
202 return;
203 }
204
205 const hasUnread = items.some(item => !item.is_read);
206 const markAllHtml = hasUnread
207 ? `<button
208 id="mark-all-read-btn"
209 data-action="mark-all-read"
210 class="btn btn-secondary"
211 style="font-size:12px;padding:4px 10px"
212 >&#10003; Mark all as read</button>`
213 : '';
214
215 contentEl.innerHTML = `
216 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-4)">
217 <h1 style="margin:0">Activity Feed</h1>
218 ${markAllHtml}
219 </div>
220 <p id="feed-error" style="color:var(--color-danger);font-size:13px"></p>
221 <div class="card" style="padding:0">
222 ${items.map(eventCard).join('')}
223 </div>`;
224 } catch (e) {
225 if ((e as Error).message !== 'auth' && contentEl) {
226 contentEl.innerHTML = '<p class="error">&#10005; ' + window.escHtml((e as Error).message) + '</p>';
227 }
228 }
229 }
230
231 // ── Entry point ───────────────────────────────────────────────────────────────
232
233 export function initFeed(): void {
234 bindActions();
235 void load();
236 }
File History 1 commit
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor 1 day ago