Update Chapters list to make modifying a chapter take less scrolling.

This commit is contained in:
tim
2026-09-27 23:38:28 +02:00
parent 5ef68fb240
commit 407f67622b
5 changed files with 89 additions and 38 deletions
+1 -1
View File
@@ -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.
+9 -7
View File
@@ -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)),
);
+25 -24
View File
@@ -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',
+50 -2
View File
@@ -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;
+4 -4
View File
@@ -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: '<p>One</p><p>Two &lt;three&gt;</p>' } });
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/);