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 - 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 download of new chapters. Authors most often fix their latest chapters. The default is 0, which
turns it off. 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 story. This recheck takes the place of the next scheduled update. The story then ignores the "last
N chapters" setting. N chapters" setting.
+9 -7
View File
@@ -224,9 +224,9 @@ export function storiesPage(main, _params, config) {
fill( fill(
main, main,
h( h(
'section', 'details',
{ class: 'card' }, { class: 'card', open: true },
h('h2', {}, 'Add stories'), h('summary', {}, h('h2', {}, 'Add stories')),
urls, urls,
h( h(
'div', 'div',
@@ -240,16 +240,18 @@ export function storiesPage(main, _params, config) {
), ),
), ),
h( h(
'section', 'details',
{ class: 'card' }, { class: 'card', open: true },
h( h(
'div', 'summary',
{ class: 'section-heading' }, { class: 'section-heading' },
h('h2', {}, 'Stories'), h('h2', {}, 'Stories'),
h('input', { h('input', {
type: 'search', type: 'search',
placeholder: 'Filter', placeholder: 'Filter',
'aria-label': 'Filter stories', 'aria-label': 'Filter stories',
// Prevents a click here from also toggling the details open or closed.
onclick: (e) => e.preventDefault(),
oninput: (e) => { oninput: (e) => {
filter = e.target.value; filter = e.target.value;
if (stories) render(); if (stories) render();
@@ -258,7 +260,7 @@ export function storiesPage(main, _params, config) {
), ),
table, 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 an epub file')), epubImport()),
h('details', { class: 'card' }, h('summary', {}, h('h2', {}, 'Import from the library')), libraryImport(config)), h('details', { class: 'card' }, h('summary', {}, h('h2', {}, 'Import from the library')), libraryImport(config)),
); );
+20 -19
View File
@@ -536,9 +536,14 @@ export function storyPage(main, { storyId }, config) {
main, main,
summary, summary,
h( h(
'section', 'details',
{ class: 'card' }, { class: 'card' },
h(
'summary',
{ class: 'section-heading' },
h('h2', {}, 'Download'), h('h2', {}, 'Download'),
button('Start', start, { class: 'primary summary-action' }),
),
h('fieldset', {}, h('legend', {}, 'What to download'), modes, modeHint), h('fieldset', {}, h('legend', {}, 'What to download'), modes, modeHint),
h( h(
'details', 'details',
@@ -555,11 +560,10 @@ export function storyPage(main, { storyId }, config) {
h('a', { download: true, href: apiUrl(`stories/${id}/epub`) }, 'Download ePub'), h('a', { download: true, href: apiUrl(`stories/${id}/epub`) }, 'Download ePub'),
), ),
), ),
h('section', { class: 'card' }, h('h2', {}, 'Updates'), updates),
h( h(
'section', 'details',
{ class: 'card' }, { class: 'card' },
h('h2', {}, 'Metadata'), h('summary', {}, h('h2', {}, 'Metadata')),
h( h(
'p', 'p',
{ class: 'hint' }, { class: 'hint' },
@@ -570,10 +574,11 @@ export function storyPage(main, { storyId }, config) {
), ),
metadata, metadata,
), ),
h('details', { class: 'card' }, h('summary', {}, h('h2', {}, 'Checking for new chapters')), updates),
h( h(
'section', 'details',
{ class: 'card' }, { class: 'card' },
h('h2', {}, 'Replacement rules'), h('summary', {}, h('h2', {}, 'Replacement rules')),
h( h(
'p', 'p',
{ class: 'hint' }, { class: 'hint' },
@@ -588,30 +593,26 @@ export function storyPage(main, { storyId }, config) {
preview.element, preview.element,
), ),
h( h(
'section', 'details',
{ class: 'card' }, { class: 'card', open: true },
h('summary', {}, h('h2', {}, 'Chapters')),
h( h(
'div', 'div',
{ class: 'section-heading' }, { class: 'controls chapter-actions' },
h('h2', {}, 'Chapters'),
h(
'div',
{ class: 'controls' },
button('Recheck selected', () => button('Recheck selected', () =>
selected.size ? queue('recheck', { chapter_ids: [...selected] }) : toast('Select chapters first', true), selected.size ? queue('recheck', { chapter_ids: [...selected] }) : toast('Select chapters first', true),
), ),
button('Take out of book', () => setIncluded(false)), button('Take out of book', () => setIncluded(false)),
button('Put back in book', () => setIncluded(true)), button('Put back in book', () => setIncluded(true)),
), ),
),
chapters, chapters,
), ),
h('details', { class: 'card' }, h('summary', {}, h('h2', {}, 'Import chapters from an epub')), epubImport(id)), 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( h(
'section', 'details',
{ class: 'card' }, { class: 'card' },
h('h2', {}, 'Library link'), h('summary', {}, h('h2', {}, 'Library link')),
h( h(
'p', 'p',
{ class: 'hint' }, { class: 'hint' },
@@ -637,9 +638,9 @@ export function storyPage(main, { storyId }, config) {
searchResults, searchResults,
), ),
h( h(
'section', 'details',
{ class: 'card card-danger' }, { 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.'), h('p', { class: 'hint' }, 'Deletes the stored chapters, revisions and rules for this story. The library book stays.'),
button( button(
'Stop tracking this story', 'Stop tracking this story',
+50 -2
View File
@@ -306,14 +306,47 @@ fieldset:has(+ .force-fields) {
margin: 0 0 12px; margin: 0 0 12px;
} }
details > summary h2 { /* A card that folds shut, such as "Import chapters from an epub". Scoped to .card so it does not
display: inline; 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 { details[open] > summary {
margin-bottom: 10px; 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, .controls,
.section-heading, .section-heading,
.account, .account,
@@ -339,6 +372,21 @@ details[open] > summary {
justify-content: space-between; 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 form,
.login label:has(> input:not([type='checkbox'])) { .login label:has(> input:not([type='checkbox'])) {
display: flex; 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'), true);
change(field(main, 'Lock Authors'), false); change(field(main, 'Lock Authors'), false);
input(field(main, 'recheck-days'), '5'); 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' }]); api.on('GET', 'jobs', [{ id: 'job', story_id: 1, status: 'done' }]);
await refreshJobs(); await refreshJobs();
await settle(); 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/settings').at(-1).body, { locked_fields: ['title'] });
assert.deepEqual(api.sent('PUT', 'stories/1/metadata')[0].body, assert.deepEqual(api.sent('PUT', 'stories/1/metadata')[0].body,
{ values: { title: 'My title', comments: '<p>One</p><p>Two &lt;three&gt;</p>' } }); { 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 }); assert.deepEqual(api.sent('PUT', 'stories/1/settings').at(-1).body, { auto_update: false, recheck_days: 5 });
input(field(main, 'recheck-days'), ''); 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); 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(); await mountStory();
const card = section(main, 'Metadata'); const card = section(main, 'Metadata');
assert.match(card.textContent, /Site value differs/); 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')); assert.ok(find(card, 'button', 'Use the site value'));
change(field(main, 'Lock Title'), true); change(field(main, 'Lock Title'), true);
assert.match(card.textContent, /Site value differs/); assert.match(card.textContent, /Site value differs/);