Stack the story and chapter lists on a phone instead of scrolling sideways
Each table had six columns, which squeezed down to unreadable widths on a narrow screen and forced horizontal scrolling. Below 600px, each row becomes a bordered card. The main value (the story or chapter title) stays unlabelled at the top, and every other value gets a small label in front of it, taken from its column heading. The chapter table's "select all" checkbox lived in the table header, which mobile now hides in favour of the per-row labels. It moves into the sticky actions bar above the list instead, next to the buttons that already act on the selection.
This commit is contained in:
@@ -151,23 +151,23 @@ function storyRow(s) {
|
||||
'tr',
|
||||
{},
|
||||
h('td', {}, link(`/story/${s.id}`, s.title || s.url), s.busy && [' ', badge('running', 'working')]),
|
||||
h('td', {}, s.author),
|
||||
h('td', { class: 'story-site' }, s.site),
|
||||
h('td', { 'data-label': 'Author' }, s.author),
|
||||
h('td', { class: 'story-site', 'data-label': 'Site' }, s.site),
|
||||
h(
|
||||
'td',
|
||||
{},
|
||||
{ 'data-label': 'Chapters' },
|
||||
String(s.chapter_count),
|
||||
s.offsite_count > 0 && [' ', badge('warn', `${s.offsite_count} removed`, 'Chapters the site no longer lists')],
|
||||
),
|
||||
h(
|
||||
'td',
|
||||
{},
|
||||
{ 'data-label': 'Status' },
|
||||
s.status && badge(s.status),
|
||||
s.upload_held && [' ', badge('warn', 'held', 'The book did not go to CWA. See the story page.')],
|
||||
' ',
|
||||
h('small', {}, s.message),
|
||||
),
|
||||
h('td', { class: 'date' }, fmtDate(s.last_checked)),
|
||||
h('td', { class: 'date', 'data-label': 'Last check' }, fmtDate(s.last_checked)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -189,7 +189,14 @@ export function storiesPage(main, _params, config) {
|
||||
return;
|
||||
}
|
||||
const head = ['Title', 'Author', 'Site', 'Chapters', 'Status', 'Last check'].map((t) => h('th', {}, t));
|
||||
fill(table, h('div', { class: 'table-wrap' }, h('table', {}, h('thead', {}, h('tr', {}, head)), h('tbody', {}, shown.map(storyRow)))));
|
||||
fill(
|
||||
table,
|
||||
h(
|
||||
'div',
|
||||
{ class: 'table-wrap stories-table' },
|
||||
h('table', {}, h('thead', {}, h('tr', {}, head)), h('tbody', {}, shown.map(storyRow))),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const load = () =>
|
||||
|
||||
@@ -57,10 +57,12 @@ const editText = (key, value) => {
|
||||
|
||||
const authorsText = (authors) => (Array.isArray(authors) ? authors.join(' & ') : authors);
|
||||
|
||||
const table = (headings, rows) =>
|
||||
// On a narrow screen, the rows stack instead of scrolling sideways. `extra` names the wrapper class
|
||||
// that gives a table's stacked rows their own layout, such as picking out the row's first cell.
|
||||
const table = (headings, rows, extra = '') =>
|
||||
h(
|
||||
'div',
|
||||
{ class: 'table-wrap' },
|
||||
{ class: `table-wrap ${extra}`.trim() },
|
||||
h('table', {}, h('thead', {}, h('tr', {}, headings.map((t) => h('th', {}, t)))), h('tbody', {}, rows)),
|
||||
);
|
||||
|
||||
@@ -85,6 +87,7 @@ export function storyPage(main, { storyId }, config) {
|
||||
const metadata = h('div', { class: 'metadata' });
|
||||
const updates = h('div');
|
||||
const chapters = h('div');
|
||||
const selectAll = h('span', { class: 'chapter-select-all' });
|
||||
const jobs = jobPanel();
|
||||
const linked = h('div');
|
||||
const searchResults = h('div');
|
||||
@@ -392,28 +395,30 @@ export function storyPage(main, { storyId }, config) {
|
||||
'aria-label': `Select ${chapterTitle(c.title)}`,
|
||||
}),
|
||||
),
|
||||
h('td', {}, c.included ? String(number) : ''),
|
||||
h('td', { 'data-label': '#' }, c.included ? String(number) : ''),
|
||||
h('td', {}, link(route, chapterTitle(c.title))),
|
||||
h(
|
||||
'td',
|
||||
{ class: 'badges' },
|
||||
{ class: 'badges', 'data-label': 'State' },
|
||||
!c.on_site && badge('warn', 'removed from site', 'The site no longer lists this chapter'),
|
||||
!c.included && badge('muted', 'not in book'),
|
||||
c.revisions > 1 && badge('info', c.current_rev === c.revisions ? 'changed' : `using revision ${c.current_rev}`),
|
||||
),
|
||||
h('td', {}, c.revisions > 1 ? link(route, `${c.revisions}, compare`) : String(c.revisions)),
|
||||
h('td', { class: 'date' }, fmtDate(c.added_at)),
|
||||
h('td', { 'data-label': 'Revisions' }, c.revisions > 1 ? link(route, `${c.revisions}, compare`) : String(c.revisions)),
|
||||
h('td', { class: 'date', 'data-label': 'Added' }, fmtDate(c.added_at)),
|
||||
);
|
||||
});
|
||||
const all = checkbox(list.length > 0 && selected.size === list.length, (on) => {
|
||||
selected = new Set(on ? list.map((c) => c.id) : []);
|
||||
renderChapters();
|
||||
}, { 'aria-label': 'Select all chapters' });
|
||||
fill(selectAll, h('label', {}, all, ' Select all'));
|
||||
fill(
|
||||
chapters,
|
||||
table(
|
||||
[all, '#', 'Title', 'State', 'Revisions', 'Added'],
|
||||
['', '#', 'Title', 'State', 'Revisions', 'Added'],
|
||||
rows.length ? rows : h('tr', {}, h('td', { colspan: 6, class: 'empty' }, 'No chapters stored yet.')),
|
||||
'chapters-table',
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -596,6 +601,7 @@ export function storyPage(main, { storyId }, config) {
|
||||
'details',
|
||||
{ class: 'card', open: true },
|
||||
h('summary', {}, h('h2', {}, 'Chapters')),
|
||||
selectAll,
|
||||
h(
|
||||
'div',
|
||||
{ class: 'controls chapter-actions' },
|
||||
|
||||
+103
-3
@@ -285,6 +285,24 @@ main.login {
|
||||
.card {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
/* Two per row uses the space of a narrow screen better than one per row. The selector needs
|
||||
both classes: .chapter-actions alone ties with the .controls rule below it in the file, and
|
||||
.controls's display: flex (a row, with wrap) would win the tie by coming later. */
|
||||
.controls.chapter-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* An odd button out spans both columns instead of leaving a gap next to it. */
|
||||
.chapter-actions > :first-child:nth-last-child(odd) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.chapter-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.card-danger {
|
||||
@@ -372,6 +390,11 @@ details.card[open] > summary .summary-action {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.chapter-select-all {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
@@ -382,9 +405,6 @@ details.card[open] > summary .summary-action {
|
||||
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,
|
||||
@@ -725,6 +745,86 @@ textarea {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* The story list and the chapter list stack into cards below 600px, instead of scrolling
|
||||
sideways. data-label repeats each column heading in front of its value. */
|
||||
@media (max-width: 600px) {
|
||||
.stories-table,
|
||||
.chapters-table {
|
||||
overflow-x: visible;
|
||||
}
|
||||
|
||||
.stories-table table,
|
||||
.stories-table thead,
|
||||
.stories-table tbody,
|
||||
.stories-table tr,
|
||||
.stories-table th,
|
||||
.stories-table td,
|
||||
.chapters-table table,
|
||||
.chapters-table thead,
|
||||
.chapters-table tbody,
|
||||
.chapters-table tr,
|
||||
.chapters-table th,
|
||||
.chapters-table td {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.stories-table thead,
|
||||
.chapters-table thead {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
clip-path: inset(50%);
|
||||
}
|
||||
|
||||
.stories-table tbody tr,
|
||||
.chapters-table tbody tr {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stories-table tbody tr:last-child,
|
||||
.chapters-table tbody tr:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.stories-table td,
|
||||
.chapters-table td {
|
||||
border-bottom: 0;
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
.stories-table td[data-label]::before,
|
||||
.chapters-table td[data-label]::before {
|
||||
content: attr(data-label) ': ';
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* The chapter list has far more rows than the story list, so each row packs its cells onto
|
||||
three lines instead of one line per cell: checkbox and number, title, then the rest. */
|
||||
.chapters-table tbody tr {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
column-gap: 10px;
|
||||
row-gap: 2px;
|
||||
}
|
||||
|
||||
.chapters-table td {
|
||||
display: inline;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chapters-table td:nth-child(3) {
|
||||
flex-basis: 100%;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
/* Jobs */
|
||||
|
||||
.jobs {
|
||||
|
||||
Reference in New Issue
Block a user