diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-11 12:40:13 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-11 12:40:13 +0200 |
| commit | edde9e441fb6b84e9d56215d6e2a8d9338b8f962 (patch) | |
| tree | be09d4cc1a64e9ccc488ca9fcc1e23b6a133a8f5 /packages/meshbay-hub/src/meshbay_hub/static/app.js | |
| parent | ccb2b85051b88578c7d739ff46385e50a65591a6 (diff) | |
| download | meshbay-edde9e441fb6b84e9d56215d6e2a8d9338b8f962.tar.gz | |
feat(hub): Phase 10.5–10.8, 10.10 — notifications, settings, search, version
- 10.5: Notification model + CRUD API (list, mark read, mark all read)
Triggered on: group invite, role change, suspend/unsuspend
- 10.6: SettingsPage shows role, per-group notification mute (localStorage)
- 10.7: GET /v1/groups?q= search filter (ilike on name)
- 10.8: NotificationFeed on home page + bell with unread badge in navbar
- 10.10: GET /v1/hub/version endpoint for client update checks
- 8 new tests (test_notifications.py), 155 total
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 115 |
1 files changed, 105 insertions, 10 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index d36a0a1..4ef334b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -85,7 +85,7 @@ function useAuth() { return useContext(AuthContext); } // ── Nav ────────────────────────────────────────────────────────────────────── -function Nav({ user, theme, onThemeToggle, onLogout, onMenuToggle }) { +function Nav({ user, theme, onThemeToggle, onLogout, onMenuToggle, unreadCount }) { return html` <nav class="nav"> <div class="nav-left"> @@ -96,6 +96,11 @@ function Nav({ user, theme, onThemeToggle, onLogout, onMenuToggle }) { <a class="nav-brand" href="#/">MeshBay</a> </div> <div class="nav-right"> + ${user && html` + <a class="nav-notif" href="#/" title=${t('notif.title')}> + ${'\u{1F514}'}${unreadCount > 0 && html`<span class="notif-badge">${unreadCount}</span>`} + </a> + `} <button class="nav-theme" onClick=${onThemeToggle} aria-label="${t('nav.toggle_menu')}" title=${theme === 'dark' ? t('nav.light_mode') : t('nav.dark_mode')}> ${theme === 'dark' ? '☀' : '☾'} @@ -273,11 +278,32 @@ function RegisterPage() { // ── Home Page ──────────────────────────────────────────────────────────────── -function HomePage({ groups }) { +function NotificationFeed({ notifications, onMarkRead }) { + if (!notifications.length) return null; + return html` + <div class="notif-feed"> + <h3>${t('notif.title')}</h3> + ${notifications.map(n => html` + <div key=${n.id} class="notif-item ${n.read ? '' : 'notif-unread'}" + onClick=${() => { + if (!n.read) onMarkRead(n.id); + if (n.link) navigate(n.link); + }}> + <span class="notif-kind">${n.kind}</span> + <span class="notif-text">${n.title}</span> + <span class="notif-time">${new Date(n.created_at).toLocaleDateString()}</span> + </div> + `)} + </div> + `; +} + +function HomePage({ groups, notifications, onMarkRead }) { if (groups.length === 0) { return html` <div> <h2>${t('home.welcome')}</h2> + <${NotificationFeed} notifications=${notifications} onMarkRead=${onMarkRead} /> <p class="page-message"> ${t('home.no_groups')} ${' '}${t('home.browse_prefix')}<a href="#/explore">${t('home.browse_link')}</a>${t('home.browse_suffix')} @@ -289,6 +315,7 @@ function HomePage({ groups }) { return html` <div> <h2>${t('home.my_groups')}</h2> + <${NotificationFeed} notifications=${notifications} onMarkRead=${onMarkRead} /> <div class="group-grid"> ${groups.map(g => html` <a key=${g.id} class="group-card" href="#/group/${g.id}"> @@ -309,17 +336,32 @@ function HomePage({ groups }) { function ExplorePage({ token }) { const [groups, setGroups] = useState([]); const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); - useEffect(() => { - hubFetch('/v1/groups', { token }) + const doSearch = useCallback((q) => { + setLoading(true); + const url = q ? `/v1/groups?q=${encodeURIComponent(q)}` : '/v1/groups'; + hubFetch(url, { token }) .then(data => setGroups(data.groups || [])) .catch(() => {}) .finally(() => setLoading(false)); }, [token]); + useEffect(() => { doSearch(''); }, [token]); + + const onSearch = useCallback((e) => { + const q = e.target.value; + setSearch(q); + doSearch(q); + }, [doSearch]); + return html` <div> <h2>${t('explore.title')}</h2> + <div class="file-toolbar" style="margin-bottom:16px"> + <input type="text" class="admin-search" placeholder="${t('explore.search')}" + value=${search} onInput=${onSearch} /> + </div> ${loading ? html`<p class="page-message">${t('explore.loading')}</p>` : groups.length === 0 @@ -938,8 +980,12 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) { const THEME_OPTIONS = ['light', 'dark', 'system']; -function SettingsPage({ user, theme, onThemeChange }) { +function SettingsPage({ user, theme, onThemeChange, groups }) { const [locale, setLoc] = useState(getLocale); + const [muted, setMuted] = useState(() => { + try { return JSON.parse(localStorage.getItem('mb_muted') || '{}'); } + catch { return {}; } + }); const onLocaleChange = useCallback((e) => { const code = e.target.value; @@ -952,6 +998,14 @@ function SettingsPage({ user, theme, onThemeChange }) { onThemeChange(e.target.value); }, [onThemeChange]); + const toggleMute = useCallback((gid) => { + setMuted(prev => { + const next = { ...prev, [gid]: !prev[gid] }; + localStorage.setItem('mb_muted', JSON.stringify(next)); + return next; + }); + }, []); + return html` <div> <h2>${t('settings.title')}</h2> @@ -962,6 +1016,10 @@ function SettingsPage({ user, theme, onThemeChange }) { <span class="settings-label">${t('settings.username')}</span> <span class="settings-value">${user.username}</span> </div> + <div class="settings-row"> + <span class="settings-label">${t('settings.role')}</span> + <span class="settings-value">${user.role || 'user'}</span> + </div> </div> <div class="settings-section"> @@ -984,6 +1042,22 @@ function SettingsPage({ user, theme, onThemeChange }) { </div> </div> + ${groups.length > 0 && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('settings.groups')}</h3> + ${groups.map(g => html` + <div class="settings-row" key=${g.id}> + <span class="settings-label">${g.name}</span> + <label class="settings-value" style="cursor:pointer"> + <input type="checkbox" checked=${!muted[g.id]} + onChange=${() => toggleMute(g.id)} /> + ${' '}${t('settings.notifications')} + </label> + </div> + `)} + </div> + `} + <div class="settings-section"> <h3 class="settings-heading">${t('settings.about')}</h3> <div class="settings-row"> @@ -1339,6 +1413,8 @@ function App() { const [user, setUser] = useState(loadAuth); const [groups, setGroups] = useState([]); const [menuOpen, setMenuOpen] = useState(false); + const [notifications, setNotifications] = useState([]); + const [unreadCount, setUnreadCount] = useState(0); const resolved = resolveTheme(theme); @@ -1347,13 +1423,31 @@ function App() { localStorage.setItem(THEME_KEY, theme); }, [theme, resolved]); + const fetchNotifications = useCallback(() => { + if (!user) return; + hubFetch('/v1/notifications?limit=20', { token: user.token }) + .then(data => { + setNotifications(data.notifications || []); + setUnreadCount(data.unread_count || 0); + }) + .catch(() => {}); + }, [user]); + useEffect(() => { - if (!user) { setGroups([]); return; } + if (!user) { setGroups([]); setNotifications([]); setUnreadCount(0); return; } hubFetch('/v1/groups/mine', { token: user.token }) .then(data => setGroups(data.groups || [])) .catch(() => setGroups([])); + fetchNotifications(); }, [user]); + const markRead = useCallback((id) => { + if (!user) return; + hubFetch(`/v1/notifications/${id}/read`, { method: 'POST', token: user.token }) + .then(() => fetchNotifications()) + .catch(() => {}); + }, [user, fetchNotifications]); + useEffect(() => { setMenuOpen(false); }, [route]); const toggleTheme = useCallback(() => { @@ -1408,12 +1502,12 @@ function App() { } else if (route === '/admin') { page = (user.role === 'moderator' || user.role === 'admin') ? html`<${AdminPage} token=${user.token} />` - : html`<${HomePage} groups=${groups} />`; + : html`<${HomePage} groups=${groups} notifications=${notifications} onMarkRead=${markRead} />`; } else if (route === '/settings') { page = html`<${SettingsPage} user=${user} theme=${theme} - onThemeChange=${setTheme} />`; + onThemeChange=${setTheme} groups=${groups} />`; } else { - page = html`<${HomePage} groups=${groups} />`; + page = html`<${HomePage} groups=${groups} notifications=${notifications} onMarkRead=${markRead} />`; } return html` @@ -1423,7 +1517,8 @@ function App() { theme=${resolved} onThemeToggle=${toggleTheme} onLogout=${authCtx.logout} - onMenuToggle=${() => setMenuOpen(o => !o)} /> + onMenuToggle=${() => setMenuOpen(o => !o)} + unreadCount=${unreadCount} /> <div class="layout"> ${user && html`<${Sidebar} groups=${groups} |