feat(magazine): filter the page grid by status from the legend

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-10-02 06:34:14 +02:00
parent 319d4401da
commit 0244fceb55
5 changed files with 177 additions and 5 deletions

View File

@ -6,6 +6,12 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
--- ---
## [Unreleased] ## [Unreleased]
### Added
- **Filter the page grid by status.** Each legend entry is now a toggle button with the number of pages in that status; choose one or several to show only pages that have any of them. It combines with *Show only pages that need me*, the summary says how many pages are shown, *Show all pages* clears everything, a status no page has is greyed out, and the buttons announce their on/off state and work from the keyboard.
### Fixed
- **Review opens at the top.** Opening a page in review no longer keeps the scroll position of the page grid, which could leave the sticky page bar covering the top of the page picture.
--- ---

View File

@ -351,6 +351,12 @@ system has settled. Each card shows, top to bottom: the page, **how many article
*Problems found*, *Approved*, *Done: audio is ready*, *Advertisement: not read*), and either a blue **To do** box or "Nothing to do *Problems found*, *Approved*, *Done: audio is ready*, *Advertisement: not read*), and either a blue **To do** box or "Nothing to do
here". Click the page image to open it in Review. here". Click the page image to open it in Review.
**Filter by status.** Every entry of the legend (*not reviewed yet*, *has content, not quality-checked*, *quality check clean*, *quality check found
problems*, *reviewed*, *advertisement*, *reviewed + audio generated*, *reviewed, nothing to narrate*) is a button showing how many pages have that status. Press one
to show only those pages; press several to show pages that have **any** of them; press one again to take it off. A status no page has is greyed out.
It combines with **Show only pages that need me** (a page must match both), the summary says "Showing N of M pages", and **Show all pages** clears every
filter at once. The buttons are real toggle buttons (they announce whether they are on) and can be used from the keyboard.
**What the to-do lines mean.** *Open the page … and approve it*: detection found text and nobody has confirmed it. *Problems found* **What the to-do lines mean.** *Open the page … and approve it*: detection found text and nobody has confirmed it. *Problems found*
lists what the quality check saw and which tool fixes it (*Find missed text*, *Clean up boxes*, *Spelling*, merge or shrink lists what the quality check saw and which tool fixes it (*Find missed text*, *Clean up boxes*, *Spelling*, merge or shrink
overlapping boxes, keep a page in one article only). *Text was found here but it belongs to no article*: link the page to the overlapping boxes, keep a page in one article only). *Text was found here but it belongs to no article*: link the page to the

View File

@ -1205,6 +1205,28 @@ function magTogglePagesTodo(on) {
renderContents(); renderContents();
} }
// Filter the page grid by status: each legend entry is a toggle, several can be on at once (a page matches if its status
// is any of the chosen ones), and it combines with "Show only pages that need me". The choice survives re-renders.
function magTogglePagesStatus(status) {
const set = state.pagesStatus || (state.pagesStatus = new Set());
if (set.has(status)) set.delete(status); else set.add(status);
renderContents();
}
function magClearPagesFilters() {
state.pagesStatus = new Set();
state.pagesOnlyTodo = false;
renderContents();
}
// Which pages the grid shows, given the current filters. Pure, so it can be tested without a DOM.
function magPageVisible(statusName, needs, filters) {
const f = filters || {};
if (f.onlyTodo && !needs) return false;
const chosen = f.status instanceof Set ? f.status : new Set(f.status || []);
return chosen.size === 0 || chosen.has(statusName);
}
function renderPagesView(host) { function renderPagesView(host) {
if (!state.layout) ensureLayout().then(() => { if (state.view !== 'review') renderContents(); }); if (!state.layout) ensureLayout().then(() => { if (state.view !== 'review') renderContents(); });
if (state.qualityDocId !== state.doc?.id) magLoadQuality(false); if (state.qualityDocId !== state.doc?.id) magLoadQuality(false);
@ -1229,17 +1251,30 @@ function renderPagesView(host) {
guides.set(p, { st, arts, guide: magPageGuide({ page: p, articles: arts, quality: quality.get(p), stateName: st, hasText: magPageHasText(p) }) }); guides.set(p, { st, arts, guide: magPageGuide({ page: p, articles: arts, quality: quality.get(p), stateName: st, hasText: magPageHasText(p) }) });
} }
const needing = [...guides.values()].filter((g) => g.guide.needs).length; const needing = [...guides.values()].filter((g) => g.guide.needs).length;
const counts = new Map();
guides.forEach((g) => counts.set(g.st, (counts.get(g.st) || 0) + 1));
const chosen = state.pagesStatus instanceof Set ? state.pagesStatus : new Set();
const filters = { onlyTodo: state.pagesOnlyTodo, status: chosen };
const shown = [...guides.values()].filter((g) => magPageVisible(g.st, g.guide.needs, filters)).length;
let html = '<div class="mag-pages-help">' + let html = '<div class="mag-pages-help">' +
'<p class="mag-pages-summary" role="status"><strong>' + (needing ? needing + ' of ' + pageCount + ' pages need you.' : 'Nothing left for you to do on these ' + pageCount + ' pages.') + '</strong> ' + '<p class="mag-pages-summary" role="status"><strong>' + (needing ? needing + ' of ' + pageCount + ' pages need you.' : 'Nothing left for you to do on these ' + pageCount + ' pages.') + '</strong> ' +
(shown !== pageCount ? '<span class="mag-pages-shown"> Showing ' + shown + ' of ' + pageCount + ' pages.</span> ' : '') +
'Each card says what state the page is in, how many articles are on it, and what to do next.</p>' + 'Each card says what state the page is in, how many articles are on it, and what to do next.</p>' +
'<label class="mag-pages-filter"><input type="checkbox" ' + (state.pagesOnlyTodo ? 'checked' : '') + ' onchange="magTogglePagesTodo(this.checked)"> Show only pages that need me</label>' + '<label class="mag-pages-filter"><input type="checkbox" ' + (state.pagesOnlyTodo ? 'checked' : '') + ' onchange="magTogglePagesTodo(this.checked)"> Show only pages that need me</label>' +
'<p class="mag-pages-legend" aria-label="Card colours">' + '<div class="mag-pages-legend" role="group" aria-label="Filter the pages by status">' +
legend.map(([cls, label]) => '<span class="mag-pages-legend-item"><span class="mag-pages-legend-swatch is-page-' + cls + '"></span>' + label + '</span>').join('') + legend.map(([cls, label]) => {
'</p></div>'; const n = counts.get(cls) || 0;
const on = chosen.has(cls);
return '<button type="button" class="mag-pages-legend-item mag-legend-filter' + (on ? ' is-on' : '') + '" aria-pressed="' + on + '" data-status="' + cls + '"' +
(!n && !on ? ' disabled' : '') + ' onclick="magTogglePagesStatus(\'' + cls + '\')" title="' + (on ? 'Stop showing only' : 'Show only') + ' pages that are: ' + esc(label) + '">' +
'<span class="mag-pages-legend-swatch is-page-' + cls + '"></span>' + esc(label) + ' <span class="mag-legend-count">' + n + '</span></button>';
}).join('') +
(chosen.size || state.pagesOnlyTodo ? '<button type="button" class="btn-sm mag-legend-clear" onclick="magClearPagesFilters()">Show all pages</button>' : '') +
'</div></div>';
html += '<div class="mag-pages-grid" style="--cols:' + state.pagesCols + '">'; html += '<div class="mag-pages-grid" style="--cols:' + state.pagesCols + '">';
for (let p = 1; p <= pageCount; p++) { for (let p = 1; p <= pageCount; p++) {
const { st, arts, guide } = guides.get(p); const { st, arts, guide } = guides.get(p);
if (state.pagesOnlyTodo && !guide.needs) continue; if (!magPageVisible(st, guide.needs, filters)) continue;
// The thumbnail is the big, easy target: it opens the page in review (in the context of the // The thumbnail is the big, easy target: it opens the page in review (in the context of the
// first article on it). A separate Review button per article only appears when the page // first article on it). A separate Review button per article only appears when the page
// really holds several, to say which one is meant. // really holds several, to say which one is meant.
@ -1276,7 +1311,7 @@ function renderPagesView(host) {
'</div>' + '</div>' +
'</div></div>'; '</div></div>';
} }
if (state.pagesOnlyTodo && !needing) html += '<p class="muted">No page needs you.</p>'; if (!shown) html += '<p class="muted">' + (chosen.size ? 'No page has that status' + (state.pagesOnlyTodo ? ' and needs you' : '') + '.' : 'No page needs you.') + '</p>';
html += '</div>'; html += '</div>';
host.innerHTML = html; host.innerHTML = html;
} }
@ -5433,12 +5468,16 @@ async function magPlayPodcast() {
// ── View switching ─────────────────────────────────────────────────────────── // ── View switching ───────────────────────────────────────────────────────────
function magShow(view) { function magShow(view) {
const changed = state.view !== view;
state.view = view; state.view = view;
if (view !== 'review') magClearFloatingPanels(); if (view !== 'review') magClearFloatingPanels();
['library', 'contents', 'review', 'read'].forEach((v) => { ['library', 'contents', 'review', 'read'].forEach((v) => {
const el = $('mag-view-' + v); const el = $('mag-view-' + v);
if (el) el.hidden = v !== view; if (el) el.hidden = v !== view;
}); });
// A new view starts at its top: the scroll offset of the page grid (which grows with the filter legend) must not
// carry into review, where it would leave the sticky page bar covering the top of the page picture.
if (changed && view === 'review') { const main = $('main-content'); if (main) main.scrollTop = 0; }
if (view !== 'read') magPause(); if (view !== 'read') magPause();
} }
@ -5900,6 +5939,9 @@ root.magRead = magRead;
root.magPageGuide = magPageGuide; root.magPageGuide = magPageGuide;
root.magArticleFlow = magArticleFlow; root.magArticleFlow = magArticleFlow;
root.magTogglePagesTodo = magTogglePagesTodo; root.magTogglePagesTodo = magTogglePagesTodo;
root.magTogglePagesStatus = magTogglePagesStatus;
root.magClearPagesFilters = magClearPagesFilters;
root.magPageVisible = magPageVisible;
root.magFindSearch = magFindSearch; root.magFindSearch = magFindSearch;
root.magCatAdd = magCatAdd; root.magCatAdd = magCatAdd;
root.magCatRemove = magCatRemove; root.magCatRemove = magCatRemove;

View File

@ -5659,3 +5659,14 @@ details.mag-report-group summary { cursor: pointer; margin-bottom: 6px; }
background: color-mix(in srgb, var(--accent) 12%, var(--surface)); font-size: 14px; line-height: 1.4; } background: color-mix(in srgb, var(--accent) 12%, var(--surface)); font-size: 14px; line-height: 1.4; }
.mag-review-split.is-stale { opacity: .4; pointer-events: none; filter: grayscale(.6); transition: opacity .15s; } .mag-review-split.is-stale { opacity: .4; pointer-events: none; filter: grayscale(.6); transition: opacity .15s; }
@media (prefers-reduced-motion: reduce) { .mag-review-split.is-stale { transition: none; } } @media (prefers-reduced-motion: reduce) { .mag-review-split.is-stale { transition: none; } }
/* Legend entries are status filters */
.mag-legend-filter { font: inherit; color: var(--text); background: transparent; border: 1px solid transparent; border-radius: 999px;
padding: 4px 10px; cursor: pointer; }
.mag-legend-filter:hover:not(:disabled) { border-color: var(--border); background: var(--panel); }
.mag-legend-filter:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
.mag-legend-filter.is-on, .mag-legend-filter.is-on:hover:not(:disabled) { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, var(--surface)); font-weight: 700; }
.mag-legend-filter:disabled { opacity: .45; cursor: default; }
.mag-legend-count { min-width: 1.6em; padding: 0 6px; border-radius: 999px; background: var(--panel); font-size: 12px; text-align: center; }
.mag-pages-shown { font-weight: 600; color: var(--accent); }
.mag-legend-clear { margin-left: 6px; }

View File

@ -2328,3 +2328,110 @@ test.describe('A turned page shows at once, and its net turn is visible', () =>
await expect(page.getByRole('button', { name: /Turn right/ })).toBeEnabled(); await expect(page.getByRole('button', { name: /Turn right/ })).toBeEnabled();
}); });
}); });
test.describe('Filtering the page grid by status', () => {
const art = (id, title, pages, status, extra = {}) => ({ id, title, author: '', pages, blocks: [], images: [], status, confidence: 0.6, section: '', ...extra });
async function openGrid(page) {
await mockBackend(page, { articleStatus: 'approved' });
await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({ json: { articles: [
art('a1', 'Done piece', [3], 'approved', { has_audio: true }),
art('a2', 'Open piece', [4], 'detected'),
art('a3', 'Approved piece', [5], 'approved'),
art('a4', 'Problem piece', [6], 'detected'),
], progress: {}, default_voice: '', podcast: { hosts: [] } } }));
await page.route(`**/api/magazine/${DOC_ID}/quality/report`, route => route.fulfill({ json: { summary: {}, pages: [
{ page: 6, needs_review: true, uncovered: [[1, 1, 1, 1]], overlaps: [], clipped: [], suspect_blocks: [], duplicates: [] }] } }));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-contents-view-toggle').click();
await expect(page.locator('.mag-pages-grid .mag-card-wrap').first()).toBeVisible();
}
const shownPages = (page) => page.locator('.mag-pages-grid .mag-card-wrap').evaluateAll(els => els.map(e => Number(e.dataset.page)));
const chip = (page, status) => page.locator(`.mag-legend-filter[data-status="${status}"]`);
test('the filter logic: nothing chosen shows everything, several choices are "any of", and it combines with needs-me', async ({ page }) => {
await mockBackend(page);
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
const v = (st, needs, f) => page.evaluate(([a, b, c]) => window.magPageVisible(a, b, c), [st, needs, f]);
expect(await v('audio', false, {})).toBe(true);
expect(await v('audio', false, { status: [] })).toBe(true);
expect(await v('audio', false, { status: ['flagged'] })).toBe(false);
expect(await v('audio', false, { status: ['flagged', 'audio'] })).toBe(true);
expect(await v('flagged', true, { onlyTodo: true, status: ['flagged'] })).toBe(true);
expect(await v('flagged', false, { onlyTodo: true, status: ['flagged'] })).toBe(false); // both filters must agree
expect(await v('audio', true, { onlyTodo: true, status: ['flagged'] })).toBe(false);
expect(await v('audio', false, { onlyTodo: true })).toBe(false);
});
test('each legend entry is a toggle button with the number of pages in that status', async ({ page }) => {
await openGrid(page);
const group = page.getByRole('group', { name: 'Filter the pages by status' });
await expect(group.getByRole('button', { pressed: false }).first()).toBeVisible();
await expect(chip(page, 'audio')).toContainText('1');
await expect(chip(page, 'detected')).toContainText('1');
await expect(chip(page, 'flagged')).toContainText('1');
await expect(chip(page, 'blank')).toContainText('4');
await expect(chip(page, 'ad')).toBeDisabled(); // none of those: nothing to filter to
await expect(chip(page, 'audio')).toHaveAttribute('aria-pressed', 'false');
});
test('choosing a status shows only those pages, and the summary says how many', async ({ page }) => {
await openGrid(page);
expect((await shownPages(page)).length).toBe(8);
await chip(page, 'blank').click();
expect(await shownPages(page)).toEqual([1, 2, 7, 8]);
await expect(chip(page, 'blank')).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('.mag-pages-shown')).toHaveText(/Showing 4 of 8 pages/);
});
test('several statuses add up, and choosing one again takes it off', async ({ page }) => {
await openGrid(page);
await chip(page, 'audio').click();
await chip(page, 'flagged').click();
expect(await shownPages(page)).toEqual([3, 6]);
await chip(page, 'audio').click();
expect(await shownPages(page)).toEqual([6]);
await chip(page, 'flagged').click();
expect((await shownPages(page)).length).toBe(8);
await expect(page.locator('.mag-pages-shown')).toHaveCount(0);
});
test('it combines with "Show only pages that need me"', async ({ page }) => {
await openGrid(page);
await page.getByLabel('Show only pages that need me').check();
const needing = await shownPages(page);
expect(needing).toEqual(expect.arrayContaining([4, 6]));
await chip(page, 'detected').click();
expect(await shownPages(page)).toEqual([4]); // needs me AND detected
await chip(page, 'detected').click();
await chip(page, 'audio').click();
expect(await shownPages(page)).toEqual([]); // page 3 is done: it does not need me
await expect(page.locator('.mag-pages-grid + *, .mag-pages-grid')).toContainText('No page has that status and needs you');
});
test('"Show all pages" clears every filter and only appears while one is on', async ({ page }) => {
await openGrid(page);
await expect(page.getByRole('button', { name: 'Show all pages' })).toHaveCount(0);
await chip(page, 'blank').click();
await page.getByLabel('Show only pages that need me').check();
await page.getByRole('button', { name: 'Show all pages' }).click();
expect((await shownPages(page)).length).toBe(8);
await expect(page.getByLabel('Show only pages that need me')).not.toBeChecked();
await expect(chip(page, 'blank')).toHaveAttribute('aria-pressed', 'false');
await expect(page.getByRole('button', { name: 'Show all pages' })).toHaveCount(0);
});
test('the filter survives a change of the column count and works from the keyboard', async ({ page }) => {
await openGrid(page);
await chip(page, 'flagged').focus();
await page.keyboard.press('Enter');
expect(await shownPages(page)).toEqual([6]);
await page.evaluate(() => window.magSetPagesCols(5));
expect(await shownPages(page)).toEqual([6]);
await expect(chip(page, 'flagged')).toHaveAttribute('aria-pressed', 'true');
});
});