Sort stories through column headings

This commit is contained in:
tim
2026-10-01 16:08:42 +02:00
parent b536a1f116
commit e4a17029c1
4 changed files with 279 additions and 11 deletions
+53 -2
View File
@@ -3,6 +3,16 @@
import { jobPanel, refreshJobs, watchJobs } from './jobs.js';
import { api, badge, button, checkbox, fill, fmtDate, h, link, showError, toast } from './lib.js';
const STORY_COLUMNS = [
{ label: 'Title', key: 'title', type: 'text' },
{ label: 'Author', key: 'author', type: 'text' },
{ label: 'Site', key: 'site', type: 'text' },
{ label: 'Chapters', key: 'chapter_count', type: 'number' },
{ label: 'Status', key: 'status', type: 'text' },
{ label: 'Last change', key: 'last_changed', type: 'date' },
{ label: 'Last check', key: 'last_checked', type: 'date' },
];
function libraryImport(config) {
if (!config.library_available) {
return h('p', { class: 'hint' }, 'Set CWA_URL to import books from the library.');
@@ -199,24 +209,64 @@ function storyRow(s) {
);
}
function sortTitle(title) {
return title.trim().replace(/^(?:the|an?)\s+/i, '');
}
export function storiesPage(main, _params, config) {
document.title = 'Stories · Bindery';
let stories = null;
let filter = '';
let sort = 'title';
let direction = 1;
const sortButtons = new Map();
const table = h('div', {}, h('p', { class: 'empty' }, 'Loading…'));
const jobs = jobPanel({ hours: config.main_jobs_hours, count: config.main_jobs_count });
const render = () => {
const focusedSort = [...sortButtons].find(([, control]) => control === document.activeElement)?.[0];
const q = filter.trim().toLowerCase();
const shown = q
? stories.filter((s) => [s.title, s.author, s.site, s.url].some((v) => v?.toLowerCase().includes(q)))
: stories;
: [...stories];
const sortType = STORY_COLUMNS.find(({ key }) => key === sort).type;
shown.sort((a, b) => {
if (sortType === 'date') {
const left = Date.parse(a[sort]);
const right = Date.parse(b[sort]);
if (Number.isNaN(left) !== Number.isNaN(right)) return Number.isNaN(left) ? 1 : -1;
const order = (left - right) * direction;
if (order) return order;
} else if (sort !== 'title') {
const order = sortType === 'number'
? a[sort] - b[sort]
: (a[sort] || '').localeCompare(b[sort] || '', undefined, { sensitivity: 'base' });
if (order) return order * direction;
}
const order = sortTitle(a.title || a.url).localeCompare(sortTitle(b.title || b.url), undefined, { sensitivity: 'base' }) || a.id - b.id;
return sort === 'title' ? order * direction : order;
});
if (!shown.length) {
const text = stories.length ? 'No stories match.' : 'No stories yet. Add a URL above, or import an ePub.';
fill(table, h('p', { class: 'empty' }, text));
return;
}
const head = ['Title', 'Author', 'Site', 'Chapters', 'Status', 'Last change', 'Last check'].map((t) => h('th', {}, t));
const heading = ({ label, key, type }) => {
const active = sort === key;
const nextDirection = active ? -direction : type === 'text' ? 1 : -1;
const orders = type === 'text' ? ['A to Z', 'Z to A']
: type === 'date' ? ['oldest first', 'newest first'] : ['fewest first', 'most first'];
const action = `Sort by ${label.toLowerCase()}, ${orders[nextDirection === 1 ? 0 : 1]}`;
const control = button(label, () => {
sort = key;
direction = nextDirection;
render();
sortButtons.get(key).focus();
}, { title: action, 'aria-label': action });
sortButtons.set(key, control);
return h('th', { scope: 'col', 'aria-sort': active ? direction === 1 ? 'ascending' : 'descending' : 'none' }, control);
};
const head = STORY_COLUMNS.map(heading);
fill(
table,
h(
@@ -225,6 +275,7 @@ export function storiesPage(main, _params, config) {
h('table', {}, h('thead', {}, h('tr', {}, head)), h('tbody', {}, shown.map(storyRow))),
),
);
if (focusedSort) sortButtons.get(focusedSort).focus();
};
const load = () =>
+49 -7
View File
@@ -745,6 +745,45 @@ textarea {
overflow-x: auto;
}
.stories-table th {
padding: 0;
}
.stories-table th button {
inline-size: 100%;
justify-content: flex-start;
padding: 6px 8px;
border: 0;
border-radius: 4px;
background: none;
color: inherit;
font: inherit;
}
.stories-table th button:is(:hover, :focus-visible) {
background: var(--info-bg);
color: var(--info);
}
.stories-table th[aria-sort] button::after {
content: '↕';
display: inline-block;
inline-size: 1em;
margin-inline-start: 0.3em;
}
.stories-table th[aria-sort='ascending'] button::after {
content: '↑';
}
.stories-table th[aria-sort='descending'] button::after {
content: '↓';
}
.stories-table th[aria-sort]:not([aria-sort='none']) {
color: var(--text);
}
.stories-table td[data-label='Chapters'] small {
display: block;
white-space: nowrap;
@@ -779,13 +818,16 @@ textarea {
display: block;
}
.stories-table thead {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
white-space: nowrap;
clip-path: inset(50%);
.stories-table thead tr {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
margin-bottom: 8px;
}
.stories-table th {
padding: 4px 0;
border: 0;
}
.stories-table tbody tr,