Files
bindery/bindery/static/js/stories.js
T
tim 03ed1a69cb 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.
2026-09-27 23:38:28 +02:00

278 lines
8.5 KiB
JavaScript

// The story list: add stories, filter them, see all jobs, and import epub files and library books.
import { jobPanel, refreshJobs, watchJobs } from './jobs.js';
import { api, badge, button, checkbox, fill, fmtDate, h, link, showError, toast } from './lib.js';
function libraryImport(config) {
if (!config.library_available) {
return h('p', { class: 'hint' }, 'Set CWA_URL to import books from the library.');
}
let candidates = [];
let selected = new Set();
let check = true;
const results = h('div');
const render = () => {
if (!candidates.length) {
fill(results, h('p', { class: 'empty' }, 'Every library book from a story site is tracked already.'));
return;
}
const toggle = (id, on) => {
if (on) selected.add(id);
else selected.delete(id);
};
fill(
results,
h(
'div',
{ class: 'table-wrap' },
h(
'table',
{},
h(
'thead',
{},
h(
'tr',
{},
h(
'th',
{},
checkbox(selected.size === candidates.length, (on) => {
selected = new Set(on ? candidates.map((c) => c.calibre_id) : []);
render();
}, { 'aria-label': 'Select all' }),
),
h('th', {}, 'Title'),
h('th', {}, 'Author'),
h('th', {}, 'Site'),
),
),
h(
'tbody',
{},
candidates.map((c) =>
h(
'tr',
{},
h('td', {}, checkbox(selected.has(c.calibre_id), (on) => toggle(c.calibre_id, on), { 'aria-label': c.title })),
h('td', {}, c.title),
h('td', {}, c.authors),
h('td', {}, c.site),
),
),
),
),
),
button(
'Import selected',
async () => {
const books = candidates
.filter((c) => selected.has(c.calibre_id))
.map((c) => ({ id: c.calibre_id, uuid: c.uuid, title: c.title }));
if (!books.length) return;
const jobs = await api('library/import', 'POST', { books, check });
toast(`Queued ${jobs.length} imports`);
fill(results);
refreshJobs();
},
{ class: 'primary' },
),
);
};
return h(
'div',
{},
h(
'p',
{ class: 'hint' },
'Finds library books whose publisher is a story site, for example books from the FanFicFare calibre plugin. ' +
'Importing downloads each ePub from CWA and keeps its chapters, including chapters the site no longer has.',
),
h(
'div',
{ class: 'controls' },
button('Find stories in the library', async () => {
candidates = await api('library/candidates');
selected = new Set(candidates.map((c) => c.calibre_id));
render();
}),
h('label', {}, checkbox(true, (on) => (check = on)), ' Check the site for new chapters after import'),
),
results,
);
}
// Upload a FanFicFare ePub. Bindery stores its chapters as if it had downloaded
// them, so chapters that the site stubbed or removed stay in the book.
export function epubImport(storyId) {
let check = true;
const file = h('input', { type: 'file', accept: '.epub,application/epub+zip', 'aria-label': 'Epub file' });
const upload = button(
'Import',
async () => {
const chosen = file.files[0];
if (!chosen) {
toast('Choose an ePub file', true);
return;
}
const form = new FormData();
form.append('file', chosen);
form.append('check', String(check));
await api(storyId ? `stories/${storyId}/import-epub` : 'stories/import-epub', 'POST', form);
toast(`Queued the import of ${chosen.name}`);
file.value = '';
refreshJobs();
},
{ class: 'primary' },
);
const hint = storyId
? 'Adds the chapters of an ePub of this story that Bindery does not have, in the order of the ePub. ' +
'Chapters that Bindery has already keep their stored text.'
: 'Starts tracking the story of an ePub made by FanFicFare, with all of its chapters. ' +
'If Bindery tracks the story already, it adds the chapters that it does not have.';
return h(
'div',
{},
h('p', { class: 'hint' }, hint, ' The next update adds the chapters that the site published after the ePub.'),
h(
'div',
{ class: 'controls' },
file,
upload,
h('label', {}, checkbox(true, (on) => (check = on)), ' Check the site for new chapters after import'),
),
);
}
function storyRow(s) {
return h(
'tr',
{},
h('td', {}, link(`/story/${s.id}`, s.title || s.url), s.busy && [' ', badge('running', 'working')]),
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', 'data-label': 'Last check' }, fmtDate(s.last_checked)),
);
}
export function storiesPage(main, _params, config) {
document.title = 'Stories · Bindery';
let stories = null;
let filter = '';
const table = h('div', {}, h('p', { class: 'empty' }, 'Loading…'));
const jobs = jobPanel();
const render = () => {
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;
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 check'].map((t) => h('th', {}, t));
fill(
table,
h(
'div',
{ class: 'table-wrap stories-table' },
h('table', {}, h('thead', {}, h('tr', {}, head)), h('tbody', {}, shown.map(storyRow))),
),
);
};
const load = () =>
api('stories')
.then((list) => {
stories = list;
render();
})
.catch(showError);
const urls = h('textarea', {
class: 'story-urls',
rows: 3,
placeholder: 'https://www.royalroad.com/fiction/… (one URL per line)',
'aria-label': 'Story URLs',
});
const add = button(
'Add',
async () => {
if (!urls.value.trim()) return;
const result = await api('stories', 'POST', { urls: urls.value });
if (result.errors.length) toast(result.errors.join('\n'), true);
if (result.jobs.length) {
toast(`Queued ${result.jobs.length} ${result.jobs.length === 1 ? 'story' : 'stories'}`);
urls.value = '';
refreshJobs();
}
},
{ class: 'primary' },
);
fill(
main,
h(
'details',
{ class: 'card', open: true },
h('summary', {}, h('h2', {}, 'Add stories')),
urls,
h(
'div',
{ class: 'controls' },
add,
h(
'span',
{ class: 'hint' },
'If CWA already has this story, Bindery links that book and keeps its chapters, then downloads only the chapters that are missing.',
),
),
),
h(
'details',
{ class: 'card', open: true },
h(
'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();
},
}),
),
table,
),
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)),
);
load();
return watchJobs({ render: jobs.render, onFinished: load });
}