Topbar unread drawer (#28)
Adds an Unread dropdown to the topbar utilities (before notifications), per docs/superpowers/plans/2026-07-17-topbar-unread-drawer.md.
- New partial `templates/partials/header/unread-drawer.tpl`: toggle with live `component="unread/count"` badge (core socket updates), server-rendered empty/footer rows.
- `public/client.js`: on dropdown open, fetches `GET /api/unread` and injects up to 10 topic rows (titles/categories via textContent only); fetch failure falls back to the empty state.
- Mobile burger drawer gets an Unread row with the same badge.
- SCSS: unread dropdown joins the shared dropdown width rule; flex row styles for topic items.
Deviations from the plan's example code, found by live verification against the dev forum:
- Plan's `api.get('/unread')` hits the v3 write API and 404s → replaced with a fetch of the `/api/unread` read route.
- NodeBB 4 has no global `window.require`, so runtime translation of the static `[[unread:...]]` strings never ran → those strings are now server-rendered in the template (also removes the plan's translate-over-innerHTML step, which would have re-parsed untrusted titles as HTML).
Verified: full plain-node test suite passes; Playwright against the dev container passed all checks (badge count, item ordering before notifications, dropdown topic links + navigation, /unread footer link, mobile drawer badge at 390px).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Reviewed-on: #28
Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
Co-committed-by: vickydotbat <vickydotbat@tutamail.com>
This commit was merged in pull request #28.
This commit is contained in:
@@ -0,0 +1,346 @@
|
|||||||
|
# Topbar Unread Drawer Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add an "Unread" dropdown to the topbar utilities (next to notifications) with a live unread-topic count badge and a fetched list of unread topics.
|
||||||
|
|
||||||
|
**Architecture:** A new template partial imported by `topbar.tpl` gives the button + badge (core's `component="unread/count"` socket updates make the badge live for free). A small addition to `public/client.js` fetches `/api/unread` on dropdown open and renders topic links with plain DOM building. SCSS reuses the existing `.wg-topbar__dropdown` vocabulary.
|
||||||
|
|
||||||
|
**Tech Stack:** NodeBB templates (benchpress `.tpl`), jQuery/Bootstrap 5 dropdown events, NodeBB client `api` module, SCSS. Tests are plain-Node assert scripts in `tests/` (run with `node tests/<file>.test.js`).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- NixOS host: no global installs; run tests with plain `node`.
|
||||||
|
- One branch, one PR: all commits go on the existing `feat/topbar-unread-drawer` branch.
|
||||||
|
- Verification against the dev container in `../sow-nodebb` (`make dev`, `make dev-build`), forum at http://localhost:4567. Theme edits need `make dev-build` before they show up.
|
||||||
|
- Titles from the API must be inserted with `textContent` (never HTML strings) — trust boundary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Unread drawer template + mobile drawer link
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `templates/partials/header/unread-drawer.tpl`
|
||||||
|
- Modify: `templates/partials/header/topbar.tpl` (logged-in utility list, before the notifications `<li>`; and the drawer-actions block)
|
||||||
|
- Test: `tests/global-topbar-contract.test.js` (append assertions)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `<li component="unread" ...>` containing a dropdown toggle and `<ul ... data-wg-unread-menu>`; Task 2's JS binds to `.wg-topbar [component="unread"]` and renders into `[data-wg-unread-menu]`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test** — append to `tests/global-topbar-contract.test.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const unreadDrawer = read('templates/partials/header/unread-drawer.tpl');
|
||||||
|
assertIncludes(
|
||||||
|
topbar,
|
||||||
|
'<!-- IMPORT partials/header/unread-drawer.tpl -->',
|
||||||
|
'Topbar should mount the unread drawer before notifications'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
topbar.indexOf('partials/header/unread-drawer.tpl') < topbar.indexOf('partials/sidebar/notifications.tpl'),
|
||||||
|
'Unread drawer should sit before the notifications item'
|
||||||
|
);
|
||||||
|
assertIncludes(
|
||||||
|
unreadDrawer,
|
||||||
|
'component="unread/count"',
|
||||||
|
'Unread toggle should carry the live core count badge'
|
||||||
|
);
|
||||||
|
assertIncludes(
|
||||||
|
unreadDrawer,
|
||||||
|
'data-wg-unread-menu',
|
||||||
|
'Unread dropdown menu should expose the JS mount hook'
|
||||||
|
);
|
||||||
|
assertMatches(
|
||||||
|
topbar,
|
||||||
|
/href="\{relative_path\}\/unread"[^>]*>[\s\S]*?component="unread\/count"/,
|
||||||
|
'Mobile drawer actions should include an Unread link with count badge'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `node tests/global-topbar-contract.test.js`
|
||||||
|
Expected: FAIL with `templates/partials/header/unread-drawer.tpl should exist`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create `templates/partials/header/unread-drawer.tpl`**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<a class="nav-link dropdown-toggle d-flex gap-2 align-items-center" href="#" role="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" aria-label="[[unread:title]]">
|
||||||
|
<i class="fa fa-fw fa-inbox" aria-hidden="true"></i>
|
||||||
|
<span component="unread/count" class="badge rounded-1 bg-primary {{{ if !unreadCount.topic }}}hidden{{{ end }}}">{unreadCount.topic}</span>
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu wg-topbar__dropdown unread-dropdown p-1 shadow" role="menu" data-wg-unread-menu>
|
||||||
|
<li class="dropdown-item disabled"><i class="fa fa-fw fa-spinner fa-spin" aria-hidden="true"></i> [[unread:title]]</li>
|
||||||
|
</ul>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Mount it in `topbar.tpl`** — insert before the notifications `<li>` (line 40):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<li component="unread" class="nav-item unread dropdown" title="[[unread:title]]">
|
||||||
|
<!-- IMPORT partials/header/unread-drawer.tpl -->
|
||||||
|
</li>
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
And in the drawer-actions block, insert before the notifications `<a>` (line 112):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<a href="{relative_path}/unread"><i class="fa fa-fw fa-inbox" aria-hidden="true"></i><span>[[unread:title]]</span><span component="unread/count" class="badge rounded-1 bg-primary {{{ if !unreadCount.topic }}}hidden{{{ end }}}">{unreadCount.topic}</span></a>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `node tests/global-topbar-contract.test.js`
|
||||||
|
Expected: exits 0, no output
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add templates/partials/header/unread-drawer.tpl templates/partials/header/topbar.tpl tests/global-topbar-contract.test.js
|
||||||
|
git commit -m "feat: add unread drawer button to topbar"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Client JS — fetch and render unread topics on open
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `public/client.js` (inside `initWestgateTopbar()`, next to the existing `shown.bs.dropdown.westgateTopbar` handler)
|
||||||
|
- Test: `tests/unread-drawer-client.test.js` (new)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `[component="unread"]` / `[data-wg-unread-menu]` markup from Task 1; NodeBB client `api.get('/unread', {}, cb)` whose payload has `topics: [{ title, slug, category: { name } }, ...]`.
|
||||||
|
- Produces: `window.westgateTheme.renderUnreadMenu(menuEl, topics)` (exported for the test): clears `menuEl`, appends up to 10 `<li><a class="dropdown-item" href="<relative_path>/topic/<slug>">` items with title text and a category `<small>`, then a footer link to `/unread`. Empty list → single disabled item `[[unread:no-unread-topics]]`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test** — create `tests/unread-drawer-client.test.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const client = fs.readFileSync(path.join(__dirname, '..', 'public', 'client.js'), 'utf8');
|
||||||
|
|
||||||
|
assert(
|
||||||
|
client.includes("'show.bs.dropdown.westgateUnread'"),
|
||||||
|
'client.js should fetch unread topics when the unread dropdown opens'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
client.includes("api.get('/unread'"),
|
||||||
|
'client.js should load the unread list from the /unread API'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
client.includes('renderUnreadMenu'),
|
||||||
|
'client.js should expose the unread menu renderer'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
/textContent/.test(client) && !/innerHTML\s*=[^=]*title/.test(client),
|
||||||
|
'Topic titles must be inserted via textContent, never innerHTML'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `node tests/unread-drawer-client.test.js`
|
||||||
|
Expected: FAIL with "client.js should fetch unread topics when the unread dropdown opens"
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement.** In `public/client.js`, add above `initWestgateTopbar()`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function renderUnreadMenu(menuEl, topics) {
|
||||||
|
if (!menuEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
menuEl.textContent = '';
|
||||||
|
const relativePath = (window.config && window.config.relative_path) || '';
|
||||||
|
|
||||||
|
function addItem(node) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.appendChild(node);
|
||||||
|
menuEl.appendChild(li);
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = (topics || []).slice(0, 10);
|
||||||
|
if (!list.length) {
|
||||||
|
const empty = document.createElement('span');
|
||||||
|
empty.className = 'dropdown-item disabled';
|
||||||
|
empty.textContent = '[[unread:no-unread-topics]]';
|
||||||
|
addItem(empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
list.forEach((topic) => {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.className = 'dropdown-item wg-unread-item text-truncate';
|
||||||
|
link.href = `${relativePath}/topic/${topic.slug}`;
|
||||||
|
link.textContent = topic.title;
|
||||||
|
if (topic.category && topic.category.name) {
|
||||||
|
const category = document.createElement('small');
|
||||||
|
category.className = 'wg-unread-item__category';
|
||||||
|
category.textContent = topic.category.name;
|
||||||
|
link.appendChild(category);
|
||||||
|
}
|
||||||
|
addItem(link);
|
||||||
|
});
|
||||||
|
|
||||||
|
const divider = document.createElement('hr');
|
||||||
|
divider.className = 'dropdown-divider';
|
||||||
|
addItem(divider);
|
||||||
|
|
||||||
|
const seeAll = document.createElement('a');
|
||||||
|
seeAll.className = 'dropdown-item wg-unread-item--all';
|
||||||
|
seeAll.href = `${relativePath}/unread`;
|
||||||
|
seeAll.textContent = '[[unread:title]]';
|
||||||
|
addItem(seeAll);
|
||||||
|
|
||||||
|
if (window.require) {
|
||||||
|
window.require(['translator'], function (translator) {
|
||||||
|
translator.translate(menuEl.innerHTML, (translated) => {
|
||||||
|
menuEl.innerHTML = translated;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
And inside `initWestgateTopbar()`, next to the existing search-focus handler:
|
||||||
|
|
||||||
|
```js
|
||||||
|
$(document)
|
||||||
|
.off('show.bs.dropdown.westgateUnread')
|
||||||
|
.on('show.bs.dropdown.westgateUnread', '.wg-topbar [component="unread"]', function () {
|
||||||
|
const menuEl = this.querySelector('[data-wg-unread-menu]');
|
||||||
|
require(['api'], function (api) {
|
||||||
|
api.get('/unread', {}, function (err, data) {
|
||||||
|
if (err) {
|
||||||
|
renderUnreadMenu(menuEl, []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderUnreadMenu(menuEl, data && data.topics);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
And with the other exports near the bottom:
|
||||||
|
|
||||||
|
```js
|
||||||
|
window.westgateTheme.renderUnreadMenu = renderUnreadMenu;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `node tests/unread-drawer-client.test.js && node tests/global-topbar-contract.test.js`
|
||||||
|
Expected: exits 0, no output
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add public/client.js tests/unread-drawer-client.test.js
|
||||||
|
git commit -m "feat: load unread topics into topbar drawer on open"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: SCSS for unread rows
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scss/westgate/_topbar.scss` (append after the `.notifications-dropdown` width block, ~line 177)
|
||||||
|
- Test: `tests/global-topbar-contract.test.js` (append)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `.unread-dropdown`, `.wg-unread-item`, `.wg-unread-item__category` class names from Tasks 1–2.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test** — append to `tests/global-topbar-contract.test.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
assertIncludes(
|
||||||
|
stylesheet,
|
||||||
|
'.unread-dropdown',
|
||||||
|
'Topbar styles should cover the unread dropdown'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `node tests/global-topbar-contract.test.js`
|
||||||
|
Expected: FAIL with "Topbar styles should cover the unread dropdown"
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement** — in `scss/westgate/_topbar.scss`, extend the existing width rule so it reads:
|
||||||
|
|
||||||
|
```scss
|
||||||
|
.wg-topbar .notifications-dropdown,
|
||||||
|
.wg-topbar .chats-dropdown,
|
||||||
|
.wg-topbar .drafts-dropdown,
|
||||||
|
.wg-topbar .unread-dropdown {
|
||||||
|
width: min(24rem, calc(100vw - 1.5rem));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
and append:
|
||||||
|
|
||||||
|
```scss
|
||||||
|
.wg-topbar .wg-unread-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wg-topbar .wg-unread-item__category {
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `node tests/global-topbar-contract.test.js`
|
||||||
|
Expected: exits 0, no output
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scss/westgate/_topbar.scss tests/global-topbar-contract.test.js
|
||||||
|
git commit -m "feat: style topbar unread dropdown rows"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Verify in the sow-nodebb dev container (Playwright)
|
||||||
|
|
||||||
|
**Files:** none (verification only)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: the running dev forum at http://localhost:4567 (`../sow-nodebb`).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Build and start the dev forum.** In `../sow-nodebb`: confirm `.env` has `PLUGIN_PATH=../sow-nodebb-theme`, `PLUGIN_ID=nodebb-theme-westgate`, `EXTRA_PLUGINS=nodebb-plugin-westgate-wiki`, then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dev
|
||||||
|
make dev-build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: NodeBB build succeeds; http://localhost:4567 responds.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Playwright checks** (playwright-skill / MCP browser tools) against http://localhost:4567, logged in with the admin creds from `../sow-nodebb/.env`:
|
||||||
|
- The topbar utility list shows the unread item (`li[component="unread"]`) immediately before the notifications item.
|
||||||
|
- With unread topics present (create a topic from a second account or incognito post), the badge `[component="unread/count"]` is visible with a number.
|
||||||
|
- Clicking the toggle opens the dropdown and lists unread topic links; clicking one lands on that topic.
|
||||||
|
- The dropdown footer link lands on `/unread`.
|
||||||
|
- At mobile width (~390px), the burger drawer shows the Unread row with badge.
|
||||||
|
|
||||||
|
Expected: all checks pass; screenshot the open dropdown for the PR.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the full local test suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for t in tests/*.test.js; do node "$t" || exit 1; done
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: exits 0.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit any fixes made during verification, then push and open the PR** on the existing `feat/topbar-unread-drawer` branch.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Topbar Unread Drawer — Design
|
||||||
|
|
||||||
|
Date: 2026-07-17
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add an "Unread" item to the topbar utilities, next to notifications. It shows
|
||||||
|
a live badge with the number of unread topics. Clicking it opens a dropdown
|
||||||
|
that lists recent unread topics.
|
||||||
|
|
||||||
|
## Template
|
||||||
|
|
||||||
|
New partial `templates/partials/header/unread-drawer.tpl`, imported in
|
||||||
|
`templates/partials/header/topbar.tpl` just before the notifications `<li>`
|
||||||
|
(logged-in menu only). It follows the structure of the neighboring utility
|
||||||
|
dropdowns:
|
||||||
|
|
||||||
|
- Toggle button: `fa-inbox` icon, `title="[[unread:title]]"`, badge
|
||||||
|
`<span component="unread/count" class="badge ... {{{ if !unreadCount.topic }}}hidden{{{ end }}}">{unreadCount.topic}</span>`.
|
||||||
|
NodeBB core sends `unreadCount.topic` in header data and live-updates any
|
||||||
|
`component="unread/count"` badge over sockets, so the count needs no theme JS.
|
||||||
|
- Dropdown body: an empty `<ul class="dropdown-menu wg-topbar__dropdown">`
|
||||||
|
shell with a loading placeholder item.
|
||||||
|
|
||||||
|
## Client JS
|
||||||
|
|
||||||
|
Small addition to `public/client.js` (inside the existing topbar init):
|
||||||
|
|
||||||
|
- On `show.bs.dropdown` for the unread item, call `api.get('/unread', ...)`
|
||||||
|
(same data the `/unread` page uses).
|
||||||
|
- Render up to 10 topics as plain links: topic title, category name,
|
||||||
|
unread-post count. Footer link "See all unread" → `/unread`.
|
||||||
|
- Re-fetch on every open (always fresh, no caching). Errors show a short
|
||||||
|
"could not load" item — never a blank menu.
|
||||||
|
|
||||||
|
Skipped: mark-all-read, pagination, watched-filter tabs — the `/unread` page
|
||||||
|
already has all of those.
|
||||||
|
|
||||||
|
## SCSS
|
||||||
|
|
||||||
|
Reuse the existing `.wg-topbar__dropdown` styles. Only a few lines for the
|
||||||
|
topic rows (truncation, spacing).
|
||||||
|
|
||||||
|
## Mobile drawer
|
||||||
|
|
||||||
|
Add a plain "Unread" link with the same `component="unread/count"` badge to
|
||||||
|
the burger-drawer actions block in `topbar.tpl`, matching the existing
|
||||||
|
notifications/chats rows.
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
- Toggle is a real `<a role="button">`/dropdown like the other utility items,
|
||||||
|
with `aria-label` and Bootstrap's dropdown keyboard handling.
|
||||||
|
- Badge count also appears in the accessible name (matching how the
|
||||||
|
notifications item does it).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Manual + Playwright checks run against the **sow-nodebb dev container**
|
||||||
|
(`../sow-nodebb`):
|
||||||
|
|
||||||
|
1. In `../sow-nodebb`, set `.env` for theme work
|
||||||
|
(`PLUGIN_PATH=../sow-nodebb-theme`, `PLUGIN_ID=nodebb-theme-westgate`,
|
||||||
|
`EXTRA_PLUGINS=nodebb-plugin-westgate-wiki`), then `make dev` →
|
||||||
|
forum at http://localhost:4567.
|
||||||
|
2. After template/JS/SCSS edits: `make dev-build` in `../sow-nodebb`, then
|
||||||
|
reload the browser.
|
||||||
|
3. Playwright (playwright-skill / MCP browser tools) against
|
||||||
|
http://localhost:4567: log in, confirm the Unread button exists next to
|
||||||
|
notifications, badge shows the unread count, opening the dropdown lists
|
||||||
|
unread topics with working links, and the footer link lands on `/unread`.
|
||||||
|
Also check the mobile burger drawer shows the Unread row.
|
||||||
|
|
||||||
|
Existing template contract test `tests/global-topbar-contract.test.js` is
|
||||||
|
updated if it asserts the utility-list structure.
|
||||||
@@ -159,6 +159,55 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderUnreadMenu(menuEl, topics) {
|
||||||
|
if (!menuEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativePath = (window.config && window.config.relative_path) || '';
|
||||||
|
|
||||||
|
// remove the loading row and any previously injected topic rows;
|
||||||
|
// the empty-state and footer/divider rows are server-rendered and stay put
|
||||||
|
const loading = menuEl.querySelector('[data-wg-unread-loading]');
|
||||||
|
if (loading) {
|
||||||
|
loading.remove();
|
||||||
|
}
|
||||||
|
toArray(menuEl.querySelectorAll('.wg-unread-topic')).forEach((li) => {
|
||||||
|
li.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
const emptyEl = menuEl.querySelector('[data-wg-unread-empty]');
|
||||||
|
const footerEl = menuEl.querySelector('[data-wg-unread-footer]');
|
||||||
|
|
||||||
|
const list = (topics || []).slice(0, 10);
|
||||||
|
if (emptyEl) {
|
||||||
|
emptyEl.classList.toggle('hidden', list.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
list.forEach((topic) => {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.className = 'dropdown-item wg-unread-item text-truncate';
|
||||||
|
link.href = `${relativePath}/topic/${topic.slug}`;
|
||||||
|
link.textContent = topic.title;
|
||||||
|
if (topic.category && topic.category.name) {
|
||||||
|
const category = document.createElement('small');
|
||||||
|
category.className = 'wg-unread-item__category';
|
||||||
|
category.textContent = topic.category.name;
|
||||||
|
link.appendChild(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'wg-unread-topic';
|
||||||
|
li.appendChild(link);
|
||||||
|
|
||||||
|
if (footerEl) {
|
||||||
|
menuEl.insertBefore(li, footerEl);
|
||||||
|
} else {
|
||||||
|
menuEl.appendChild(li);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getWestgateTopbar() {
|
function getWestgateTopbar() {
|
||||||
if (!document || typeof document.querySelector !== 'function') {
|
if (!document || typeof document.querySelector !== 'function') {
|
||||||
return null;
|
return null;
|
||||||
@@ -225,11 +274,31 @@
|
|||||||
.on('shown.bs.dropdown.westgateTopbar', '.wg-topbar [component="sidebar/search"]', function () {
|
.on('shown.bs.dropdown.westgateTopbar', '.wg-topbar [component="sidebar/search"]', function () {
|
||||||
$(this).find('[component="search/fields"] input[name="query"]').trigger('focus');
|
$(this).find('[component="search/fields"] input[name="query"]').trigger('focus');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$(document)
|
||||||
|
.off('show.bs.dropdown.westgateUnread')
|
||||||
|
.on('show.bs.dropdown.westgateUnread', '.wg-topbar [component="unread"]', function () {
|
||||||
|
const menuEl = this.querySelector('[data-wg-unread-menu]');
|
||||||
|
const relativePath = (window.config && window.config.relative_path) || '';
|
||||||
|
fetch(relativePath + '/api/unread', {
|
||||||
|
headers: { accept: 'application/json' },
|
||||||
|
}).then(function (res) {
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('unread fetch failed: ' + res.status);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}).then(function (data) {
|
||||||
|
renderUnreadMenu(menuEl, data && data.topics);
|
||||||
|
}).catch(function () {
|
||||||
|
renderUnreadMenu(menuEl, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
window.westgateTheme = window.westgateTheme || {};
|
window.westgateTheme = window.westgateTheme || {};
|
||||||
window.westgateTheme.wrapWikiTables = wrapWestgateWikiTables;
|
window.westgateTheme.wrapWikiTables = wrapWestgateWikiTables;
|
||||||
window.westgateTheme.initTopbar = initWestgateTopbar;
|
window.westgateTheme.initTopbar = initWestgateTopbar;
|
||||||
|
window.westgateTheme.renderUnreadMenu = renderUnreadMenu;
|
||||||
|
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
wrapWestgateWikiTables(document);
|
wrapWestgateWikiTables(document);
|
||||||
|
|||||||
@@ -172,10 +172,23 @@ $wg-topbar-panel-bg: rgba(21, 17, 26, 0.98);
|
|||||||
|
|
||||||
.wg-topbar .notifications-dropdown,
|
.wg-topbar .notifications-dropdown,
|
||||||
.wg-topbar .chats-dropdown,
|
.wg-topbar .chats-dropdown,
|
||||||
.wg-topbar .drafts-dropdown {
|
.wg-topbar .drafts-dropdown,
|
||||||
|
.wg-topbar .unread-dropdown {
|
||||||
width: min(24rem, calc(100vw - 1.5rem));
|
width: min(24rem, calc(100vw - 1.5rem));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wg-topbar .wg-unread-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wg-topbar .wg-unread-item__category {
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
.wg-topbar .dropdown-item,
|
.wg-topbar .dropdown-item,
|
||||||
.wg-topbar .btn.btn-ghost,
|
.wg-topbar .btn.btn-ghost,
|
||||||
.wg-topbar .btn.btn-light {
|
.wg-topbar .btn.btn-light {
|
||||||
|
|||||||
@@ -37,6 +37,10 @@
|
|||||||
</li>
|
</li>
|
||||||
{{{ end }}}
|
{{{ end }}}
|
||||||
|
|
||||||
|
<li component="unread" class="nav-item unread dropdown" title="[[unread:title]]">
|
||||||
|
<!-- IMPORT partials/header/unread-drawer.tpl -->
|
||||||
|
</li>
|
||||||
|
|
||||||
<li component="notifications" class="nav-item notifications dropdown" title="[[global:header.notifications]]">
|
<li component="notifications" class="nav-item notifications dropdown" title="[[global:header.notifications]]">
|
||||||
<!-- IMPORT partials/sidebar/notifications.tpl -->
|
<!-- IMPORT partials/sidebar/notifications.tpl -->
|
||||||
</li>
|
</li>
|
||||||
@@ -109,6 +113,7 @@
|
|||||||
|
|
||||||
{{{ if config.loggedIn }}}
|
{{{ if config.loggedIn }}}
|
||||||
<div class="wg-topbar__drawer-actions">
|
<div class="wg-topbar__drawer-actions">
|
||||||
|
<a href="{relative_path}/unread"><i class="fa fa-fw fa-inbox" aria-hidden="true"></i><span>[[unread:title]]</span><span component="unread/count" class="badge rounded-1 bg-primary {{{ if !unreadCount.topic }}}hidden{{{ end }}}">{unreadCount.topic}</span></a>
|
||||||
<a href="{relative_path}/notifications"><i class="fa fa-fw fa-bell" aria-hidden="true"></i><span>[[global:header.notifications]]</span><span component="notifications/count" class="badge rounded-1 bg-primary {{{ if !unreadCount.notification }}}hidden{{{ end }}}">{unreadCount.notification}</span></a>
|
<a href="{relative_path}/notifications"><i class="fa fa-fw fa-bell" aria-hidden="true"></i><span>[[global:header.notifications]]</span><span component="notifications/count" class="badge rounded-1 bg-primary {{{ if !unreadCount.notification }}}hidden{{{ end }}}">{unreadCount.notification}</span></a>
|
||||||
{{{ if canChat }}}
|
{{{ if canChat }}}
|
||||||
<a href="{relative_path}/user/{user.userslug}/chats{{{ if user.lastRoomId }}}/{user.lastRoomId}{{{ end }}}"><i class="fa fa-fw fa-comment" aria-hidden="true"></i><span>[[global:header.chats]]</span><span component="chat/count" class="badge rounded-1 bg-primary {{{ if !unreadCount.chat }}}hidden{{{ end }}}">{unreadCount.chat}</span></a>
|
<a href="{relative_path}/user/{user.userslug}/chats{{{ if user.lastRoomId }}}/{user.lastRoomId}{{{ end }}}"><i class="fa fa-fw fa-comment" aria-hidden="true"></i><span>[[global:header.chats]]</span><span component="chat/count" class="badge rounded-1 bg-primary {{{ if !unreadCount.chat }}}hidden{{{ end }}}">{unreadCount.chat}</span></a>
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<a class="nav-link dropdown-toggle d-flex gap-2 align-items-center" href="#" role="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" aria-label="[[unread:title]]">
|
||||||
|
<i class="fa fa-fw fa-inbox" aria-hidden="true"></i>
|
||||||
|
<span component="unread/count" class="badge rounded-1 bg-primary {{{ if !unreadCount.topic }}}hidden{{{ end }}}">{unreadCount.topic}</span>
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu wg-topbar__dropdown unread-dropdown p-1 shadow" role="menu" data-wg-unread-menu>
|
||||||
|
<li data-wg-unread-loading class="dropdown-item disabled"><i class="fa fa-fw fa-spinner fa-spin" aria-hidden="true"></i> [[unread:title]]</li>
|
||||||
|
<li data-wg-unread-empty class="hidden"><span class="dropdown-item disabled">[[unread:no-unread-topics]]</span></li>
|
||||||
|
<li data-wg-unread-footer><hr class="dropdown-divider"><a class="dropdown-item wg-unread-item--all" href="{relative_path}/unread">[[unread:title]]</a></li>
|
||||||
|
</ul>
|
||||||
@@ -148,3 +148,35 @@ assertExcludes(
|
|||||||
'brand-header',
|
'brand-header',
|
||||||
'Removed brand banner widget area should not remain registered'
|
'Removed brand banner widget area should not remain registered'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const unreadDrawer = read('templates/partials/header/unread-drawer.tpl');
|
||||||
|
assertIncludes(
|
||||||
|
topbar,
|
||||||
|
'<!-- IMPORT partials/header/unread-drawer.tpl -->',
|
||||||
|
'Topbar should mount the unread drawer before notifications'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
topbar.indexOf('partials/header/unread-drawer.tpl') < topbar.indexOf('partials/sidebar/notifications.tpl'),
|
||||||
|
'Unread drawer should sit before the notifications item'
|
||||||
|
);
|
||||||
|
assertIncludes(
|
||||||
|
unreadDrawer,
|
||||||
|
'component="unread/count"',
|
||||||
|
'Unread toggle should carry the live core count badge'
|
||||||
|
);
|
||||||
|
assertIncludes(
|
||||||
|
unreadDrawer,
|
||||||
|
'data-wg-unread-menu',
|
||||||
|
'Unread dropdown menu should expose the JS mount hook'
|
||||||
|
);
|
||||||
|
assertMatches(
|
||||||
|
topbar,
|
||||||
|
/href="\{relative_path\}\/unread"[^>]*>[\s\S]*?component="unread\/count"/,
|
||||||
|
'Mobile drawer actions should include an Unread link with count badge'
|
||||||
|
);
|
||||||
|
|
||||||
|
assertIncludes(
|
||||||
|
stylesheet,
|
||||||
|
'.unread-dropdown',
|
||||||
|
'Topbar styles should cover the unread dropdown'
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const client = fs.readFileSync(path.join(__dirname, '..', 'public', 'client.js'), 'utf8');
|
||||||
|
|
||||||
|
assert(
|
||||||
|
client.includes("'show.bs.dropdown.westgateUnread'"),
|
||||||
|
'client.js should fetch unread topics when the unread dropdown opens'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
client.includes("'/api/unread'"),
|
||||||
|
'client.js should load the unread list from the /unread API'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
client.includes('renderUnreadMenu'),
|
||||||
|
'client.js should expose the unread menu renderer'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
/textContent/.test(client) && !/innerHTML\s*=[^=]*title/.test(client),
|
||||||
|
'Topic titles must be inserted via textContent, never innerHTML'
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user