Update Chapters list to make modifying a chapter take less scrolling.
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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)),
|
||||||
);
|
);
|
||||||
|
|||||||
+25
-24
@@ -536,9 +536,14 @@ export function storyPage(main, { storyId }, config) {
|
|||||||
main,
|
main,
|
||||||
summary,
|
summary,
|
||||||
h(
|
h(
|
||||||
'section',
|
'details',
|
||||||
{ class: 'card' },
|
{ 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('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'),
|
button('Recheck selected', () =>
|
||||||
h(
|
selected.size ? queue('recheck', { chapter_ids: [...selected] }) : toast('Select chapters first', true),
|
||||||
'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)),
|
|
||||||
),
|
),
|
||||||
|
button('Take out of book', () => setIncluded(false)),
|
||||||
|
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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 <three></p>' } });
|
{ values: { title: 'My title', comments: '<p>One</p><p>Two <three></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/);
|
||||||
|
|||||||
Reference in New Issue
Block a user