From 407f67622b42420df75d6456c140487ba4912269 Mon Sep 17 00:00:00 2001 From: Timothy Allen Date: Sun, 27 Sep 2026 06:56:48 +0200 Subject: [PATCH] Update Chapters list to make modifying a chapter take less scrolling. --- README.md | 2 +- bindery/static/js/stories.js | 16 ++++++----- bindery/static/js/story.js | 49 ++++++++++++++++----------------- bindery/static/style.css | 52 ++++++++++++++++++++++++++++++++++-- tests/js/flows.test.mjs | 8 +++--- 5 files changed, 89 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 4760b36..427da88 100644 --- a/README.md +++ b/README.md @@ -362,7 +362,7 @@ recheck, or use one of these settings: - On the Settings page, "also recheck the last N chapters" rechecks the last N chapters with each download of new chapters. Authors most often fix their latest chapters. The default is 0, which turns it off. -- In the Updates section of a story page, "Recheck all chapters every N days" rechecks the whole +- In the "Checking for new chapters" section of a story page, "Recheck all chapters every N days" rechecks the whole story. This recheck takes the place of the next scheduled update. The story then ignores the "last N chapters" setting. diff --git a/bindery/static/js/stories.js b/bindery/static/js/stories.js index 6253c8a..bfb94df 100644 --- a/bindery/static/js/stories.js +++ b/bindery/static/js/stories.js @@ -224,9 +224,9 @@ export function storiesPage(main, _params, config) { fill( main, h( - 'section', - { class: 'card' }, - h('h2', {}, 'Add stories'), + 'details', + { class: 'card', open: true }, + h('summary', {}, h('h2', {}, 'Add stories')), urls, h( 'div', @@ -240,16 +240,18 @@ export function storiesPage(main, _params, config) { ), ), h( - 'section', - { class: 'card' }, + 'details', + { class: 'card', open: true }, h( - 'div', + 'summary', { class: 'section-heading' }, h('h2', {}, 'Stories'), h('input', { type: 'search', placeholder: 'Filter', 'aria-label': 'Filter stories', + // Prevents a click here from also toggling the details open or closed. + onclick: (e) => e.preventDefault(), oninput: (e) => { filter = e.target.value; if (stories) render(); @@ -258,7 +260,7 @@ export function storiesPage(main, _params, config) { ), table, ), - h('section', { class: 'card' }, h('h2', {}, 'Jobs'), jobs.el), + h('details', { class: 'card', open: true }, h('summary', {}, h('h2', {}, 'Jobs')), jobs.el), h('details', { class: 'card' }, h('summary', {}, h('h2', {}, 'Import an epub file')), epubImport()), h('details', { class: 'card' }, h('summary', {}, h('h2', {}, 'Import from the library')), libraryImport(config)), ); diff --git a/bindery/static/js/story.js b/bindery/static/js/story.js index 1c4b82e..cbbca2f 100644 --- a/bindery/static/js/story.js +++ b/bindery/static/js/story.js @@ -536,9 +536,14 @@ export function storyPage(main, { storyId }, config) { main, summary, h( - 'section', + 'details', { class: 'card' }, - h('h2', {}, 'Download'), + h( + 'summary', + { class: 'section-heading' }, + h('h2', {}, 'Download'), + button('Start', start, { class: 'primary summary-action' }), + ), h('fieldset', {}, h('legend', {}, 'What to download'), modes, modeHint), h( 'details', @@ -555,11 +560,10 @@ export function storyPage(main, { storyId }, config) { h('a', { download: true, href: apiUrl(`stories/${id}/epub`) }, 'Download ePub'), ), ), - h('section', { class: 'card' }, h('h2', {}, 'Updates'), updates), h( - 'section', + 'details', { class: 'card' }, - h('h2', {}, 'Metadata'), + h('summary', {}, h('h2', {}, 'Metadata')), h( 'p', { class: 'hint' }, @@ -570,10 +574,11 @@ export function storyPage(main, { storyId }, config) { ), metadata, ), + h('details', { class: 'card' }, h('summary', {}, h('h2', {}, 'Checking for new chapters')), updates), h( - 'section', + 'details', { class: 'card' }, - h('h2', {}, 'Replacement rules'), + h('summary', {}, h('h2', {}, 'Replacement rules')), h( 'p', { class: 'hint' }, @@ -588,30 +593,26 @@ export function storyPage(main, { storyId }, config) { preview.element, ), h( - 'section', - { class: 'card' }, + 'details', + { class: 'card', open: true }, + h('summary', {}, h('h2', {}, 'Chapters')), h( 'div', - { class: 'section-heading' }, - h('h2', {}, 'Chapters'), - h( - 'div', - { class: 'controls' }, - button('Recheck selected', () => - selected.size ? queue('recheck', { chapter_ids: [...selected] }) : toast('Select chapters first', true), - ), - button('Take out of book', () => setIncluded(false)), - button('Put back in book', () => setIncluded(true)), + { class: 'controls chapter-actions' }, + button('Recheck selected', () => + selected.size ? queue('recheck', { chapter_ids: [...selected] }) : toast('Select chapters first', true), ), + button('Take out of book', () => setIncluded(false)), + button('Put back in book', () => setIncluded(true)), ), chapters, ), h('details', { class: 'card' }, h('summary', {}, h('h2', {}, 'Import chapters from an epub')), epubImport(id)), - h('section', { class: 'card' }, h('h2', {}, 'Recent jobs'), jobs.el), + h('details', { class: 'card', open: true }, h('summary', {}, h('h2', {}, 'Recent jobs')), jobs.el), h( - 'section', + 'details', { class: 'card' }, - h('h2', {}, 'Library link'), + h('summary', {}, h('h2', {}, 'Library link')), h( 'p', { class: 'hint' }, @@ -637,9 +638,9 @@ export function storyPage(main, { storyId }, config) { searchResults, ), h( - 'section', + 'details', { class: 'card card-danger' }, - h('h2', {}, 'Stop tracking'), + h('summary', {}, h('h2', {}, 'Stop tracking')), h('p', { class: 'hint' }, 'Deletes the stored chapters, revisions and rules for this story. The library book stays.'), button( 'Stop tracking this story', diff --git a/bindery/static/style.css b/bindery/static/style.css index ee071ce..c23eed1 100644 --- a/bindery/static/style.css +++ b/bindery/static/style.css @@ -306,14 +306,47 @@ fieldset:has(+ .force-fields) { margin: 0 0 12px; } -details > summary h2 { - display: inline; +/* A card that folds shut, such as "Import chapters from an epub". Scoped to .card so it does not + change the small "Site value differs" fold of a metadata field. */ +details.card > summary { + list-style: none; + display: flex; + align-items: center; + gap: 8px; +} + +details.card > summary::-webkit-details-marker { + display: none; +} + +details.card > summary::before { + content: '▸'; + flex: none; + color: var(--muted); + transition: transform 0.15s ease; +} + +details.card[open] > summary::before { + transform: rotate(90deg); +} + +details.card > summary h2 { + flex: 1; + min-width: 0; + /* Without this, the h2's own bottom margin throws off the vertical centering of the + arrow next to it, and leaves extra space under the heading. */ + margin: 0; } details[open] > summary { margin-bottom: 10px; } +/* The Download card offers this button even while closed. Once open, the controls below repeat it. */ +details.card[open] > summary .summary-action { + display: none; +} + .controls, .section-heading, .account, @@ -339,6 +372,21 @@ details[open] > summary { justify-content: space-between; } +/* Stays visible while the chapter list scrolls, so the buttons need no scrolling back up to + reach, but only once a chapter is selected. Otherwise it would cover the top of the list for + no reason. It stops following once the Chapters card itself scrolls out of view. */ +.card:has(.chapters-table input:checked) .chapter-actions { + position: sticky; + top: 0; + z-index: 1; + background: var(--card-bg); + padding: 8px 0; + border-bottom: 1px solid var(--border); + /* Without its own compositing layer, some phone browsers repaint a stuck element with + several stacked children wrong while scrolling, showing only the last child. */ + transform: translateZ(0); +} + .login form, .login label:has(> input:not([type='checkbox'])) { display: flex; diff --git a/tests/js/flows.test.mjs b/tests/js/flows.test.mjs index 3e4de20..455ba56 100644 --- a/tests/js/flows.test.mjs +++ b/tests/js/flows.test.mjs @@ -287,7 +287,7 @@ test('unsaved metadata and update settings survive job completion, then save typ change(field(main, 'Lock Authors'), true); change(field(main, 'Lock Authors'), false); input(field(main, 'recheck-days'), '5'); - change(nodes(section(main, 'Updates')).find((el) => el.attributes.type === 'checkbox'), false); + change(nodes(section(main, 'Checking for new chapters')).find((el) => el.attributes.type === 'checkbox'), false); api.on('GET', 'jobs', [{ id: 'job', story_id: 1, status: 'done' }]); await refreshJobs(); await settle(); @@ -300,10 +300,10 @@ test('unsaved metadata and update settings survive job completion, then save typ assert.deepEqual(api.sent('PUT', 'stories/1/settings').at(-1).body, { locked_fields: ['title'] }); assert.deepEqual(api.sent('PUT', 'stories/1/metadata')[0].body, { values: { title: 'My title', comments: '

One

Two <three>

' } }); - await click(section(main, 'Updates'), 'Save'); + await click(section(main, 'Checking for new chapters'), 'Save'); assert.deepEqual(api.sent('PUT', 'stories/1/settings').at(-1).body, { auto_update: false, recheck_days: 5 }); input(field(main, 'recheck-days'), ''); - await click(section(main, 'Updates'), 'Save'); + await click(section(main, 'Checking for new chapters'), 'Save'); assert.equal(api.sent('PUT', 'stories/1/settings').at(-1).body.recheck_days, 0); }); @@ -325,7 +325,7 @@ test('the metadata card shows the site value only when it differs, and drops the await mountStory(); const card = section(main, 'Metadata'); assert.match(card.textContent, /Site value differs/); - assert.equal(nodes(card).filter((el) => el.tagName === 'summary').length, 1); + assert.equal(nodes(card).filter((el) => el.className === 'field-site').length, 1); assert.ok(find(card, 'button', 'Use the site value')); change(field(main, 'Lock Title'), true); assert.match(card.textContent, /Site value differs/);