diff --git a/CHANGELOG.md b/CHANGELOG.md index 24f9d94..df2bcf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi --- ## [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. + --- diff --git a/Manual.md b/Manual.md index 7224038..47f3aa0 100644 --- a/Manual.md +++ b/Manual.md @@ -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 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* 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 diff --git a/static/js/magazine.js b/static/js/magazine.js index bbe4ed5..7b7888e 100644 --- a/static/js/magazine.js +++ b/static/js/magazine.js @@ -1205,6 +1205,28 @@ function magTogglePagesTodo(on) { 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) { if (!state.layout) ensureLayout().then(() => { if (state.view !== 'review') renderContents(); }); 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) }) }); } 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 = '
' + (needing ? needing + ' of ' + pageCount + ' pages need you.' : 'Nothing left for you to do on these ' + pageCount + ' pages.') + ' ' + + (shown !== pageCount ? ' Showing ' + shown + ' of ' + pageCount + ' pages. ' : '') + 'Each card says what state the page is in, how many articles are on it, and what to do next.
' + '' + - '' + - legend.map(([cls, label]) => '' + label + '').join('') + - '
No page needs you.
'; + if (!shown) html += '' + (chosen.size ? 'No page has that status' + (state.pagesOnlyTodo ? ' and needs you' : '') + '.' : 'No page needs you.') + '
'; html += ''; host.innerHTML = html; } @@ -5433,12 +5468,16 @@ async function magPlayPodcast() { // ── View switching ─────────────────────────────────────────────────────────── function magShow(view) { + const changed = state.view !== view; state.view = view; if (view !== 'review') magClearFloatingPanels(); ['library', 'contents', 'review', 'read'].forEach((v) => { const el = $('mag-view-' + v); 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(); } @@ -5900,6 +5939,9 @@ root.magRead = magRead; root.magPageGuide = magPageGuide; root.magArticleFlow = magArticleFlow; root.magTogglePagesTodo = magTogglePagesTodo; +root.magTogglePagesStatus = magTogglePagesStatus; +root.magClearPagesFilters = magClearPagesFilters; +root.magPageVisible = magPageVisible; root.magFindSearch = magFindSearch; root.magCatAdd = magCatAdd; root.magCatRemove = magCatRemove; diff --git a/static/style.css b/static/style.css index 1cf2b01..002f772 100644 --- a/static/style.css +++ b/static/style.css @@ -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; } .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; } } + +/* 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; } diff --git a/tests/magazine.spec.js b/tests/magazine.spec.js index 1162879..81a3c1c 100644 --- a/tests/magazine.spec.js +++ b/tests/magazine.spec.js @@ -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(); }); }); + +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'); + }); +});