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 | |
| 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')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 115 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/i18n.js | 9 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/style.css | 64 |
3 files changed, 178 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} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js index cb5133e..11543fd 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js @@ -60,6 +60,7 @@ const en = { // Explore 'explore.title': 'Public Groups', + 'explore.search': 'Search groups...', 'explore.loading': 'Loading...', 'explore.empty': 'No public groups available.', @@ -114,6 +115,9 @@ const en = { 'settings.about': 'About', 'settings.version': 'Version', 'settings.protocol': 'Protocol', + 'settings.role': 'Role', + 'settings.groups': 'Group notifications', + 'settings.notifications': 'Notifications', // Sidebar 'sidebar.settings': 'Settings', @@ -177,6 +181,11 @@ const en = { 'admin.reason_placeholder': 'Reason', 'admin.btn_block': 'Block', 'admin.btn_unblock': 'Unblock', + + // Notifications + 'notif.title': 'Notifications', + 'notif.empty': 'No notifications', + 'notif.mark_all_read': 'Mark all read', }; // ── Locale registry ───────────────────────────────────────────────────────── diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index b1b1a7e..3aea648 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -1003,3 +1003,67 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } .th-type, .td-type { display: none; } .th-date, .td-date { display: none; } } + +/* ── Notification bell ───────────────────────────────────────────────────── */ + +.nav-notif { + position: relative; + color: var(--text); + text-decoration: none; + font-size: 1.1rem; + margin-right: 4px; +} +.notif-badge { + position: absolute; + top: -6px; + right: -8px; + background: var(--danger); + color: #fff; + font-size: 0.65rem; + padding: 1px 5px; + border-radius: 10px; + font-weight: 700; +} + +/* ── Notification feed ───────────────────────────────────────────────────── */ + +.notif-feed { + margin-bottom: 24px; +} +.notif-feed h3 { + margin: 0 0 8px; + font-size: 1rem; +} +.notif-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: var(--radius); + background: var(--bg-surface); + margin-bottom: 4px; + cursor: pointer; + border: 1px solid var(--border); +} +.notif-item:hover { + background: var(--bg-raised); +} +.notif-unread { + border-left: 3px solid var(--primary); + font-weight: 600; +} +.notif-kind { + font-size: 0.75rem; + color: var(--text-secondary); + text-transform: uppercase; + white-space: nowrap; +} +.notif-text { + flex: 1; + font-size: 0.9rem; +} +.notif-time { + font-size: 0.75rem; + color: var(--text-secondary); + white-space: nowrap; +} |